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 da3efce2..fd3e5344 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -1,5 +1,6 @@ package docspell.backend.ops +import cats.data.NonEmptyList import cats.data.OptionT import cats.effect.{Effect, Resource} import cats.implicits._ @@ -13,62 +14,124 @@ import docspell.store.queue.JobQueue import docspell.store.records._ import docspell.store.{AddResult, Store} -import doobie._ import doobie.implicits._ import org.log4s.getLogger trait OItem[F[_]] { /** Sets the given tags (removing all existing ones). */ - def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult] + def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[UpdateResult] + + /** Sets tags for multiple items. The tags of the items will be + * replaced with the given ones. Same as `setTags` but for multiple + * items. + */ + def setTagsMultipleItems( + items: NonEmptyList[Ident], + tags: List[Ident], + collective: Ident + ): F[UpdateResult] /** Create a new tag and add it to the item. */ def addNewTag(item: Ident, tag: RTag): F[AddResult] - /** Apply all tags to the given item. Tags must exist, but can be IDs or names. */ + /** Apply all tags to the given item. Tags must exist, but can be IDs + * or names. Existing tags on the item are left unchanged. + */ def linkTags(item: Ident, tags: List[String], collective: Ident): F[UpdateResult] + def linkTagsMultipleItems( + items: NonEmptyList[Ident], + tags: List[String], + collective: Ident + ): F[UpdateResult] + /** Toggles tags of the given item. Tags must exist, but can be IDs or names. */ def toggleTags(item: Ident, tags: List[String], collective: Ident): F[UpdateResult] - def setDirection(item: Ident, direction: Direction, collective: Ident): F[AddResult] + def setDirection( + item: NonEmptyList[Ident], + direction: Direction, + collective: Ident + ): F[UpdateResult] - def setFolder(item: Ident, folder: Option[Ident], collective: Ident): F[AddResult] + def setFolder(item: Ident, folder: Option[Ident], collective: Ident): F[UpdateResult] - def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult] + def setFolderMultiple( + items: NonEmptyList[Ident], + folder: Option[Ident], + collective: Ident + ): F[UpdateResult] + + def setCorrOrg( + items: NonEmptyList[Ident], + org: Option[Ident], + collective: Ident + ): F[UpdateResult] def addCorrOrg(item: Ident, org: OOrganization.OrgAndContacts): F[AddResult] - def setCorrPerson(item: Ident, person: Option[Ident], collective: Ident): F[AddResult] + def setCorrPerson( + items: NonEmptyList[Ident], + person: Option[Ident], + collective: Ident + ): F[UpdateResult] def addCorrPerson(item: Ident, person: OOrganization.PersonAndContacts): F[AddResult] - def setConcPerson(item: Ident, person: Option[Ident], collective: Ident): F[AddResult] + def setConcPerson( + items: NonEmptyList[Ident], + person: Option[Ident], + collective: Ident + ): F[UpdateResult] def addConcPerson(item: Ident, person: OOrganization.PersonAndContacts): F[AddResult] - def setConcEquip(item: Ident, equip: Option[Ident], collective: Ident): F[AddResult] + def setConcEquip( + items: NonEmptyList[Ident], + equip: Option[Ident], + collective: Ident + ): F[UpdateResult] def addConcEquip(item: Ident, equip: REquipment): F[AddResult] - def setNotes(item: Ident, notes: Option[String], collective: Ident): F[AddResult] + def setNotes(item: Ident, notes: Option[String], collective: Ident): F[UpdateResult] - def setName(item: Ident, name: String, collective: Ident): F[AddResult] + def setName(item: Ident, name: String, collective: Ident): F[UpdateResult] - def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] + def setNameMultiple( + items: NonEmptyList[Ident], + name: String, + collective: Ident + ): F[UpdateResult] - def setItemDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult] + def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] = + setStates(NonEmptyList.of(item), state, collective) - def setItemDueDate( - item: Ident, - date: Option[Timestamp], + def setStates( + item: NonEmptyList[Ident], + state: ItemState, collective: Ident ): F[AddResult] + def setItemDate( + item: NonEmptyList[Ident], + date: Option[Timestamp], + collective: Ident + ): F[UpdateResult] + + def setItemDueDate( + item: NonEmptyList[Ident], + date: Option[Timestamp], + collective: Ident + ): F[UpdateResult] + def getProposals(item: Ident, collective: Ident): F[MetaProposalList] def deleteItem(itemId: Ident, collective: Ident): F[Int] + def deleteItemMultiple(items: NonEmptyList[Ident], collective: Ident): F[Int] + def deleteAttachment(id: Ident, collective: Ident): F[Int] def moveAttachmentBefore(itemId: Ident, source: Ident, target: Ident): F[AddResult] @@ -77,7 +140,7 @@ trait OItem[F[_]] { attachId: Ident, name: Option[String], collective: Ident - ): F[AddResult] + ): F[UpdateResult] /** Submits the item for re-processing. The list of attachment ids can * be used to only re-process a subset of the item's attachments. @@ -91,6 +154,12 @@ trait OItem[F[_]] { notifyJoex: Boolean ): F[UpdateResult] + def reprocessAll( + items: NonEmptyList[Ident], + account: AccountId, + notifyJoex: Boolean + ): F[UpdateResult] + /** Submits a task that finds all non-converted pdfs and triggers * converting them using ocrmypdf. Each file is converted by a * separate task. @@ -130,21 +199,30 @@ object OItem { item: Ident, tags: List[String], collective: Ident + ): F[UpdateResult] = + linkTagsMultipleItems(NonEmptyList.of(item), tags, collective) + + def linkTagsMultipleItems( + items: NonEmptyList[Ident], + tags: List[String], + collective: Ident ): F[UpdateResult] = tags.distinct match { case Nil => UpdateResult.success.pure[F] - case kws => - val db = + case ws => + store.transact { (for { - _ <- OptionT(RItem.checkByIdAndCollective(item, collective)) - given <- OptionT.liftF(RTag.findAllByNameOrId(kws, collective)) - exist <- OptionT.liftF(RTagItem.findAllIn(item, given.map(_.tagId))) + itemIds <- OptionT + .liftF(RItem.filterItems(items, collective)) + .filter(_.nonEmpty) + given <- OptionT.liftF(RTag.findAllByNameOrId(ws, collective)) _ <- OptionT.liftF( - RTagItem.setAllTags(item, given.map(_.tagId).diff(exist.map(_.tagId))) + itemIds.traverse(item => + RTagItem.appendTags(item, given.map(_.tagId).toList) + ) ) } yield UpdateResult.success).getOrElse(UpdateResult.notFound) - - store.transact(db) + } } def toggleTags( @@ -169,20 +247,23 @@ object OItem { store.transact(db) } - def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult] = { - val db = for { - cid <- RItem.getCollective(item) - nd <- - if (cid.contains(collective)) RTagItem.deleteItemTags(item) - else 0.pure[ConnectionIO] - ni <- - if (tagIds.nonEmpty && cid.contains(collective)) - RTagItem.insertItemTags(item, tagIds) - else 0.pure[ConnectionIO] - } yield nd + ni + def setTags( + item: Ident, + tagIds: List[Ident], + collective: Ident + ): F[UpdateResult] = + setTagsMultipleItems(NonEmptyList.of(item), tagIds, collective) - store.transact(db).attempt.map(AddResult.fromUpdate) - } + def setTagsMultipleItems( + items: NonEmptyList[Ident], + tags: List[Ident], + collective: Ident + ): F[UpdateResult] = + UpdateResult.fromUpdate(store.transact(for { + k <- RTagItem.deleteItemTags(items, collective) + res <- items.traverse(i => RTagItem.setAllTags(i, tags)) + n = res.fold + } yield k + n)) def addNewTag(item: Ident, tag: RTag): F[AddResult] = (for { @@ -192,7 +273,7 @@ object OItem { _ <- addres match { case AddResult.Success => OptionT.liftF( - store.transact(RTagItem.insertItemTags(item, List(tag.tagId))) + store.transact(RTagItem.setAllTags(item, List(tag.tagId))) ) case AddResult.EntityExists(_) => OptionT.pure[F](0) @@ -203,33 +284,59 @@ object OItem { .getOrElse(AddResult.Failure(new Exception("Collective mismatch"))) def setDirection( - item: Ident, + items: NonEmptyList[Ident], direction: Direction, collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateDirection(item, collective, direction)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateDirection(items, collective, direction)) + ) def setFolder( item: Ident, folder: Option[Ident], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateFolder(item, collective, folder)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult + .fromUpdate( + store + .transact(RItem.updateFolder(item, collective, folder)) + ) .flatTap( onSuccessIgnoreError(fts.updateFolder(logger, item, collective, folder)) ) - def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult] = - store - .transact(RItem.updateCorrOrg(item, collective, org)) - .attempt - .map(AddResult.fromUpdate) + def setFolderMultiple( + items: NonEmptyList[Ident], + folder: Option[Ident], + collective: Ident + ): F[UpdateResult] = + for { + results <- items.traverse(i => setFolder(i, folder, collective)) + err <- results.traverse { + case UpdateResult.NotFound => + logger.info("An item was not found when updating the folder") *> 0.pure[F] + case UpdateResult.Failure(err) => + logger.error(err)("An item failed to update its folder") *> 1.pure[F] + case UpdateResult.Success => + 0.pure[F] + } + res = + if (results.size == err.fold) + UpdateResult.failure(new Exception("All items failed to update")) + else UpdateResult.success + } yield res + + def setCorrOrg( + items: NonEmptyList[Ident], + org: Option[Ident], + collective: Ident + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateCorrOrg(items, collective, org)) + ) def addCorrOrg(item: Ident, org: OOrganization.OrgAndContacts): F[AddResult] = (for { @@ -240,7 +347,11 @@ object OItem { case AddResult.Success => OptionT.liftF( store.transact( - RItem.updateCorrOrg(item, org.org.cid, Some(org.org.oid)) + RItem.updateCorrOrg( + NonEmptyList.of(item), + org.org.cid, + Some(org.org.oid) + ) ) ) case AddResult.EntityExists(_) => @@ -252,14 +363,14 @@ object OItem { .getOrElse(AddResult.Failure(new Exception("Collective mismatch"))) def setCorrPerson( - item: Ident, + items: NonEmptyList[Ident], person: Option[Ident], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateCorrPerson(item, collective, person)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateCorrPerson(items, collective, person)) + ) def addCorrPerson( item: Ident, @@ -274,7 +385,11 @@ object OItem { OptionT.liftF( store.transact( RItem - .updateCorrPerson(item, person.person.cid, Some(person.person.pid)) + .updateCorrPerson( + NonEmptyList.of(item), + person.person.cid, + Some(person.person.pid) + ) ) ) case AddResult.EntityExists(_) => @@ -286,14 +401,14 @@ object OItem { .getOrElse(AddResult.Failure(new Exception("Collective mismatch"))) def setConcPerson( - item: Ident, + items: NonEmptyList[Ident], person: Option[Ident], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateConcPerson(item, collective, person)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateConcPerson(items, collective, person)) + ) def addConcPerson( item: Ident, @@ -308,7 +423,11 @@ object OItem { OptionT.liftF( store.transact( RItem - .updateConcPerson(item, person.person.cid, Some(person.person.pid)) + .updateConcPerson( + NonEmptyList.of(item), + person.person.cid, + Some(person.person.pid) + ) ) ) case AddResult.EntityExists(_) => @@ -320,14 +439,14 @@ object OItem { .getOrElse(AddResult.Failure(new Exception("Collective mismatch"))) def setConcEquip( - item: Ident, + items: NonEmptyList[Ident], equip: Option[Ident], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateConcEquip(item, collective, equip)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateConcEquip(items, collective, equip)) + ) def addConcEquip(item: Ident, equip: REquipment): F[AddResult] = (for { @@ -338,7 +457,8 @@ object OItem { case AddResult.Success => OptionT.liftF( store.transact( - RItem.updateConcEquip(item, equip.cid, Some(equip.eid)) + RItem + .updateConcEquip(NonEmptyList.of(item), equip.cid, Some(equip.eid)) ) ) case AddResult.EntityExists(_) => @@ -353,55 +473,89 @@ object OItem { item: Ident, notes: Option[String], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateNotes(item, collective, notes)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult + .fromUpdate( + store + .transact(RItem.updateNotes(item, collective, notes)) + ) .flatTap( onSuccessIgnoreError(fts.updateItemNotes(logger, item, collective, notes)) ) - def setName(item: Ident, name: String, collective: Ident): F[AddResult] = - store - .transact(RItem.updateName(item, collective, name)) - .attempt - .map(AddResult.fromUpdate) + def setName(item: Ident, name: String, collective: Ident): F[UpdateResult] = + UpdateResult + .fromUpdate( + store + .transact(RItem.updateName(item, collective, name)) + ) .flatTap( onSuccessIgnoreError(fts.updateItemName(logger, item, collective, name)) ) - def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] = + def setNameMultiple( + items: NonEmptyList[Ident], + name: String, + collective: Ident + ): F[UpdateResult] = + for { + results <- items.traverse(i => setName(i, name, collective)) + err <- results.traverse { + case UpdateResult.NotFound => + logger.info("An item was not found when updating the name") *> 0.pure[F] + case UpdateResult.Failure(err) => + logger.error(err)("An item failed to update its name") *> 1.pure[F] + case UpdateResult.Success => + 0.pure[F] + } + res = + if (results.size == err.fold) + UpdateResult.failure(new Exception("All items failed to update")) + else UpdateResult.success + } yield res + + def setStates( + items: NonEmptyList[Ident], + state: ItemState, + collective: Ident + ): F[AddResult] = store - .transact(RItem.updateStateForCollective(item, state, collective)) + .transact(RItem.updateStateForCollective(items, state, collective)) .attempt .map(AddResult.fromUpdate) def setItemDate( - item: Ident, + items: NonEmptyList[Ident], date: Option[Timestamp], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateDate(item, collective, date)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateDate(items, collective, date)) + ) def setItemDueDate( - item: Ident, + items: NonEmptyList[Ident], date: Option[Timestamp], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateDueDate(item, collective, date)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateDueDate(items, collective, date)) + ) def deleteItem(itemId: Ident, collective: Ident): F[Int] = QItem .delete(store)(itemId, collective) .flatTap(_ => fts.removeItem(logger, itemId)) + def deleteItemMultiple(items: NonEmptyList[Ident], collective: Ident): F[Int] = + for { + itemIds <- store.transact(RItem.filterItems(items, collective)) + results <- itemIds.traverse(item => deleteItem(item, collective)) + n = results.fold(0)(_ + _) + } yield n + def getProposals(item: Ident, collective: Ident): F[MetaProposalList] = store.transact(QAttachment.getMetaProposals(item, collective)) @@ -414,11 +568,12 @@ object OItem { attachId: Ident, name: Option[String], collective: Ident - ): F[AddResult] = - store - .transact(RAttachment.updateName(attachId, collective, name)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult + .fromUpdate( + store + .transact(RAttachment.updateName(attachId, collective, name)) + ) .flatTap( onSuccessIgnoreError( OptionT(store.transact(RAttachment.findItemId(attachId))) @@ -447,6 +602,20 @@ object OItem { _ <- OptionT.liftF(if (notifyJoex) joex.notifyAllNodes else ().pure[F]) } yield UpdateResult.success).getOrElse(UpdateResult.notFound) + def reprocessAll( + items: NonEmptyList[Ident], + account: AccountId, + notifyJoex: Boolean + ): F[UpdateResult] = + UpdateResult.fromUpdate(for { + items <- store.transact(RItem.filterItems(items, account.collective)) + jobs <- items + .map(item => ReProcessItemArgs(item, Nil)) + .traverse(arg => JobFactory.reprocessItem[F](arg, account, Priority.Low)) + _ <- queue.insertAllIfNew(jobs) + _ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F] + } yield items.size) + def convertAllPdf( collective: Option[Ident], account: AccountId, @@ -458,17 +627,17 @@ object OItem { _ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F] } yield UpdateResult.success - private def onSuccessIgnoreError(update: F[Unit])(ar: AddResult): F[Unit] = + private def onSuccessIgnoreError(update: F[Unit])(ar: UpdateResult): F[Unit] = ar match { - case AddResult.Success => + case UpdateResult.Success => update.attempt.flatMap { case Right(()) => ().pure[F] case Left(ex) => logger.warn(s"Error updating full-text index: ${ex.getMessage}") } - case AddResult.Failure(_) => + case UpdateResult.Failure(_) => ().pure[F] - case AddResult.EntityExists(_) => + case UpdateResult.NotFound => ().pure[F] } }) diff --git a/modules/joex/src/main/scala/docspell/joex/process/LinkProposal.scala b/modules/joex/src/main/scala/docspell/joex/process/LinkProposal.scala index fde5fcd3..58df16ac 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/LinkProposal.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/LinkProposal.scala @@ -1,5 +1,6 @@ package docspell.joex.process +import cats.data.NonEmptyList import cats.effect.Sync import cats.implicits._ @@ -65,22 +66,38 @@ object LinkProposal { case MetaProposalType.CorrOrg => ctx.logger.debug(s"Updating item organization with: ${value.id}") *> ctx.store.transact( - RItem.updateCorrOrg(itemId, ctx.args.meta.collective, Some(value)) + RItem.updateCorrOrg( + NonEmptyList.of(itemId), + ctx.args.meta.collective, + Some(value) + ) ) case MetaProposalType.ConcPerson => ctx.logger.debug(s"Updating item concerning person with: $value") *> ctx.store.transact( - RItem.updateConcPerson(itemId, ctx.args.meta.collective, Some(value)) + RItem.updateConcPerson( + NonEmptyList.of(itemId), + ctx.args.meta.collective, + Some(value) + ) ) case MetaProposalType.CorrPerson => ctx.logger.debug(s"Updating item correspondent person with: $value") *> ctx.store.transact( - RItem.updateCorrPerson(itemId, ctx.args.meta.collective, Some(value)) + RItem.updateCorrPerson( + NonEmptyList.of(itemId), + ctx.args.meta.collective, + Some(value) + ) ) case MetaProposalType.ConcEquip => ctx.logger.debug(s"Updating item concerning equipment with: $value") *> ctx.store.transact( - RItem.updateConcEquip(itemId, ctx.args.meta.collective, Some(value)) + RItem.updateConcEquip( + NonEmptyList.of(itemId), + ctx.args.meta.collective, + Some(value) + ) ) case MetaProposalType.DocDate => MetaProposal.parseDate(value) match { @@ -88,7 +105,11 @@ object LinkProposal { val ts = Timestamp.from(ld.atStartOfDay(Timestamp.UTC)) ctx.logger.debug(s"Updating item date ${value.id}") *> ctx.store.transact( - RItem.updateDate(itemId, ctx.args.meta.collective, Some(ts)) + RItem.updateDate( + NonEmptyList.of(itemId), + ctx.args.meta.collective, + Some(ts) + ) ) case None => ctx.logger.info(s"Cannot read value '${value.id}' into a date.") *> @@ -100,7 +121,11 @@ object LinkProposal { val ts = Timestamp.from(ld.atStartOfDay(Timestamp.UTC)) ctx.logger.debug(s"Updating item due-date suggestion ${value.id}") *> ctx.store.transact( - RItem.updateDueDate(itemId, ctx.args.meta.collective, Some(ts)) + RItem.updateDueDate( + NonEmptyList.of(itemId), + ctx.args.meta.collective, + Some(ts) + ) ) case None => ctx.logger.info(s"Cannot read value '${value.id}' into a date.") *> diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 37e5c95e..2eabe73f 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1384,7 +1384,9 @@ paths: tags: [ Item ] summary: Set new set of tags. description: | - Update the tags associated to an item. + Update the tags associated to an item. This will remove all + existing ones and sets the given tags, such that after this + returns, the item has exactly the tags as given. security: - authTokenHeader: [] parameters: @@ -1845,6 +1847,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ItemProposals" + /sec/item/{itemId}/reprocess: post: tags: [ Item ] @@ -1895,6 +1898,359 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + + /sec/items/deleteAll: + post: + tags: + - Item (Multi Edit) + summary: Delete multiple items. + description: | + Given a list of item ids, deletes all of them. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IdList" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/tags: + post: + tags: + - Item (Multi Edit) + summary: Add tags to multiple items + description: | + Add the given tags to all given items. The tags that are + currently attached to the items are not changed. If there are + new tags in the given list, then they are added. Otherwise, + the item is left unchanged. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRefs" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + put: + tags: + - Item (Multi Edit) + summary: Sets tags to multiple items + description: | + Sets the given tags to all given items. If the tag list is + empty, then all tags are removed from the items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRefs" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/name: + put: + tags: + - Item (Multi Edit) + summary: Change the name of multiple items + description: | + Sets the name of multiple items at once. The name must not be + empty. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndName" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/folder: + put: + tags: + - Item (Multi Edit) + summary: Sets a folder to multiple items. + description: | + Given a folder id, sets it on all given items. If the folder + reference is not present, the folder is removed from all + items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRef" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/direction: + put: + tags: + - Item (Multi Edit) + summary: Set the direction of multiple items + description: | + Given multiple item ids and a direction value, sets it to all + items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndDirection" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/date: + put: + tags: + - Item (Multi Edit) + summary: Set the date of multiple items + description: | + Given multiple item ids and a date, sets it to all items as + the item date. If no date is present, remove the date from the + items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndDate" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/duedate: + put: + tags: + - Item (Multi Edit) + summary: Set the direction of multiple items + description: | + Given multiple item ids and a date value, sets it to all items + as the due date. If the date is missing, remove the due-date + from the items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndDate" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/corrOrg: + put: + tags: + - Item (Multi Edit) + summary: Sets an organization to multiple items. + description: | + Given an organization id, sets it on all given items. If the + organization is missing, the reference is removed from all + items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRef" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/corrPerson: + put: + tags: + - Item (Multi Edit) + summary: Sets an correspondent person to multiple items. + description: | + Given an person id, sets it on all given items as + correspondent person. If the person is missing, the reference + is removed from all items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRef" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/concPerson: + put: + tags: + - Item (Multi Edit) + summary: Sets an concerning person to multiple items. + description: | + Given an person id, sets it on all given items as concerning + person. If the person is missing, it is removed from all + items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRef" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/concEquipment: + put: + tags: + - Item (Multi Edit) + summary: Sets an equipment to multiple items. + description: | + Given an equipment id, sets it on all given items. If no + equipment is given, the reference is removed from all given + items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRef" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/confirm: + put: + tags: + - Item (Multi Edit) + summary: Confirm multiple items. + description: | + Given a list of item ids, confirm all of them. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IdList" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/unconfirm: + put: + tags: + - Item (Multi Edit) + summary: Un-confirm multiple items. + description: | + Given a list of item ids, un-confirm all of them. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IdList" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/reprocess: + post: + tags: + - Item (Multi Edit) + summary: Submit multiple items to re-processing + description: | + Given a list of item-ids, submits all these items for + reprocessing. All attachments of these items will be + reprocessed. Item metadata is not changed. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IdList" + + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/attachment/{id}: delete: tags: [ Attachment ] @@ -2702,6 +3058,84 @@ paths: components: schemas: + ItemsAndRefs: + description: | + Holds a list of item ids and a list of ids of some other + related entity (e.g. tags). + required: + - items + - refs + properties: + items: + type: array + items: + type: string + format: ident + refs: + type: array + items: + type: string + format: ident + ItemsAndRef: + description: | + Holds a list of item ids and a single optional id of some + other related entity (e.g. person, org). + required: + - items + properties: + items: + type: array + items: + type: string + format: ident + ref: + type: string + format: ident + ItemsAndName: + description: | + Holds a list of item ids and an item name. + required: + - items + - name + properties: + items: + type: array + items: + type: string + format: ident + name: + type: string + ItemsAndDirection: + description: | + Holds a list of item ids and a direction value. + required: + - items + - direction + properties: + items: + type: array + items: + type: string + format: ident + direction: + type: string + format: direction + ItemsAndDate: + description: | + Holds a list of item ids and a date value. + required: + - items + properties: + items: + type: array + items: + type: string + format: ident + date: + type: integer + format: date-time + + JobPriority: description: | Transfer the priority of a job. @@ -3828,7 +4262,7 @@ components: format: date-time ReferenceList: description: - Listing of items. + Listing of entities with their id and a name. required: - items properties: @@ -4077,6 +4511,8 @@ components: dueDateUntil: type: integer format: date-time + itemSubset: + $ref: "#/components/schemas/IdList" ItemLight: description: | An item with only a few important properties. diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 6d856522..de4dfbfb 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -73,6 +73,7 @@ object RestServer { "collective" -> CollectiveRoutes(restApp.backend, token), "queue" -> JobQueueRoutes(restApp.backend, token), "item" -> ItemRoutes(cfg, restApp.backend, token), + "items" -> ItemMultiRoutes(restApp.backend, token), "attachment" -> AttachmentRoutes(restApp.backend, token), "upload" -> UploadRoutes.secured(restApp.backend, cfg, token), "checkfile" -> CheckFileRoutes.secured(restApp.backend, token), diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 1def28d4..4e872606 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -134,7 +134,9 @@ trait Conversions { m.dueDateFrom, m.dueDateUntil, m.allNames, - None, + m.itemSubset + .map(_.ids.flatMap(i => Ident.fromString(i).toOption).toSet) + .filter(_.nonEmpty), None ) diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala new file mode 100644 index 00000000..3cb50f6e --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -0,0 +1,203 @@ +package docspell.restserver.routes + +import cats.ApplicativeError +import cats.MonadError +import cats.data.NonEmptyList +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.common.{Ident, ItemState} +import docspell.restapi.model._ +import docspell.restserver.conv.Conversions + +import io.circe.DecodingFailure +import org.http4s.HttpRoutes +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +object ItemMultiRoutes { + + def apply[F[_]: Effect]( + backend: BackendApp[F], + user: AuthToken + ): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case req @ PUT -> Root / "confirm" => + for { + json <- req.as[IdList] + data <- readIds[F](json.ids) + res <- backend.item.setStates( + data, + ItemState.Confirmed, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Item data confirmed")) + } yield resp + + case req @ PUT -> Root / "unconfirm" => + for { + json <- req.as[IdList] + data <- readIds[F](json.ids) + res <- backend.item.setStates( + data, + ItemState.Created, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Item back to created.")) + } yield resp + + case req @ PUT -> Root / "tags" => + for { + json <- req.as[ItemsAndRefs] + items <- readIds[F](json.items) + tags <- json.refs.traverse(readId[F]) + res <- backend.item.setTagsMultipleItems(items, tags, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Tags updated")) + } yield resp + + case req @ POST -> Root / "tags" => + for { + json <- req.as[ItemsAndRefs] + items <- readIds[F](json.items) + res <- backend.item.linkTagsMultipleItems( + items, + json.refs, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Tags added.")) + } yield resp + + case req @ PUT -> Root / "name" => + for { + json <- req.as[ItemsAndName] + items <- readIds[F](json.items) + res <- backend.item.setNameMultiple( + items, + json.name.notEmpty.getOrElse(""), + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Name updated")) + } yield resp + + case req @ PUT -> Root / "folder" => + for { + json <- req.as[ItemsAndRef] + items <- readIds[F](json.items) + res <- backend.item.setFolderMultiple(items, json.ref, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Folder updated")) + } yield resp + + case req @ PUT -> Root / "direction" => + for { + json <- req.as[ItemsAndDirection] + items <- readIds[F](json.items) + res <- backend.item.setDirection(items, json.direction, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Direction updated")) + } yield resp + + case req @ PUT -> Root / "date" => + for { + json <- req.as[ItemsAndDate] + items <- readIds[F](json.items) + res <- backend.item.setItemDate(items, json.date, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Item date updated")) + } yield resp + + case req @ PUT -> Root / "duedate" => + for { + json <- req.as[ItemsAndDate] + items <- readIds[F](json.items) + res <- backend.item.setItemDueDate(items, json.date, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Item due date updated")) + } yield resp + + case req @ PUT -> Root / "corrOrg" => + for { + json <- req.as[ItemsAndRef] + items <- readIds[F](json.items) + res <- backend.item.setCorrOrg(items, json.ref, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Correspondent organization updated")) + } yield resp + + case req @ PUT -> Root / "corrPerson" => + for { + json <- req.as[ItemsAndRef] + items <- readIds[F](json.items) + res <- backend.item.setCorrPerson(items, json.ref, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Correspondent person updated")) + } yield resp + + case req @ PUT -> Root / "concPerson" => + for { + json <- req.as[ItemsAndRef] + items <- readIds[F](json.items) + res <- backend.item.setConcPerson(items, json.ref, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Concerned person updated")) + } yield resp + + case req @ PUT -> Root / "concEquipment" => + for { + json <- req.as[ItemsAndRef] + items <- readIds[F](json.items) + res <- backend.item.setConcEquip(items, json.ref, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) + } yield resp + + case req @ POST -> Root / "reprocess" => + for { + json <- req.as[IdList] + items <- readIds[F](json.ids) + res <- backend.item.reprocessAll(items, user.account, true) + resp <- Ok(Conversions.basicResult(res, "Re-process task(s) submitted.")) + } yield resp + + case req @ POST -> Root / "deleteAll" => + for { + json <- req.as[IdList] + items <- readIds[F](json.ids) + n <- backend.item.deleteItemMultiple(items, user.account.collective) + res = BasicResult( + n > 0, + if (n > 0) "Item(s) deleted" else "Item deletion failed." + ) + resp <- Ok(res) + } yield resp + } + } + + implicit final class OptionString(opt: Option[String]) { + def notEmpty: Option[String] = + opt.map(_.trim).filter(_.nonEmpty) + } + implicit final class StringOps(str: String) { + def notEmpty: Option[String] = + Option(str).notEmpty + } + + private def readId[F[_]]( + id: String + )(implicit F: ApplicativeError[F, Throwable]): F[Ident] = + Ident + .fromString(id) + .fold( + err => F.raiseError(DecodingFailure(err, Nil)), + F.pure + ) + + private def readIds[F[_]](ids: List[String])(implicit + F: MonadError[F, Throwable] + ): F[NonEmptyList[Ident]] = + ids.traverse(readId[F]).map(NonEmptyList.fromList).flatMap { + case Some(nel) => nel.pure[F] + case None => + F.raiseError( + DecodingFailure("Empty list found, at least one element required", Nil) + ) + } +} 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 a033791d..1966a6f1 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -1,5 +1,6 @@ package docspell.restserver.routes +import cats.data.NonEmptyList import cats.effect._ import cats.implicits._ @@ -165,8 +166,12 @@ object ItemRoutes { case req @ PUT -> Root / Ident(id) / "direction" => for { - dir <- req.as[DirectionValue] - res <- backend.item.setDirection(id, dir.direction, user.account.collective) + dir <- req.as[DirectionValue] + res <- backend.item.setDirection( + NonEmptyList.of(id), + dir.direction, + user.account.collective + ) resp <- Ok(Conversions.basicResult(res, "Direction updated")) } yield resp @@ -180,8 +185,12 @@ object ItemRoutes { case req @ PUT -> Root / Ident(id) / "corrOrg" => for { idref <- req.as[OptionalId] - res <- backend.item.setCorrOrg(id, idref.id, user.account.collective) - resp <- Ok(Conversions.basicResult(res, "Correspondent organization updated")) + res <- backend.item.setCorrOrg( + NonEmptyList.of(id), + idref.id, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Correspondent organization updated")) } yield resp case req @ POST -> Root / Ident(id) / "corrOrg" => @@ -195,8 +204,12 @@ object ItemRoutes { case req @ PUT -> Root / Ident(id) / "corrPerson" => for { idref <- req.as[OptionalId] - res <- backend.item.setCorrPerson(id, idref.id, user.account.collective) - resp <- Ok(Conversions.basicResult(res, "Correspondent person updated")) + res <- backend.item.setCorrPerson( + NonEmptyList.of(id), + idref.id, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Correspondent person updated")) } yield resp case req @ POST -> Root / Ident(id) / "corrPerson" => @@ -210,8 +223,12 @@ object ItemRoutes { case req @ PUT -> Root / Ident(id) / "concPerson" => for { idref <- req.as[OptionalId] - res <- backend.item.setConcPerson(id, idref.id, user.account.collective) - resp <- Ok(Conversions.basicResult(res, "Concerned person updated")) + res <- backend.item.setConcPerson( + NonEmptyList.of(id), + idref.id, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Concerned person updated")) } yield resp case req @ POST -> Root / Ident(id) / "concPerson" => @@ -225,8 +242,12 @@ object ItemRoutes { case req @ PUT -> Root / Ident(id) / "concEquipment" => for { idref <- req.as[OptionalId] - res <- backend.item.setConcEquip(id, idref.id, user.account.collective) - resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) + res <- backend.item.setConcEquip( + NonEmptyList.of(id), + idref.id, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) } yield resp case req @ POST -> Root / Ident(id) / "concEquipment" => @@ -259,7 +280,11 @@ object ItemRoutes { for { date <- req.as[OptionalDate] _ <- logger.fdebug(s"Setting item due date to ${date.date}") - res <- backend.item.setItemDueDate(id, date.date, user.account.collective) + res <- backend.item.setItemDueDate( + NonEmptyList.of(id), + date.date, + user.account.collective + ) resp <- Ok(Conversions.basicResult(res, "Item due date updated")) } yield resp @@ -267,7 +292,11 @@ object ItemRoutes { for { date <- req.as[OptionalDate] _ <- logger.fdebug(s"Setting item date to ${date.date}") - res <- backend.item.setItemDate(id, date.date, user.account.collective) + res <- backend.item.setItemDate( + NonEmptyList.of(id), + date.date, + user.account.collective + ) resp <- Ok(Conversions.basicResult(res, "Item date updated")) } yield resp 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 87253163..4dec4d6c 100644 --- a/modules/store/src/main/scala/docspell/store/impl/Column.scala +++ b/modules/store/src/main/scala/docspell/store/impl/Column.scala @@ -57,7 +57,12 @@ case class Column(name: String, ns: String = "", alias: String = "") { f ++ fr"IN (" ++ commas(values) ++ fr")" def isIn[A: Put](values: NonEmptyList[A]): Fragment = - isIn(values.map(a => sql"$a").toList) + values.tail match { + case Nil => + is(values.head) + case _ => + isIn(values.map(a => sql"$a").toList) + } def isLowerIn[A: Put](values: NonEmptyList[A]): Fragment = fr"lower(" ++ f ++ fr") IN (" ++ commas(values.map(a => sql"$a").toList) ++ fr")" diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index a0025ddb..a023e136 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -132,7 +132,7 @@ object RItem { } yield n def updateStateForCollective( - itemId: Ident, + itemIds: NonEmptyList[Ident], itemState: ItemState, coll: Ident ): ConnectionIO[Int] = @@ -140,27 +140,35 @@ object RItem { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(state.setTo(itemState), updated.setTo(t)) ).update.run } yield n - def updateDirection(itemId: Ident, coll: Ident, dir: Direction): ConnectionIO[Int] = + def updateDirection( + itemIds: NonEmptyList[Ident], + coll: Ident, + dir: Direction + ): ConnectionIO[Int] = for { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(incoming.setTo(dir), updated.setTo(t)) ).update.run } yield n - def updateCorrOrg(itemId: Ident, coll: Ident, org: Option[Ident]): ConnectionIO[Int] = + def updateCorrOrg( + itemIds: NonEmptyList[Ident], + coll: Ident, + org: Option[Ident] + ): ConnectionIO[Int] = for { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(corrOrg.setTo(org), updated.setTo(t)) ).update.run } yield n @@ -176,7 +184,7 @@ object RItem { } yield n def updateCorrPerson( - itemId: Ident, + itemIds: NonEmptyList[Ident], coll: Ident, person: Option[Ident] ): ConnectionIO[Int] = @@ -184,7 +192,7 @@ object RItem { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(corrPerson.setTo(person), updated.setTo(t)) ).update.run } yield n @@ -200,7 +208,7 @@ object RItem { } yield n def updateConcPerson( - itemId: Ident, + itemIds: NonEmptyList[Ident], coll: Ident, person: Option[Ident] ): ConnectionIO[Int] = @@ -208,7 +216,7 @@ object RItem { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(concPerson.setTo(person), updated.setTo(t)) ).update.run } yield n @@ -224,7 +232,7 @@ object RItem { } yield n def updateConcEquip( - itemId: Ident, + itemIds: NonEmptyList[Ident], coll: Ident, equip: Option[Ident] ): ConnectionIO[Int] = @@ -232,7 +240,7 @@ object RItem { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(concEquipment.setTo(equip), updated.setTo(t)) ).update.run } yield n @@ -281,18 +289,8 @@ object RItem { ).update.run } yield n - def updateDate(itemId: Ident, coll: Ident, date: Option[Timestamp]): ConnectionIO[Int] = - for { - t <- currentTime - n <- updateRow( - table, - and(id.is(itemId), cid.is(coll)), - commas(itemDate.setTo(date), updated.setTo(t)) - ).update.run - } yield n - - def updateDueDate( - itemId: Ident, + def updateDate( + itemIds: NonEmptyList[Ident], coll: Ident, date: Option[Timestamp] ): ConnectionIO[Int] = @@ -300,7 +298,21 @@ object RItem { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), + commas(itemDate.setTo(date), updated.setTo(t)) + ).update.run + } yield n + + def updateDueDate( + itemIds: NonEmptyList[Ident], + coll: Ident, + date: Option[Timestamp] + ): ConnectionIO[Int] = + for { + t <- currentTime + n <- updateRow( + table, + and(id.isIn(itemIds), cid.is(coll)), commas(dueDate.setTo(date), updated.setTo(t)) ).update.run } yield n @@ -324,4 +336,10 @@ object RItem { val empty: Option[Ident] = None updateRow(table, folder.is(folderId), folder.setTo(empty)).update.run } + + def filterItemsFragment(items: NonEmptyList[Ident], coll: Ident): Fragment = + selectSimple(Seq(id), table, and(cid.is(coll), id.isIn(items))) + + def filterItems(items: NonEmptyList[Ident], coll: Ident): ConnectionIO[Vector[Ident]] = + filterItemsFragment(items, coll).query[Ident].to[Vector] } diff --git a/modules/store/src/main/scala/docspell/store/records/RTagItem.scala b/modules/store/src/main/scala/docspell/store/records/RTagItem.scala index 706e64b4..c9aad9db 100644 --- a/modules/store/src/main/scala/docspell/store/records/RTagItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RTagItem.scala @@ -30,18 +30,17 @@ object RTagItem { def deleteItemTags(item: Ident): ConnectionIO[Int] = deleteFrom(table, itemId.is(item)).update.run + def deleteItemTags(items: NonEmptyList[Ident], cid: Ident): ConnectionIO[Int] = { + val itemsFiltered = + RItem.filterItemsFragment(items, cid) + val sql = fr"DELETE FROM" ++ table ++ fr"WHERE" ++ itemId.isIn(itemsFiltered) + + sql.update.run + } + def deleteTag(tid: Ident): ConnectionIO[Int] = deleteFrom(table, tagId.is(tid)).update.run - def insertItemTags(item: Ident, tags: Seq[Ident]): ConnectionIO[Int] = - for { - tagValues <- tags.toList.traverse(id => - Ident.randomId[ConnectionIO].map(rid => RTagItem(rid, item, id)) - ) - tagFrag = tagValues.map(v => fr"${v.tagItemId},${v.itemId},${v.tagId}") - ins <- insertRows(table, all, tagFrag).update.run - } yield ins - def findByItem(item: Ident): ConnectionIO[Vector[RTagItem]] = selectSimple(all, table, itemId.is(item)).query[RTagItem].to[Vector] @@ -76,4 +75,12 @@ object RTagItem { entities.map(v => fr"${v.tagItemId},${v.itemId},${v.tagId}") ).update.run } yield n + + def appendTags(item: Ident, tags: List[Ident]): ConnectionIO[Int] = + for { + existing <- findByItem(item) + toadd = tags.toSet.diff(existing.map(_.tagId).toSet) + n <- setAllTags(item, toadd.toSeq) + } yield n + } diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index c90865ab..77ee3c20 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -5,6 +5,7 @@ module Api exposing , addCorrPerson , addMember , addTag + , addTagsMultiple , cancelJob , changeFolderName , changePassword @@ -14,6 +15,7 @@ module Api exposing , createNewFolder , createNotifyDueItems , createScanMailbox + , deleteAllItems , deleteAttachment , deleteEquip , deleteFolder @@ -76,18 +78,28 @@ module Api exposing , setAttachmentName , setCollectiveSettings , setConcEquip + , setConcEquipmentMultiple , setConcPerson + , setConcPersonMultiple , setConfirmed , setCorrOrg + , setCorrOrgMultiple , setCorrPerson + , setCorrPersonMultiple + , setDateMultiple , setDirection + , setDirectionMultiple + , setDueDateMultiple , setFolder + , setFolderMultiple , setItemDate , setItemDueDate , setItemName , setItemNotes , setJobPrio + , setNameMultiple , setTags + , setTagsMultiple , setUnconfirmed , startClassifier , startOnceNotifyDueItems @@ -119,6 +131,7 @@ import Api.Model.EquipmentList exposing (EquipmentList) import Api.Model.FolderDetail exposing (FolderDetail) import Api.Model.FolderList exposing (FolderList) import Api.Model.GenInvite exposing (GenInvite) +import Api.Model.IdList exposing (IdList) import Api.Model.IdResult exposing (IdResult) import Api.Model.ImapSettings exposing (ImapSettings) import Api.Model.ImapSettingsList exposing (ImapSettingsList) @@ -130,6 +143,11 @@ import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) +import Api.Model.ItemsAndDate exposing (ItemsAndDate) +import Api.Model.ItemsAndDirection exposing (ItemsAndDirection) +import Api.Model.ItemsAndName exposing (ItemsAndName) +import Api.Model.ItemsAndRef exposing (ItemsAndRef) +import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) import Api.Model.JobPriority exposing (JobPriority) import Api.Model.JobQueueState exposing (JobQueueState) import Api.Model.MoveAttachment exposing (MoveAttachment) @@ -166,6 +184,7 @@ import Data.Priority exposing (Priority) import File exposing (File) import Http import Json.Encode as JsonEncode +import Set exposing (Set) import Task import Url import Util.File @@ -1262,6 +1281,178 @@ getJobQueueStateTask flags = +--- Item (Mulit Edit) + + +setTagsMultiple : + Flags + -> ItemsAndRefs + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setTagsMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/tags" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRefs.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +addTagsMultiple : + Flags + -> ItemsAndRefs + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +addTagsMultiple flags data receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/items/tags" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRefs.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setNameMultiple : + Flags + -> ItemsAndName + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setNameMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/name" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndName.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setFolderMultiple : + Flags + -> ItemsAndRef + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setFolderMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/folder" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRef.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setDirectionMultiple : + Flags + -> ItemsAndDirection + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setDirectionMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/direction" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndDirection.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setDateMultiple : + Flags + -> ItemsAndDate + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setDateMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/date" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndDate.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setDueDateMultiple : + Flags + -> ItemsAndDate + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setDueDateMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/duedate" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndDate.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setCorrOrgMultiple : + Flags + -> ItemsAndRef + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setCorrOrgMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/corrOrg" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRef.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setCorrPersonMultiple : + Flags + -> ItemsAndRef + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setCorrPersonMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/corrPerson" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRef.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setConcPersonMultiple : + Flags + -> ItemsAndRef + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setConcPersonMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/concPerson" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRef.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setConcEquipmentMultiple : + Flags + -> ItemsAndRef + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setConcEquipmentMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/concEquipment" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRef.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +deleteAllItems : + Flags + -> Set String + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +deleteAllItems flags ids receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/items/deleteAll" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.IdList.encode (IdList (Set.toList ids))) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + + --- Item diff --git a/modules/webapp/src/main/elm/Comp/ItemCardList.elm b/modules/webapp/src/main/elm/Comp/ItemCardList.elm index 945febf5..b2b110ec 100644 --- a/modules/webapp/src/main/elm/Comp/ItemCardList.elm +++ b/modules/webapp/src/main/elm/Comp/ItemCardList.elm @@ -1,6 +1,7 @@ module Comp.ItemCardList exposing ( Model , Msg(..) + , ViewConfig , init , nextItem , prevItem @@ -17,12 +18,16 @@ import Data.Direction import Data.Fields import Data.Flags exposing (Flags) import Data.Icons as Icons +import Data.ItemSelection exposing (ItemSelection) import Data.Items import Data.UiSettings exposing (UiSettings) import Html exposing (..) import Html.Attributes exposing (..) +import Html.Events exposing (onClick) import Markdown import Page exposing (Page(..)) +import Set exposing (Set) +import Util.Html import Util.ItemDragDrop as DD import Util.List import Util.String @@ -38,6 +43,7 @@ type Msg = SetResults ItemLightList | AddResults ItemLightList | ItemDDMsg DD.Msg + | ToggleSelectItem (Set String) String init : Model @@ -75,6 +81,7 @@ type alias UpdateResult = { model : Model , cmd : Cmd Msg , dragModel : DD.Model + , selection : ItemSelection } @@ -91,51 +98,78 @@ updateDrag dm _ msg model = newModel = { model | results = list } in - UpdateResult newModel Cmd.none dm + UpdateResult newModel Cmd.none dm Data.ItemSelection.Inactive AddResults list -> if list.groups == [] then - UpdateResult model Cmd.none dm + UpdateResult model Cmd.none dm Data.ItemSelection.Inactive else let newModel = { model | results = Data.Items.concat model.results list } in - UpdateResult newModel Cmd.none dm + UpdateResult newModel Cmd.none dm Data.ItemSelection.Inactive ItemDDMsg lm -> let ddd = DD.update lm dm in - UpdateResult model Cmd.none ddd.model + UpdateResult model Cmd.none ddd.model Data.ItemSelection.Inactive + + ToggleSelectItem ids id -> + let + newSet = + if Set.member id ids then + Set.remove id ids + + else + Set.insert id ids + in + UpdateResult model Cmd.none dm (Data.ItemSelection.Active newSet) --- View -view : Maybe String -> UiSettings -> Model -> Html Msg -view current settings model = +type alias ViewConfig = + { current : Maybe String + , selection : ItemSelection + } + + +isSelected : ViewConfig -> String -> Bool +isSelected cfg id = + case cfg.selection of + Data.ItemSelection.Active ids -> + Set.member id ids + + Data.ItemSelection.Inactive -> + False + + +view : ViewConfig -> UiSettings -> Model -> Html Msg +view cfg settings model = div [ class "ui container" ] - (List.map (viewGroup current settings) model.results.groups) + (List.map (viewGroup cfg settings) model.results.groups) -viewGroup : Maybe String -> UiSettings -> ItemLightGroup -> Html Msg -viewGroup current settings group = +viewGroup : ViewConfig -> UiSettings -> ItemLightGroup -> Html Msg +viewGroup cfg settings group = div [ class "item-group" ] [ div [ class "ui horizontal divider header item-list" ] [ i [ class "calendar alternate outline icon" ] [] , text group.name ] , div [ class "ui stackable three cards" ] - (List.map (viewItem current settings) group.items) + (List.map (viewItem cfg settings) group.items) ] -viewItem : Maybe String -> UiSettings -> ItemLight -> Html Msg -viewItem current settings item = +viewItem : ViewConfig -> UiSettings -> ItemLight -> Html Msg +viewItem cfg settings item = let dirIcon = i [ class (Data.Direction.iconFromMaybe item.direction) ] [] @@ -163,43 +197,69 @@ viewItem current settings item = isConfirmed = item.state /= "created" - newColor = - "blue" + cardColor = + if isSelected cfg item.id then + "purple" + + else if not isConfirmed then + "blue" + + else + "" fieldHidden f = Data.UiSettings.fieldHidden settings f + + cardAction = + case cfg.selection of + Data.ItemSelection.Inactive -> + Page.href (ItemDetailPage item.id) + + Data.ItemSelection.Active ids -> + onClick (ToggleSelectItem ids item.id) in a ([ classList [ ( "ui fluid card", True ) - , ( newColor, not isConfirmed ) - , ( "current", current == Just item.id ) + , ( cardColor, True ) + , ( "current", cfg.current == Just item.id ) ] , id item.id - , Page.href (ItemDetailPage item.id) + , href "#" + , cardAction ] ++ DD.draggable ItemDDMsg item.id ) [ div [ class "content" ] - [ if fieldHidden Data.Fields.Direction then - div [ class "header" ] - [ Util.String.underscoreToSpace item.name |> text - ] + [ case cfg.selection of + Data.ItemSelection.Active ids -> + div [ class "header" ] + [ Util.Html.checkbox (Set.member item.id ids) + , dirIcon + , Util.String.underscoreToSpace item.name + |> text + ] - else - div - [ class "header" - , Data.Direction.labelFromMaybe item.direction - |> title - ] - [ dirIcon - , Util.String.underscoreToSpace item.name - |> text - ] + Data.ItemSelection.Inactive -> + if fieldHidden Data.Fields.Direction then + div [ class "header" ] + [ Util.String.underscoreToSpace item.name |> text + ] + + else + div + [ class "header" + , Data.Direction.labelFromMaybe item.direction + |> title + ] + [ dirIcon + , Util.String.underscoreToSpace item.name + |> text + ] , div [ classList [ ( "ui right corner label", True ) - , ( newColor, True ) + , ( cardColor, True ) , ( "invisible", isConfirmed ) ] , title "New" diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm new file mode 100644 index 00000000..561f1ef8 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm @@ -0,0 +1,693 @@ +module Comp.ItemDetail.EditMenu exposing + ( Model + , Msg + , SaveNameState(..) + , defaultViewConfig + , init + , loadModel + , update + , view + ) + +import Api +import Api.Model.EquipmentList exposing (EquipmentList) +import Api.Model.FolderItem exposing (FolderItem) +import Api.Model.FolderList exposing (FolderList) +import Api.Model.IdName exposing (IdName) +import Api.Model.ItemProposals exposing (ItemProposals) +import Api.Model.ReferenceList exposing (ReferenceList) +import Api.Model.Tag exposing (Tag) +import Api.Model.TagList exposing (TagList) +import Comp.DatePicker +import Comp.DetailEdit +import Comp.Dropdown exposing (isDropdownChangeMsg) +import Comp.ItemDetail.FormChange exposing (FormChange(..)) +import Data.Direction exposing (Direction) +import Data.Fields +import Data.Flags exposing (Flags) +import Data.Icons as Icons +import Data.UiSettings exposing (UiSettings) +import DatePicker exposing (DatePicker) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput) +import Http +import Markdown +import Page exposing (Page(..)) +import Task +import Throttle exposing (Throttle) +import Time +import Util.Folder exposing (mkFolderOption) +import Util.List +import Util.Maybe +import Util.Tag + + + +--- Model + + +type SaveNameState + = Saving + | SaveSuccess + | SaveFailed + + +type alias Model = + { tagModel : Comp.Dropdown.Model Tag + , nameModel : String + , nameSaveThrottle : Throttle Msg + , folderModel : Comp.Dropdown.Model IdName + , allFolders : List FolderItem + , directionModel : Comp.Dropdown.Model Direction + , itemDatePicker : DatePicker + , itemDate : Maybe Int + , itemProposals : ItemProposals + , dueDate : Maybe Int + , dueDatePicker : DatePicker + , corrOrgModel : Comp.Dropdown.Model IdName + , corrPersonModel : Comp.Dropdown.Model IdName + , concPersonModel : Comp.Dropdown.Model IdName + , concEquipModel : Comp.Dropdown.Model IdName + , modalEdit : Maybe Comp.DetailEdit.Model + } + + +type Msg + = ItemDatePickerMsg Comp.DatePicker.Msg + | DueDatePickerMsg Comp.DatePicker.Msg + | SetName String + | SaveName + | UpdateThrottle + | RemoveDueDate + | RemoveDate + | FolderDropdownMsg (Comp.Dropdown.Msg IdName) + | TagDropdownMsg (Comp.Dropdown.Msg Tag) + | DirDropdownMsg (Comp.Dropdown.Msg Direction) + | OrgDropdownMsg (Comp.Dropdown.Msg IdName) + | CorrPersonMsg (Comp.Dropdown.Msg IdName) + | ConcPersonMsg (Comp.Dropdown.Msg IdName) + | ConcEquipMsg (Comp.Dropdown.Msg IdName) + | GetTagsResp (Result Http.Error TagList) + | GetOrgResp (Result Http.Error ReferenceList) + | GetPersonResp (Result Http.Error ReferenceList) + | GetEquipResp (Result Http.Error EquipmentList) + | GetFolderResp (Result Http.Error FolderList) + + +init : Model +init = + { tagModel = + Util.Tag.makeDropdownModel + , directionModel = + Comp.Dropdown.makeSingleList + { makeOption = + \entry -> + { value = Data.Direction.toString entry + , text = Data.Direction.toString entry + , additional = "" + } + , options = Data.Direction.all + , placeholder = "Choose a direction…" + , selected = Nothing + } + , corrOrgModel = + Comp.Dropdown.makeSingle + { makeOption = \e -> { value = e.id, text = e.name, additional = "" } + , placeholder = "" + } + , corrPersonModel = + Comp.Dropdown.makeSingle + { makeOption = \e -> { value = e.id, text = e.name, additional = "" } + , placeholder = "" + } + , concPersonModel = + Comp.Dropdown.makeSingle + { makeOption = \e -> { value = e.id, text = e.name, additional = "" } + , placeholder = "" + } + , concEquipModel = + Comp.Dropdown.makeSingle + { makeOption = \e -> { value = e.id, text = e.name, additional = "" } + , placeholder = "" + } + , folderModel = + Comp.Dropdown.makeSingle + { makeOption = \e -> { value = e.id, text = e.name, additional = "" } + , placeholder = "" + } + , allFolders = [] + , nameModel = "" + , nameSaveThrottle = Throttle.create 1 + , itemDatePicker = Comp.DatePicker.emptyModel + , itemDate = Nothing + , itemProposals = Api.Model.ItemProposals.empty + , dueDate = Nothing + , dueDatePicker = Comp.DatePicker.emptyModel + , modalEdit = Nothing + } + + +loadModel : Flags -> Cmd Msg +loadModel flags = + let + ( _, dpc ) = + Comp.DatePicker.init + in + Cmd.batch + [ Api.getTags flags "" GetTagsResp + , Api.getOrgLight flags GetOrgResp + , Api.getPersonsLight flags GetPersonResp + , Api.getEquipments flags "" GetEquipResp + , Api.getFolders flags "" False GetFolderResp + , Cmd.map ItemDatePickerMsg dpc + , Cmd.map DueDatePickerMsg dpc + ] + + +isFolderMember : Model -> Bool +isFolderMember model = + let + selected = + Comp.Dropdown.getSelected model.folderModel + |> List.head + |> Maybe.map .id + in + Util.Folder.isFolderMember model.allFolders selected + + + +--- Update + + +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , sub : Sub Msg + , change : FormChange + } + + +resultNoCmd : FormChange -> Model -> UpdateResult +resultNoCmd change model = + UpdateResult model Cmd.none Sub.none change + + +resultNone : Model -> UpdateResult +resultNone model = + resultNoCmd NoFormChange model + + +update : Flags -> Msg -> Model -> UpdateResult +update flags msg model = + case msg of + TagDropdownMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.tagModel + + newModel = + { model | tagModel = m2 } + + change = + if isDropdownChangeMsg m then + Comp.Dropdown.getSelected newModel.tagModel + |> Util.List.distinct + |> List.map (\t -> IdName t.id t.name) + |> ReferenceList + |> TagChange + + else + NoFormChange + in + resultNoCmd change newModel + + GetTagsResp (Ok tags) -> + let + tagList = + Comp.Dropdown.SetOptions tags.items + in + update flags (TagDropdownMsg tagList) model + + GetTagsResp (Err _) -> + resultNone model + + FolderDropdownMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.folderModel + + newModel = + { model | folderModel = m2 } + + idref = + Comp.Dropdown.getSelected m2 |> List.head + + change = + if isDropdownChangeMsg m then + FolderChange idref + + else + NoFormChange + in + resultNoCmd change newModel + + GetFolderResp (Ok fs) -> + let + model_ = + { model + | allFolders = fs.items + , folderModel = + Comp.Dropdown.setMkOption + (mkFolderOption flags fs.items) + model.folderModel + } + + mkIdName fitem = + IdName fitem.id fitem.name + + opts = + fs.items + |> List.map mkIdName + |> Comp.Dropdown.SetOptions + in + update flags (FolderDropdownMsg opts) model_ + + GetFolderResp (Err _) -> + resultNone model + + DirDropdownMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.directionModel + + newModel = + { model | directionModel = m2 } + + change = + if isDropdownChangeMsg m then + let + dir = + Comp.Dropdown.getSelected m2 |> List.head + in + case dir of + Just d -> + DirectionChange d + + Nothing -> + NoFormChange + + else + NoFormChange + in + resultNoCmd change newModel + + OrgDropdownMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.corrOrgModel + + newModel = + { model | corrOrgModel = m2 } + + idref = + Comp.Dropdown.getSelected m2 |> List.head + + change = + if isDropdownChangeMsg m then + OrgChange idref + + else + NoFormChange + in + resultNoCmd change newModel + + GetOrgResp (Ok orgs) -> + let + opts = + Comp.Dropdown.SetOptions orgs.items + in + update flags (OrgDropdownMsg opts) model + + GetOrgResp (Err _) -> + resultNone model + + CorrPersonMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.corrPersonModel + + newModel = + { model | corrPersonModel = m2 } + + idref = + Comp.Dropdown.getSelected m2 |> List.head + + change = + if isDropdownChangeMsg m then + CorrPersonChange idref + + else + NoFormChange + in + resultNoCmd change newModel + + ConcPersonMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.concPersonModel + + newModel = + { model | concPersonModel = m2 } + + idref = + Comp.Dropdown.getSelected m2 |> List.head + + change = + if isDropdownChangeMsg m then + ConcPersonChange idref + + else + NoFormChange + in + resultNoCmd change newModel + + GetPersonResp (Ok ps) -> + let + opts = + Comp.Dropdown.SetOptions ps.items + + res1 = + update flags (CorrPersonMsg opts) model + + res2 = + update flags (ConcPersonMsg opts) res1.model + in + res2 + + GetPersonResp (Err _) -> + resultNone model + + ConcEquipMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.concEquipModel + + newModel = + { model | concEquipModel = m2 } + + idref = + Comp.Dropdown.getSelected m2 |> List.head + + change = + if isDropdownChangeMsg m then + EquipChange idref + + else + NoFormChange + in + resultNoCmd change newModel + + GetEquipResp (Ok equips) -> + let + opts = + Comp.Dropdown.SetOptions + (List.map (\e -> IdName e.id e.name) + equips.items + ) + in + update flags (ConcEquipMsg opts) model + + GetEquipResp (Err _) -> + resultNone model + + ItemDatePickerMsg m -> + let + ( dp, event ) = + Comp.DatePicker.updateDefault m model.itemDatePicker + in + case event of + DatePicker.Picked date -> + let + newModel = + { model | itemDatePicker = dp, itemDate = Just (Comp.DatePicker.midOfDay date) } + in + resultNoCmd (ItemDateChange newModel.itemDate) newModel + + _ -> + resultNone { model | itemDatePicker = dp } + + RemoveDate -> + resultNoCmd (ItemDateChange Nothing) { model | itemDate = Nothing } + + DueDatePickerMsg m -> + let + ( dp, event ) = + Comp.DatePicker.updateDefault m model.dueDatePicker + in + case event of + DatePicker.Picked date -> + let + newModel = + { model | dueDatePicker = dp, dueDate = Just (Comp.DatePicker.midOfDay date) } + in + resultNoCmd (DueDateChange newModel.dueDate) newModel + + _ -> + resultNone { model | dueDatePicker = dp } + + RemoveDueDate -> + resultNoCmd (DueDateChange Nothing) { model | dueDate = Nothing } + + SetName str -> + case Util.Maybe.fromString str of + Just newName -> + let + cmd_ = + Task.succeed () + |> Task.perform (\_ -> SaveName) + + ( newThrottle, cmd ) = + Throttle.try cmd_ model.nameSaveThrottle + + newModel = + { model + | nameSaveThrottle = newThrottle + , nameModel = newName + } + + sub = + nameThrottleSub newModel + in + UpdateResult newModel cmd sub NoFormChange + + Nothing -> + resultNone { model | nameModel = str } + + SaveName -> + case Util.Maybe.fromString model.nameModel of + Just n -> + resultNoCmd (NameChange n) model + + Nothing -> + resultNone model + + UpdateThrottle -> + let + ( newThrottle, cmd ) = + Throttle.update model.nameSaveThrottle + + newModel = + { model | nameSaveThrottle = newThrottle } + + sub = + nameThrottleSub newModel + in + UpdateResult newModel cmd sub NoFormChange + + +nameThrottleSub : Model -> Sub Msg +nameThrottleSub model = + Throttle.ifNeeded + (Time.every 400 (\_ -> UpdateThrottle)) + model.nameSaveThrottle + + + +--- View + + +type alias ViewConfig = + { menuClass : String + , nameState : SaveNameState + } + + +defaultViewConfig : ViewConfig +defaultViewConfig = + { menuClass = "ui vertical segment" + , nameState = SaveSuccess + } + + +view : ViewConfig -> UiSettings -> Model -> Html Msg +view = + renderEditForm + + +renderEditForm : ViewConfig -> UiSettings -> Model -> Html Msg +renderEditForm cfg settings model = + let + fieldVisible field = + Data.UiSettings.fieldVisible settings field + + optional fields html = + if + List.map fieldVisible fields + |> List.foldl (||) False + then + html + + else + span [ class "invisible hidden" ] [] + in + div [ class cfg.menuClass ] + [ div [ class "ui form warning" ] + [ optional [ Data.Fields.Tag ] <| + div [ class "field" ] + [ label [] + [ Icons.tagsIcon "grey" + , text "Tags" + ] + , Html.map TagDropdownMsg (Comp.Dropdown.view settings model.tagModel) + ] + , div [ class " field" ] + [ label [] [ text "Name" ] + , div [ class "ui icon input" ] + [ input [ type_ "text", value model.nameModel, onInput SetName ] [] + , i + [ classList + [ ( "green check icon", cfg.nameState == SaveSuccess ) + , ( "red exclamation triangle icon", cfg.nameState == SaveFailed ) + , ( "sync loading icon", cfg.nameState == Saving ) + ] + ] + [] + ] + ] + , optional [ Data.Fields.Folder ] <| + div [ class "field" ] + [ label [] + [ Icons.folderIcon "grey" + , text "Folder" + ] + , Html.map FolderDropdownMsg (Comp.Dropdown.view settings model.folderModel) + , div + [ classList + [ ( "ui warning message", True ) + , ( "hidden", isFolderMember model ) + ] + ] + [ Markdown.toHtml [] """ +You are **not a member** of this folder. This item will be **hidden** +from any search now. Use a folder where you are a member of to make this +item visible. This message will disappear then. + """ + ] + ] + , optional [ Data.Fields.Direction ] <| + div [ class "field" ] + [ label [] + [ Icons.directionIcon "grey" + , text "Direction" + ] + , Html.map DirDropdownMsg (Comp.Dropdown.view settings model.directionModel) + ] + , optional [ Data.Fields.Date ] <| + div [ class "field" ] + [ label [] + [ Icons.dateIcon "grey" + , text "Date" + ] + , div [ class "ui action input" ] + [ Html.map ItemDatePickerMsg + (Comp.DatePicker.viewTime + model.itemDate + actionInputDatePicker + model.itemDatePicker + ) + , a [ class "ui icon button", href "", onClick RemoveDate ] + [ i [ class "trash alternate outline icon" ] [] + ] + ] + ] + , optional [ Data.Fields.DueDate ] <| + div [ class " field" ] + [ label [] + [ Icons.dueDateIcon "grey" + , text "Due Date" + ] + , div [ class "ui action input" ] + [ Html.map DueDatePickerMsg + (Comp.DatePicker.viewTime + model.dueDate + actionInputDatePicker + model.dueDatePicker + ) + , a [ class "ui icon button", href "", onClick RemoveDueDate ] + [ i [ class "trash alternate outline icon" ] [] ] + ] + ] + , optional [ Data.Fields.CorrOrg, Data.Fields.CorrPerson ] <| + h4 [ class "ui dividing header" ] + [ Icons.correspondentIcon "" + , text "Correspondent" + ] + , optional [ Data.Fields.CorrOrg ] <| + div [ class "field" ] + [ label [] + [ Icons.organizationIcon "grey" + , text "Organization" + ] + , Html.map OrgDropdownMsg (Comp.Dropdown.view settings model.corrOrgModel) + ] + , optional [ Data.Fields.CorrPerson ] <| + div [ class "field" ] + [ label [] + [ Icons.personIcon "grey" + , text "Person" + ] + , Html.map CorrPersonMsg (Comp.Dropdown.view settings model.corrPersonModel) + ] + , optional [ Data.Fields.ConcPerson, Data.Fields.ConcEquip ] <| + h4 [ class "ui dividing header" ] + [ Icons.concernedIcon + , text "Concerning" + ] + , optional [ Data.Fields.ConcPerson ] <| + div [ class "field" ] + [ label [] + [ Icons.personIcon "grey" + , text "Person" + ] + , Html.map ConcPersonMsg (Comp.Dropdown.view settings model.concPersonModel) + ] + , optional [ Data.Fields.ConcEquip ] <| + div [ class "field" ] + [ label [] + [ Icons.equipmentIcon "grey" + , text "Equipment" + ] + , Html.map ConcEquipMsg (Comp.Dropdown.view settings model.concEquipModel) + ] + ] + ] + + +actionInputDatePicker : DatePicker.Settings +actionInputDatePicker = + let + ds = + Comp.DatePicker.defaultSettings + in + { ds | containerClassList = [ ( "ui action input", True ) ] } diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm new file mode 100644 index 00000000..7d9c1c85 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm @@ -0,0 +1,118 @@ +module Comp.ItemDetail.FormChange exposing + ( FormChange(..) + , multiUpdate + ) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.IdName exposing (IdName) +import Api.Model.ItemsAndDate exposing (ItemsAndDate) +import Api.Model.ItemsAndDirection exposing (ItemsAndDirection) +import Api.Model.ItemsAndName exposing (ItemsAndName) +import Api.Model.ItemsAndRef exposing (ItemsAndRef) +import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) +import Api.Model.ReferenceList exposing (ReferenceList) +import Data.Direction exposing (Direction) +import Data.Flags exposing (Flags) +import Http +import Set exposing (Set) + + +type FormChange + = NoFormChange + | TagChange ReferenceList + | FolderChange (Maybe IdName) + | DirectionChange Direction + | OrgChange (Maybe IdName) + | CorrPersonChange (Maybe IdName) + | ConcPersonChange (Maybe IdName) + | EquipChange (Maybe IdName) + | ItemDateChange (Maybe Int) + | DueDateChange (Maybe Int) + | NameChange String + + +multiUpdate : + Flags + -> Set String + -> FormChange + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +multiUpdate flags ids change receive = + let + items = + Set.toList ids + in + case change of + TagChange tags -> + let + data = + ItemsAndRefs items (List.map .id tags.items) + in + Api.setTagsMultiple flags data receive + + NameChange name -> + let + data = + ItemsAndName items name + in + Api.setNameMultiple flags data receive + + FolderChange id -> + let + data = + ItemsAndRef items (Maybe.map .id id) + in + Api.setFolderMultiple flags data receive + + DirectionChange dir -> + let + data = + ItemsAndDirection items (Data.Direction.toString dir) + in + Api.setDirectionMultiple flags data receive + + ItemDateChange date -> + let + data = + ItemsAndDate items date + in + Api.setDateMultiple flags data receive + + DueDateChange date -> + let + data = + ItemsAndDate items date + in + Api.setDueDateMultiple flags data receive + + OrgChange ref -> + let + data = + ItemsAndRef items (Maybe.map .id ref) + in + Api.setCorrOrgMultiple flags data receive + + CorrPersonChange ref -> + let + data = + ItemsAndRef items (Maybe.map .id ref) + in + Api.setCorrPersonMultiple flags data receive + + ConcPersonChange ref -> + let + data = + ItemsAndRef items (Maybe.map .id ref) + in + Api.setConcPersonMultiple flags data receive + + EquipChange ref -> + let + data = + ItemsAndRef items (Maybe.map .id ref) + in + Api.setConcEquipmentMultiple flags data receive + + NoFormChange -> + Cmd.none diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm index ba2b76b9..40a5aeaa 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm @@ -24,7 +24,6 @@ import File exposing (File) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onCheck, onClick, onInput) -import Html5.DragDrop as DD import Markdown import Page exposing (Page(..)) import Set diff --git a/modules/webapp/src/main/elm/Comp/YesNoDimmer.elm b/modules/webapp/src/main/elm/Comp/YesNoDimmer.elm index 927d6463..8421bdb5 100644 --- a/modules/webapp/src/main/elm/Comp/YesNoDimmer.elm +++ b/modules/webapp/src/main/elm/Comp/YesNoDimmer.elm @@ -6,6 +6,8 @@ module Comp.YesNoDimmer exposing , defaultSettings , disable , emptyModel + , initActive + , initInactive , update , view , view2 @@ -27,6 +29,18 @@ emptyModel = } +initInactive : Model +initInactive = + { active = False + } + + +initActive : Model +initActive = + { active = True + } + + type Msg = Activate | Disable @@ -40,6 +54,7 @@ type alias Settings = , confirmButton : String , cancelButton : String , invertedDimmer : Bool + , extraClass : String } @@ -51,6 +66,7 @@ defaultSettings = , confirmButton = "Yes, do it!" , cancelButton = "No" , invertedDimmer = False + , extraClass = "" } @@ -87,6 +103,7 @@ view2 active settings model = div [ classList [ ( "ui dimmer", True ) + , ( settings.extraClass, True ) , ( "inverted", settings.invertedDimmer ) , ( "active", active && model.active ) ] diff --git a/modules/webapp/src/main/elm/Data/ItemSelection.elm b/modules/webapp/src/main/elm/Data/ItemSelection.elm new file mode 100644 index 00000000..96ea8e5e --- /dev/null +++ b/modules/webapp/src/main/elm/Data/ItemSelection.elm @@ -0,0 +1,32 @@ +module Data.ItemSelection exposing + ( ItemSelection(..) + , isActive + , isSelected + ) + +import Set exposing (Set) + + +type ItemSelection + = Inactive + | Active (Set String) + + +isSelected : String -> ItemSelection -> Bool +isSelected id set = + case set of + Inactive -> + False + + Active ids -> + Set.member id ids + + +isActive : ItemSelection -> Bool +isActive sel = + case sel of + Active _ -> + True + + Inactive -> + False diff --git a/modules/webapp/src/main/elm/Data/Items.elm b/modules/webapp/src/main/elm/Data/Items.elm index 04b9aaad..5e9d124e 100644 --- a/modules/webapp/src/main/elm/Data/Items.elm +++ b/modules/webapp/src/main/elm/Data/Items.elm @@ -1,12 +1,16 @@ module Data.Items exposing ( concat , first + , idSet , length + , replaceIn ) import Api.Model.ItemLight exposing (ItemLight) import Api.Model.ItemLightGroup exposing (ItemLightGroup) import Api.Model.ItemLightList exposing (ItemLightList) +import Dict exposing (Dict) +import Set exposing (Set) import Util.List @@ -65,3 +69,54 @@ lastGroup : ItemLightList -> Maybe ItemLightGroup lastGroup list = List.reverse list.groups |> List.head + + +idSet : ItemLightList -> Set String +idSet items = + List.map idSetGroup items.groups + |> List.foldl Set.union Set.empty + + +idSetGroup : ItemLightGroup -> Set String +idSetGroup group = + List.map .id group.items + |> Set.fromList + + +replaceIn : ItemLightList -> ItemLightList -> ItemLightList +replaceIn origin replacements = + let + newItems = + mkItemDict replacements + + replaceItem item = + case Dict.get item.id newItems of + Just ni -> + ni + + Nothing -> + item + + replaceGroup g = + List.map replaceItem g.items + |> ItemLightGroup g.name + in + List.map replaceGroup origin.groups + |> ItemLightList + + + +--- Helper + + +mkItemDict : ItemLightList -> Dict String ItemLight +mkItemDict list = + let + insertItems : Dict String ItemLight -> List ItemLight -> Dict String ItemLight + insertItems dict items = + List.foldl (\i -> \d -> Dict.insert i.id i d) dict items + + insertGroup dict groups = + List.foldl (\g -> \d -> insertItems d g.items) dict groups + in + insertGroup Dict.empty list.groups diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index 0c995564..2c6909a8 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -2,26 +2,36 @@ module Page.Home.Data exposing ( Model , Msg(..) , SearchType(..) + , SelectActionMode(..) + , SelectViewModel + , ViewMode(..) , defaultSearchType , doSearchCmd , init + , initSelectViewModel , itemNav + , menuCollapsed , resultsBelowLimit , searchTypeString + , selectActive ) import Api +import Api.Model.BasicResult exposing (BasicResult) import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemSearch import Browser.Dom as Dom import Comp.FixedDropdown import Comp.ItemCardList +import Comp.ItemDetail.EditMenu import Comp.SearchMenu +import Comp.YesNoDimmer import Data.Flags exposing (Flags) import Data.ItemNav exposing (ItemNav) import Data.Items import Data.UiSettings exposing (UiSettings) import Http +import Set exposing (Set) import Throttle exposing (Throttle) import Util.Html exposing (KeyCode(..)) import Util.ItemDragDrop as DD @@ -31,7 +41,7 @@ type alias Model = { searchMenuModel : Comp.SearchMenu.Model , itemListModel : Comp.ItemCardList.Model , searchInProgress : Bool - , menuCollapsed : Bool + , viewMode : ViewMode , searchOffset : Int , moreAvailable : Bool , moreInProgress : Bool @@ -45,6 +55,29 @@ type alias Model = } +type alias SelectViewModel = + { ids : Set String + , action : SelectActionMode + , deleteAllConfirm : Comp.YesNoDimmer.Model + , editModel : Comp.ItemDetail.EditMenu.Model + } + + +initSelectViewModel : SelectViewModel +initSelectViewModel = + { ids = Set.empty + , action = NoneAction + , deleteAllConfirm = Comp.YesNoDimmer.initActive + , editModel = Comp.ItemDetail.EditMenu.init + } + + +type ViewMode + = SimpleView + | SearchView + | SelectView SelectViewModel + + init : Flags -> Model init flags = let @@ -58,7 +91,6 @@ init flags = { searchMenuModel = Comp.SearchMenu.init , itemListModel = Comp.ItemCardList.init , searchInProgress = False - , menuCollapsed = True , searchOffset = 0 , moreAvailable = True , moreInProgress = False @@ -72,6 +104,7 @@ init flags = , dragDropData = DD.DragDropData DD.init Nothing , scrollToCard = Nothing + , viewMode = SimpleView } @@ -84,6 +117,32 @@ defaultSearchType flags = BasicSearch +menuCollapsed : Model -> Bool +menuCollapsed model = + case model.viewMode of + SimpleView -> + True + + SearchView -> + False + + SelectView _ -> + False + + +selectActive : Model -> Bool +selectActive model = + case model.viewMode of + SimpleView -> + False + + SearchView -> + False + + SelectView _ -> + True + + type Msg = Init | SearchMenuMsg Comp.SearchMenu.Msg @@ -93,6 +152,7 @@ type Msg | ItemSearchAddResp (Result Http.Error ItemLightList) | DoSearch | ToggleSearchMenu + | ToggleSelectView | LoadMore | UpdateThrottle | SetBasicSearch String @@ -101,6 +161,15 @@ type Msg | SetContentOnly String | ScrollResult (Result Dom.Error ()) | ClearItemDetailId + | SelectAllItems + | SelectNoItems + | RequestDeleteSelected + | DeleteSelectedConfirmMsg Comp.YesNoDimmer.Msg + | EditSelectedItems + | EditMenuMsg Comp.ItemDetail.EditMenu.Msg + | MultiUpdateResp (Result Http.Error BasicResult) + | ReplaceChangedItemsResp (Result Http.Error ItemLightList) + | DeleteAllResp (Result Http.Error BasicResult) type SearchType @@ -109,6 +178,12 @@ type SearchType | ContentOnlySearch +type SelectActionMode + = NoneAction + | DeleteSelected + | EditSelected + + searchTypeString : SearchType -> String searchTypeString st = case st of diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index 9e01cb06..b9c3684f 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -1,15 +1,25 @@ module Page.Home.Update exposing (update) +import Api +import Api.Model.IdList exposing (IdList) +import Api.Model.ItemLightList exposing (ItemLightList) +import Api.Model.ItemSearch exposing (ItemSearch) import Browser.Navigation as Nav import Comp.FixedDropdown import Comp.ItemCardList +import Comp.ItemDetail.EditMenu +import Comp.ItemDetail.FormChange import Comp.SearchMenu +import Comp.YesNoDimmer import Data.Flags exposing (Flags) +import Data.ItemSelection +import Data.Items import Data.UiSettings exposing (UiSettings) import Page exposing (Page(..)) import Page.Home.Data exposing (..) import Process import Scroll +import Set exposing (Set) import Task import Throttle import Time @@ -82,10 +92,19 @@ update mId key flags settings msg model = flags m model.itemListModel + + nextView = + case ( model.viewMode, result.selection ) of + ( SelectView svm, Data.ItemSelection.Active ids ) -> + SelectView { svm | ids = ids } + + ( v, _ ) -> + v in withSub ( { model | itemListModel = result.model + , viewMode = nextView , dragDropData = DD.DragDropData result.dragModel Nothing } , Cmd.batch [ Cmd.map ItemCardListMsg result.cmd ] @@ -159,11 +178,43 @@ update mId key flags settings msg model = doSearch flags settings False nm ToggleSearchMenu -> + let + nextView = + case model.viewMode of + SimpleView -> + SearchView + + SearchView -> + SimpleView + + SelectView _ -> + SimpleView + in withSub - ( { model | menuCollapsed = not model.menuCollapsed } + ( { model | viewMode = nextView } , Cmd.none ) + ToggleSelectView -> + let + ( nextView, cmd ) = + case model.viewMode of + SimpleView -> + ( SelectView initSelectViewModel, loadEditModel flags ) + + SearchView -> + ( SelectView initSelectViewModel, loadEditModel flags ) + + SelectView _ -> + ( SearchView, Cmd.none ) + in + withSub + ( { model + | viewMode = nextView + } + , cmd + ) + LoadMore -> if model.moreAvailable then doSearchMore flags settings model |> withSub @@ -253,11 +304,223 @@ update mId key flags settings msg model = ClearItemDetailId -> noSub ( { model | scrollToCard = Nothing }, Cmd.none ) + SelectAllItems -> + case model.viewMode of + SelectView svm -> + let + visible = + Data.Items.idSet model.itemListModel.results + + svm_ = + { svm | ids = Set.union svm.ids visible } + in + noSub + ( { model | viewMode = SelectView svm_ } + , Cmd.none + ) + + _ -> + noSub ( model, Cmd.none ) + + SelectNoItems -> + case model.viewMode of + SelectView svm -> + let + svm_ = + { svm | ids = Set.empty } + in + noSub + ( { model | viewMode = SelectView svm_ } + , Cmd.none + ) + + _ -> + noSub ( model, Cmd.none ) + + DeleteSelectedConfirmMsg lmsg -> + case model.viewMode of + SelectView svm -> + let + ( confirmModel, confirmed ) = + Comp.YesNoDimmer.update lmsg svm.deleteAllConfirm + + cmd = + if confirmed then + Api.deleteAllItems flags svm.ids DeleteAllResp + + else + Cmd.none + + act = + if confirmModel.active || confirmed then + DeleteSelected + + else + NoneAction + in + noSub + ( { model + | viewMode = + SelectView + { svm + | deleteAllConfirm = confirmModel + , action = act + } + } + , cmd + ) + + _ -> + noSub ( model, Cmd.none ) + + DeleteAllResp (Ok res) -> + if res.success then + let + nm = + { model | viewMode = SearchView } + in + doSearch flags settings False nm + + else + noSub ( model, Cmd.none ) + + DeleteAllResp (Err _) -> + noSub ( model, Cmd.none ) + + RequestDeleteSelected -> + case model.viewMode of + SelectView svm -> + if svm.ids == Set.empty then + noSub ( model, Cmd.none ) + + else + let + lmsg = + DeleteSelectedConfirmMsg Comp.YesNoDimmer.activate + + model_ = + { model | viewMode = SelectView { svm | action = DeleteSelected } } + in + update mId key flags settings lmsg model_ + + _ -> + noSub ( model, Cmd.none ) + + EditSelectedItems -> + case model.viewMode of + SelectView svm -> + if svm.action == EditSelected then + noSub + ( { model | viewMode = SelectView { svm | action = NoneAction } } + , Cmd.none + ) + + else if svm.ids == Set.empty then + noSub ( model, Cmd.none ) + + else + noSub + ( { model | viewMode = SelectView { svm | action = EditSelected } } + , Cmd.none + ) + + _ -> + noSub ( model, Cmd.none ) + + EditMenuMsg lmsg -> + case model.viewMode of + SelectView svm -> + let + res = + Comp.ItemDetail.EditMenu.update flags lmsg svm.editModel + + svm_ = + { svm | editModel = res.model } + + cmd_ = + Cmd.map EditMenuMsg res.cmd + + sub_ = + Sub.map EditMenuMsg res.sub + + upCmd = + Comp.ItemDetail.FormChange.multiUpdate flags + svm.ids + res.change + MultiUpdateResp + in + ( { model | viewMode = SelectView svm_ } + , Cmd.batch [ cmd_, upCmd ] + , sub_ + ) + + _ -> + noSub ( model, Cmd.none ) + + MultiUpdateResp (Ok res) -> + if res.success then + case model.viewMode of + SelectView svm -> + -- replace changed items in the view + noSub ( model, loadChangedItems flags svm.ids ) + + _ -> + noSub ( model, Cmd.none ) + + else + noSub ( model, Cmd.none ) + + MultiUpdateResp (Err _) -> + noSub ( model, Cmd.none ) + + ReplaceChangedItemsResp (Ok items) -> + noSub ( replaceItems model items, Cmd.none ) + + ReplaceChangedItemsResp (Err _) -> + noSub ( model, Cmd.none ) + --- Helpers +replaceItems : Model -> ItemLightList -> Model +replaceItems model newItems = + let + listModel = + model.itemListModel + + changed = + Data.Items.replaceIn listModel.results newItems + + newList = + { listModel | results = changed } + in + { model | itemListModel = newList } + + +loadChangedItems : Flags -> Set String -> Cmd Msg +loadChangedItems flags ids = + if Set.isEmpty ids then + Cmd.none + + else + let + searchInit = + Api.Model.ItemSearch.empty + + idList = + IdList (Set.toList ids) + + search = + { searchInit + | itemSubset = Just idList + , limit = Set.size ids + } + in + Api.itemSearch flags search ReplaceChangedItemsResp + + scrollToCard : Maybe String -> Model -> ( Model, Cmd Msg, Sub Msg ) scrollToCard mId model = let @@ -275,12 +538,17 @@ scrollToCard mId model = ( model, Cmd.none, Sub.none ) +loadEditModel : Flags -> Cmd Msg +loadEditModel flags = + Cmd.map EditMenuMsg (Comp.ItemDetail.EditMenu.loadModel flags) + + doSearch : Flags -> UiSettings -> Bool -> Model -> ( Model, Cmd Msg, Sub Msg ) doSearch flags settings scroll model = let stype = if - not model.menuCollapsed + not (menuCollapsed model) || Util.String.isNothingOrBlank model.contentOnlySearch then BasicSearch diff --git a/modules/webapp/src/main/elm/Page/Home/View.elm b/modules/webapp/src/main/elm/Page/Home/View.elm index ce4d8969..593a29a3 100644 --- a/modules/webapp/src/main/elm/Page/Home/View.elm +++ b/modules/webapp/src/main/elm/Page/Home/View.elm @@ -3,26 +3,51 @@ module Page.Home.View exposing (view) import Api.Model.ItemSearch import Comp.FixedDropdown import Comp.ItemCardList +import Comp.ItemDetail.EditMenu import Comp.SearchMenu +import Comp.YesNoDimmer import Data.Flags exposing (Flags) +import Data.ItemSelection import Data.UiSettings exposing (UiSettings) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onClick, onInput) import Page exposing (Page(..)) import Page.Home.Data exposing (..) +import Set import Util.Html view : Flags -> UiSettings -> Model -> Html Msg view flags settings model = + let + itemViewCfg = + case model.viewMode of + SelectView svm -> + Comp.ItemCardList.ViewConfig + model.scrollToCard + (Data.ItemSelection.Active svm.ids) + + _ -> + Comp.ItemCardList.ViewConfig + model.scrollToCard + Data.ItemSelection.Inactive + + selectAction = + case model.viewMode of + SelectView svm -> + svm.action + + _ -> + NoneAction + in div [ class "home-page ui padded grid" ] [ div [ classList [ ( "sixteen wide mobile six wide tablet four wide computer search-menu column" , True ) - , ( "invisible hidden", model.menuCollapsed ) + , ( "invisible hidden", menuCollapsed model ) ] ] [ div @@ -38,6 +63,17 @@ view flags settings model = ] , div [ class "right floated menu" ] [ a + [ classList + [ ( "borderless item", True ) + , ( "active", selectActive model ) + ] + , href "#" + , title "Toggle select items" + , onClick ToggleSelectView + ] + [ i [ class "tasks icon" ] [] + ] + , a [ class "borderless item" , onClick ResetSearch , title "Reset form" @@ -63,26 +99,30 @@ view flags settings model = ] ] , div [ class "" ] - [ Html.map SearchMenuMsg - (Comp.SearchMenu.viewDrop model.dragDropData - flags - settings - model.searchMenuModel - ) - ] + (viewLeftMenu flags settings model) ] , div [ classList [ ( "sixteen wide mobile ten wide tablet twelve wide computer column" - , not model.menuCollapsed + , not (menuCollapsed model) ) - , ( "sixteen wide column", model.menuCollapsed ) + , ( "sixteen wide column", menuCollapsed model ) , ( "item-card-list", True ) ] ] - [ viewSearchBar flags model + [ viewBar flags model + , case model.viewMode of + SelectView svm -> + Html.map DeleteSelectedConfirmMsg + (Comp.YesNoDimmer.view2 (selectAction == DeleteSelected) + deleteAllDimmer + svm.deleteAllConfirm + ) + + _ -> + span [ class "invisible" ] [] , Html.map ItemCardListMsg - (Comp.ItemCardList.view model.scrollToCard settings model.itemListModel) + (Comp.ItemCardList.view itemViewCfg settings model.itemListModel) ] , div [ classList @@ -117,6 +157,113 @@ view flags settings model = ] +viewLeftMenu : Flags -> UiSettings -> Model -> List (Html Msg) +viewLeftMenu flags settings model = + let + searchMenu = + [ Html.map SearchMenuMsg + (Comp.SearchMenu.viewDrop model.dragDropData + flags + settings + model.searchMenuModel + ) + ] + in + case model.viewMode of + SelectView svm -> + case svm.action of + EditSelected -> + let + cfg = + Comp.ItemDetail.EditMenu.defaultViewConfig + in + [ div [ class "ui dividing header" ] + [ text "Multi-Edit" + ] + , div [ class "ui info message" ] + [ text "Note that a change here immediatly affects all selected items on the right!" + ] + , Html.map EditMenuMsg + (Comp.ItemDetail.EditMenu.view cfg settings svm.editModel) + ] + + _ -> + searchMenu + + _ -> + searchMenu + + +viewBar : Flags -> Model -> Html Msg +viewBar flags model = + case model.viewMode of + SimpleView -> + viewSearchBar flags model + + SearchView -> + div [ class "hidden invisible" ] [] + + SelectView svm -> + viewActionBar flags svm model + + +viewActionBar : Flags -> SelectViewModel -> Model -> Html Msg +viewActionBar _ svm model = + let + selectCount = + Set.size svm.ids |> String.fromInt + in + div + [ class "ui ablue-comp icon menu" + ] + [ a + [ classList + [ ( "borderless item", True ) + , ( "active", svm.action == EditSelected ) + ] + , href "#" + , title <| "Edit " ++ selectCount ++ " selected items" + , onClick EditSelectedItems + ] + [ i [ class "ui edit icon" ] [] + ] + , a + [ classList + [ ( "borderless item", True ) + , ( "active", svm.action == DeleteSelected ) + ] + , href "#" + , title <| "Delete " ++ selectCount ++ " selected items" + , onClick RequestDeleteSelected + ] + [ i [ class "trash icon" ] [] + ] + , div [ class "right menu" ] + [ a + [ class "item" + , href "#" + , onClick SelectAllItems + , title "Select all" + ] + [ i [ class "check square outline icon" ] [] + ] + , a + [ class "borderless item" + , href "#" + , title "Select none" + , onClick SelectNoItems + ] + [ i [ class "square outline icon" ] [] + ] + , div [ class "borderless label item" ] + [ div [ class "ui circular purple icon label" ] + [ text selectCount + ] + ] + ] + ] + + viewSearchBar : Flags -> Model -> Html Msg viewSearchBar flags model = let @@ -145,7 +292,7 @@ viewSearchBar flags model = in div [ classList - [ ( "invisible hidden", not model.menuCollapsed ) + [ ( "invisible hidden", not (menuCollapsed model) ) , ( "ui secondary stackable menu container", True ) ] ] @@ -221,3 +368,15 @@ hasMoreSearch model = Api.Model.ItemSearch.empty in is_ /= Api.Model.ItemSearch.empty + + +deleteAllDimmer : Comp.YesNoDimmer.Settings +deleteAllDimmer = + { message = "Really delete all selected items?" + , headerIcon = "exclamation icon" + , headerClass = "ui inverted icon header" + , confirmButton = "Yes" + , cancelButton = "No" + , invertedDimmer = False + , extraClass = "top aligned" + } diff --git a/modules/webapp/src/main/webjar/docspell.css b/modules/webapp/src/main/webjar/docspell.css index 31ee7eda..e24781e6 100644 --- a/modules/webapp/src/main/webjar/docspell.css +++ b/modules/webapp/src/main/webjar/docspell.css @@ -31,6 +31,9 @@ margin: 0 1em; } +.default-layout .ui.icon.menu .label.item { + padding: 0 0.5em 0 0; +} .default-layout .right-float { float: right;