From d12c672dcf0c8eed7fc30816f996145be3f16387 Mon Sep 17 00:00:00 2001 From: eikek Date: Sun, 13 Mar 2022 21:08:09 +0100 Subject: [PATCH 1/7] Log structured details during job execution --- .../docspell/scheduler/impl/ContextImpl.scala | 9 ++++++++- .../docspell/scheduler/impl}/LogEvent.scala | 10 ++++++++-- .../scala/docspell/scheduler/impl/LogSink.scala | 6 ++++-- .../docspell/scheduler/impl/QueueLogger.scala | 16 +++++++++++++--- 4 files changed, 33 insertions(+), 8 deletions(-) rename modules/scheduler/{api/src/main/scala/docspell/scheduler => impl/src/main/scala/docspell/scheduler/impl}/LogEvent.scala (71%) diff --git a/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/ContextImpl.scala b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/ContextImpl.scala index d801a169..59016b9f 100644 --- a/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/ContextImpl.scala +++ b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/ContextImpl.scala @@ -59,7 +59,14 @@ object ContextImpl { val log = docspell.logging.getLogger[F] for { _ <- log.trace("Creating logger for task run") - logger <- QueueLogger(job.id, job.info, config.logBufferSize, logSink) + logger <- QueueLogger( + job.id, + job.task, + job.group, + job.info, + config.logBufferSize, + logSink + ) _ <- log.trace("Logger created, instantiating context") ctx = create[F, A](job.id, arg, config, logger, store) } yield ctx diff --git a/modules/scheduler/api/src/main/scala/docspell/scheduler/LogEvent.scala b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/LogEvent.scala similarity index 71% rename from modules/scheduler/api/src/main/scala/docspell/scheduler/LogEvent.scala rename to modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/LogEvent.scala index 29a91631..1744e330 100644 --- a/modules/scheduler/api/src/main/scala/docspell/scheduler/LogEvent.scala +++ b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/LogEvent.scala @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package docspell.scheduler +package docspell.scheduler.impl import cats.effect.Sync import cats.implicits._ @@ -13,6 +13,8 @@ import docspell.common._ case class LogEvent( jobId: Ident, + taskName: Ident, + group: Ident, jobInfo: String, time: Timestamp, level: LogLevel, @@ -29,10 +31,14 @@ object LogEvent { def create[F[_]: Sync]( jobId: Ident, + taskName: Ident, + group: Ident, jobInfo: String, level: LogLevel, msg: String ): F[LogEvent] = - Timestamp.current[F].map(now => LogEvent(jobId, jobInfo, now, level, msg)) + Timestamp + .current[F] + .map(now => LogEvent(jobId, taskName, group, jobInfo, now, level, msg)) } diff --git a/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/LogSink.scala b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/LogSink.scala index c59b45ce..e133a1df 100644 --- a/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/LogSink.scala +++ b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/LogSink.scala @@ -12,7 +12,6 @@ import fs2.Pipe import docspell.common._ import docspell.logging -import docspell.scheduler.LogEvent import docspell.store.Store import docspell.store.records.RJobLog @@ -32,7 +31,10 @@ object LogSink { def logInternal[F[_]: Sync](e: LogEvent): F[Unit] = { val logger = docspell.logging.getLogger[F] val addData: logging.LogEvent => logging.LogEvent = - _.data("jobId", e.jobId).data("jobInfo", e.jobInfo) + _.data("jobId", e.jobId) + .data("task", e.taskName) + .data("group", e.group) + .data("jobInfo", e.jobInfo) e.level match { case LogLevel.Info => diff --git a/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/QueueLogger.scala b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/QueueLogger.scala index 8a4a0824..3a3992ff 100644 --- a/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/QueueLogger.scala +++ b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/QueueLogger.scala @@ -14,12 +14,13 @@ import fs2.Stream import docspell.common.{Ident, LogLevel} import docspell.logging import docspell.logging.{Level, Logger} -import docspell.scheduler.LogEvent object QueueLogger { def create[F[_]: Sync]( jobId: Ident, + taskName: Ident, + group: Ident, jobInfo: String, q: Queue[F, LogEvent] ): Logger[F] = @@ -27,7 +28,14 @@ object QueueLogger { def log(logEvent: logging.LogEvent) = LogEvent - .create[F](jobId, jobInfo, level2Level(logEvent.level), logEvent.msg()) + .create[F]( + jobId, + taskName, + group, + jobInfo, + level2Level(logEvent.level), + logEvent.msg() + ) .flatMap { ev => val event = logEvent.findErrors.headOption @@ -42,13 +50,15 @@ object QueueLogger { def apply[F[_]: Async]( jobId: Ident, + taskName: Ident, + group: Ident, jobInfo: String, bufferSize: Int, sink: LogSink[F] ): F[Logger[F]] = for { q <- Queue.circularBuffer[F, LogEvent](bufferSize) - log = create(jobId, jobInfo, q) + log = create(jobId, taskName, group, jobInfo, q) _ <- Async[F].start( Stream.fromQueueUnterminated(q).through(sink.receive).compile.drain ) From 8d5fc7f9dad513d4fa5f9e4794e179f18ea679a1 Mon Sep 17 00:00:00 2001 From: eikek Date: Mon, 14 Mar 2022 12:41:36 +0100 Subject: [PATCH 2/7] Move scheduler queries into the new module --- build.sbt | 7 +- .../scala/docspell/backend/ops/OJob.scala | 4 +- .../docspell/scheduler/impl/JobQueue.scala | 1 - .../scala/docspell/scheduler/impl}/QJob.scala | 56 ++-------------- .../scheduler/impl/SchedulerImpl.scala | 1 - .../docspell/scheduler/impl}/QJobTest.scala | 5 +- .../docspell/store/queries/QJobQueue.scala | 66 +++++++++++++++++++ 7 files changed, 80 insertions(+), 60 deletions(-) rename modules/{store/src/main/scala/docspell/store/queries => scheduler/impl/src/main/scala/docspell/scheduler/impl}/QJob.scala (80%) rename modules/{store/src/test/scala/docspell/store/queries => scheduler/impl/src/test/scala/docspell/scheduler/impl}/QJobTest.scala (96%) create mode 100644 modules/store/src/main/scala/docspell/store/queries/QJobQueue.scala diff --git a/build.sbt b/build.sbt index 7f395a82..510aabb6 100644 --- a/build.sbt +++ b/build.sbt @@ -540,7 +540,12 @@ val schedulerImpl = project .settings( name := "docspell-scheduler-impl" ) - .dependsOn(store, schedulerApi, notificationApi, pubsubApi) + .dependsOn( + store % "compile->compile;test->test", + schedulerApi, + notificationApi, + pubsubApi + ) val extract = project .in(file("modules/extract")) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OJob.scala b/modules/backend/src/main/scala/docspell/backend/ops/OJob.scala index e0c14552..a5d88d35 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OJob.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OJob.scala @@ -16,7 +16,7 @@ import docspell.pubsub.api.PubSubT import docspell.scheduler.msg.JobDone import docspell.store.Store import docspell.store.UpdateResult -import docspell.store.queries.QJob +import docspell.store.queries.QJobQueue import docspell.store.records.{RJob, RJobLog} trait OJob[F[_]] { @@ -64,7 +64,7 @@ object OJob { def queueState(collective: Ident, maxResults: Int): F[CollectiveQueueState] = store .transact( - QJob.queueStateSnapshot(collective, maxResults.toLong) + QJobQueue.queueStateSnapshot(collective, maxResults.toLong) ) .map(t => JobDetail(t._1, t._2)) .compile diff --git a/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/JobQueue.scala b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/JobQueue.scala index 5317b7dc..fab38496 100644 --- a/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/JobQueue.scala +++ b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/JobQueue.scala @@ -11,7 +11,6 @@ import cats.implicits._ import docspell.common._ import docspell.store.Store -import docspell.store.queries.QJob import docspell.store.records.RJob trait JobQueue[F[_]] { diff --git a/modules/store/src/main/scala/docspell/store/queries/QJob.scala b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/QJob.scala similarity index 80% rename from modules/store/src/main/scala/docspell/store/queries/QJob.scala rename to modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/QJob.scala index a172540b..91c37804 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QJob.scala +++ b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/QJob.scala @@ -4,10 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package docspell.store.queries +package docspell.scheduler.impl -import cats.data.NonEmptyList -import cats.effect._ +import cats.effect.Async import cats.implicits._ import fs2.Stream @@ -15,10 +14,9 @@ import docspell.common._ import docspell.store.Store import docspell.store.qb.DSL._ import docspell.store.qb._ -import docspell.store.records.{RJob, RJobGroupUse, RJobLog} +import docspell.store.records.{RJob, RJobGroupUse} -import doobie._ -import doobie.implicits._ +import doobie.ConnectionIO object QJob { private[this] val cioLogger = docspell.logging.getLogger[ConnectionIO] @@ -231,50 +229,4 @@ object QJob { def findAll[F[_]](ids: Seq[Ident], store: Store[F]): F[Vector[RJob]] = store.transact(RJob.findFromIds(ids)) - - def queueStateSnapshot( - collective: Ident, - max: Long - ): Stream[ConnectionIO, (RJob, Vector[RJobLog])] = { - val JC = RJob.T - val waiting = NonEmptyList.of(JobState.Waiting, JobState.Stuck, JobState.Scheduled) - val running = NonEmptyList.of(JobState.Running) - // val done = JobState.all.filterNot(js => ).diff(waiting).diff(running) - - def selectJobs(now: Timestamp): Stream[ConnectionIO, RJob] = { - val refDate = now.minusHours(24) - val runningJobs = Select( - select(JC.all), - from(JC), - JC.group === collective && JC.state.in(running) - ).orderBy(JC.submitted.desc).build.query[RJob].stream - - val waitingJobs = Select( - select(JC.all), - from(JC), - JC.group === collective && JC.state.in(waiting) && JC.submitted > refDate - ).orderBy(JC.submitted.desc).build.query[RJob].stream.take(max) - - val doneJobs = Select( - select(JC.all), - from(JC), - and( - JC.group === collective, - JC.state.in(JobState.done), - JC.submitted > refDate - ) - ).orderBy(JC.submitted.desc).build.query[RJob].stream.take(max) - - runningJobs ++ waitingJobs ++ doneJobs - } - - def selectLogs(job: RJob): ConnectionIO[Vector[RJobLog]] = - RJobLog.findLogs(job.id) - - for { - now <- Stream.eval(Timestamp.current[ConnectionIO]) - job <- selectJobs(now) - res <- Stream.eval(selectLogs(job)) - } yield (job, res) - } } diff --git a/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/SchedulerImpl.scala b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/SchedulerImpl.scala index 955a3b59..bc87d0fa 100644 --- a/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/SchedulerImpl.scala +++ b/modules/scheduler/impl/src/main/scala/docspell/scheduler/impl/SchedulerImpl.scala @@ -21,7 +21,6 @@ import docspell.scheduler._ import docspell.scheduler.impl.SchedulerImpl._ import docspell.scheduler.msg.{CancelJob, JobDone, JobsNotify} import docspell.store.Store -import docspell.store.queries.QJob import docspell.store.records.RJob import io.circe.Json diff --git a/modules/store/src/test/scala/docspell/store/queries/QJobTest.scala b/modules/scheduler/impl/src/test/scala/docspell/scheduler/impl/QJobTest.scala similarity index 96% rename from modules/store/src/test/scala/docspell/store/queries/QJobTest.scala rename to modules/scheduler/impl/src/test/scala/docspell/scheduler/impl/QJobTest.scala index adf6facd..6c78a050 100644 --- a/modules/store/src/test/scala/docspell/store/queries/QJobTest.scala +++ b/modules/scheduler/impl/src/test/scala/docspell/scheduler/impl/QJobTest.scala @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package docspell.store.queries +package docspell.scheduler.impl import java.time.Instant import java.util.concurrent.atomic.AtomicLong @@ -14,8 +14,7 @@ import cats.implicits._ import docspell.common._ import docspell.logging.TestLoggingConfig import docspell.store.StoreFixture -import docspell.store.records.RJob -import docspell.store.records.RJobGroupUse +import docspell.store.records.{RJob, RJobGroupUse} import doobie.implicits._ import munit._ diff --git a/modules/store/src/main/scala/docspell/store/queries/QJobQueue.scala b/modules/store/src/main/scala/docspell/store/queries/QJobQueue.scala new file mode 100644 index 00000000..9ab45d2a --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QJobQueue.scala @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.queries + +import cats.data.NonEmptyList +import fs2.Stream + +import docspell.common._ +import docspell.store.qb.DSL._ +import docspell.store.qb._ +import docspell.store.records.{RJob, RJobLog} + +import doobie.ConnectionIO + +object QJobQueue { + + def queueStateSnapshot( + collective: Ident, + max: Long + ): Stream[ConnectionIO, (RJob, Vector[RJobLog])] = { + val JC = RJob.T + val waiting = NonEmptyList.of(JobState.Waiting, JobState.Stuck, JobState.Scheduled) + val running = NonEmptyList.of(JobState.Running) + // val done = JobState.all.filterNot(js => ).diff(waiting).diff(running) + + def selectJobs(now: Timestamp): Stream[ConnectionIO, RJob] = { + val refDate = now.minusHours(24) + val runningJobs = Select( + select(JC.all), + from(JC), + JC.group === collective && JC.state.in(running) + ).orderBy(JC.submitted.desc).build.query[RJob].stream + + val waitingJobs = Select( + select(JC.all), + from(JC), + JC.group === collective && JC.state.in(waiting) && JC.submitted > refDate + ).orderBy(JC.submitted.desc).build.query[RJob].stream.take(max) + + val doneJobs = Select( + select(JC.all), + from(JC), + and( + JC.group === collective, + JC.state.in(JobState.done), + JC.submitted > refDate + ) + ).orderBy(JC.submitted.desc).build.query[RJob].stream.take(max) + + runningJobs ++ waitingJobs ++ doneJobs + } + + def selectLogs(job: RJob): ConnectionIO[Vector[RJobLog]] = + RJobLog.findLogs(job.id) + + for { + now <- Stream.eval(Timestamp.current[ConnectionIO]) + job <- selectJobs(now) + res <- Stream.eval(selectLogs(job)) + } yield (job, res) + } +} From 1874ac070f65d487b9652036f018858c260dc2d2 Mon Sep 17 00:00:00 2001 From: eikek Date: Mon, 14 Mar 2022 16:53:13 +0100 Subject: [PATCH 3/7] Fix executing collective user tasks now --- .../src/main/scala/docspell/backend/ops/OCollective.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index e59ddb9a..bd4451e3 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -205,7 +205,7 @@ object OCollective { args ) _ <- uts - .updateOneTask(UserTaskScope(collective), args.makeSubject.some, ut) + .executeNow(UserTaskScope(collective), args.makeSubject.some, ut) _ <- joex.notifyAllNodes } yield () @@ -221,7 +221,7 @@ object OCollective { args ) _ <- uts - .updateOneTask(UserTaskScope(args.collective), args.makeSubject.some, ut) + .executeNow(UserTaskScope(args.collective), args.makeSubject.some, ut) _ <- joex.notifyAllNodes } yield () From 232baf5858f394a13d7dff8e810c0e61489286f9 Mon Sep 17 00:00:00 2001 From: eikek Date: Mon, 14 Mar 2022 16:54:39 +0100 Subject: [PATCH 4/7] Add routes to link items --- .../scala/docspell/backend/BackendApp.scala | 3 + .../docspell/backend/ops/OItemLink.scala | 89 +++++++++++++ .../scala/docspell/query/ItemQueryDsl.scala | 2 + .../src/main/resources/docspell-openapi.yml | 109 ++++++++++++++++ .../docspell/restserver/RestAppImpl.scala | 1 + .../restserver/conv/Conversions.scala | 6 +- .../restserver/routes/ItemLinkRoutes.scala | 82 ++++++++++++ .../migration/h2/V1.34.0__item_relation.sql | 11 ++ .../mariadb/V1.34.0__item_relation.sql | 11 ++ .../postgresql/V1.34.0__item_relation.sql | 11 ++ .../main/scala/docspell/store/qb/DML.scala | 7 + .../docspell/store/records/RItemLink.scala | 120 ++++++++++++++++++ 12 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/ops/OItemLink.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/ItemLinkRoutes.scala create mode 100644 modules/store/src/main/resources/db/migration/h2/V1.34.0__item_relation.sql create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.34.0__item_relation.sql create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.34.0__item_relation.sql create mode 100644 modules/store/src/main/scala/docspell/store/records/RItemLink.scala diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index 5d5dc532..2413b37c 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -50,6 +50,7 @@ trait BackendApp[F[_]] { def notification: ONotification[F] def bookmarks: OQueryBookmarks[F] def fileRepository: OFileRepository[F] + def itemLink: OItemLink[F] } object BackendApp { @@ -106,6 +107,7 @@ object BackendApp { notifyImpl <- ONotification(store, notificationMod) bookmarksImpl <- OQueryBookmarks(store) fileRepoImpl <- OFileRepository(store, schedulerModule.jobs, joexImpl) + itemLinkImpl <- Resource.pure(OItemLink(store, itemSearchImpl)) } yield new BackendApp[F] { val pubSub = pubSubT val login = loginImpl @@ -134,5 +136,6 @@ object BackendApp { val notification = notifyImpl val bookmarks = bookmarksImpl val fileRepository = fileRepoImpl + val itemLink = itemLinkImpl } } diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItemLink.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItemLink.scala new file mode 100644 index 00000000..16077b10 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItemLink.scala @@ -0,0 +1,89 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.backend.ops + +import cats.data.NonEmptyList +import cats.effect._ +import cats.implicits._ + +import docspell.backend.ops.OItemLink.LinkResult +import docspell.common.{AccountId, Ident} +import docspell.query.ItemQuery +import docspell.query.ItemQueryDsl._ +import docspell.store.qb.Batch +import docspell.store.queries.Query +import docspell.store.records.RItemLink +import docspell.store.{AddResult, Store} + +trait OItemLink[F[_]] { + + def addAll(cid: Ident, target: Ident, related: NonEmptyList[Ident]): F[LinkResult] + + def removeAll(cid: Ident, target: Ident, related: NonEmptyList[Ident]): F[Unit] + + def getRelated( + account: AccountId, + item: Ident, + batch: Batch + ): F[Vector[OItemSearch.ListItemWithTags]] +} + +object OItemLink { + + sealed trait LinkResult + object LinkResult { + + /** When the target item is in the related list. */ + case object LinkTargetItemError extends LinkResult + case object Success extends LinkResult + + def linkTargetItemError: LinkResult = LinkTargetItemError + } + + def apply[F[_]: Sync](store: Store[F], search: OItemSearch[F]): OItemLink[F] = + new OItemLink[F] { + def getRelated( + accountId: AccountId, + item: Ident, + batch: Batch + ): F[Vector[OItemSearch.ListItemWithTags]] = + store + .transact(RItemLink.findLinked(accountId.collective, item)) + .map(ids => NonEmptyList.fromList(ids.toList)) + .flatMap { + case Some(nel) => + val expr = Q.itemIdsIn(nel.map(_.id)) + val query = Query( + Query + .Fix(accountId, Some(ItemQuery.Expr.ValidItemStates), None), + Query.QueryExpr(expr) + ) + search.findItemsWithTags(0)(query, batch) + + case None => + Vector.empty[OItemSearch.ListItemWithTags].pure[F] + } + + def addAll(cid: Ident, target: Ident, related: NonEmptyList[Ident]): F[LinkResult] = + if (related.contains_(target)) LinkResult.linkTargetItemError.pure[F] + else related.traverse(addSingle(cid, target, _)).as(LinkResult.Success) + + def removeAll(cid: Ident, target: Ident, related: NonEmptyList[Ident]): F[Unit] = + store.transact(RItemLink.deleteAll(cid, target, related)).void + + def addSingle(cid: Ident, target: Ident, related: Ident): F[Unit] = { + val exists = RItemLink.exists(cid, target, related) + val insert = RItemLink.insertNew(cid, target, related) + store.add(insert, exists).flatMap { + case AddResult.Success => ().pure[F] + case AddResult.EntityExists(_) => ().pure[F] + case AddResult.Failure(ex) => + Sync[F].raiseError(ex) + } + } + } +} diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQueryDsl.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQueryDsl.scala index e1d92309..0fb9ea22 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ItemQueryDsl.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ItemQueryDsl.scala @@ -78,5 +78,7 @@ object ItemQueryDsl { def tagsEq(values: NonEmptyList[String]): Expr = Expr.TagsMatch(TagOperator.AllMatch, values) + def itemIdsIn(values: NonEmptyList[String]): Expr = + Expr.InExpr(Attr.ItemId, values) } } diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index ed349c57..98f71bb2 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -3675,6 +3675,99 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/itemlink/{itemId}: + get: + operationId: "sec-itemlink-get" + tags: [ Item ] + summary: Get related items + description: | + Returns a list of related items for the given one. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/itemId" + responses: + 422: + description: BadRequest + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ItemLightGroup" + + /sec/itemlink/{itemId}/{id}: + delete: + operationId: "sec-itemlink-delete" + tags: [Item] + summary: Delete an item from the list of related items + description: | + Deletes the item `id` from the list of related items on + `itemId`. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/itemId" + - $ref: "#/components/parameters/id" + responses: + 422: + description: BadRequest + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/itemlink/addAll: + post: + operationId: "sec-itemlink-appendall-post" + tags: [ Item ] + summary: Add more items as related + description: | + Add one or more items to anothers list of related items. + Duplicates are ignored. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemLinkData" + responses: + 422: + description: BadRequest + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/itemlink/removeAll: + post: + operationId: "sec-itemlink-removeall-post" + tags: [ Item ] + summary: Remove items from the list of related items + description: | + Remove all given items from the list of related items + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemLinkData" + responses: + 422: + description: BadRequest + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/items/merge: post: @@ -5486,6 +5579,22 @@ paths: components: schemas: + ItemLinkData: + description: | + Data for changing the list of related items. + required: + - item + - related + properties: + item: + type: string + format: ident + related: + type: array + items: + type: string + format: ident + FileIntegrityCheckRequest: description: | Data for running a file integrity check diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala b/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala index 6b0f87b8..7e6c7025 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala @@ -128,6 +128,7 @@ final class RestAppImpl[F[_]: Async]( "queue" -> JobQueueRoutes(backend, token), "item" -> ItemRoutes(config, backend, token), "items" -> ItemMultiRoutes(config, backend, token), + "itemlink" -> ItemLinkRoutes(token.account, backend.itemLink), "attachment" -> AttachmentRoutes(backend, token), "attachments" -> AttachmentMultiRoutes(backend, token), "upload" -> UploadRoutes.secured(backend, config, token), diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 5f2b6a71..13791be3 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -188,7 +188,7 @@ trait Conversions { ItemLightGroup(g._1, g._2.map(mkItemLight).toList) val gs = - groups.map(mkGroup _).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0) + groups.map(mkGroup).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0) ItemLightList(gs) } @@ -199,7 +199,7 @@ trait Conversions { ItemLightGroup(g._1, g._2.map(mkItemLightWithTags).toList) val gs = - groups.map(mkGroup _).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0) + groups.map(mkGroup).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0) ItemLightList(gs) } @@ -210,7 +210,7 @@ trait Conversions { ItemLightGroup(g._1, g._2.map(mkItemLightWithTags).toList) val gs = - groups.map(mkGroup _).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0) + groups.map(mkGroup).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0) ItemLightList(gs) } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemLinkRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemLinkRoutes.scala new file mode 100644 index 00000000..2469c99e --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemLinkRoutes.scala @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.routes + +import cats.data.{NonEmptyList, OptionT} +import cats.effect._ +import cats.implicits._ + +import docspell.backend.ops.OItemLink +import docspell.backend.ops.OItemLink.LinkResult +import docspell.common._ +import docspell.joexapi.model.BasicResult +import docspell.restapi.model.{ItemLightGroup, ItemLinkData} +import docspell.restserver.conv.Conversions +import docspell.store.qb.Batch + +import org.http4s.HttpRoutes +import org.http4s.circe.CirceEntityCodec._ +import org.http4s.dsl.Http4sDsl + +class ItemLinkRoutes[F[_]: Async](account: AccountId, backend: OItemLink[F]) + extends Http4sDsl[F] { + def get: HttpRoutes[F] = + HttpRoutes.of { + case GET -> Root / Ident(id) => + for { + results <- backend.getRelated(account, id, Batch.all) + conv = results.map(Conversions.mkItemLightWithTags) + res = ItemLightGroup("related", conv.toList) + resp <- Ok(res) + } yield resp + + case DELETE -> Root / Ident(target) / Ident(id) => + for { + _ <- backend.removeAll(account.collective, target, NonEmptyList.of(id)) + resp <- Ok(BasicResult(true, "Related items removed")) + } yield resp + + case req @ POST -> Root / "addAll" => + for { + input <- req.as[ItemLinkData] + related = NonEmptyList.fromList(input.related) + res <- OptionT + .fromOption[F](related) + .semiflatMap(backend.addAll(account.collective, input.item, _)) + .value + resp <- Ok(convertResult(res)) + } yield resp + + case req @ POST -> Root / "removeAll" => + for { + input <- req.as[ItemLinkData] + related = NonEmptyList.fromList(input.related) + _ <- related + .map(backend.removeAll(account.collective, input.item, _)) + .getOrElse( + BadRequest(BasicResult(false, "List of related items must not be empty")) + ) + resp <- Ok(BasicResult(true, "Related items removed")) + } yield resp + } + + private def convertResult(r: Option[LinkResult]): BasicResult = + r match { + case Some(LinkResult.Success) => BasicResult(true, "Related items added") + case Some(LinkResult.LinkTargetItemError) => + BasicResult(false, "Items cannot be related to itself.") + case None => + BasicResult(false, "List of related items must not be empty") + } + +} + +object ItemLinkRoutes { + + def apply[F[_]: Async](account: AccountId, itemLink: OItemLink[F]): HttpRoutes[F] = + new ItemLinkRoutes[F](account, itemLink).get +} diff --git a/modules/store/src/main/resources/db/migration/h2/V1.34.0__item_relation.sql b/modules/store/src/main/resources/db/migration/h2/V1.34.0__item_relation.sql new file mode 100644 index 00000000..4fee15c4 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.34.0__item_relation.sql @@ -0,0 +1,11 @@ +create table "item_link" ( + "id" varchar(254) not null primary key, + "cid" varchar(254) not null, + "item1" varchar(254) not null, + "item2" varchar(254) not null, + "created" timestamp not null, + unique ("cid", "item1", "item2"), + foreign key ("cid") references "collective"("cid") on delete cascade, + foreign key ("item1") references "item"("itemid") on delete cascade, + foreign key ("item2") references "item"("itemid") on delete cascade +); diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.34.0__item_relation.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.34.0__item_relation.sql new file mode 100644 index 00000000..d1904a62 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.34.0__item_relation.sql @@ -0,0 +1,11 @@ +create table `item_link` ( + `id` varchar(254) not null primary key, + `cid` varchar(254) not null, + `item1` varchar(254) not null, + `item2` varchar(254) not null, + `created` timestamp not null, + unique (`cid`, `item1`, `item2`), + foreign key (`cid`) references `collective`(`cid`) on delete cascade, + foreign key (`item1`) references `item`(`itemid`) on delete cascade, + foreign key (`item2`) references `item`(`itemid`) on delete cascade +); diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.34.0__item_relation.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.34.0__item_relation.sql new file mode 100644 index 00000000..4fee15c4 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.34.0__item_relation.sql @@ -0,0 +1,11 @@ +create table "item_link" ( + "id" varchar(254) not null primary key, + "cid" varchar(254) not null, + "item1" varchar(254) not null, + "item2" varchar(254) not null, + "created" timestamp not null, + unique ("cid", "item1", "item2"), + foreign key ("cid") references "collective"("cid") on delete cascade, + foreign key ("item1") references "item"("itemid") on delete cascade, + foreign key ("item2") references "item"("itemid") on delete cascade +); diff --git a/modules/store/src/main/scala/docspell/store/qb/DML.scala b/modules/store/src/main/scala/docspell/store/qb/DML.scala index 311b78f3..5d77b8ed 100644 --- a/modules/store/src/main/scala/docspell/store/qb/DML.scala +++ b/modules/store/src/main/scala/docspell/store/qb/DML.scala @@ -27,6 +27,13 @@ object DML extends DoobieMeta { def insert(table: TableDef, cols: Nel[Column[_]], values: Fragment): ConnectionIO[Int] = insertFragment(table, cols, List(values)).update.run + def insertSilent( + table: TableDef, + cols: Nel[Column[_]], + values: Fragment + ): ConnectionIO[Int] = + insertFragment(table, cols, List(values)).update(LogHandler.nop).run + def insertMany( table: TableDef, cols: Nel[Column[_]], diff --git a/modules/store/src/main/scala/docspell/store/records/RItemLink.scala b/modules/store/src/main/scala/docspell/store/records/RItemLink.scala new file mode 100644 index 00000000..8270a34a --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RItemLink.scala @@ -0,0 +1,120 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.Order +import cats.data.NonEmptyList +import cats.effect.Sync +import cats.implicits._ + +import docspell.common._ +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ + +final case class RItemLink( + id: Ident, + cid: Ident, + item1: Ident, + item2: Ident, + created: Timestamp +) + +object RItemLink { + def create[F[_]: Sync](cid: Ident, item1: Ident, item2: Ident): F[RItemLink] = + for { + id <- Ident.randomId[F] + now <- Timestamp.current[F] + } yield RItemLink(id, cid, item1, item2, now) + + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "item_link" + + val id: Column[Ident] = Column("id", this) + val cid: Column[Ident] = Column("cid", this) + val item1: Column[Ident] = Column("item1", this) + val item2: Column[Ident] = Column("item2", this) + val created: Column[Timestamp] = Column("created", this) + + val all: NonEmptyList[Column[_]] = + NonEmptyList.of(id, cid, item1, item2, created) + } + + def as(alias: String): Table = + Table(Some(alias)) + + val T: Table = Table(None) + + private def orderIds(item1: Ident, item2: Ident): (Ident, Ident) = { + val i1 = Order[Ident].min(item1, item2) + val i2 = Order[Ident].max(item1, item2) + (i1, i2) + } + + def insert(r: RItemLink): ConnectionIO[Int] = { + val (i1, i2) = orderIds(r.item1, r.item2) + DML.insertSilent(T, T.all, sql"${r.id},${r.cid},$i1,$i2,${r.created}") + } + + def insertNew(cid: Ident, item1: Ident, item2: Ident): ConnectionIO[Int] = + create[ConnectionIO](cid, item1, item2).flatMap(insert) + + def update(r: RItemLink): ConnectionIO[Int] = { + val (i1, i2) = orderIds(r.item1, r.item2) + DML.update( + T, + T.id === r.id && T.cid === r.cid, + DML.set( + T.item1.setTo(i1), + T.item2.setTo(i2) + ) + ) + } + + def exists(cid: Ident, item1: Ident, item2: Ident): ConnectionIO[Boolean] = { + val (i1, i2) = orderIds(item1, item2) + Select( + select(count(T.id)), + from(T), + T.cid === cid && T.item1 === i1 && T.item2 === i2 + ).build.query[Int].unique.map(_ > 0) + } + + def findLinked(cid: Ident, item: Ident): ConnectionIO[Vector[Ident]] = + union( + Select( + select(T.item1), + from(T), + T.cid === cid && T.item2 === item + ), + Select( + select(T.item2), + from(T), + T.cid === cid && T.item1 === item + ) + ).build.query[Ident].to[Vector] + + def deleteAll( + cid: Ident, + item: Ident, + related: NonEmptyList[Ident] + ): ConnectionIO[Int] = + DML.delete( + T, + T.cid === cid && ( + (T.item1 === item && T.item2.in(related)) || + (T.item2 === item && T.item1.in(related)) + ) + ) + + def delete(cid: Ident, item1: Ident, item2: Ident): ConnectionIO[Int] = { + val (i1, i2) = orderIds(item1, item2) + DML.delete(T, T.cid === cid && T.item1 === i1 && T.item2 === i2) + } +} From c7b2a1271aaa00859b8ce21f7dd9ee1745347f82 Mon Sep 17 00:00:00 2001 From: eikek Date: Wed, 16 Mar 2022 23:15:51 +0100 Subject: [PATCH 5/7] Link items it detail view --- modules/webapp/src/main/elm/Api.elm | 48 +++ .../src/main/elm/Comp/ItemDetail/Model.elm | 4 + .../src/main/elm/Comp/ItemDetail/Update.elm | 26 ++ .../src/main/elm/Comp/ItemDetail/View2.elm | 11 +- .../webapp/src/main/elm/Comp/ItemLinkForm.elm | 304 +++++++++++++ .../src/main/elm/Comp/ItemSearchInput.elm | 406 ++++++++++++++++++ .../src/main/elm/Comp/SimpleTextInput.elm | 25 +- .../webapp/src/main/elm/Data/ItemQuery.elm | 9 + .../webapp/src/main/elm/Data/ItemTemplate.elm | 6 + .../src/main/elm/Messages/Comp/ItemDetail.elm | 9 + .../main/elm/Messages/Comp/ItemLinkForm.elm | 49 +++ .../elm/Messages/Comp/ItemSearchInput.elm | 36 ++ modules/webapp/src/main/elm/Styles.elm | 7 +- modules/webapp/src/main/elm/Util/String.elm | 13 +- 14 files changed, 942 insertions(+), 11 deletions(-) create mode 100644 modules/webapp/src/main/elm/Comp/ItemLinkForm.elm create mode 100644 modules/webapp/src/main/elm/Comp/ItemSearchInput.elm create mode 100644 modules/webapp/src/main/elm/Messages/Comp/ItemLinkForm.elm create mode 100644 modules/webapp/src/main/elm/Messages/Comp/ItemSearchInput.elm diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 13249814..c8f43b96 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -13,6 +13,7 @@ module Api exposing , addCorrPerson , addDashboard , addMember + , addRelatedItems , addShare , addTag , addTagsMultiple @@ -91,6 +92,7 @@ module Api exposing , getPersonFull , getPersons , getPersonsLight + , getRelatedItems , getScanMailbox , getSentMails , getShare @@ -130,6 +132,8 @@ module Api exposing , refreshSession , register , removeMember + , removeRelatedItem + , removeRelatedItems , removeTagsMultiple , replaceDashboard , reprocessItem @@ -227,7 +231,9 @@ import Api.Model.ImapSettingsList exposing (ImapSettingsList) import Api.Model.InviteResult exposing (InviteResult) import Api.Model.ItemDetail exposing (ItemDetail) import Api.Model.ItemInsights exposing (ItemInsights) +import Api.Model.ItemLightGroup exposing (ItemLightGroup) import Api.Model.ItemLightList exposing (ItemLightList) +import Api.Model.ItemLinkData exposing (ItemLinkData) import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ItemQuery exposing (ItemQuery) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) @@ -3007,6 +3013,48 @@ verifyJsonFilter flags query receive = +--- Item Links + + +getRelatedItems : Flags -> String -> (Result Http.Error ItemLightGroup -> msg) -> Cmd msg +getRelatedItems flags itemId receive = + Http2.authGet + { url = flags.config.baseUrl ++ "/api/v1/sec/itemlink/" ++ itemId + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.ItemLightGroup.decoder + } + + +addRelatedItems : Flags -> ItemLinkData -> (Result Http.Error BasicResult -> msg) -> Cmd msg +addRelatedItems flags data receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/itemlink/addAll" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemLinkData.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +removeRelatedItems : Flags -> ItemLinkData -> (Result Http.Error BasicResult -> msg) -> Cmd msg +removeRelatedItems flags data receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/itemlink/removeAll" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemLinkData.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +removeRelatedItem : Flags -> String -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg +removeRelatedItem flags item1 item2 receive = + Http2.authDelete + { url = flags.config.baseUrl ++ "/api/v1/sec/itemlink/" ++ item1 ++ "/" ++ item2 + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + + --- Helper diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm index b0f577be..726166fb 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm @@ -48,6 +48,7 @@ import Comp.DatePicker import Comp.DetailEdit import Comp.Dropdown import Comp.Dropzone +import Comp.ItemLinkForm import Comp.ItemMail import Comp.KeyInput import Comp.LinkTarget exposing (LinkTarget) @@ -121,6 +122,7 @@ type alias Model = , editMenuTabsOpen : Set String , viewMode : ViewMode , showQrModel : ShowQrModel + , itemLinkModel : Comp.ItemLinkForm.Model } @@ -256,6 +258,7 @@ emptyModel = , editMenuTabsOpen = Set.empty , viewMode = SimpleView , showQrModel = initShowQrModel + , itemLinkModel = Comp.ItemLinkForm.emptyModel } @@ -369,6 +372,7 @@ type Msg | PrintElement String | SetNameMsg Comp.SimpleTextInput.Msg | ToggleSelectItem + | ItemLinkFormMsg Comp.ItemLinkForm.Msg type SaveNameState diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm index fa5ac06a..f5884061 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -46,6 +46,7 @@ import Comp.ItemDetail.Model , resultModelCmd , resultModelCmdSub ) +import Comp.ItemLinkForm import Comp.ItemMail import Comp.KeyInput import Comp.LinkTarget @@ -95,6 +96,13 @@ update inav env msg model = ( cm, cc ) = Comp.CustomFieldMultiInput.init env.flags + + ( ilm, ilc ) = + if model.item.id == "" then + ( model.itemLinkModel, Cmd.none ) + + else + Comp.ItemLinkForm.init env.flags model.item.id in resultModelCmd ( { model @@ -104,6 +112,7 @@ update inav env msg model = , visibleAttach = 0 , attachMenuOpen = False , customFieldsModel = cm + , itemLinkModel = ilm } , Cmd.batch [ getOptions env.flags @@ -111,6 +120,7 @@ update inav env msg model = , Cmd.map DueDatePickerMsg dpc , Cmd.map ItemMailMsg ic , Cmd.map CustomFieldMsg cc + , Cmd.map ItemLinkFormMsg ilc , Api.getSentMails env.flags model.item.id SentMailsResp ] ) @@ -217,6 +227,9 @@ update inav env msg model = else Cmd.none + ( ilm, ilc ) = + Comp.ItemLinkForm.init env.flags item.id + lastModel = res9.model in @@ -237,6 +250,7 @@ update inav env msg model = , dueDate = item.dueDate , visibleAttach = 0 , modalEdit = Nothing + , itemLinkModel = ilm } , cmd = Cmd.batch @@ -254,6 +268,7 @@ update inav env msg model = , Api.getSentMails env.flags item.id SentMailsResp , Api.getPersons env.flags "" Data.PersonOrder.NameAsc GetPersonResp , Cmd.map CustomFieldMsg (Comp.CustomFieldMultiInput.initCmd env.flags) + , Cmd.map ItemLinkFormMsg ilc ] , sub = Sub.batch @@ -1613,6 +1628,17 @@ update inav env msg model = in { res | selectionChange = newSelection } + ItemLinkFormMsg lm -> + let + ( ilm, ilc, ils ) = + Comp.ItemLinkForm.update env.flags lm model.itemLinkModel + in + resultModelCmdSub + ( { model | itemLinkModel = ilm } + , Cmd.map ItemLinkFormMsg ilc + , Sub.map ItemLinkFormMsg ils + ) + --- Helper diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/View2.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/View2.elm index acc8d06a..54a1d3d9 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/View2.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/View2.elm @@ -24,6 +24,7 @@ import Comp.ItemDetail.Model import Comp.ItemDetail.Notes import Comp.ItemDetail.ShowQrCode import Comp.ItemDetail.SingleAttachment +import Comp.ItemLinkForm import Comp.ItemMail import Comp.MenuBar as MB import Comp.SentMails @@ -45,8 +46,6 @@ view : Texts -> ItemNav -> Env.View -> Model -> Html Msg view texts inav env model = div [ class "flex flex-col h-full" ] [ header texts inav env model - - -- , menuBar texts inav settings model , body texts env.flags inav env.settings model , itemModal texts model ] @@ -407,12 +406,18 @@ itemActions texts flags settings model classes = notesAndSentMails : Texts -> Flags -> UiSettings -> Model -> String -> Html Msg -notesAndSentMails texts _ _ model classes = +notesAndSentMails texts _ settings model classes = div [ class "w-full md:mr-2 flex flex-col" , class classes ] [ Comp.ItemDetail.Notes.view texts.notes model + , div [ class "mb-4 mt-4" ] + [ div [ class "font-bold text-lg" ] + [ text texts.relatedItems + ] + , Html.map ItemLinkFormMsg (Comp.ItemLinkForm.view texts.itemLinkForm settings model.itemLinkModel) + ] , div [ classList [ ( "hidden", Comp.SentMails.isEmpty model.sentMails ) diff --git a/modules/webapp/src/main/elm/Comp/ItemLinkForm.elm b/modules/webapp/src/main/elm/Comp/ItemLinkForm.elm new file mode 100644 index 00000000..924867da --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ItemLinkForm.elm @@ -0,0 +1,304 @@ +module Comp.ItemLinkForm exposing (Model, Msg, emptyModel, init, update, view) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.ItemLight exposing (ItemLight) +import Api.Model.ItemLightGroup exposing (ItemLightGroup) +import Comp.ItemSearchInput +import Data.Flags exposing (Flags) +import Data.ItemQuery as IQ +import Data.ItemTemplate as IT +import Data.UiSettings exposing (UiSettings) +import Html exposing (Html, a, div, i, text) +import Html.Attributes exposing (class, classList, href, title) +import Html.Events exposing (onClick) +import Http +import Messages.Comp.ItemLinkForm exposing (Texts) +import Page exposing (Page(..)) +import Styles as S + + +type alias Model = + { itemSearchModel : Comp.ItemSearchInput.Model + , relatedItems : List ItemLight + , targetItemId : String + , editMode : EditMode + , formState : FormState + } + + +type EditMode + = AddRelated + | RemoveRelated + + +type FormState + = FormOk + | FormHttpError Http.Error + | FormError String + + +emptyModel : Model +emptyModel = + let + cfg = + Comp.ItemSearchInput.defaultConfig + in + { itemSearchModel = Comp.ItemSearchInput.init cfg + , relatedItems = [] + , targetItemId = "" + , editMode = AddRelated + , formState = FormOk + } + + +type Msg + = ItemSearchMsg Comp.ItemSearchInput.Msg + | RelatedItemsResp (Result Http.Error ItemLightGroup) + | UpdateRelatedResp (Result Http.Error BasicResult) + | DeleteRelatedItem ItemLight + | ToggleEditMode + + +init : Flags -> String -> ( Model, Cmd Msg ) +init flags itemId = + let + searchCfg = + Comp.ItemSearchInput.defaultConfig + in + ( { itemSearchModel = Comp.ItemSearchInput.init searchCfg + , relatedItems = [] + , targetItemId = itemId + , editMode = AddRelated + , formState = FormOk + } + , initCmd flags itemId + ) + + +initCmd : Flags -> String -> Cmd Msg +initCmd flags itemId = + Api.getRelatedItems flags itemId RelatedItemsResp + + +excludeResults : Model -> Maybe IQ.ItemQuery +excludeResults model = + let + relatedIds = + List.map .id model.relatedItems + + all = + if model.targetItemId == "" then + relatedIds + + else + model.targetItemId :: relatedIds + in + case all of + [] -> + Nothing + + ids -> + Just <| IQ.Not (IQ.ItemIdIn ids) + + + +--- Update + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +update flags msg model = + case msg of + ItemSearchMsg lm -> + case model.editMode of + AddRelated -> + let + result = + Comp.ItemSearchInput.update flags (excludeResults model) lm model.itemSearchModel + + cmd = + case result.selected of + Just item -> + if model.targetItemId == "" then + Cmd.none + + else + Api.addRelatedItems flags + { item = model.targetItemId + , related = [ item.id ] + } + UpdateRelatedResp + + Nothing -> + Cmd.none + in + ( { model | itemSearchModel = result.model } + , Cmd.batch + [ Cmd.map ItemSearchMsg result.cmd + , cmd + ] + , Sub.map ItemSearchMsg result.sub + ) + + RemoveRelated -> + ( model, Cmd.none, Sub.none ) + + RelatedItemsResp (Ok list) -> + ( { model + | relatedItems = list.items + , formState = FormOk + , editMode = + if List.isEmpty list.items then + AddRelated + + else + model.editMode + } + , Cmd.none + , Sub.none + ) + + RelatedItemsResp (Err err) -> + ( { model | formState = FormHttpError err }, Cmd.none, Sub.none ) + + UpdateRelatedResp (Ok res) -> + if res.success then + ( { model | formState = FormOk } + , initCmd flags model.targetItemId + , Sub.none + ) + + else + ( { model | formState = FormError res.message }, Cmd.none, Sub.none ) + + UpdateRelatedResp (Err err) -> + ( { model | formState = FormHttpError err }, Cmd.none, Sub.none ) + + ToggleEditMode -> + let + next = + if model.editMode == RemoveRelated then + AddRelated + + else + RemoveRelated + in + ( { model | editMode = next }, Cmd.none, Sub.none ) + + DeleteRelatedItem item -> + case model.editMode of + RemoveRelated -> + if model.targetItemId == "" then + ( model, Cmd.none, Sub.none ) + + else + ( model, Api.removeRelatedItem flags model.targetItemId item.id UpdateRelatedResp, Sub.none ) + + AddRelated -> + ( model, Cmd.none, Sub.none ) + + + +--- View + + +view : Texts -> UiSettings -> Model -> Html Msg +view texts settings model = + div + [ classList + [ ( "bg-red-100 bg-opacity-25", model.editMode == RemoveRelated ) + , ( "dark:bg-orange-80 dark:bg-opacity-10", model.editMode == RemoveRelated ) + ] + ] + [ div [ class "relative" ] + [ Html.map ItemSearchMsg + (Comp.ItemSearchInput.view texts.itemSearchInput + settings + model.itemSearchModel + [ class "text-sm py-1 pr-6" + , classList [ ( "disabled", model.editMode == RemoveRelated ) ] + ] + ) + , a + [ classList + [ ( "hidden", Comp.ItemSearchInput.hasFocus model.itemSearchModel ) + , ( "bg-red-600 text-white dark:bg-orange-500 dark:text-slate-900 ", model.editMode == RemoveRelated ) + , ( "opacity-50", model.editMode == AddRelated ) + , ( S.deleteButtonBase, model.editMode == AddRelated ) + ] + , class " absolute right-0 top-0 rounded-r py-1 px-2 h-full block text-sm" + , href "#" + , onClick ToggleEditMode + ] + [ i [ class "fa fa-trash " ] [] + ] + , div + [ class "absolute right-0 top-0 py-1 mr-1 w-4" + , classList [ ( "hidden", not (Comp.ItemSearchInput.isSearching model.itemSearchModel) ) ] + ] + [ i [ class "fa fa-circle-notch animate-spin" ] [] + ] + ] + , case model.formState of + FormOk -> + viewRelatedItems texts settings model + + FormHttpError err -> + div [ class S.errorText ] + [ text <| texts.httpError err + ] + + FormError msg -> + div [ class S.errorText ] + [ text msg + ] + ] + + +viewRelatedItems : Texts -> UiSettings -> Model -> Html Msg +viewRelatedItems texts settings model = + div [ class "px-1.5 pb-0.5" ] + (List.map (viewItem texts settings model) model.relatedItems) + + +viewItem : Texts -> UiSettings -> Model -> ItemLight -> Html Msg +viewItem texts _ model item = + let + mainTpl = + IT.name + + tooltipTpl = + IT.concat + [ IT.dateShort + , IT.literal ", " + , IT.correspondent + ] + + tctx = + { dateFormatLong = texts.dateFormatLong + , dateFormatShort = texts.dateFormatShort + , directionLabel = texts.directionLabel + } + in + case model.editMode of + AddRelated -> + a + [ class "flex items-center my-2" + , class S.link + , Page.href (ItemDetailPage item.id) + , title <| IT.render tooltipTpl tctx item + ] + [ i [ class "fa fa-link text-xs mr-1" ] [] + , IT.render mainTpl tctx item |> text + ] + + RemoveRelated -> + a + [ class "flex items-center my-2" + , class " text-red-600 hover:text-red-500 dark:text-orange-400 dark:hover:text-orange-300 " + , href "#" + , onClick (DeleteRelatedItem item) + ] + [ i [ class "fa fa-trash mr-2" ] [] + , IT.render mainTpl tctx item |> text + ] diff --git a/modules/webapp/src/main/elm/Comp/ItemSearchInput.elm b/modules/webapp/src/main/elm/Comp/ItemSearchInput.elm new file mode 100644 index 00000000..4777d3f4 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ItemSearchInput.elm @@ -0,0 +1,406 @@ +module Comp.ItemSearchInput exposing (Config, Model, Msg, defaultConfig, hasFocus, init, isSearching, update, view) + +import Api +import Api.Model.ItemLight exposing (ItemLight) +import Api.Model.ItemLightList exposing (ItemLightList) +import Comp.SimpleTextInput +import Data.Flags exposing (Flags) +import Data.ItemQuery as IQ +import Data.Items +import Data.UiSettings exposing (UiSettings) +import Html exposing (Attribute, Html, a, div, span, text) +import Html.Attributes exposing (class, classList, href, placeholder) +import Html.Events exposing (onBlur, onClick, onFocus) +import Http +import Messages.Comp.ItemSearchInput exposing (Texts) +import Process +import Styles as S +import Task +import Util.Html +import Util.List +import Util.String + + +type alias Model = + { searchModel : Comp.SimpleTextInput.Model + , config : Config + , results : List ItemLight + , searchProgress : Bool + , menuState : MenuState + , focus : Bool + , errorState : ErrorState + } + + +type alias MenuState = + { open : Bool + , active : Maybe String + } + + +type ErrorState + = NoError + | HttpError Http.Error + + +type alias Config = + { makeQuery : String -> IQ.ItemQuery + , limit : Int + } + + +defaultConfig : Config +defaultConfig = + { limit = 15 + , makeQuery = defaultMakeQuery + } + + +defaultMakeQuery : String -> IQ.ItemQuery +defaultMakeQuery str = + let + qstr = + Util.String.appendIfAbsent "*" str + in + IQ.Or + [ IQ.ItemIdMatch qstr + , IQ.AllNames qstr + ] + + +init : Config -> Model +init cfg = + let + textCfg = + { delay = 200 + , setOnTyping = True + , setOnEnter = True + , setOnBlur = False + } + in + { searchModel = Comp.SimpleTextInput.init textCfg Nothing + , config = cfg + , results = [] + , searchProgress = False + , menuState = + { open = False + , active = Nothing + } + , errorState = NoError + , focus = False + } + + +type Msg + = SetSearchMsg Comp.SimpleTextInput.Msg + | SearchResultResp (Result Http.Error ItemLightList) + | SelectItem ItemLight + | FocusGained + | FocusRemoved Bool + + +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , sub : Sub Msg + , selected : Maybe ItemLight + } + + +isSearching : Model -> Bool +isSearching model = + model.searchProgress + + +hasFocus : Model -> Bool +hasFocus model = + model.focus + + + +--- Update + + +unit : Model -> UpdateResult +unit model = + UpdateResult model Cmd.none Sub.none Nothing + + +update : Flags -> Maybe IQ.ItemQuery -> Msg -> Model -> UpdateResult +update flags addQuery msg model = + case msg of + SetSearchMsg lm -> + let + res = + Comp.SimpleTextInput.update lm model.searchModel + + findActiveItem results = + Maybe.andThen (\id -> List.filter (\e -> e.id == id) results |> List.head) model.menuState.active + + ( mm, selectAction ) = + case res.keyPressed of + Just Util.Html.ESC -> + ( setMenuOpen False model, False ) + + Just Util.Html.Enter -> + if model.menuState.open then + ( model, True ) + + else + ( setMenuOpen True model, False ) + + Just Util.Html.Up -> + ( setActivePrev model, False ) + + Just Util.Html.Down -> + ( setActiveNext model, False ) + + _ -> + ( model, False ) + + ( model_, searchCmd ) = + case res.change of + Comp.SimpleTextInput.ValueUnchanged -> + ( mm, Cmd.none ) + + Comp.SimpleTextInput.ValueUpdated v -> + let + cmd = + makeSearchCmd flags model addQuery v + in + ( { mm | searchProgress = cmd /= Cmd.none }, cmd ) + in + if selectAction then + findActiveItem model.results + |> Maybe.map SelectItem + |> Maybe.map (\m -> update flags addQuery m model) + |> Maybe.withDefault (unit model) + + else + { model = { model_ | searchModel = res.model } + , cmd = Cmd.batch [ Cmd.map SetSearchMsg res.cmd, searchCmd ] + , sub = Sub.map SetSearchMsg res.sub + , selected = Nothing + } + + SearchResultResp (Ok list) -> + unit + { model + | results = Data.Items.flatten list + , errorState = NoError + , searchProgress = False + } + + SearchResultResp (Err err) -> + unit { model | errorState = HttpError err, searchProgress = False } + + SelectItem item -> + let + ms = + model.menuState + + ( searchModel, sub ) = + Comp.SimpleTextInput.setValue model.searchModel "" + + res = + unit + { model + | menuState = { ms | open = False } + , searchModel = searchModel + } + in + { res | selected = Just item, sub = Sub.map SetSearchMsg sub } + + FocusGained -> + unit (setMenuOpen True model |> setFocus True) + + FocusRemoved flag -> + if flag then + unit (setMenuOpen False model |> setFocus False) + + else + { model = model + , cmd = + Process.sleep 100 + |> Task.perform (\_ -> FocusRemoved True) + , sub = Sub.none + , selected = Nothing + } + + +makeSearchCmd : Flags -> Model -> Maybe IQ.ItemQuery -> Maybe String -> Cmd Msg +makeSearchCmd flags model addQuery str = + let + itemQuery = + IQ.and + [ addQuery + , Maybe.map model.config.makeQuery str + ] + + qstr = + IQ.renderMaybe itemQuery + + q = + { offset = Nothing + , limit = Just model.config.limit + , withDetails = Just False + , searchMode = Nothing + , query = qstr + } + in + if str == Nothing then + Cmd.none + + else + Api.itemSearch flags q SearchResultResp + + +setMenuOpen : Bool -> Model -> Model +setMenuOpen flag model = + let + ms = + model.menuState + in + { model | menuState = { ms | open = flag } } + + +setFocus : Bool -> Model -> Model +setFocus flag model = + { model | focus = flag } + + +setActiveNext : Model -> Model +setActiveNext model = + let + find ms = + case ms.active of + Just id -> + Util.List.findNext (\e -> e.id == id) model.results + + Nothing -> + List.head model.results + + set ms act = + { ms | active = act } + + updateMs = + find >> Maybe.map .id >> set model.menuState + in + if model.menuState.open then + { model | menuState = updateMs model.menuState } + + else + model + + +setActivePrev : Model -> Model +setActivePrev model = + let + find ms = + case ms.active of + Just id -> + Util.List.findPrev (\e -> e.id == id) model.results + + Nothing -> + List.reverse model.results |> List.head + + set ms act = + { ms | active = act } + + updateMs = + find >> Maybe.map .id >> set model.menuState + in + if model.menuState.open then + { model | menuState = updateMs model.menuState } + + else + model + + + +--- View + + +view : Texts -> UiSettings -> Model -> List (Attribute Msg) -> Html Msg +view texts settings model attrs = + let + inputAttrs = + [ class S.textInput + , onFocus FocusGained + , onBlur (FocusRemoved False) + , placeholder texts.placeholder + ] + in + div + [ class "relative" + ] + [ Comp.SimpleTextInput.viewMap SetSearchMsg + (inputAttrs ++ attrs) + model.searchModel + , renderResultMenu texts settings model + ] + + +renderResultMenu : Texts -> UiSettings -> Model -> Html Msg +renderResultMenu texts _ model = + div + [ class "z-50 max-h-96 overflow-y-auto" + , class dropdownMenu + , classList [ ( "hidden", not model.menuState.open ) ] + ] + (case model.errorState of + HttpError err -> + [ div + [ class dropdownItem + , class S.errorText + ] + [ text <| texts.httpError err + ] + ] + + NoError -> + case model.results of + [] -> + [ div [ class dropdownItem ] + [ span [ class "italic" ] + [ text texts.noResults + ] + ] + ] + + _ -> + List.map (renderResultItem model) model.results + ) + + +renderResultItem : Model -> ItemLight -> Html Msg +renderResultItem model item = + let + active = + model.menuState.active == Just item.id + in + a + [ classList + [ ( dropdownItem, not active ) + , ( activeItem, active ) + ] + , href "#" + , onClick (SelectItem item) + ] + [ text item.name + ] + + +dropdownMenu : String +dropdownMenu = + " absolute left-0 bg-white dark:bg-slate-800 border dark:border-slate-700 dark:text-slate-300 shadow-lg opacity-1 transition duration-200 w-full " + + +dropdownItem : String +dropdownItem = + "transition-colors duration-200 items-center block px-4 py-2 text-normal hover:bg-gray-200 dark:hover:bg-slate-700 dark:hover:text-slate-50" + + +activeItem : String +activeItem = + "transition-colors duration-200 items-center block px-4 py-2 text-normal bg-gray-200 dark:bg-slate-700 dark:text-slate-50" diff --git a/modules/webapp/src/main/elm/Comp/SimpleTextInput.elm b/modules/webapp/src/main/elm/Comp/SimpleTextInput.elm index ce48747c..a830198c 100644 --- a/modules/webapp/src/main/elm/Comp/SimpleTextInput.elm +++ b/modules/webapp/src/main/elm/Comp/SimpleTextInput.elm @@ -119,6 +119,7 @@ type alias Result = , change : ValueChange , cmd : Cmd Msg , sub : Sub Msg + , keyPressed : Maybe KeyCode } @@ -144,6 +145,7 @@ update msg (Model model) = , change = ValueUnchanged , cmd = cmd , sub = makeSub model newThrottle + , keyPressed = Nothing } UpdateThrottle -> @@ -155,6 +157,7 @@ update msg (Model model) = , change = ValueUnchanged , cmd = cmd , sub = makeSub model newThrottle + , keyPressed = Nothing } DelayedSet -> @@ -172,14 +175,22 @@ update msg (Model model) = unit model KeyPressed (Just Util.Html.Enter) -> - if model.cfg.setOnEnter then - publishChange model + let + res = + if model.cfg.setOnEnter then + publishChange model - else - unit model + else + unit model + in + { res | keyPressed = Just Util.Html.Enter } - KeyPressed _ -> - unit model + KeyPressed kc -> + let + res = + unit model + in + { res | keyPressed = kc } publishChange : InnerModel -> Result @@ -192,6 +203,7 @@ publishChange model = (ValueUpdated model.value) Cmd.none (makeSub model model.throttle) + Nothing unit : InnerModel -> Result @@ -200,6 +212,7 @@ unit model = , change = ValueUnchanged , cmd = Cmd.none , sub = makeSub model model.throttle + , keyPressed = Nothing } diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm index 36370a6b..2d957716 100644 --- a/modules/webapp/src/main/elm/Data/ItemQuery.elm +++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm @@ -22,6 +22,7 @@ import Api.Model.CustomFieldValue exposing (CustomFieldValue) import Api.Model.ItemQuery as RQ import Data.Direction exposing (Direction) import Data.SearchMode exposing (SearchMode) +import Util.String type TagMatch @@ -58,6 +59,7 @@ type ItemQuery | Source AttrMatch String | Dir Direction | ItemIdIn (List String) + | ItemIdMatch String | ItemName AttrMatch String | AllNames String | Contents String @@ -207,6 +209,13 @@ render q = ItemIdIn ids -> "id~=" ++ String.join "," ids + ItemIdMatch id -> + if String.length id == 47 then + "id" ++ attrMatch Eq ++ id + + else + "id" ++ attrMatch Like ++ Util.String.appendIfAbsent "*" id + ItemName m str -> "name" ++ attrMatch m ++ quoteStr str diff --git a/modules/webapp/src/main/elm/Data/ItemTemplate.elm b/modules/webapp/src/main/elm/Data/ItemTemplate.elm index 32771eee..132e2b9e 100644 --- a/modules/webapp/src/main/elm/Data/ItemTemplate.elm +++ b/modules/webapp/src/main/elm/Data/ItemTemplate.elm @@ -13,6 +13,7 @@ module Data.ItemTemplate exposing , concat , concerning , corrOrg + , corrOrgOrPerson , corrPerson , correspondent , dateLong @@ -229,6 +230,11 @@ correspondent = combine ", " corrOrg corrPerson +corrOrgOrPerson : ItemTemplate +corrOrgOrPerson = + firstNonEmpty [ corrOrg, corrPerson ] + + concPerson : ItemTemplate concPerson = from (.concPerson >> getName) diff --git a/modules/webapp/src/main/elm/Messages/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Messages/Comp/ItemDetail.elm index 9940350c..3ea89606 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/ItemDetail.elm @@ -21,6 +21,7 @@ import Messages.Comp.ItemDetail.ConfirmModal import Messages.Comp.ItemDetail.ItemInfoHeader import Messages.Comp.ItemDetail.Notes import Messages.Comp.ItemDetail.SingleAttachment +import Messages.Comp.ItemLinkForm import Messages.Comp.ItemMail import Messages.Comp.SentMails import Messages.DateFormat as DF @@ -36,6 +37,7 @@ type alias Texts = , itemMail : Messages.Comp.ItemMail.Texts , detailEdit : Messages.Comp.DetailEdit.Texts , confirmModal : Messages.Comp.ItemDetail.ConfirmModal.Texts + , itemLinkForm : Messages.Comp.ItemLinkForm.Texts , httpError : Http.Error -> String , key : String , backToSearchResults : String @@ -61,6 +63,7 @@ type alias Texts = , close : String , selectItem : String , deselectItem : String + , relatedItems : String } @@ -74,6 +77,7 @@ gb tz = , itemMail = Messages.Comp.ItemMail.gb , detailEdit = Messages.Comp.DetailEdit.gb , confirmModal = Messages.Comp.ItemDetail.ConfirmModal.gb + , itemLinkForm = Messages.Comp.ItemLinkForm.gb tz , httpError = Messages.Comp.HttpError.gb , key = "Key" , backToSearchResults = "Back to search results" @@ -99,6 +103,7 @@ gb tz = , close = "Close" , selectItem = "Select this item" , deselectItem = "Deselect this item" + , relatedItems = "Linked items" } @@ -112,6 +117,7 @@ de tz = , itemMail = Messages.Comp.ItemMail.de , detailEdit = Messages.Comp.DetailEdit.de , confirmModal = Messages.Comp.ItemDetail.ConfirmModal.de + , itemLinkForm = Messages.Comp.ItemLinkForm.de tz , httpError = Messages.Comp.HttpError.de , key = "Taste" , backToSearchResults = "Zurück zur Suche" @@ -137,6 +143,7 @@ de tz = , close = "Schließen" , selectItem = "Zur Auswahl hinzufügen" , deselectItem = "Aus Auswahl entfernen" + , relatedItems = "Verknüpfte Dokumente" } @@ -150,6 +157,7 @@ fr tz = , itemMail = Messages.Comp.ItemMail.fr , detailEdit = Messages.Comp.DetailEdit.fr , confirmModal = Messages.Comp.ItemDetail.ConfirmModal.fr + , itemLinkForm = Messages.Comp.ItemLinkForm.fr tz , httpError = Messages.Comp.HttpError.fr , key = "Clé" , backToSearchResults = "Retour aux résultat de recherche" @@ -175,4 +183,5 @@ fr tz = , close = "Fermer" , selectItem = "Sélectionner ce document" , deselectItem = "Désélectionner ce document" + , relatedItems = "Documents associés" } diff --git a/modules/webapp/src/main/elm/Messages/Comp/ItemLinkForm.elm b/modules/webapp/src/main/elm/Messages/Comp/ItemLinkForm.elm new file mode 100644 index 00000000..b460252e --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/ItemLinkForm.elm @@ -0,0 +1,49 @@ +module Messages.Comp.ItemLinkForm exposing (Texts, de, fr, gb) + +import Data.Direction exposing (Direction) +import Data.TimeZone exposing (TimeZone) +import Http +import Messages.Comp.HttpError +import Messages.Comp.ItemSearchInput +import Messages.Data.Direction +import Messages.DateFormat as DF +import Messages.UiLanguage exposing (UiLanguage(..)) + + +type alias Texts = + { dateFormatLong : Int -> String + , dateFormatShort : Int -> String + , directionLabel : Direction -> String + , itemSearchInput : Messages.Comp.ItemSearchInput.Texts + , httpError : Http.Error -> String + } + + +gb : TimeZone -> Texts +gb tz = + { dateFormatLong = DF.formatDateLong English tz + , dateFormatShort = DF.formatDateShort English tz + , directionLabel = Messages.Data.Direction.gb + , itemSearchInput = Messages.Comp.ItemSearchInput.gb + , httpError = Messages.Comp.HttpError.gb + } + + +de : TimeZone -> Texts +de tz = + { dateFormatLong = DF.formatDateLong German tz + , dateFormatShort = DF.formatDateShort German tz + , directionLabel = Messages.Data.Direction.de + , itemSearchInput = Messages.Comp.ItemSearchInput.de + , httpError = Messages.Comp.HttpError.de + } + + +fr : TimeZone -> Texts +fr tz = + { dateFormatLong = DF.formatDateLong French tz + , dateFormatShort = DF.formatDateShort French tz + , directionLabel = Messages.Data.Direction.fr + , itemSearchInput = Messages.Comp.ItemSearchInput.fr + , httpError = Messages.Comp.HttpError.fr + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/ItemSearchInput.elm b/modules/webapp/src/main/elm/Messages/Comp/ItemSearchInput.elm new file mode 100644 index 00000000..2fe5d23f --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/ItemSearchInput.elm @@ -0,0 +1,36 @@ +module Messages.Comp.ItemSearchInput exposing (Texts, de, fr, gb) + +import Http +import Messages.Basics +import Messages.Comp.HttpError + + +type alias Texts = + { noResults : String + , placeholder : String + , httpError : Http.Error -> String + } + + +gb : Texts +gb = + { noResults = "No results" + , placeholder = Messages.Basics.gb.searchPlaceholder + , httpError = Messages.Comp.HttpError.gb + } + + +de : Texts +de = + { noResults = "Keine Resultate" + , placeholder = Messages.Basics.de.searchPlaceholder + , httpError = Messages.Comp.HttpError.de + } + + +fr : Texts +fr = + { noResults = "Aucun document trouvé" + , placeholder = Messages.Basics.fr.searchPlaceholder + , httpError = Messages.Comp.HttpError.fr + } diff --git a/modules/webapp/src/main/elm/Styles.elm b/modules/webapp/src/main/elm/Styles.elm index 894526c2..48a866c4 100644 --- a/modules/webapp/src/main/elm/Styles.elm +++ b/modules/webapp/src/main/elm/Styles.elm @@ -233,9 +233,14 @@ deleteButton = deleteButtonMain ++ deleteButtonHover +deleteButtonBase : String +deleteButtonBase = + " my-auto whitespace-nowrap border border-red-500 dark:border-lightred-500 text-red-500 dark:text-orange-500 text-center " + + deleteButtonMain : String deleteButtonMain = - " rounded my-auto whitespace-nowrap border border-red-500 dark:border-lightred-500 text-red-500 dark:text-orange-500 text-center px-4 py-2 shadow-none focus:outline-none focus:ring focus:ring-opacity-75 " + " rounded px-4 py-2 shadow-none focus:outline-none focus:ring focus:ring-opacity-75 " ++ deleteButtonBase deleteButtonHover : String diff --git a/modules/webapp/src/main/elm/Util/String.elm b/modules/webapp/src/main/elm/Util/String.elm index 6f0cd492..54cfa9ea 100644 --- a/modules/webapp/src/main/elm/Util/String.elm +++ b/modules/webapp/src/main/elm/Util/String.elm @@ -6,7 +6,8 @@ module Util.String exposing - ( crazyEncode + ( appendIfAbsent + , crazyEncode , ellipsis , isBlank , isNothingOrBlank @@ -15,6 +16,7 @@ module Util.String exposing ) import Base64 +import Html exposing (strong) crazyEncode : String -> String @@ -66,3 +68,12 @@ isNothingOrBlank : Maybe String -> Bool isNothingOrBlank ms = Maybe.map isBlank ms |> Maybe.withDefault True + + +appendIfAbsent : String -> String -> String +appendIfAbsent suffix str = + if String.endsWith suffix str then + str + + else + str ++ suffix From 288ed83b7f6d8c8224fb428f3d1c2d0f67d45e8a Mon Sep 17 00:00:00 2001 From: eikek Date: Thu, 17 Mar 2022 23:25:04 +0100 Subject: [PATCH 6/7] Make ItemMerge independent from the action --- modules/webapp/src/main/elm/Api.elm | 21 +++-- .../webapp/src/main/elm/Comp/ItemMerge.elm | 86 +++++++++++-------- .../src/main/elm/Messages/Comp/ItemMerge.elm | 36 +------- .../src/main/elm/Messages/Page/Search.elm | 28 ++++++ .../src/main/elm/Page/Search/Update.elm | 9 +- .../webapp/src/main/elm/Page/Search/View2.elm | 13 ++- 6 files changed, 117 insertions(+), 76 deletions(-) diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index c8f43b96..e51c15b4 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -115,6 +115,7 @@ module Api exposing , loginSession , logout , mergeItems + , mergeItemsTask , moveAttachmentBefore , newInvite , openIdAuthLink @@ -1717,18 +1718,26 @@ getJobQueueStateTask flags = --- Item (Mulit Edit) +mergeItemsTask : Flags -> List String -> Task.Task Http.Error BasicResult +mergeItemsTask flags ids = + Http2.authTask + { url = flags.config.baseUrl ++ "/api/v1/sec/items/merge" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.IdList.encode (IdList ids)) + , method = "POST" + , headers = [] + , resolver = Http2.jsonResolver Api.Model.BasicResult.decoder + , timeout = Nothing + } + + mergeItems : Flags -> List String -> (Result Http.Error BasicResult -> msg) -> Cmd msg mergeItems flags items receive = - Http2.authPost - { url = flags.config.baseUrl ++ "/api/v1/sec/items/merge" - , account = getAccount flags - , body = Http.jsonBody (Api.Model.IdList.encode (IdList items)) - , expect = Http.expectJson receive Api.Model.BasicResult.decoder - } + mergeItemsTask flags items |> Task.attempt receive reprocessMultiple : diff --git a/modules/webapp/src/main/elm/Comp/ItemMerge.elm b/modules/webapp/src/main/elm/Comp/ItemMerge.elm index 6206e03d..52241a1e 100644 --- a/modules/webapp/src/main/elm/Comp/ItemMerge.elm +++ b/modules/webapp/src/main/elm/Comp/ItemMerge.elm @@ -36,6 +36,7 @@ import Html5.DragDrop as DD import Http import Messages.Comp.ItemMerge exposing (Texts) import Styles as S +import Task exposing (Task) import Util.CustomField import Util.Item import Util.List @@ -89,18 +90,22 @@ type alias DDMsg = type FormState = FormStateInitial | FormStateHttp Http.Error - | FormStateMergeSuccessful + | FormStateActionSuccessful | FormStateError String - | FormStateMergeInProcess + | FormStateActionInProcess --- Update +type alias Action = + List String -> Task Http.Error BasicResult + + type Outcome = OutcomeCancel - | OutcomeMerged + | OutcomeActionDone | OutcomeNotYet @@ -123,15 +128,15 @@ type Msg = ItemResp (Result Http.Error ItemLightList) | ToggleInfoText | DragDrop (DD.Msg Int Int) - | SubmitMerge - | CancelMerge - | MergeResp (Result Http.Error BasicResult) + | SubmitAction + | CancelAction + | ActionResp (Result Http.Error BasicResult) | RemoveItem String | MoveItem Int Int -update : Flags -> Msg -> Model -> UpdateResult -update flags msg model = +update : Flags -> Action -> Msg -> Model -> UpdateResult +update _ action msg model = case msg of ItemResp (Ok list) -> notDoneResult ( init (flatten list), Cmd.none ) @@ -139,11 +144,11 @@ update flags msg model = ItemResp (Err err) -> notDoneResult ( { model | formState = FormStateHttp err }, Cmd.none ) - MergeResp (Ok result) -> + ActionResp (Ok result) -> if result.success then - { model = { model | formState = FormStateMergeSuccessful } + { model = { model | formState = FormStateActionSuccessful } , cmd = Cmd.none - , outcome = OutcomeMerged + , outcome = OutcomeActionDone } else @@ -152,7 +157,7 @@ update flags msg model = , outcome = OutcomeNotYet } - MergeResp (Err err) -> + ActionResp (Err err) -> { model = { model | formState = FormStateHttp err } , cmd = Cmd.none , outcome = OutcomeNotYet @@ -203,17 +208,17 @@ update flags msg model = in notDoneResult ( { model | items = items }, Cmd.none ) - SubmitMerge -> + SubmitAction -> let ids = List.map .id model.items in notDoneResult - ( { model | formState = FormStateMergeInProcess } - , Api.mergeItems flags ids MergeResp + ( { model | formState = FormStateActionInProcess } + , action ids |> Task.attempt ActionResp ) - CancelMerge -> + CancelAction -> { model = model , cmd = Cmd.none , outcome = OutcomeCancel @@ -229,14 +234,26 @@ flatten list = --- View -view : Texts -> UiSettings -> Model -> Html Msg -view texts settings model = +type alias ViewConfig = + { infoMessage : String + , warnMessage : String + , actionButton : String + , actionTitle : String + , cancelTitle : String + , actionSuccessful : String + , actionInProcess : String + } + + +view : Texts -> ViewConfig -> UiSettings -> Model -> Html Msg +view texts cfg settings model = div [ class "px-2 mb-4" ] [ h1 [ class S.header1 ] [ text texts.title , a [ class "ml-2" , class S.link + , classList [ ( "hidden", cfg.infoMessage == "" ) ] , href "#" , onClick ToggleInfoText ] @@ -245,36 +262,37 @@ view texts settings model = ] , p [ class S.infoMessage - , classList [ ( "hidden", not model.showInfoText ) ] + , classList [ ( "hidden", not model.showInfoText || cfg.infoMessage == "" ) ] ] - [ text texts.infoText + [ text cfg.infoMessage ] , p [ class S.warnMessage , class "mt-2" + , classList [ ( "hidden", cfg.warnMessage == "" ) ] ] - [ text texts.deleteWarn + [ text cfg.warnMessage ] , MB.view <| { start = [ MB.PrimaryButton - { tagger = SubmitMerge - , title = texts.submitMergeTitle + { tagger = SubmitAction + , title = cfg.actionTitle , icon = Just "fa fa-less-than" - , label = texts.submitMerge + , label = cfg.actionButton } , MB.SecondaryButton - { tagger = CancelMerge - , title = texts.cancelMergeTitle + { tagger = CancelAction + , title = cfg.cancelTitle , icon = Just "fa fa-times" - , label = texts.cancelMerge + , label = texts.cancelView } ] , end = [] , rootClasses = "my-4" , sticky = True } - , renderFormState texts model + , renderFormState texts cfg model , div [ class "flex-col px-2" ] (List.indexedMap (itemCard texts settings model) model.items) ] @@ -494,8 +512,8 @@ mainTagsAndFields2 settings item = (renderFields ++ renderTags) -renderFormState : Texts -> Model -> Html Msg -renderFormState texts model = +renderFormState : Texts -> ViewConfig -> Model -> Html Msg +renderFormState texts cfg model = case model.formState of FormStateInitial -> span [ class "hidden" ] [] @@ -516,18 +534,18 @@ renderFormState texts model = [ text (texts.httpError err) ] - FormStateMergeSuccessful -> + FormStateActionSuccessful -> div [ class S.successMessage , class "my-2" ] - [ text texts.mergeSuccessful + [ text cfg.actionSuccessful ] - FormStateMergeInProcess -> + FormStateActionInProcess -> Comp.Basic.loadingDimmer { active = True - , label = texts.mergeInProcess + , label = cfg.actionInProcess } diff --git a/modules/webapp/src/main/elm/Messages/Comp/ItemMerge.elm b/modules/webapp/src/main/elm/Messages/Comp/ItemMerge.elm index 54de08ee..20e725bc 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/ItemMerge.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/ItemMerge.elm @@ -24,16 +24,9 @@ type alias Texts = { basics : Messages.Basics.Texts , httpError : Http.Error -> String , title : String - , infoText : String - , deleteWarn : String , formatDateLong : Int -> String , formatDateShort : Int -> String - , submitMerge : String - , cancelMerge : String - , submitMergeTitle : String - , cancelMergeTitle : String - , mergeSuccessful : String - , mergeInProcess : String + , cancelView : String } @@ -42,16 +35,9 @@ gb tz = { basics = Messages.Basics.gb , httpError = Messages.Comp.HttpError.gb , title = "Merge Items" - , infoText = "When merging items the first item in the list acts as the target. Every other items metadata is copied into the target item. If the property is a single value (like correspondent), it is only set if not already present. Tags, custom fields and attachments are added. The items can be reordered using drag&drop." - , deleteWarn = "Note that all items but the first one is deleted after a successful merge!" , formatDateLong = Messages.DateFormat.formatDateLong Messages.UiLanguage.English tz , formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.English tz - , submitMerge = "Merge" - , submitMergeTitle = "Merge the documents now" - , cancelMerge = "Cancel" - , cancelMergeTitle = "Back to select view" - , mergeSuccessful = "Items merged successfully" - , mergeInProcess = "Items are merged …" + , cancelView = "Cancel" } @@ -60,16 +46,9 @@ de tz = { basics = Messages.Basics.de , httpError = Messages.Comp.HttpError.de , title = "Dokumente zusammenführen" - , infoText = "Beim Zusammenführen der Dokumente, wird das erste in der Liste als Zieldokument verwendet. Die Metadaten der anderen Dokumente werden der Reihe nach auf des Zieldokument geschrieben. Metadaten die nur einen Wert haben, werden nur gesetzt falls noch kein Wert existiert. Tags, Benutzerfelder und Anhänge werden zu dem Zieldokument hinzugefügt. Die Einträge können mit Drag&Drop umgeordnet werden." - , deleteWarn = "Bitte beachte, dass nach erfolgreicher Zusammenführung alle anderen Dokumente gelöscht werden!" , formatDateLong = Messages.DateFormat.formatDateLong Messages.UiLanguage.German tz , formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.German tz - , submitMerge = "Zusammenführen" - , submitMergeTitle = "Dokumente jetzt zusammenführen" - , cancelMerge = "Abbrechen" - , cancelMergeTitle = "Zurück zur Auswahl" - , mergeSuccessful = "Die Dokumente wurden erfolgreich zusammengeführt." - , mergeInProcess = "Dokumente werden zusammengeführt…" + , cancelView = "Abbrechen" } @@ -78,14 +57,7 @@ fr tz = { basics = Messages.Basics.fr , httpError = Messages.Comp.HttpError.fr , title = "Fusionner des documents" - , infoText = "Lors d'une fusion, le premier document sert de cible. Les métadonnées des autres documents sont ajoutées à la cible. Si la propriété est un valeur seule (comme correspondant), ceci est ajouté si pas déjà présent. Tags, champs personnalisés et pièces-jointes sont ajoutés. Les documents peuvent être réordonnés avec le glisser/déposer." - , deleteWarn = "Veuillez noter que tous les documents sont supprimés après une fusion réussie !" , formatDateLong = Messages.DateFormat.formatDateLong Messages.UiLanguage.French tz , formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.French tz - , submitMerge = "Fusionner" - , submitMergeTitle = "Lancer la fusion" - , cancelMerge = "Annuler" - , cancelMergeTitle = "Annuler la fusion" - , mergeSuccessful = "Documents fusionnés avec succès" - , mergeInProcess = "Fusion en cours ..." + , cancelView = "Annuler" } diff --git a/modules/webapp/src/main/elm/Messages/Page/Search.elm b/modules/webapp/src/main/elm/Messages/Page/Search.elm index c2c70cda..c1504f03 100644 --- a/modules/webapp/src/main/elm/Messages/Page/Search.elm +++ b/modules/webapp/src/main/elm/Messages/Page/Search.elm @@ -60,6 +60,13 @@ type alias Texts = , expandCollapseRows : String , bookmarkQuery : String , nothingToBookmark : String + , submitMerge : String + , mergeInfoText : String + , mergeDeleteWarn : String + , submitMergeTitle : String + , cancelMergeTitle : String + , mergeSuccessful : String + , mergeInProcess : String } @@ -102,6 +109,13 @@ gb tz = , expandCollapseRows = "Expand/Collapse all" , bookmarkQuery = "Bookmark query" , nothingToBookmark = "Nothing selected to bookmark" + , submitMerge = "Merge" + , mergeInfoText = "When merging items the first item in the list acts as the target. Every other items metadata is copied into the target item. If the property is a single value (like correspondent), it is only set if not already present. Tags, custom fields and attachments are added. The items can be reordered using drag&drop." + , mergeDeleteWarn = "Note that all items but the first one is deleted after a successful merge!" + , submitMergeTitle = "Merge the documents now" + , cancelMergeTitle = "Back to select view" + , mergeSuccessful = "Items merged successfully" + , mergeInProcess = "Items are merged …" } @@ -144,6 +158,13 @@ de tz = , expandCollapseRows = "Alle ein-/ausklappen" , bookmarkQuery = "Abfrage merken" , nothingToBookmark = "Keine Abfrage vorhanden" + , submitMerge = "Zusammenführen" + , mergeInfoText = "Beim Zusammenführen der Dokumente, wird das erste in der Liste als Zieldokument verwendet. Die Metadaten der anderen Dokumente werden der Reihe nach auf des Zieldokument geschrieben. Metadaten die nur einen Wert haben, werden nur gesetzt falls noch kein Wert existiert. Tags, Benutzerfelder und Anhänge werden zu dem Zieldokument hinzugefügt. Die Einträge können mit Drag&Drop umgeordnet werden." + , mergeDeleteWarn = "Bitte beachte, dass nach erfolgreicher Zusammenführung alle anderen Dokumente gelöscht werden!" + , submitMergeTitle = "Dokumente jetzt zusammenführen" + , cancelMergeTitle = "Zurück zur Auswahl" + , mergeSuccessful = "Die Dokumente wurden erfolgreich zusammengeführt." + , mergeInProcess = "Dokumente werden zusammengeführt…" } @@ -186,4 +207,11 @@ fr tz = , expandCollapseRows = "Étendre/Réduire tout" , bookmarkQuery = "Requête de favoris" , nothingToBookmark = "Rien n'est sélectionné en favori" + , submitMerge = "Fusionner" + , mergeInfoText = "Lors d'une fusion, le premier document sert de cible. Les métadonnées des autres documents sont ajoutées à la cible. Si la propriété est un valeur seule (comme correspondant), ceci est ajouté si pas déjà présent. Tags, champs personnalisés et pièces-jointes sont ajoutés. Les documents peuvent être réordonnés avec le glisser/déposer." + , mergeDeleteWarn = "Veuillez noter que tous les documents sont supprimés après une fusion réussie !" + , submitMergeTitle = "Lancer la fusion" + , cancelMergeTitle = "Annuler la fusion" + , mergeSuccessful = "Documents fusionnés avec succès" + , mergeInProcess = "Fusion en cours ..." } diff --git a/modules/webapp/src/main/elm/Page/Search/Update.elm b/modules/webapp/src/main/elm/Page/Search/Update.elm index 3b1b5771..3895d359 100644 --- a/modules/webapp/src/main/elm/Page/Search/Update.elm +++ b/modules/webapp/src/main/elm/Page/Search/Update.elm @@ -600,8 +600,11 @@ update texts bookmarkId lastViewedItemId env msg model = case model.viewMode of SelectView svm -> let + action = + Api.mergeItemsTask env.flags + result = - Comp.ItemMerge.update env.flags lmsg svm.mergeModel + Comp.ItemMerge.update env.flags action lmsg svm.mergeModel nextView = case result.outcome of @@ -611,13 +614,13 @@ update texts bookmarkId lastViewedItemId env msg model = Comp.ItemMerge.OutcomeNotYet -> SelectView { svm | mergeModel = result.model } - Comp.ItemMerge.OutcomeMerged -> + Comp.ItemMerge.OutcomeActionDone -> SearchView model_ = { model | viewMode = nextView } in - if result.outcome == Comp.ItemMerge.OutcomeMerged then + if result.outcome == Comp.ItemMerge.OutcomeActionDone then update texts bookmarkId lastViewedItemId diff --git a/modules/webapp/src/main/elm/Page/Search/View2.elm b/modules/webapp/src/main/elm/Page/Search/View2.elm index 30461f18..aa5d6ee9 100644 --- a/modules/webapp/src/main/elm/Page/Search/View2.elm +++ b/modules/webapp/src/main/elm/Page/Search/View2.elm @@ -129,8 +129,19 @@ itemPublishView texts settings flags svm = itemMergeView : Texts -> UiSettings -> SelectViewModel -> List (Html Msg) itemMergeView texts settings svm = + let + cfg = + { infoMessage = texts.mergeInfoText + , warnMessage = texts.mergeDeleteWarn + , actionButton = texts.submitMerge + , actionTitle = texts.submitMergeTitle + , cancelTitle = texts.cancelMergeTitle + , actionSuccessful = texts.mergeSuccessful + , actionInProcess = texts.mergeInProcess + } + in [ Html.map MergeItemsMsg - (Comp.ItemMerge.view texts.itemMerge settings svm.mergeModel) + (Comp.ItemMerge.view texts.itemMerge cfg settings svm.mergeModel) ] From e957d6e96f94e856bb16fa24b07df00f000182ad Mon Sep 17 00:00:00 2001 From: eikek Date: Fri, 18 Mar 2022 00:08:53 +0100 Subject: [PATCH 7/7] Link multiple items via selection --- modules/webapp/src/main/elm/Api.elm | 20 ++++++++++ .../webapp/src/main/elm/Comp/ItemLinkForm.elm | 7 ++++ .../webapp/src/main/elm/Comp/ItemMerge.elm | 8 ++-- .../src/main/elm/Comp/ItemSearchInput.elm | 7 ++++ modules/webapp/src/main/elm/Data/Icons.elm | 6 +++ .../main/elm/Messages/Comp/ItemLinkForm.elm | 7 ++++ .../src/main/elm/Messages/Comp/ItemMerge.elm | 4 -- .../elm/Messages/Comp/ItemSearchInput.elm | 7 ++++ .../src/main/elm/Messages/Page/Search.elm | 36 +++++++++++++++++ .../webapp/src/main/elm/Page/Search/Data.elm | 12 ++++-- .../src/main/elm/Page/Search/Update.elm | 19 +++++---- .../webapp/src/main/elm/Page/Search/View2.elm | 39 +++++++++++++++++-- 12 files changed, 152 insertions(+), 20 deletions(-) diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index e51c15b4..a9da28dd 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -14,6 +14,7 @@ module Api exposing , addDashboard , addMember , addRelatedItems + , addRelatedItemsTask , addShare , addTag , addTagsMultiple @@ -3044,6 +3045,25 @@ addRelatedItems flags data receive = } +addRelatedItemsTask : Flags -> List String -> Task.Task Http.Error BasicResult +addRelatedItemsTask flags ids = + let + itemData = + { item = List.head ids |> Maybe.withDefault "" + , related = List.tail ids |> Maybe.withDefault [] + } + in + Http2.authTask + { url = flags.config.baseUrl ++ "/api/v1/sec/itemlink/addAll" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemLinkData.encode itemData) + , method = "POST" + , headers = [] + , resolver = Http2.jsonResolver Api.Model.BasicResult.decoder + , timeout = Nothing + } + + removeRelatedItems : Flags -> ItemLinkData -> (Result Http.Error BasicResult -> msg) -> Cmd msg removeRelatedItems flags data receive = Http2.authPost diff --git a/modules/webapp/src/main/elm/Comp/ItemLinkForm.elm b/modules/webapp/src/main/elm/Comp/ItemLinkForm.elm index 924867da..bc525ddb 100644 --- a/modules/webapp/src/main/elm/Comp/ItemLinkForm.elm +++ b/modules/webapp/src/main/elm/Comp/ItemLinkForm.elm @@ -1,3 +1,10 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + module Comp.ItemLinkForm exposing (Model, Msg, emptyModel, init, update, view) import Api diff --git a/modules/webapp/src/main/elm/Comp/ItemMerge.elm b/modules/webapp/src/main/elm/Comp/ItemMerge.elm index 52241a1e..55e823d7 100644 --- a/modules/webapp/src/main/elm/Comp/ItemMerge.elm +++ b/modules/webapp/src/main/elm/Comp/ItemMerge.elm @@ -235,9 +235,11 @@ flatten list = type alias ViewConfig = - { infoMessage : String + { title : String + , infoMessage : String , warnMessage : String , actionButton : String + , actionIcon : String , actionTitle : String , cancelTitle : String , actionSuccessful : String @@ -249,7 +251,7 @@ view : Texts -> ViewConfig -> UiSettings -> Model -> Html Msg view texts cfg settings model = div [ class "px-2 mb-4" ] [ h1 [ class S.header1 ] - [ text texts.title + [ text cfg.title , a [ class "ml-2" , class S.link @@ -278,7 +280,7 @@ view texts cfg settings model = [ MB.PrimaryButton { tagger = SubmitAction , title = cfg.actionTitle - , icon = Just "fa fa-less-than" + , icon = Just cfg.actionIcon , label = cfg.actionButton } , MB.SecondaryButton diff --git a/modules/webapp/src/main/elm/Comp/ItemSearchInput.elm b/modules/webapp/src/main/elm/Comp/ItemSearchInput.elm index 4777d3f4..6fe83107 100644 --- a/modules/webapp/src/main/elm/Comp/ItemSearchInput.elm +++ b/modules/webapp/src/main/elm/Comp/ItemSearchInput.elm @@ -1,3 +1,10 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + module Comp.ItemSearchInput exposing (Config, Model, Msg, defaultConfig, hasFocus, init, isSearching, update, view) import Api diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm index 2a769a37..236390a7 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -47,6 +47,7 @@ module Data.Icons exposing , folderIcon , gotifyIcon , itemDatesIcon + , linkItems , matrixIcon , metadata , metadataIcon @@ -150,6 +151,11 @@ share = "fa fa-share-alt" +linkItems : String +linkItems = + "fa fa-link" + + shareIcon : String -> Html msg shareIcon classes = i [ class (classes ++ " " ++ share) ] [] diff --git a/modules/webapp/src/main/elm/Messages/Comp/ItemLinkForm.elm b/modules/webapp/src/main/elm/Messages/Comp/ItemLinkForm.elm index b460252e..640fae9e 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/ItemLinkForm.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/ItemLinkForm.elm @@ -1,3 +1,10 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + module Messages.Comp.ItemLinkForm exposing (Texts, de, fr, gb) import Data.Direction exposing (Direction) diff --git a/modules/webapp/src/main/elm/Messages/Comp/ItemMerge.elm b/modules/webapp/src/main/elm/Messages/Comp/ItemMerge.elm index 20e725bc..935b8202 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/ItemMerge.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/ItemMerge.elm @@ -23,7 +23,6 @@ import Messages.UiLanguage type alias Texts = { basics : Messages.Basics.Texts , httpError : Http.Error -> String - , title : String , formatDateLong : Int -> String , formatDateShort : Int -> String , cancelView : String @@ -34,7 +33,6 @@ gb : TimeZone -> Texts gb tz = { basics = Messages.Basics.gb , httpError = Messages.Comp.HttpError.gb - , title = "Merge Items" , formatDateLong = Messages.DateFormat.formatDateLong Messages.UiLanguage.English tz , formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.English tz , cancelView = "Cancel" @@ -45,7 +43,6 @@ de : TimeZone -> Texts de tz = { basics = Messages.Basics.de , httpError = Messages.Comp.HttpError.de - , title = "Dokumente zusammenführen" , formatDateLong = Messages.DateFormat.formatDateLong Messages.UiLanguage.German tz , formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.German tz , cancelView = "Abbrechen" @@ -56,7 +53,6 @@ fr : TimeZone -> Texts fr tz = { basics = Messages.Basics.fr , httpError = Messages.Comp.HttpError.fr - , title = "Fusionner des documents" , formatDateLong = Messages.DateFormat.formatDateLong Messages.UiLanguage.French tz , formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.French tz , cancelView = "Annuler" diff --git a/modules/webapp/src/main/elm/Messages/Comp/ItemSearchInput.elm b/modules/webapp/src/main/elm/Messages/Comp/ItemSearchInput.elm index 2fe5d23f..04bb10e7 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/ItemSearchInput.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/ItemSearchInput.elm @@ -1,3 +1,10 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + module Messages.Comp.ItemSearchInput exposing (Texts, de, fr, gb) import Http diff --git a/modules/webapp/src/main/elm/Messages/Page/Search.elm b/modules/webapp/src/main/elm/Messages/Page/Search.elm index c1504f03..3997ae99 100644 --- a/modules/webapp/src/main/elm/Messages/Page/Search.elm +++ b/modules/webapp/src/main/elm/Messages/Page/Search.elm @@ -67,6 +67,15 @@ type alias Texts = , cancelMergeTitle : String , mergeSuccessful : String , mergeInProcess : String + , mergeHeader : String + , linkItemsTitle : Int -> String + , linkItemsMessage : String + , submitLinkItems : String + , submitLinkItemsTitle : String + , cancelLinkItemsTitle : String + , linkItemsSuccessful : String + , linkItemsInProcess : String + , linkItemsHeader : String } @@ -116,6 +125,15 @@ gb tz = , cancelMergeTitle = "Back to select view" , mergeSuccessful = "Items merged successfully" , mergeInProcess = "Items are merged …" + , linkItemsTitle = \n -> "Link " ++ String.fromInt n ++ " items" + , linkItemsMessage = "There must be at least 2 items in the list. The first is the target item and all remaining are added to its related items list." + , submitLinkItems = "Link" + , submitLinkItemsTitle = "" + , cancelLinkItemsTitle = "" + , linkItemsSuccessful = "Linking items successful" + , linkItemsInProcess = "Linking items ..." + , mergeHeader = "Merge Items" + , linkItemsHeader = "Link Items" } @@ -165,6 +183,15 @@ de tz = , cancelMergeTitle = "Zurück zur Auswahl" , mergeSuccessful = "Die Dokumente wurden erfolgreich zusammengeführt." , mergeInProcess = "Dokumente werden zusammengeführt…" + , linkItemsTitle = \n -> String.fromInt n ++ " Dokumente verknüpfen" + , linkItemsMessage = "Die Liste muss mindestens 2 Dokumenta haben. Das erste Dokument erhält alle folgenden als verknüpfte Dokumente." + , submitLinkItems = "Verknüpfen" + , submitLinkItemsTitle = "" + , cancelLinkItemsTitle = "" + , linkItemsSuccessful = "Das Verknüpfen war erflogreich" + , linkItemsInProcess = "Dokumente werden verknüpft ..." + , mergeHeader = "Dokumente zusammenführen" + , linkItemsHeader = "Dokument verknüpfen" } @@ -214,4 +241,13 @@ fr tz = , cancelMergeTitle = "Annuler la fusion" , mergeSuccessful = "Documents fusionnés avec succès" , mergeInProcess = "Fusion en cours ..." + , linkItemsTitle = \n -> String.fromInt n ++ " Lier des documents" + , linkItemsMessage = "La liste doit comporter au moins deux documents. Le premier document reçoit tous les documents suivants en tant que documents liés." + , submitLinkItems = "Relier" + , submitLinkItemsTitle = "" + , cancelLinkItemsTitle = "" + , linkItemsSuccessful = "L'association a été un succès" + , linkItemsInProcess = "Relier en cours ..." + , mergeHeader = "Fusionner des documents" + , linkItemsHeader = "Lier des documents" } diff --git a/modules/webapp/src/main/elm/Page/Search/Data.elm b/modules/webapp/src/main/elm/Page/Search/Data.elm index 6679ea77..9ae4d838 100644 --- a/modules/webapp/src/main/elm/Page/Search/Data.elm +++ b/modules/webapp/src/main/elm/Page/Search/Data.elm @@ -7,6 +7,7 @@ module Page.Search.Data exposing ( ConfirmModalValue(..) + , ItemMergeModel(..) , Model , Msg(..) , SearchParam @@ -88,19 +89,24 @@ type alias SelectViewModel = { action : SelectActionMode , confirmModal : Maybe ConfirmModalValue , editModel : Comp.ItemDetail.MultiEditMenu.Model - , mergeModel : Comp.ItemMerge.Model + , mergeModel : ItemMergeModel , publishModel : Comp.PublishItems.Model , saveNameState : SaveNameState , saveCustomFieldState : Set String } +type ItemMergeModel + = MergeItems Comp.ItemMerge.Model + | LinkItems Comp.ItemMerge.Model + + initSelectViewModel : Flags -> SelectViewModel initSelectViewModel flags = { action = NoneAction , confirmModal = Nothing , editModel = Comp.ItemDetail.MultiEditMenu.init - , mergeModel = Comp.ItemMerge.init [] + , mergeModel = MergeItems (Comp.ItemMerge.init []) , publishModel = Tuple.first (Comp.PublishItems.init flags) , saveNameState = SaveSuccess , saveCustomFieldState = Set.empty @@ -221,7 +227,7 @@ type Msg | ReprocessSelectedConfirmed | ClientSettingsSaveResp (Result Http.Error BasicResult) | RemoveItem String - | MergeSelectedItems + | MergeSelectedItems (Comp.ItemMerge.Model -> ItemMergeModel) | MergeItemsMsg Comp.ItemMerge.Msg | PublishSelectedItems | PublishItemsMsg Comp.PublishItems.Msg diff --git a/modules/webapp/src/main/elm/Page/Search/Update.elm b/modules/webapp/src/main/elm/Page/Search/Update.elm index 3895d359..c8f53d60 100644 --- a/modules/webapp/src/main/elm/Page/Search/Update.elm +++ b/modules/webapp/src/main/elm/Page/Search/Update.elm @@ -555,7 +555,7 @@ update texts bookmarkId lastViewedItemId env msg model = _ -> resultModelCmd env.selectedItems ( model, Cmd.none ) - MergeSelectedItems -> + MergeSelectedItems createModel -> case model.viewMode of SelectView svm -> if svm.action == MergeSelected then @@ -565,7 +565,7 @@ update texts bookmarkId lastViewedItemId env msg model = SelectView { svm | action = NoneAction - , mergeModel = Comp.ItemMerge.init [] + , mergeModel = createModel <| Comp.ItemMerge.init [] } } , Cmd.none @@ -584,7 +584,7 @@ update texts bookmarkId lastViewedItemId env msg model = SelectView { svm | action = MergeSelected - , mergeModel = mm + , mergeModel = createModel mm } } , Cmd.map MergeItemsMsg mc @@ -600,11 +600,16 @@ update texts bookmarkId lastViewedItemId env msg model = case model.viewMode of SelectView svm -> let - action = - Api.mergeItemsTask env.flags + ( mergeModel, createModel, action ) = + case svm.mergeModel of + MergeItems a -> + ( a, MergeItems, Api.mergeItemsTask env.flags ) + + LinkItems a -> + ( a, LinkItems, Api.addRelatedItemsTask env.flags ) result = - Comp.ItemMerge.update env.flags action lmsg svm.mergeModel + Comp.ItemMerge.update env.flags action lmsg mergeModel nextView = case result.outcome of @@ -612,7 +617,7 @@ update texts bookmarkId lastViewedItemId env msg model = SelectView { svm | action = NoneAction } Comp.ItemMerge.OutcomeNotYet -> - SelectView { svm | mergeModel = result.model } + SelectView { svm | mergeModel = createModel result.model } Comp.ItemMerge.OutcomeActionDone -> SearchView diff --git a/modules/webapp/src/main/elm/Page/Search/View2.elm b/modules/webapp/src/main/elm/Page/Search/View2.elm index aa5d6ee9..4bdfe151 100644 --- a/modules/webapp/src/main/elm/Page/Search/View2.elm +++ b/modules/webapp/src/main/elm/Page/Search/View2.elm @@ -130,7 +130,7 @@ itemPublishView texts settings flags svm = itemMergeView : Texts -> UiSettings -> SelectViewModel -> List (Html Msg) itemMergeView texts settings svm = let - cfg = + cfgMerge = { infoMessage = texts.mergeInfoText , warnMessage = texts.mergeDeleteWarn , actionButton = texts.submitMerge @@ -138,10 +138,32 @@ itemMergeView texts settings svm = , cancelTitle = texts.cancelMergeTitle , actionSuccessful = texts.mergeSuccessful , actionInProcess = texts.mergeInProcess + , title = texts.mergeHeader + , actionIcon = "fa fa-less-than" } + + cfgLink = + { infoMessage = "" + , warnMessage = texts.linkItemsMessage + , actionButton = texts.submitLinkItems + , actionTitle = texts.submitLinkItemsTitle + , cancelTitle = texts.cancelLinkItemsTitle + , actionSuccessful = texts.linkItemsSuccessful + , actionInProcess = texts.linkItemsInProcess + , title = texts.linkItemsHeader + , actionIcon = "fa fa-link" + } + + ( mergeModel, cfg ) = + case svm.mergeModel of + MergeItems a -> + ( a, cfgMerge ) + + LinkItems a -> + ( a, cfgLink ) in [ Html.map MergeItemsMsg - (Comp.ItemMerge.view texts.itemMerge cfg settings svm.mergeModel) + (Comp.ItemMerge.view texts.itemMerge cfg settings mergeModel) ] @@ -496,7 +518,7 @@ editMenuBar texts model selectedItems svm = ] } , MB.CustomButton - { tagger = MergeSelectedItems + { tagger = MergeSelectedItems MergeItems , label = "" , icon = Just "fa fa-less-than" , title = texts.mergeItemsTitle selectCount @@ -517,6 +539,17 @@ editMenuBar texts model selectedItems svm = , ( "hidden", model.searchMenuModel.searchMode == Data.SearchMode.Trashed ) ] } + , MB.CustomButton + { tagger = MergeSelectedItems LinkItems + , label = "" + , icon = Just Icons.linkItems + , title = texts.linkItemsTitle selectCount + , inputClass = + [ ( btnStyle, True ) + , ( "bg-gray-200 dark:bg-slate-600", svm.action == PublishSelected ) + , ( "hidden", model.searchMenuModel.searchMode == Data.SearchMode.Trashed ) + ] + } ] , end = [ MB.CustomButton