Merge pull request #388 from eikek/parallel-edit

Parallel edit
This commit is contained in:
mergify[bot] 2020-10-26 14:24:13 +00:00 committed by GitHub
commit d3f5e4782f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 2781 additions and 216 deletions

View File

@ -1,5 +1,6 @@
package docspell.backend.ops package docspell.backend.ops
import cats.data.NonEmptyList
import cats.data.OptionT import cats.data.OptionT
import cats.effect.{Effect, Resource} import cats.effect.{Effect, Resource}
import cats.implicits._ import cats.implicits._
@ -13,62 +14,124 @@ import docspell.store.queue.JobQueue
import docspell.store.records._ import docspell.store.records._
import docspell.store.{AddResult, Store} import docspell.store.{AddResult, Store}
import doobie._
import doobie.implicits._ import doobie.implicits._
import org.log4s.getLogger import org.log4s.getLogger
trait OItem[F[_]] { trait OItem[F[_]] {
/** Sets the given tags (removing all existing ones). */ /** 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. */ /** Create a new tag and add it to the item. */
def addNewTag(item: Ident, tag: RTag): F[AddResult] 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 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. */ /** 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 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 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 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 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 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( def setStates(
item: Ident, item: NonEmptyList[Ident],
date: Option[Timestamp], state: ItemState,
collective: Ident collective: Ident
): F[AddResult] ): 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 getProposals(item: Ident, collective: Ident): F[MetaProposalList]
def deleteItem(itemId: Ident, collective: Ident): F[Int] 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 deleteAttachment(id: Ident, collective: Ident): F[Int]
def moveAttachmentBefore(itemId: Ident, source: Ident, target: Ident): F[AddResult] def moveAttachmentBefore(itemId: Ident, source: Ident, target: Ident): F[AddResult]
@ -77,7 +140,7 @@ trait OItem[F[_]] {
attachId: Ident, attachId: Ident,
name: Option[String], name: Option[String],
collective: Ident collective: Ident
): F[AddResult] ): F[UpdateResult]
/** Submits the item for re-processing. The list of attachment ids can /** 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. * be used to only re-process a subset of the item's attachments.
@ -91,6 +154,12 @@ trait OItem[F[_]] {
notifyJoex: Boolean notifyJoex: Boolean
): F[UpdateResult] ): F[UpdateResult]
def reprocessAll(
items: NonEmptyList[Ident],
account: AccountId,
notifyJoex: Boolean
): F[UpdateResult]
/** Submits a task that finds all non-converted pdfs and triggers /** Submits a task that finds all non-converted pdfs and triggers
* converting them using ocrmypdf. Each file is converted by a * converting them using ocrmypdf. Each file is converted by a
* separate task. * separate task.
@ -130,21 +199,30 @@ object OItem {
item: Ident, item: Ident,
tags: List[String], tags: List[String],
collective: Ident collective: Ident
): F[UpdateResult] =
linkTagsMultipleItems(NonEmptyList.of(item), tags, collective)
def linkTagsMultipleItems(
items: NonEmptyList[Ident],
tags: List[String],
collective: Ident
): F[UpdateResult] = ): F[UpdateResult] =
tags.distinct match { tags.distinct match {
case Nil => UpdateResult.success.pure[F] case Nil => UpdateResult.success.pure[F]
case kws => case ws =>
val db = store.transact {
(for { (for {
_ <- OptionT(RItem.checkByIdAndCollective(item, collective)) itemIds <- OptionT
given <- OptionT.liftF(RTag.findAllByNameOrId(kws, collective)) .liftF(RItem.filterItems(items, collective))
exist <- OptionT.liftF(RTagItem.findAllIn(item, given.map(_.tagId))) .filter(_.nonEmpty)
given <- OptionT.liftF(RTag.findAllByNameOrId(ws, collective))
_ <- OptionT.liftF( _ <- 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) } yield UpdateResult.success).getOrElse(UpdateResult.notFound)
}
store.transact(db)
} }
def toggleTags( def toggleTags(
@ -169,20 +247,23 @@ object OItem {
store.transact(db) store.transact(db)
} }
def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult] = { def setTags(
val db = for { item: Ident,
cid <- RItem.getCollective(item) tagIds: List[Ident],
nd <- collective: Ident
if (cid.contains(collective)) RTagItem.deleteItemTags(item) ): F[UpdateResult] =
else 0.pure[ConnectionIO] setTagsMultipleItems(NonEmptyList.of(item), tagIds, collective)
ni <-
if (tagIds.nonEmpty && cid.contains(collective))
RTagItem.insertItemTags(item, tagIds)
else 0.pure[ConnectionIO]
} yield nd + ni
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] = def addNewTag(item: Ident, tag: RTag): F[AddResult] =
(for { (for {
@ -192,7 +273,7 @@ object OItem {
_ <- addres match { _ <- addres match {
case AddResult.Success => case AddResult.Success =>
OptionT.liftF( OptionT.liftF(
store.transact(RTagItem.insertItemTags(item, List(tag.tagId))) store.transact(RTagItem.setAllTags(item, List(tag.tagId)))
) )
case AddResult.EntityExists(_) => case AddResult.EntityExists(_) =>
OptionT.pure[F](0) OptionT.pure[F](0)
@ -203,33 +284,59 @@ object OItem {
.getOrElse(AddResult.Failure(new Exception("Collective mismatch"))) .getOrElse(AddResult.Failure(new Exception("Collective mismatch")))
def setDirection( def setDirection(
item: Ident, items: NonEmptyList[Ident],
direction: Direction, direction: Direction,
collective: Ident collective: Ident
): F[AddResult] = ): F[UpdateResult] =
store UpdateResult.fromUpdate(
.transact(RItem.updateDirection(item, collective, direction)) store
.attempt .transact(RItem.updateDirection(items, collective, direction))
.map(AddResult.fromUpdate) )
def setFolder( def setFolder(
item: Ident, item: Ident,
folder: Option[Ident], folder: Option[Ident],
collective: Ident collective: Ident
): F[AddResult] = ): F[UpdateResult] =
store UpdateResult
.transact(RItem.updateFolder(item, collective, folder)) .fromUpdate(
.attempt store
.map(AddResult.fromUpdate) .transact(RItem.updateFolder(item, collective, folder))
)
.flatTap( .flatTap(
onSuccessIgnoreError(fts.updateFolder(logger, item, collective, folder)) onSuccessIgnoreError(fts.updateFolder(logger, item, collective, folder))
) )
def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult] = def setFolderMultiple(
store items: NonEmptyList[Ident],
.transact(RItem.updateCorrOrg(item, collective, org)) folder: Option[Ident],
.attempt collective: Ident
.map(AddResult.fromUpdate) ): 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] = def addCorrOrg(item: Ident, org: OOrganization.OrgAndContacts): F[AddResult] =
(for { (for {
@ -240,7 +347,11 @@ object OItem {
case AddResult.Success => case AddResult.Success =>
OptionT.liftF( OptionT.liftF(
store.transact( 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(_) => case AddResult.EntityExists(_) =>
@ -252,14 +363,14 @@ object OItem {
.getOrElse(AddResult.Failure(new Exception("Collective mismatch"))) .getOrElse(AddResult.Failure(new Exception("Collective mismatch")))
def setCorrPerson( def setCorrPerson(
item: Ident, items: NonEmptyList[Ident],
person: Option[Ident], person: Option[Ident],
collective: Ident collective: Ident
): F[AddResult] = ): F[UpdateResult] =
store UpdateResult.fromUpdate(
.transact(RItem.updateCorrPerson(item, collective, person)) store
.attempt .transact(RItem.updateCorrPerson(items, collective, person))
.map(AddResult.fromUpdate) )
def addCorrPerson( def addCorrPerson(
item: Ident, item: Ident,
@ -274,7 +385,11 @@ object OItem {
OptionT.liftF( OptionT.liftF(
store.transact( store.transact(
RItem RItem
.updateCorrPerson(item, person.person.cid, Some(person.person.pid)) .updateCorrPerson(
NonEmptyList.of(item),
person.person.cid,
Some(person.person.pid)
)
) )
) )
case AddResult.EntityExists(_) => case AddResult.EntityExists(_) =>
@ -286,14 +401,14 @@ object OItem {
.getOrElse(AddResult.Failure(new Exception("Collective mismatch"))) .getOrElse(AddResult.Failure(new Exception("Collective mismatch")))
def setConcPerson( def setConcPerson(
item: Ident, items: NonEmptyList[Ident],
person: Option[Ident], person: Option[Ident],
collective: Ident collective: Ident
): F[AddResult] = ): F[UpdateResult] =
store UpdateResult.fromUpdate(
.transact(RItem.updateConcPerson(item, collective, person)) store
.attempt .transact(RItem.updateConcPerson(items, collective, person))
.map(AddResult.fromUpdate) )
def addConcPerson( def addConcPerson(
item: Ident, item: Ident,
@ -308,7 +423,11 @@ object OItem {
OptionT.liftF( OptionT.liftF(
store.transact( store.transact(
RItem RItem
.updateConcPerson(item, person.person.cid, Some(person.person.pid)) .updateConcPerson(
NonEmptyList.of(item),
person.person.cid,
Some(person.person.pid)
)
) )
) )
case AddResult.EntityExists(_) => case AddResult.EntityExists(_) =>
@ -320,14 +439,14 @@ object OItem {
.getOrElse(AddResult.Failure(new Exception("Collective mismatch"))) .getOrElse(AddResult.Failure(new Exception("Collective mismatch")))
def setConcEquip( def setConcEquip(
item: Ident, items: NonEmptyList[Ident],
equip: Option[Ident], equip: Option[Ident],
collective: Ident collective: Ident
): F[AddResult] = ): F[UpdateResult] =
store UpdateResult.fromUpdate(
.transact(RItem.updateConcEquip(item, collective, equip)) store
.attempt .transact(RItem.updateConcEquip(items, collective, equip))
.map(AddResult.fromUpdate) )
def addConcEquip(item: Ident, equip: REquipment): F[AddResult] = def addConcEquip(item: Ident, equip: REquipment): F[AddResult] =
(for { (for {
@ -338,7 +457,8 @@ object OItem {
case AddResult.Success => case AddResult.Success =>
OptionT.liftF( OptionT.liftF(
store.transact( store.transact(
RItem.updateConcEquip(item, equip.cid, Some(equip.eid)) RItem
.updateConcEquip(NonEmptyList.of(item), equip.cid, Some(equip.eid))
) )
) )
case AddResult.EntityExists(_) => case AddResult.EntityExists(_) =>
@ -353,55 +473,89 @@ object OItem {
item: Ident, item: Ident,
notes: Option[String], notes: Option[String],
collective: Ident collective: Ident
): F[AddResult] = ): F[UpdateResult] =
store UpdateResult
.transact(RItem.updateNotes(item, collective, notes)) .fromUpdate(
.attempt store
.map(AddResult.fromUpdate) .transact(RItem.updateNotes(item, collective, notes))
)
.flatTap( .flatTap(
onSuccessIgnoreError(fts.updateItemNotes(logger, item, collective, notes)) onSuccessIgnoreError(fts.updateItemNotes(logger, item, collective, notes))
) )
def setName(item: Ident, name: String, collective: Ident): F[AddResult] = def setName(item: Ident, name: String, collective: Ident): F[UpdateResult] =
store UpdateResult
.transact(RItem.updateName(item, collective, name)) .fromUpdate(
.attempt store
.map(AddResult.fromUpdate) .transact(RItem.updateName(item, collective, name))
)
.flatTap( .flatTap(
onSuccessIgnoreError(fts.updateItemName(logger, item, collective, name)) 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 store
.transact(RItem.updateStateForCollective(item, state, collective)) .transact(RItem.updateStateForCollective(items, state, collective))
.attempt .attempt
.map(AddResult.fromUpdate) .map(AddResult.fromUpdate)
def setItemDate( def setItemDate(
item: Ident, items: NonEmptyList[Ident],
date: Option[Timestamp], date: Option[Timestamp],
collective: Ident collective: Ident
): F[AddResult] = ): F[UpdateResult] =
store UpdateResult.fromUpdate(
.transact(RItem.updateDate(item, collective, date)) store
.attempt .transact(RItem.updateDate(items, collective, date))
.map(AddResult.fromUpdate) )
def setItemDueDate( def setItemDueDate(
item: Ident, items: NonEmptyList[Ident],
date: Option[Timestamp], date: Option[Timestamp],
collective: Ident collective: Ident
): F[AddResult] = ): F[UpdateResult] =
store UpdateResult.fromUpdate(
.transact(RItem.updateDueDate(item, collective, date)) store
.attempt .transact(RItem.updateDueDate(items, collective, date))
.map(AddResult.fromUpdate) )
def deleteItem(itemId: Ident, collective: Ident): F[Int] = def deleteItem(itemId: Ident, collective: Ident): F[Int] =
QItem QItem
.delete(store)(itemId, collective) .delete(store)(itemId, collective)
.flatTap(_ => fts.removeItem(logger, itemId)) .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] = def getProposals(item: Ident, collective: Ident): F[MetaProposalList] =
store.transact(QAttachment.getMetaProposals(item, collective)) store.transact(QAttachment.getMetaProposals(item, collective))
@ -414,11 +568,12 @@ object OItem {
attachId: Ident, attachId: Ident,
name: Option[String], name: Option[String],
collective: Ident collective: Ident
): F[AddResult] = ): F[UpdateResult] =
store UpdateResult
.transact(RAttachment.updateName(attachId, collective, name)) .fromUpdate(
.attempt store
.map(AddResult.fromUpdate) .transact(RAttachment.updateName(attachId, collective, name))
)
.flatTap( .flatTap(
onSuccessIgnoreError( onSuccessIgnoreError(
OptionT(store.transact(RAttachment.findItemId(attachId))) OptionT(store.transact(RAttachment.findItemId(attachId)))
@ -447,6 +602,20 @@ object OItem {
_ <- OptionT.liftF(if (notifyJoex) joex.notifyAllNodes else ().pure[F]) _ <- OptionT.liftF(if (notifyJoex) joex.notifyAllNodes else ().pure[F])
} yield UpdateResult.success).getOrElse(UpdateResult.notFound) } 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( def convertAllPdf(
collective: Option[Ident], collective: Option[Ident],
account: AccountId, account: AccountId,
@ -458,17 +627,17 @@ object OItem {
_ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F] _ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F]
} yield UpdateResult.success } 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 { ar match {
case AddResult.Success => case UpdateResult.Success =>
update.attempt.flatMap { update.attempt.flatMap {
case Right(()) => ().pure[F] case Right(()) => ().pure[F]
case Left(ex) => case Left(ex) =>
logger.warn(s"Error updating full-text index: ${ex.getMessage}") logger.warn(s"Error updating full-text index: ${ex.getMessage}")
} }
case AddResult.Failure(_) => case UpdateResult.Failure(_) =>
().pure[F] ().pure[F]
case AddResult.EntityExists(_) => case UpdateResult.NotFound =>
().pure[F] ().pure[F]
} }
}) })

View File

@ -1,5 +1,6 @@
package docspell.joex.process package docspell.joex.process
import cats.data.NonEmptyList
import cats.effect.Sync import cats.effect.Sync
import cats.implicits._ import cats.implicits._
@ -65,22 +66,38 @@ object LinkProposal {
case MetaProposalType.CorrOrg => case MetaProposalType.CorrOrg =>
ctx.logger.debug(s"Updating item organization with: ${value.id}") *> ctx.logger.debug(s"Updating item organization with: ${value.id}") *>
ctx.store.transact( 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 => case MetaProposalType.ConcPerson =>
ctx.logger.debug(s"Updating item concerning person with: $value") *> ctx.logger.debug(s"Updating item concerning person with: $value") *>
ctx.store.transact( 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 => case MetaProposalType.CorrPerson =>
ctx.logger.debug(s"Updating item correspondent person with: $value") *> ctx.logger.debug(s"Updating item correspondent person with: $value") *>
ctx.store.transact( 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 => case MetaProposalType.ConcEquip =>
ctx.logger.debug(s"Updating item concerning equipment with: $value") *> ctx.logger.debug(s"Updating item concerning equipment with: $value") *>
ctx.store.transact( 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 => case MetaProposalType.DocDate =>
MetaProposal.parseDate(value) match { MetaProposal.parseDate(value) match {
@ -88,7 +105,11 @@ object LinkProposal {
val ts = Timestamp.from(ld.atStartOfDay(Timestamp.UTC)) val ts = Timestamp.from(ld.atStartOfDay(Timestamp.UTC))
ctx.logger.debug(s"Updating item date ${value.id}") *> ctx.logger.debug(s"Updating item date ${value.id}") *>
ctx.store.transact( 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 => case None =>
ctx.logger.info(s"Cannot read value '${value.id}' into a date.") *> 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)) val ts = Timestamp.from(ld.atStartOfDay(Timestamp.UTC))
ctx.logger.debug(s"Updating item due-date suggestion ${value.id}") *> ctx.logger.debug(s"Updating item due-date suggestion ${value.id}") *>
ctx.store.transact( 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 => case None =>
ctx.logger.info(s"Cannot read value '${value.id}' into a date.") *> ctx.logger.info(s"Cannot read value '${value.id}' into a date.") *>

View File

@ -1384,7 +1384,9 @@ paths:
tags: [ Item ] tags: [ Item ]
summary: Set new set of tags. summary: Set new set of tags.
description: | 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: security:
- authTokenHeader: [] - authTokenHeader: []
parameters: parameters:
@ -1845,6 +1847,7 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/ItemProposals" $ref: "#/components/schemas/ItemProposals"
/sec/item/{itemId}/reprocess: /sec/item/{itemId}/reprocess:
post: post:
tags: [ Item ] tags: [ Item ]
@ -1895,6 +1898,359 @@ paths:
schema: schema:
$ref: "#/components/schemas/BasicResult" $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}: /sec/attachment/{id}:
delete: delete:
tags: [ Attachment ] tags: [ Attachment ]
@ -2702,6 +3058,84 @@ paths:
components: components:
schemas: 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: JobPriority:
description: | description: |
Transfer the priority of a job. Transfer the priority of a job.
@ -3828,7 +4262,7 @@ components:
format: date-time format: date-time
ReferenceList: ReferenceList:
description: description:
Listing of items. Listing of entities with their id and a name.
required: required:
- items - items
properties: properties:
@ -4077,6 +4511,8 @@ components:
dueDateUntil: dueDateUntil:
type: integer type: integer
format: date-time format: date-time
itemSubset:
$ref: "#/components/schemas/IdList"
ItemLight: ItemLight:
description: | description: |
An item with only a few important properties. An item with only a few important properties.

View File

@ -73,6 +73,7 @@ object RestServer {
"collective" -> CollectiveRoutes(restApp.backend, token), "collective" -> CollectiveRoutes(restApp.backend, token),
"queue" -> JobQueueRoutes(restApp.backend, token), "queue" -> JobQueueRoutes(restApp.backend, token),
"item" -> ItemRoutes(cfg, restApp.backend, token), "item" -> ItemRoutes(cfg, restApp.backend, token),
"items" -> ItemMultiRoutes(restApp.backend, token),
"attachment" -> AttachmentRoutes(restApp.backend, token), "attachment" -> AttachmentRoutes(restApp.backend, token),
"upload" -> UploadRoutes.secured(restApp.backend, cfg, token), "upload" -> UploadRoutes.secured(restApp.backend, cfg, token),
"checkfile" -> CheckFileRoutes.secured(restApp.backend, token), "checkfile" -> CheckFileRoutes.secured(restApp.backend, token),

View File

@ -134,7 +134,9 @@ trait Conversions {
m.dueDateFrom, m.dueDateFrom,
m.dueDateUntil, m.dueDateUntil,
m.allNames, m.allNames,
None, m.itemSubset
.map(_.ids.flatMap(i => Ident.fromString(i).toOption).toSet)
.filter(_.nonEmpty),
None None
) )

View File

@ -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)
)
}
}

View File

@ -1,5 +1,6 @@
package docspell.restserver.routes package docspell.restserver.routes
import cats.data.NonEmptyList
import cats.effect._ import cats.effect._
import cats.implicits._ import cats.implicits._
@ -165,8 +166,12 @@ object ItemRoutes {
case req @ PUT -> Root / Ident(id) / "direction" => case req @ PUT -> Root / Ident(id) / "direction" =>
for { for {
dir <- req.as[DirectionValue] dir <- req.as[DirectionValue]
res <- backend.item.setDirection(id, dir.direction, user.account.collective) res <- backend.item.setDirection(
NonEmptyList.of(id),
dir.direction,
user.account.collective
)
resp <- Ok(Conversions.basicResult(res, "Direction updated")) resp <- Ok(Conversions.basicResult(res, "Direction updated"))
} yield resp } yield resp
@ -180,8 +185,12 @@ object ItemRoutes {
case req @ PUT -> Root / Ident(id) / "corrOrg" => case req @ PUT -> Root / Ident(id) / "corrOrg" =>
for { for {
idref <- req.as[OptionalId] idref <- req.as[OptionalId]
res <- backend.item.setCorrOrg(id, idref.id, user.account.collective) res <- backend.item.setCorrOrg(
resp <- Ok(Conversions.basicResult(res, "Correspondent organization updated")) NonEmptyList.of(id),
idref.id,
user.account.collective
)
resp <- Ok(Conversions.basicResult(res, "Correspondent organization updated"))
} yield resp } yield resp
case req @ POST -> Root / Ident(id) / "corrOrg" => case req @ POST -> Root / Ident(id) / "corrOrg" =>
@ -195,8 +204,12 @@ object ItemRoutes {
case req @ PUT -> Root / Ident(id) / "corrPerson" => case req @ PUT -> Root / Ident(id) / "corrPerson" =>
for { for {
idref <- req.as[OptionalId] idref <- req.as[OptionalId]
res <- backend.item.setCorrPerson(id, idref.id, user.account.collective) res <- backend.item.setCorrPerson(
resp <- Ok(Conversions.basicResult(res, "Correspondent person updated")) NonEmptyList.of(id),
idref.id,
user.account.collective
)
resp <- Ok(Conversions.basicResult(res, "Correspondent person updated"))
} yield resp } yield resp
case req @ POST -> Root / Ident(id) / "corrPerson" => case req @ POST -> Root / Ident(id) / "corrPerson" =>
@ -210,8 +223,12 @@ object ItemRoutes {
case req @ PUT -> Root / Ident(id) / "concPerson" => case req @ PUT -> Root / Ident(id) / "concPerson" =>
for { for {
idref <- req.as[OptionalId] idref <- req.as[OptionalId]
res <- backend.item.setConcPerson(id, idref.id, user.account.collective) res <- backend.item.setConcPerson(
resp <- Ok(Conversions.basicResult(res, "Concerned person updated")) NonEmptyList.of(id),
idref.id,
user.account.collective
)
resp <- Ok(Conversions.basicResult(res, "Concerned person updated"))
} yield resp } yield resp
case req @ POST -> Root / Ident(id) / "concPerson" => case req @ POST -> Root / Ident(id) / "concPerson" =>
@ -225,8 +242,12 @@ object ItemRoutes {
case req @ PUT -> Root / Ident(id) / "concEquipment" => case req @ PUT -> Root / Ident(id) / "concEquipment" =>
for { for {
idref <- req.as[OptionalId] idref <- req.as[OptionalId]
res <- backend.item.setConcEquip(id, idref.id, user.account.collective) res <- backend.item.setConcEquip(
resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) NonEmptyList.of(id),
idref.id,
user.account.collective
)
resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated"))
} yield resp } yield resp
case req @ POST -> Root / Ident(id) / "concEquipment" => case req @ POST -> Root / Ident(id) / "concEquipment" =>
@ -259,7 +280,11 @@ object ItemRoutes {
for { for {
date <- req.as[OptionalDate] date <- req.as[OptionalDate]
_ <- logger.fdebug(s"Setting item due date to ${date.date}") _ <- 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")) resp <- Ok(Conversions.basicResult(res, "Item due date updated"))
} yield resp } yield resp
@ -267,7 +292,11 @@ object ItemRoutes {
for { for {
date <- req.as[OptionalDate] date <- req.as[OptionalDate]
_ <- logger.fdebug(s"Setting item date to ${date.date}") _ <- 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")) resp <- Ok(Conversions.basicResult(res, "Item date updated"))
} yield resp } yield resp

View File

@ -57,7 +57,12 @@ case class Column(name: String, ns: String = "", alias: String = "") {
f ++ fr"IN (" ++ commas(values) ++ fr")" f ++ fr"IN (" ++ commas(values) ++ fr")"
def isIn[A: Put](values: NonEmptyList[A]): Fragment = 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 = def isLowerIn[A: Put](values: NonEmptyList[A]): Fragment =
fr"lower(" ++ f ++ fr") IN (" ++ commas(values.map(a => sql"$a").toList) ++ fr")" fr"lower(" ++ f ++ fr") IN (" ++ commas(values.map(a => sql"$a").toList) ++ fr")"

View File

@ -132,7 +132,7 @@ object RItem {
} yield n } yield n
def updateStateForCollective( def updateStateForCollective(
itemId: Ident, itemIds: NonEmptyList[Ident],
itemState: ItemState, itemState: ItemState,
coll: Ident coll: Ident
): ConnectionIO[Int] = ): ConnectionIO[Int] =
@ -140,27 +140,35 @@ object RItem {
t <- currentTime t <- currentTime
n <- updateRow( n <- updateRow(
table, table,
and(id.is(itemId), cid.is(coll)), and(id.isIn(itemIds), cid.is(coll)),
commas(state.setTo(itemState), updated.setTo(t)) commas(state.setTo(itemState), updated.setTo(t))
).update.run ).update.run
} yield n } yield n
def updateDirection(itemId: Ident, coll: Ident, dir: Direction): ConnectionIO[Int] = def updateDirection(
itemIds: NonEmptyList[Ident],
coll: Ident,
dir: Direction
): ConnectionIO[Int] =
for { for {
t <- currentTime t <- currentTime
n <- updateRow( n <- updateRow(
table, table,
and(id.is(itemId), cid.is(coll)), and(id.isIn(itemIds), cid.is(coll)),
commas(incoming.setTo(dir), updated.setTo(t)) commas(incoming.setTo(dir), updated.setTo(t))
).update.run ).update.run
} yield n } 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 { for {
t <- currentTime t <- currentTime
n <- updateRow( n <- updateRow(
table, table,
and(id.is(itemId), cid.is(coll)), and(id.isIn(itemIds), cid.is(coll)),
commas(corrOrg.setTo(org), updated.setTo(t)) commas(corrOrg.setTo(org), updated.setTo(t))
).update.run ).update.run
} yield n } yield n
@ -176,7 +184,7 @@ object RItem {
} yield n } yield n
def updateCorrPerson( def updateCorrPerson(
itemId: Ident, itemIds: NonEmptyList[Ident],
coll: Ident, coll: Ident,
person: Option[Ident] person: Option[Ident]
): ConnectionIO[Int] = ): ConnectionIO[Int] =
@ -184,7 +192,7 @@ object RItem {
t <- currentTime t <- currentTime
n <- updateRow( n <- updateRow(
table, table,
and(id.is(itemId), cid.is(coll)), and(id.isIn(itemIds), cid.is(coll)),
commas(corrPerson.setTo(person), updated.setTo(t)) commas(corrPerson.setTo(person), updated.setTo(t))
).update.run ).update.run
} yield n } yield n
@ -200,7 +208,7 @@ object RItem {
} yield n } yield n
def updateConcPerson( def updateConcPerson(
itemId: Ident, itemIds: NonEmptyList[Ident],
coll: Ident, coll: Ident,
person: Option[Ident] person: Option[Ident]
): ConnectionIO[Int] = ): ConnectionIO[Int] =
@ -208,7 +216,7 @@ object RItem {
t <- currentTime t <- currentTime
n <- updateRow( n <- updateRow(
table, table,
and(id.is(itemId), cid.is(coll)), and(id.isIn(itemIds), cid.is(coll)),
commas(concPerson.setTo(person), updated.setTo(t)) commas(concPerson.setTo(person), updated.setTo(t))
).update.run ).update.run
} yield n } yield n
@ -224,7 +232,7 @@ object RItem {
} yield n } yield n
def updateConcEquip( def updateConcEquip(
itemId: Ident, itemIds: NonEmptyList[Ident],
coll: Ident, coll: Ident,
equip: Option[Ident] equip: Option[Ident]
): ConnectionIO[Int] = ): ConnectionIO[Int] =
@ -232,7 +240,7 @@ object RItem {
t <- currentTime t <- currentTime
n <- updateRow( n <- updateRow(
table, table,
and(id.is(itemId), cid.is(coll)), and(id.isIn(itemIds), cid.is(coll)),
commas(concEquipment.setTo(equip), updated.setTo(t)) commas(concEquipment.setTo(equip), updated.setTo(t))
).update.run ).update.run
} yield n } yield n
@ -281,18 +289,8 @@ object RItem {
).update.run ).update.run
} yield n } yield n
def updateDate(itemId: Ident, coll: Ident, date: Option[Timestamp]): ConnectionIO[Int] = def updateDate(
for { itemIds: NonEmptyList[Ident],
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,
coll: Ident, coll: Ident,
date: Option[Timestamp] date: Option[Timestamp]
): ConnectionIO[Int] = ): ConnectionIO[Int] =
@ -300,7 +298,21 @@ object RItem {
t <- currentTime t <- currentTime
n <- updateRow( n <- updateRow(
table, 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)) commas(dueDate.setTo(date), updated.setTo(t))
).update.run ).update.run
} yield n } yield n
@ -324,4 +336,10 @@ object RItem {
val empty: Option[Ident] = None val empty: Option[Ident] = None
updateRow(table, folder.is(folderId), folder.setTo(empty)).update.run 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]
} }

View File

@ -30,18 +30,17 @@ object RTagItem {
def deleteItemTags(item: Ident): ConnectionIO[Int] = def deleteItemTags(item: Ident): ConnectionIO[Int] =
deleteFrom(table, itemId.is(item)).update.run 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] = def deleteTag(tid: Ident): ConnectionIO[Int] =
deleteFrom(table, tagId.is(tid)).update.run 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]] = def findByItem(item: Ident): ConnectionIO[Vector[RTagItem]] =
selectSimple(all, table, itemId.is(item)).query[RTagItem].to[Vector] 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}") entities.map(v => fr"${v.tagItemId},${v.itemId},${v.tagId}")
).update.run ).update.run
} yield n } 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
} }

View File

@ -5,6 +5,7 @@ module Api exposing
, addCorrPerson , addCorrPerson
, addMember , addMember
, addTag , addTag
, addTagsMultiple
, cancelJob , cancelJob
, changeFolderName , changeFolderName
, changePassword , changePassword
@ -14,6 +15,7 @@ module Api exposing
, createNewFolder , createNewFolder
, createNotifyDueItems , createNotifyDueItems
, createScanMailbox , createScanMailbox
, deleteAllItems
, deleteAttachment , deleteAttachment
, deleteEquip , deleteEquip
, deleteFolder , deleteFolder
@ -76,18 +78,28 @@ module Api exposing
, setAttachmentName , setAttachmentName
, setCollectiveSettings , setCollectiveSettings
, setConcEquip , setConcEquip
, setConcEquipmentMultiple
, setConcPerson , setConcPerson
, setConcPersonMultiple
, setConfirmed , setConfirmed
, setCorrOrg , setCorrOrg
, setCorrOrgMultiple
, setCorrPerson , setCorrPerson
, setCorrPersonMultiple
, setDateMultiple
, setDirection , setDirection
, setDirectionMultiple
, setDueDateMultiple
, setFolder , setFolder
, setFolderMultiple
, setItemDate , setItemDate
, setItemDueDate , setItemDueDate
, setItemName , setItemName
, setItemNotes , setItemNotes
, setJobPrio , setJobPrio
, setNameMultiple
, setTags , setTags
, setTagsMultiple
, setUnconfirmed , setUnconfirmed
, startClassifier , startClassifier
, startOnceNotifyDueItems , startOnceNotifyDueItems
@ -119,6 +131,7 @@ import Api.Model.EquipmentList exposing (EquipmentList)
import Api.Model.FolderDetail exposing (FolderDetail) import Api.Model.FolderDetail exposing (FolderDetail)
import Api.Model.FolderList exposing (FolderList) import Api.Model.FolderList exposing (FolderList)
import Api.Model.GenInvite exposing (GenInvite) import Api.Model.GenInvite exposing (GenInvite)
import Api.Model.IdList exposing (IdList)
import Api.Model.IdResult exposing (IdResult) import Api.Model.IdResult exposing (IdResult)
import Api.Model.ImapSettings exposing (ImapSettings) import Api.Model.ImapSettings exposing (ImapSettings)
import Api.Model.ImapSettingsList exposing (ImapSettingsList) 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.ItemProposals exposing (ItemProposals)
import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemSearch exposing (ItemSearch)
import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) 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.JobPriority exposing (JobPriority)
import Api.Model.JobQueueState exposing (JobQueueState) import Api.Model.JobQueueState exposing (JobQueueState)
import Api.Model.MoveAttachment exposing (MoveAttachment) import Api.Model.MoveAttachment exposing (MoveAttachment)
@ -166,6 +184,7 @@ import Data.Priority exposing (Priority)
import File exposing (File) import File exposing (File)
import Http import Http
import Json.Encode as JsonEncode import Json.Encode as JsonEncode
import Set exposing (Set)
import Task import Task
import Url import Url
import Util.File 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 --- Item

View File

@ -1,6 +1,7 @@
module Comp.ItemCardList exposing module Comp.ItemCardList exposing
( Model ( Model
, Msg(..) , Msg(..)
, ViewConfig
, init , init
, nextItem , nextItem
, prevItem , prevItem
@ -17,12 +18,16 @@ import Data.Direction
import Data.Fields import Data.Fields
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
import Data.Icons as Icons import Data.Icons as Icons
import Data.ItemSelection exposing (ItemSelection)
import Data.Items import Data.Items
import Data.UiSettings exposing (UiSettings) import Data.UiSettings exposing (UiSettings)
import Html exposing (..) import Html exposing (..)
import Html.Attributes exposing (..) import Html.Attributes exposing (..)
import Html.Events exposing (onClick)
import Markdown import Markdown
import Page exposing (Page(..)) import Page exposing (Page(..))
import Set exposing (Set)
import Util.Html
import Util.ItemDragDrop as DD import Util.ItemDragDrop as DD
import Util.List import Util.List
import Util.String import Util.String
@ -38,6 +43,7 @@ type Msg
= SetResults ItemLightList = SetResults ItemLightList
| AddResults ItemLightList | AddResults ItemLightList
| ItemDDMsg DD.Msg | ItemDDMsg DD.Msg
| ToggleSelectItem (Set String) String
init : Model init : Model
@ -75,6 +81,7 @@ type alias UpdateResult =
{ model : Model { model : Model
, cmd : Cmd Msg , cmd : Cmd Msg
, dragModel : DD.Model , dragModel : DD.Model
, selection : ItemSelection
} }
@ -91,51 +98,78 @@ updateDrag dm _ msg model =
newModel = newModel =
{ model | results = list } { model | results = list }
in in
UpdateResult newModel Cmd.none dm UpdateResult newModel Cmd.none dm Data.ItemSelection.Inactive
AddResults list -> AddResults list ->
if list.groups == [] then if list.groups == [] then
UpdateResult model Cmd.none dm UpdateResult model Cmd.none dm Data.ItemSelection.Inactive
else else
let let
newModel = newModel =
{ model | results = Data.Items.concat model.results list } { model | results = Data.Items.concat model.results list }
in in
UpdateResult newModel Cmd.none dm UpdateResult newModel Cmd.none dm Data.ItemSelection.Inactive
ItemDDMsg lm -> ItemDDMsg lm ->
let let
ddd = ddd =
DD.update lm dm DD.update lm dm
in 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
view : Maybe String -> UiSettings -> Model -> Html Msg type alias ViewConfig =
view current settings model = { 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" ] 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 : ViewConfig -> UiSettings -> ItemLightGroup -> Html Msg
viewGroup current settings group = viewGroup cfg settings group =
div [ class "item-group" ] div [ class "item-group" ]
[ div [ class "ui horizontal divider header item-list" ] [ div [ class "ui horizontal divider header item-list" ]
[ i [ class "calendar alternate outline icon" ] [] [ i [ class "calendar alternate outline icon" ] []
, text group.name , text group.name
] ]
, div [ class "ui stackable three cards" ] , 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 : ViewConfig -> UiSettings -> ItemLight -> Html Msg
viewItem current settings item = viewItem cfg settings item =
let let
dirIcon = dirIcon =
i [ class (Data.Direction.iconFromMaybe item.direction) ] [] i [ class (Data.Direction.iconFromMaybe item.direction) ] []
@ -163,43 +197,69 @@ viewItem current settings item =
isConfirmed = isConfirmed =
item.state /= "created" item.state /= "created"
newColor = cardColor =
"blue" if isSelected cfg item.id then
"purple"
else if not isConfirmed then
"blue"
else
""
fieldHidden f = fieldHidden f =
Data.UiSettings.fieldHidden settings 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 in
a a
([ classList ([ classList
[ ( "ui fluid card", True ) [ ( "ui fluid card", True )
, ( newColor, not isConfirmed ) , ( cardColor, True )
, ( "current", current == Just item.id ) , ( "current", cfg.current == Just item.id )
] ]
, id item.id , id item.id
, Page.href (ItemDetailPage item.id) , href "#"
, cardAction
] ]
++ DD.draggable ItemDDMsg item.id ++ DD.draggable ItemDDMsg item.id
) )
[ div [ class "content" ] [ div [ class "content" ]
[ if fieldHidden Data.Fields.Direction then [ case cfg.selection of
div [ class "header" ] Data.ItemSelection.Active ids ->
[ Util.String.underscoreToSpace item.name |> text div [ class "header" ]
] [ Util.Html.checkbox (Set.member item.id ids)
, dirIcon
, Util.String.underscoreToSpace item.name
|> text
]
else Data.ItemSelection.Inactive ->
div if fieldHidden Data.Fields.Direction then
[ class "header" div [ class "header" ]
, Data.Direction.labelFromMaybe item.direction [ Util.String.underscoreToSpace item.name |> text
|> title ]
]
[ dirIcon else
, Util.String.underscoreToSpace item.name div
|> text [ class "header"
] , Data.Direction.labelFromMaybe item.direction
|> title
]
[ dirIcon
, Util.String.underscoreToSpace item.name
|> text
]
, div , div
[ classList [ classList
[ ( "ui right corner label", True ) [ ( "ui right corner label", True )
, ( newColor, True ) , ( cardColor, True )
, ( "invisible", isConfirmed ) , ( "invisible", isConfirmed )
] ]
, title "New" , title "New"

View File

@ -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 ) ] }

View File

@ -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

View File

@ -24,7 +24,6 @@ import File exposing (File)
import Html exposing (..) import Html exposing (..)
import Html.Attributes exposing (..) import Html.Attributes exposing (..)
import Html.Events exposing (onCheck, onClick, onInput) import Html.Events exposing (onCheck, onClick, onInput)
import Html5.DragDrop as DD
import Markdown import Markdown
import Page exposing (Page(..)) import Page exposing (Page(..))
import Set import Set

View File

@ -6,6 +6,8 @@ module Comp.YesNoDimmer exposing
, defaultSettings , defaultSettings
, disable , disable
, emptyModel , emptyModel
, initActive
, initInactive
, update , update
, view , view
, view2 , view2
@ -27,6 +29,18 @@ emptyModel =
} }
initInactive : Model
initInactive =
{ active = False
}
initActive : Model
initActive =
{ active = True
}
type Msg type Msg
= Activate = Activate
| Disable | Disable
@ -40,6 +54,7 @@ type alias Settings =
, confirmButton : String , confirmButton : String
, cancelButton : String , cancelButton : String
, invertedDimmer : Bool , invertedDimmer : Bool
, extraClass : String
} }
@ -51,6 +66,7 @@ defaultSettings =
, confirmButton = "Yes, do it!" , confirmButton = "Yes, do it!"
, cancelButton = "No" , cancelButton = "No"
, invertedDimmer = False , invertedDimmer = False
, extraClass = ""
} }
@ -87,6 +103,7 @@ view2 active settings model =
div div
[ classList [ classList
[ ( "ui dimmer", True ) [ ( "ui dimmer", True )
, ( settings.extraClass, True )
, ( "inverted", settings.invertedDimmer ) , ( "inverted", settings.invertedDimmer )
, ( "active", active && model.active ) , ( "active", active && model.active )
] ]

View File

@ -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

View File

@ -1,12 +1,16 @@
module Data.Items exposing module Data.Items exposing
( concat ( concat
, first , first
, idSet
, length , length
, replaceIn
) )
import Api.Model.ItemLight exposing (ItemLight) import Api.Model.ItemLight exposing (ItemLight)
import Api.Model.ItemLightGroup exposing (ItemLightGroup) import Api.Model.ItemLightGroup exposing (ItemLightGroup)
import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemLightList exposing (ItemLightList)
import Dict exposing (Dict)
import Set exposing (Set)
import Util.List import Util.List
@ -65,3 +69,54 @@ lastGroup : ItemLightList -> Maybe ItemLightGroup
lastGroup list = lastGroup list =
List.reverse list.groups List.reverse list.groups
|> List.head |> 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

View File

@ -2,26 +2,36 @@ module Page.Home.Data exposing
( Model ( Model
, Msg(..) , Msg(..)
, SearchType(..) , SearchType(..)
, SelectActionMode(..)
, SelectViewModel
, ViewMode(..)
, defaultSearchType , defaultSearchType
, doSearchCmd , doSearchCmd
, init , init
, initSelectViewModel
, itemNav , itemNav
, menuCollapsed
, resultsBelowLimit , resultsBelowLimit
, searchTypeString , searchTypeString
, selectActive
) )
import Api import Api
import Api.Model.BasicResult exposing (BasicResult)
import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemLightList exposing (ItemLightList)
import Api.Model.ItemSearch import Api.Model.ItemSearch
import Browser.Dom as Dom import Browser.Dom as Dom
import Comp.FixedDropdown import Comp.FixedDropdown
import Comp.ItemCardList import Comp.ItemCardList
import Comp.ItemDetail.EditMenu
import Comp.SearchMenu import Comp.SearchMenu
import Comp.YesNoDimmer
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
import Data.ItemNav exposing (ItemNav) import Data.ItemNav exposing (ItemNav)
import Data.Items import Data.Items
import Data.UiSettings exposing (UiSettings) import Data.UiSettings exposing (UiSettings)
import Http import Http
import Set exposing (Set)
import Throttle exposing (Throttle) import Throttle exposing (Throttle)
import Util.Html exposing (KeyCode(..)) import Util.Html exposing (KeyCode(..))
import Util.ItemDragDrop as DD import Util.ItemDragDrop as DD
@ -31,7 +41,7 @@ type alias Model =
{ searchMenuModel : Comp.SearchMenu.Model { searchMenuModel : Comp.SearchMenu.Model
, itemListModel : Comp.ItemCardList.Model , itemListModel : Comp.ItemCardList.Model
, searchInProgress : Bool , searchInProgress : Bool
, menuCollapsed : Bool , viewMode : ViewMode
, searchOffset : Int , searchOffset : Int
, moreAvailable : Bool , moreAvailable : Bool
, moreInProgress : 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 -> Model
init flags = init flags =
let let
@ -58,7 +91,6 @@ init flags =
{ searchMenuModel = Comp.SearchMenu.init { searchMenuModel = Comp.SearchMenu.init
, itemListModel = Comp.ItemCardList.init , itemListModel = Comp.ItemCardList.init
, searchInProgress = False , searchInProgress = False
, menuCollapsed = True
, searchOffset = 0 , searchOffset = 0
, moreAvailable = True , moreAvailable = True
, moreInProgress = False , moreInProgress = False
@ -72,6 +104,7 @@ init flags =
, dragDropData = , dragDropData =
DD.DragDropData DD.init Nothing DD.DragDropData DD.init Nothing
, scrollToCard = Nothing , scrollToCard = Nothing
, viewMode = SimpleView
} }
@ -84,6 +117,32 @@ defaultSearchType flags =
BasicSearch 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 type Msg
= Init = Init
| SearchMenuMsg Comp.SearchMenu.Msg | SearchMenuMsg Comp.SearchMenu.Msg
@ -93,6 +152,7 @@ type Msg
| ItemSearchAddResp (Result Http.Error ItemLightList) | ItemSearchAddResp (Result Http.Error ItemLightList)
| DoSearch | DoSearch
| ToggleSearchMenu | ToggleSearchMenu
| ToggleSelectView
| LoadMore | LoadMore
| UpdateThrottle | UpdateThrottle
| SetBasicSearch String | SetBasicSearch String
@ -101,6 +161,15 @@ type Msg
| SetContentOnly String | SetContentOnly String
| ScrollResult (Result Dom.Error ()) | ScrollResult (Result Dom.Error ())
| ClearItemDetailId | 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 type SearchType
@ -109,6 +178,12 @@ type SearchType
| ContentOnlySearch | ContentOnlySearch
type SelectActionMode
= NoneAction
| DeleteSelected
| EditSelected
searchTypeString : SearchType -> String searchTypeString : SearchType -> String
searchTypeString st = searchTypeString st =
case st of case st of

View File

@ -1,15 +1,25 @@
module Page.Home.Update exposing (update) 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 Browser.Navigation as Nav
import Comp.FixedDropdown import Comp.FixedDropdown
import Comp.ItemCardList import Comp.ItemCardList
import Comp.ItemDetail.EditMenu
import Comp.ItemDetail.FormChange
import Comp.SearchMenu import Comp.SearchMenu
import Comp.YesNoDimmer
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
import Data.ItemSelection
import Data.Items
import Data.UiSettings exposing (UiSettings) import Data.UiSettings exposing (UiSettings)
import Page exposing (Page(..)) import Page exposing (Page(..))
import Page.Home.Data exposing (..) import Page.Home.Data exposing (..)
import Process import Process
import Scroll import Scroll
import Set exposing (Set)
import Task import Task
import Throttle import Throttle
import Time import Time
@ -82,10 +92,19 @@ update mId key flags settings msg model =
flags flags
m m
model.itemListModel model.itemListModel
nextView =
case ( model.viewMode, result.selection ) of
( SelectView svm, Data.ItemSelection.Active ids ) ->
SelectView { svm | ids = ids }
( v, _ ) ->
v
in in
withSub withSub
( { model ( { model
| itemListModel = result.model | itemListModel = result.model
, viewMode = nextView
, dragDropData = DD.DragDropData result.dragModel Nothing , dragDropData = DD.DragDropData result.dragModel Nothing
} }
, Cmd.batch [ Cmd.map ItemCardListMsg result.cmd ] , Cmd.batch [ Cmd.map ItemCardListMsg result.cmd ]
@ -159,11 +178,43 @@ update mId key flags settings msg model =
doSearch flags settings False nm doSearch flags settings False nm
ToggleSearchMenu -> ToggleSearchMenu ->
let
nextView =
case model.viewMode of
SimpleView ->
SearchView
SearchView ->
SimpleView
SelectView _ ->
SimpleView
in
withSub withSub
( { model | menuCollapsed = not model.menuCollapsed } ( { model | viewMode = nextView }
, Cmd.none , 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 -> LoadMore ->
if model.moreAvailable then if model.moreAvailable then
doSearchMore flags settings model |> withSub doSearchMore flags settings model |> withSub
@ -253,11 +304,223 @@ update mId key flags settings msg model =
ClearItemDetailId -> ClearItemDetailId ->
noSub ( { model | scrollToCard = Nothing }, Cmd.none ) 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 --- 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 : Maybe String -> Model -> ( Model, Cmd Msg, Sub Msg )
scrollToCard mId model = scrollToCard mId model =
let let
@ -275,12 +538,17 @@ scrollToCard mId model =
( model, Cmd.none, Sub.none ) ( 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 -> UiSettings -> Bool -> Model -> ( Model, Cmd Msg, Sub Msg )
doSearch flags settings scroll model = doSearch flags settings scroll model =
let let
stype = stype =
if if
not model.menuCollapsed not (menuCollapsed model)
|| Util.String.isNothingOrBlank model.contentOnlySearch || Util.String.isNothingOrBlank model.contentOnlySearch
then then
BasicSearch BasicSearch

View File

@ -3,26 +3,51 @@ module Page.Home.View exposing (view)
import Api.Model.ItemSearch import Api.Model.ItemSearch
import Comp.FixedDropdown import Comp.FixedDropdown
import Comp.ItemCardList import Comp.ItemCardList
import Comp.ItemDetail.EditMenu
import Comp.SearchMenu import Comp.SearchMenu
import Comp.YesNoDimmer
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
import Data.ItemSelection
import Data.UiSettings exposing (UiSettings) import Data.UiSettings exposing (UiSettings)
import Html exposing (..) import Html exposing (..)
import Html.Attributes exposing (..) import Html.Attributes exposing (..)
import Html.Events exposing (onClick, onInput) import Html.Events exposing (onClick, onInput)
import Page exposing (Page(..)) import Page exposing (Page(..))
import Page.Home.Data exposing (..) import Page.Home.Data exposing (..)
import Set
import Util.Html import Util.Html
view : Flags -> UiSettings -> Model -> Html Msg view : Flags -> UiSettings -> Model -> Html Msg
view flags settings model = 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 [ class "home-page ui padded grid" ]
[ div [ div
[ classList [ classList
[ ( "sixteen wide mobile six wide tablet four wide computer search-menu column" [ ( "sixteen wide mobile six wide tablet four wide computer search-menu column"
, True , True
) )
, ( "invisible hidden", model.menuCollapsed ) , ( "invisible hidden", menuCollapsed model )
] ]
] ]
[ div [ div
@ -38,6 +63,17 @@ view flags settings model =
] ]
, div [ class "right floated menu" ] , div [ class "right floated menu" ]
[ a [ a
[ classList
[ ( "borderless item", True )
, ( "active", selectActive model )
]
, href "#"
, title "Toggle select items"
, onClick ToggleSelectView
]
[ i [ class "tasks icon" ] []
]
, a
[ class "borderless item" [ class "borderless item"
, onClick ResetSearch , onClick ResetSearch
, title "Reset form" , title "Reset form"
@ -63,26 +99,30 @@ view flags settings model =
] ]
] ]
, div [ class "" ] , div [ class "" ]
[ Html.map SearchMenuMsg (viewLeftMenu flags settings model)
(Comp.SearchMenu.viewDrop model.dragDropData
flags
settings
model.searchMenuModel
)
]
] ]
, div , div
[ classList [ classList
[ ( "sixteen wide mobile ten wide tablet twelve wide computer column" [ ( "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 ) , ( "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 , Html.map ItemCardListMsg
(Comp.ItemCardList.view model.scrollToCard settings model.itemListModel) (Comp.ItemCardList.view itemViewCfg settings model.itemListModel)
] ]
, div , div
[ classList [ 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 -> Html Msg
viewSearchBar flags model = viewSearchBar flags model =
let let
@ -145,7 +292,7 @@ viewSearchBar flags model =
in in
div div
[ classList [ classList
[ ( "invisible hidden", not model.menuCollapsed ) [ ( "invisible hidden", not (menuCollapsed model) )
, ( "ui secondary stackable menu container", True ) , ( "ui secondary stackable menu container", True )
] ]
] ]
@ -221,3 +368,15 @@ hasMoreSearch model =
Api.Model.ItemSearch.empty Api.Model.ItemSearch.empty
in in
is_ /= Api.Model.ItemSearch.empty 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"
}

View File

@ -31,6 +31,9 @@
margin: 0 1em; margin: 0 1em;
} }
.default-layout .ui.icon.menu .label.item {
padding: 0 0.5em 0 0;
}
.default-layout .right-float { .default-layout .right-float {
float: right; float: right;