diff --git a/Changelog.md b/Changelog.md index 6e7165a2..0612cc85 100644 --- a/Changelog.md +++ b/Changelog.md @@ -9,6 +9,7 @@ - New feature "Integration Endpoint". Allows an admin to upload files to any collective using a separate endpoint. - New feature: add files to existing items. +- New feature: reorder attachments via drag and drop. - The document list on the front-page has been rewritten. The table is removed and documents are now presented in a “card view”. - Amend the mail-to-pdf conversion to include the e-mail date. @@ -47,6 +48,8 @@ The joex and rest-server component have new config sections: - The data used in `/sec/collective/settings` was extended with a boolean value to enable/disable the "integration endpoint" for a collective. +- Add `/sec/item/{itemId}/attachment/movebefore` to move an attachment + before another. ## v0.5.0 diff --git a/elm.json b/elm.json index 3d52f6ca..bbc41890 100644 --- a/elm.json +++ b/elm.json @@ -20,6 +20,7 @@ "elm/url": "1.0.0", "elm-explorations/markdown": "1.0.0", "justinmimbs/date": "3.1.2", + "norpan/elm-html5-drag-drop": "3.1.4", "ryannhg/date-format": "2.3.0", "truqu/elm-base64": "2.0.4" }, diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala index 3668e778..c62cc064 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -75,6 +75,8 @@ trait OItem[F[_]] { def findByFileSource(checksum: String, sourceId: Ident): F[Vector[RItem]] def deleteAttachment(id: Ident, collective: Ident): F[Int] + + def moveAttachmentBefore(itemId: Ident, source: Ident, target: Ident): F[AddResult] } object OItem { @@ -121,6 +123,16 @@ object OItem { def apply[F[_]: Effect](store: Store[F]): Resource[F, OItem[F]] = Resource.pure[F, OItem[F]](new OItem[F] { + def moveAttachmentBefore( + itemId: Ident, + source: Ident, + target: Ident + ): F[AddResult] = + store + .transact(QItem.moveAttachmentBefore(itemId, source, target)) + .attempt + .map(AddResult.fromUpdate) + def findItem(id: Ident, collective: Ident): F[Option[ItemData]] = store .transact(QItem.findItem(id)) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 5932a998..4fcc44a0 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1239,6 +1239,30 @@ paths: application/json: schema: $ref: "#/components/schemas/ItemProposals" + /sec/item/{itemId}/attachment/movebefore: + post: + tags: [ Item ] + summary: Reorder attachments within an item + description: | + Moves the `source` attachment before the `target` attachment, + such that `source` becomes the immediate neighbor of `target` + with a lower position. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/itemId" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MoveAttachment" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" /sec/attachment/{id}: delete: @@ -1945,6 +1969,19 @@ paths: components: schemas: + MoveAttachment: + description: | + Data to move an attachment to another position. + required: + - source + - target + properties: + source: + type: string + format: ident + target: + type: string + format: ident ScanMailboxSettingsList: description: | A list of scan-mailbox tasks. diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala index 389c79de..c22d7bca 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -137,6 +137,14 @@ object ItemRoutes { resp <- Ok(ip) } yield resp + case req @ POST -> Root / Ident(id) / "attachment" / "movebefore" => + for { + data <- req.as[MoveAttachment] + _ <- logger.fdebug(s"Move item (${id.id}) attachment $data") + res <- backend.item.moveAttachmentBefore(id, data.source, data.target) + resp <- Ok(Conversions.basicResult(res, "Attachment moved.")) + } yield resp + case DELETE -> Root / Ident(id) => for { n <- backend.item.deleteItem(id, user.account.collective) diff --git a/modules/store/src/main/scala/docspell/store/impl/Column.scala b/modules/store/src/main/scala/docspell/store/impl/Column.scala index 0cdc0be3..72a69e08 100644 --- a/modules/store/src/main/scala/docspell/store/impl/Column.scala +++ b/modules/store/src/main/scala/docspell/store/impl/Column.scala @@ -72,12 +72,18 @@ case class Column(name: String, ns: String = "", alias: String = "") { def isGt[A: Put](a: A): Fragment = f ++ fr"> $a" + def isGte[A: Put](a: A): Fragment = + f ++ fr">= $a" + def isGt(c: Column): Fragment = f ++ fr">" ++ c.f def isLt[A: Put](a: A): Fragment = f ++ fr"< $a" + def isLte[A: Put](a: A): Fragment = + f ++ fr"<= $a" + def isLt(c: Column): Fragment = f ++ fr"<" ++ c.f @@ -103,4 +109,10 @@ case class Column(name: String, ns: String = "", alias: String = "") { def max: Fragment = fr"MAX(" ++ f ++ fr")" + + def increment[A: Put](a: A): Fragment = + f ++ fr"=" ++ f ++ fr"+ $a" + + def decrement[A: Put](a: A): Fragment = + f ++ fr"=" ++ f ++ fr"- $a" } diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index 619c0dbf..5abb2c92 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -1,8 +1,9 @@ package docspell.store.queries import bitpeace.FileMeta -import cats.implicits._ import cats.effect.Sync +import cats.data.OptionT +import cats.implicits._ import fs2.Stream import doobie._ import doobie.implicits._ @@ -16,6 +17,44 @@ import org.log4s._ object QItem { private[this] val logger = getLogger + def moveAttachmentBefore( + itemId: Ident, + source: Ident, + target: Ident + ): ConnectionIO[Int] = { + + // rs < rt + def moveBack(rs: RAttachment, rt: RAttachment): ConnectionIO[Int] = + for { + n <- RAttachment.decPositions(itemId, rs.position, rt.position) + k <- RAttachment.updatePosition(rs.id, rt.position) + } yield n + k + + // rs > rt + def moveForward(rs: RAttachment, rt: RAttachment): ConnectionIO[Int] = + for { + n <- RAttachment.incPositions(itemId, rt.position, rs.position) + k <- RAttachment.updatePosition(rs.id, rt.position) + } yield n + k + + (for { + _ <- OptionT.liftF( + if (source == target) + Sync[ConnectionIO].raiseError(new Exception("Attachments are the same!")) + else ().pure[ConnectionIO] + ) + rs <- OptionT(RAttachment.findById(source)).filter(_.itemId == itemId) + rt <- OptionT(RAttachment.findById(target)).filter(_.itemId == itemId) + n <- OptionT.liftF( + if (rs.position == rt.position || rs.position + 1 == rt.position) + 0.pure[ConnectionIO] + else if (rs.position < rt.position) moveBack(rs, rt) + else moveForward(rs, rt) + ) + } yield n).getOrElse(0) + + } + case class ItemData( item: RItem, corrOrg: Option[ROrganization], diff --git a/modules/store/src/main/scala/docspell/store/records/RAttachment.scala b/modules/store/src/main/scala/docspell/store/records/RAttachment.scala index b997bb5e..def33fa6 100644 --- a/modules/store/src/main/scala/docspell/store/records/RAttachment.scala +++ b/modules/store/src/main/scala/docspell/store/records/RAttachment.scala @@ -38,6 +38,20 @@ object RAttachment { fr"${v.id},${v.itemId},${v.fileId.id},${v.position},${v.created},${v.name}" ).update.run + def decPositions(iId: Ident, lowerBound: Int, upperBound: Int): ConnectionIO[Int] = + updateRow( + table, + and(itemId.is(iId), position.isGte(lowerBound), position.isLte(upperBound)), + position.decrement(1) + ).update.run + + def incPositions(iId: Ident, lowerBound: Int, upperBound: Int): ConnectionIO[Int] = + updateRow( + table, + and(itemId.is(iId), position.isGte(lowerBound), position.isLte(upperBound)), + position.increment(1) + ).update.run + def nextPosition(id: Ident): ConnectionIO[Int] = for { max <- selectSimple(position.max, table, itemId.is(id)).query[Option[Int]].unique diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 4a085d4f..f5fbd474 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -42,6 +42,7 @@ module Api exposing , login , loginSession , logout + , moveAttachmentBefore , newInvite , postEquipment , postNewUser @@ -100,6 +101,7 @@ import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) import Api.Model.JobQueueState exposing (JobQueueState) +import Api.Model.MoveAttachment exposing (MoveAttachment) import Api.Model.NotificationSettings exposing (NotificationSettings) import Api.Model.OptionalDate exposing (OptionalDate) import Api.Model.OptionalId exposing (OptionalId) @@ -1009,6 +1011,21 @@ getJobQueueStateTask flags = -- Item +moveAttachmentBefore : + Flags + -> String + -> MoveAttachment + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +moveAttachmentBefore flags itemId data receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/item/" ++ itemId ++ "/attachment/movebefore" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.MoveAttachment.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + itemSearch : Flags -> ItemSearch -> (Result Http.Error ItemLightList -> msg) -> Cmd msg itemSearch flags search receive = Http2.authPost diff --git a/modules/webapp/src/main/elm/Comp/Dropzone.elm b/modules/webapp/src/main/elm/Comp/Dropzone.elm index 67268f8b..802d1353 100644 --- a/modules/webapp/src/main/elm/Comp/Dropzone.elm +++ b/modules/webapp/src/main/elm/Comp/Dropzone.elm @@ -18,6 +18,7 @@ import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (..) import Json.Decode as D +import Util.Html exposing (onDragEnter, onDragLeave, onDragOver, onDropFiles) type alias State = @@ -111,10 +112,10 @@ view : Model -> Html Msg view model = div [ classList (model.settings.classList model.state) - , hijackOn "dragenter" (D.succeed DragEnter) - , hijackOn "dragover" (D.succeed DragEnter) - , hijackOn "dragleave" (D.succeed DragLeave) - , hijackOn "drop" dropDecoder + , onDragEnter DragEnter + , onDragOver DragEnter + , onDragLeave DragLeave + , onDropFiles GotFiles ] [ div [ class "ui icon header" ] [ i [ class "mouse pointer icon" ] [] @@ -156,18 +157,3 @@ filterMime settings files = else List.filter pred files - - -dropDecoder : D.Decoder Msg -dropDecoder = - D.at [ "dataTransfer", "files" ] (D.oneOrMore GotFiles File.decoder) - - -hijackOn : String -> D.Decoder msg -> Attribute msg -hijackOn event decoder = - preventDefaultOn event (D.map hijack decoder) - - -hijack : msg -> ( msg, Bool ) -hijack msg = - ( msg, True ) diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm index b4e32739..83c07598 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail.elm @@ -14,6 +14,7 @@ import Api.Model.EquipmentList exposing (EquipmentList) import Api.Model.IdName exposing (IdName) import Api.Model.ItemDetail exposing (ItemDetail) import Api.Model.ItemProposals exposing (ItemProposals) +import Api.Model.MoveAttachment exposing (MoveAttachment) import Api.Model.OptionalDate exposing (OptionalDate) import Api.Model.OptionalId exposing (OptionalId) import Api.Model.OptionalText exposing (OptionalText) @@ -39,6 +40,7 @@ import File exposing (File) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onCheck, onClick, onInput) +import Html5.DragDrop as DD import Http import Markdown import Page exposing (Page(..)) @@ -88,6 +90,7 @@ type alias Model = , completed : Set String , errored : Set String , loading : Set String + , attachDD : DD.Model String String } @@ -182,6 +185,7 @@ emptyModel = , completed = Set.empty , errored = Set.empty , loading = Set.empty + , attachDD = DD.init } @@ -244,6 +248,7 @@ type Msg | AddFilesUploadResp String (Result Http.Error BasicResult) | AddFilesProgress String Http.Progress | AddFilesReset + | AttachDDMsg (DD.Msg String String) @@ -1174,6 +1179,28 @@ update key flags next msg model = in noSub ( model, updateBars ) + AttachDDMsg lm -> + let + ( model_, result ) = + DD.update lm model.attachDD + + cmd = + case result of + Just ( src, trg, _ ) -> + if src /= trg then + Api.moveAttachmentBefore flags + model.item.id + (MoveAttachment src trg) + SaveResp + + else + Cmd.none + + Nothing -> + Cmd.none + in + noSub ( { model | attachDD = model_ }, cmd ) + -- view @@ -1419,20 +1446,38 @@ renderAttachmentsTabMenu model = [ text "E-Mails" ] ] + + highlight el = + let + dropId = + DD.getDropId model.attachDD + + dragId = + DD.getDragId model.attachDD + + enable = + Just el.id == dropId && dropId /= dragId + in + [ ( "current-drop-target", enable ) + ] in div [ class "ui top attached tabular menu" ] (List.indexedMap (\pos -> \el -> a - [ classList + ([ classList <| [ ( "item", True ) , ( "active", attachmentVisible model pos ) ] - , title (Maybe.withDefault "No Name" el.name) - , href "" - , onClick (SetActiveAttachment pos) - ] + ++ highlight el + , title (Maybe.withDefault "No Name" el.name) + , href "" + , onClick (SetActiveAttachment pos) + ] + ++ DD.draggable AttachDDMsg el.id + ++ DD.droppable AttachDDMsg el.id + ) [ Maybe.map (Util.String.ellipsis 20) el.name |> Maybe.withDefault "No Name" |> text diff --git a/modules/webapp/src/main/elm/Util/Html.elm b/modules/webapp/src/main/elm/Util/Html.elm index e29476ad..a25e7ad0 100644 --- a/modules/webapp/src/main/elm/Util/Html.elm +++ b/modules/webapp/src/main/elm/Util/Html.elm @@ -4,13 +4,18 @@ module Util.Html exposing , classActive , intToKeyCode , onClickk + , onDragEnter + , onDragLeave + , onDragOver + , onDropFiles , onKeyUp ) +import File exposing (File) import Html exposing (Attribute, Html, i) import Html.Attributes exposing (class) -import Html.Events exposing (keyCode, on) -import Json.Decode as Decode +import Html.Events exposing (keyCode, on, preventDefaultOn) +import Json.Decode as D checkboxChecked : Html msg @@ -68,12 +73,12 @@ intToKeyCode code = onKeyUp : (Int -> msg) -> Attribute msg onKeyUp tagger = - on "keyup" (Decode.map tagger keyCode) + on "keyup" (D.map tagger keyCode) onClickk : msg -> Attribute msg onClickk msg = - Html.Events.preventDefaultOn "click" (Decode.map alwaysPreventDefault (Decode.succeed msg)) + Html.Events.preventDefaultOn "click" (D.map alwaysPreventDefault (D.succeed msg)) alwaysPreventDefault : msg -> ( msg, Bool ) @@ -92,3 +97,42 @@ classActive active classes = "" ) ) + + +onDragEnter : msg -> Attribute msg +onDragEnter m = + hijackOn "dragenter" (D.succeed m) + + +onDragOver : msg -> Attribute msg +onDragOver m = + hijackOn "dragover" (D.succeed m) + + +onDragLeave : msg -> Attribute msg +onDragLeave m = + hijackOn "dragleave" (D.succeed m) + + +onDrop : msg -> Attribute msg +onDrop m = + hijackOn "drop" (D.succeed m) + + +onDropFiles : (File -> List File -> msg) -> Attribute msg +onDropFiles tagger = + let + dropFilesDecoder = + D.at [ "dataTransfer", "files" ] (D.oneOrMore tagger File.decoder) + in + hijackOn "drop" dropFilesDecoder + + +hijackOn : String -> D.Decoder msg -> Attribute msg +hijackOn event decoder = + preventDefaultOn event (D.map hijack decoder) + + +hijack : msg -> ( msg, Bool ) +hijack msg = + ( msg, True ) diff --git a/modules/webapp/src/main/webjar/docspell.css b/modules/webapp/src/main/webjar/docspell.css index ff0e3f8f..6e335da6 100644 --- a/modules/webapp/src/main/webjar/docspell.css +++ b/modules/webapp/src/main/webjar/docspell.css @@ -143,6 +143,10 @@ textarea.markdown-editor { background: rgba(240,248,255,0.4); } +.default-layout .ui.menu .item.current-drop-target { + background: rgba(0,0,0,0.2); +} + label span.muted { font-size: smaller; color: rgba(0,0,0,0.6);