diff --git a/.scalafmt.conf b/.scalafmt.conf index c76c4213..bffbf1d3 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -2,7 +2,7 @@ version = "3.3.1" preset = default align.preset = some -runner.dialect = scala213 +runner.dialect = scala213source3 maxColumn = 90 diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index 7bcaec3d..c18e72e8 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -49,6 +49,7 @@ trait BackendApp[F[_]] { def pubSub: PubSubT[F] def events: EventExchange[F] def notification: ONotification[F] + def bookmarks: OQueryBookmarks[F] } object BackendApp { @@ -89,6 +90,7 @@ object BackendApp { OShare(store, itemSearchImpl, simpleSearchImpl, javaEmil) ) notifyImpl <- ONotification(store, notificationMod) + bookmarksImpl <- OQueryBookmarks(store) } yield new BackendApp[F] { val pubSub = pubSubT val login = loginImpl @@ -115,5 +117,6 @@ object BackendApp { val share = shareImpl val events = notificationMod val notification = notifyImpl + val bookmarks = bookmarksImpl } } diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala b/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala index a7e71cbe..4d2d71a2 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala @@ -12,56 +12,78 @@ import cats.implicits._ import docspell.common.AccountId import docspell.common._ -import docspell.common.syntax.all._ import docspell.store.Store -import docspell.store.records.RClientSettings +import docspell.store.records.RClientSettingsCollective +import docspell.store.records.RClientSettingsUser import docspell.store.records.RUser import io.circe.Json -import org.log4s._ trait OClientSettings[F[_]] { - def delete(clientId: Ident, account: AccountId): F[Boolean] - def save(clientId: Ident, account: AccountId, data: Json): F[Unit] - def load(clientId: Ident, account: AccountId): F[Option[RClientSettings]] + def deleteUser(clientId: Ident, account: AccountId): F[Boolean] + def saveUser(clientId: Ident, account: AccountId, data: Json): F[Unit] + def loadUser(clientId: Ident, account: AccountId): F[Option[RClientSettingsUser]] + + def deleteCollective(clientId: Ident, account: AccountId): F[Boolean] + def saveCollective(clientId: Ident, account: AccountId, data: Json): F[Unit] + def loadCollective( + clientId: Ident, + account: AccountId + ): F[Option[RClientSettingsCollective]] } object OClientSettings { - private[this] val logger = getLogger + private[this] val logger = org.log4s.getLogger def apply[F[_]: Async](store: Store[F]): Resource[F, OClientSettings[F]] = Resource.pure[F, OClientSettings[F]](new OClientSettings[F] { + val log = Logger.log4s[F](logger) private def getUserId(account: AccountId): OptionT[F, Ident] = OptionT(store.transact(RUser.findByAccount(account))).map(_.uid) - def delete(clientId: Ident, account: AccountId): F[Boolean] = + def deleteCollective(clientId: Ident, account: AccountId): F[Boolean] = + store + .transact(RClientSettingsCollective.delete(clientId, account.collective)) + .map(_ > 0) + + def deleteUser(clientId: Ident, account: AccountId): F[Boolean] = (for { _ <- OptionT.liftF( - logger.fdebug( + log.debug( s"Deleting client settings for client ${clientId.id} and account $account" ) ) userId <- getUserId(account) n <- OptionT.liftF( store.transact( - RClientSettings.delete(clientId, userId) + RClientSettingsUser.delete(clientId, userId) ) ) } yield n > 0).getOrElse(false) - def save(clientId: Ident, account: AccountId, data: Json): F[Unit] = + def saveCollective(clientId: Ident, account: AccountId, data: Json): F[Unit] = + for { + n <- store.transact( + RClientSettingsCollective.upsert(clientId, account.collective, data) + ) + _ <- + if (n <= 0) Async[F].raiseError(new IllegalStateException("No rows updated!")) + else ().pure[F] + } yield () + + def saveUser(clientId: Ident, account: AccountId, data: Json): F[Unit] = (for { _ <- OptionT.liftF( - logger.fdebug( + log.debug( s"Storing client settings for client ${clientId.id} and account $account" ) ) userId <- getUserId(account) n <- OptionT.liftF( - store.transact(RClientSettings.upsert(clientId, userId, data)) + store.transact(RClientSettingsUser.upsert(clientId, userId, data)) ) _ <- OptionT.liftF( if (n <= 0) Async[F].raiseError(new Exception("No rows updated!")) @@ -69,15 +91,21 @@ object OClientSettings { ) } yield ()).getOrElse(()) - def load(clientId: Ident, account: AccountId): F[Option[RClientSettings]] = + def loadCollective( + clientId: Ident, + account: AccountId + ): F[Option[RClientSettingsCollective]] = + store.transact(RClientSettingsCollective.find(clientId, account.collective)) + + def loadUser(clientId: Ident, account: AccountId): F[Option[RClientSettingsUser]] = (for { _ <- OptionT.liftF( - logger.fdebug( + log.debug( s"Loading client settings for client ${clientId.id} and account $account" ) ) userId <- getUserId(account) - data <- OptionT(store.transact(RClientSettings.find(clientId, userId))) + data <- OptionT(store.transact(RClientSettingsUser.find(clientId, userId))) } yield data).value }) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OQueryBookmarks.scala b/modules/backend/src/main/scala/docspell/backend/ops/OQueryBookmarks.scala new file mode 100644 index 00000000..242b0d17 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OQueryBookmarks.scala @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.backend.ops + +import cats.effect._ +import cats.implicits._ + +import docspell.common._ +import docspell.query.ItemQuery +import docspell.store.AddResult +import docspell.store.Store +import docspell.store.UpdateResult +import docspell.store.records.RQueryBookmark + +trait OQueryBookmarks[F[_]] { + + def getAll(account: AccountId): F[Vector[OQueryBookmarks.Bookmark]] + + def findOne(account: AccountId, nameOrId: String): F[Option[OQueryBookmarks.Bookmark]] + + def create(account: AccountId, bookmark: OQueryBookmarks.NewBookmark): F[AddResult] + + def update( + account: AccountId, + id: Ident, + bookmark: OQueryBookmarks.NewBookmark + ): F[UpdateResult] + + def delete(account: AccountId, bookmark: Ident): F[Unit] +} + +object OQueryBookmarks { + final case class NewBookmark( + name: String, + label: Option[String], + query: ItemQuery, + personal: Boolean + ) + + final case class Bookmark( + id: Ident, + name: String, + label: Option[String], + query: ItemQuery, + personal: Boolean, + created: Timestamp + ) + + def apply[F[_]: Sync](store: Store[F]): Resource[F, OQueryBookmarks[F]] = + Resource.pure(new OQueryBookmarks[F] { + def getAll(account: AccountId): F[Vector[Bookmark]] = + store + .transact(RQueryBookmark.allForUser(account)) + .map(_.map(convert.toModel)) + + def findOne( + account: AccountId, + nameOrId: String + ): F[Option[OQueryBookmarks.Bookmark]] = + store + .transact(RQueryBookmark.findByNameOrId(account, nameOrId)) + .map(_.map(convert.toModel)) + + def create(account: AccountId, b: NewBookmark): F[AddResult] = { + val record = + RQueryBookmark.createNew(account, b.name, b.label, b.query, b.personal) + store.transact(RQueryBookmark.insertIfNotExists(account, record)) + } + + def update(account: AccountId, id: Ident, b: NewBookmark): F[UpdateResult] = + UpdateResult.fromUpdate( + store.transact(RQueryBookmark.update(convert.toRecord(account, id, b))) + ) + + def delete(account: AccountId, bookmark: Ident): F[Unit] = + store.transact(RQueryBookmark.deleteById(account.collective, bookmark)).as(()) + }) + + private object convert { + + def toModel(r: RQueryBookmark): Bookmark = + Bookmark(r.id, r.name, r.label, r.query, r.isPersonal, r.created) + + def toRecord(account: AccountId, id: Ident, b: NewBookmark): RQueryBookmark = + RQueryBookmark( + id, + b.name, + b.label, + None, // userId and some other values are not used + account.collective, + b.query, + Timestamp.Epoch + ) + + } +} diff --git a/modules/joex/src/main/scala/docspell/joex/notify/PeriodicQueryTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/PeriodicQueryTask.scala index a21a8a0f..c24e47e8 100644 --- a/modules/joex/src/main/scala/docspell/joex/notify/PeriodicQueryTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/notify/PeriodicQueryTask.scala @@ -7,6 +7,7 @@ package docspell.joex.notify import cats.data.OptionT +import cats.data.{NonEmptyList => Nel} import cats.effect._ import cats.implicits._ @@ -17,10 +18,14 @@ import docspell.joex.scheduler.Task import docspell.notification.api.EventContext import docspell.notification.api.NotificationChannel import docspell.notification.api.PeriodicQueryArgs +import docspell.query.ItemQuery +import docspell.query.ItemQuery.Expr.AndExpr import docspell.query.ItemQueryParser import docspell.store.qb.Batch import docspell.store.queries.ListItem import docspell.store.queries.{QItem, Query} +import docspell.store.records.RQueryBookmark +import docspell.store.records.RShare import docspell.store.records.RUser object PeriodicQueryTask { @@ -54,22 +59,77 @@ object PeriodicQueryTask { ) .getOrElse(()) + private def queryString(q: ItemQuery.Expr) = + ItemQueryParser.asString(q) + + def withQuery[F[_]: Sync](ctx: Context[F, Args])(cont: Query => F[Unit]): F[Unit] = { + def fromBookmark(id: String) = + ctx.store + .transact(RQueryBookmark.findByNameOrId(ctx.args.account, id)) + .map(_.map(_.query)) + .flatTap(q => + ctx.logger.debug(s"Loaded bookmark '$id': ${q.map(_.expr).map(queryString)}") + ) + + def fromShare(id: String) = + ctx.store + .transact(RShare.findOneByCollective(ctx.args.account.collective, Some(true), id)) + .map(_.map(_.query)) + .flatTap(q => + ctx.logger.debug(s"Loaded share '$id': ${q.map(_.expr).map(queryString)}") + ) + + def fromBookmarkOrShare(id: String) = + OptionT(fromBookmark(id)).orElse(OptionT(fromShare(id))).value + + def runQuery(bm: Option[ItemQuery], str: String): F[Unit] = + ItemQueryParser.parse(str) match { + case Right(q) => + val expr = bm.map(b => AndExpr(Nel.of(b.expr, q.expr))).getOrElse(q.expr) + val query = Query(Query.Fix(ctx.args.account, Some(expr), None)) + ctx.logger.debug(s"Running query: ${queryString(expr)}") *> cont(query) + + case Left(err) => + ctx.logger.error( + s"Item query is invalid, stopping: ${ctx.args.query.map(_.query)} - ${err.render}" + ) + } + + (ctx.args.bookmark, ctx.args.query) match { + case (Some(bm), Some(qstr)) => + ctx.logger.debug(s"Using bookmark $bm and query $qstr") *> + fromBookmarkOrShare(bm).flatMap(bq => runQuery(bq, qstr.query)) + + case (Some(bm), None) => + fromBookmarkOrShare(bm).flatMap { + case Some(bq) => + val query = Query(Query.Fix(ctx.args.account, Some(bq.expr), None)) + ctx.logger.debug(s"Using bookmark: ${queryString(bq.expr)}") *> cont(query) + + case None => + ctx.logger.error( + s"No bookmark found for id: $bm. Can't continue. Please fix the task query." + ) + } + + case (None, Some(qstr)) => + ctx.logger.debug(s"Using query: ${qstr.query}") *> runQuery(None, qstr.query) + + case (None, None) => + ctx.logger.error(s"No query provided for task $taskName!") + } + } + def withItems[F[_]: Sync](ctx: Context[F, Args], limit: Int, now: Timestamp)( cont: Vector[ListItem] => F[Unit] ): F[Unit] = - ItemQueryParser.parse(ctx.args.query.query) match { - case Right(q) => - val query = Query(Query.Fix(ctx.args.account, Some(q.expr), None)) - val items = ctx.store - .transact(QItem.findItems(query, now.toUtcDate, 0, Batch.limit(limit))) - .compile - .to(Vector) + withQuery(ctx) { query => + val items = ctx.store + .transact(QItem.findItems(query, now.toUtcDate, 0, Batch.limit(limit))) + .compile + .to(Vector) - items.flatMap(cont) - case Left(err) => - ctx.logger.error( - s"Item query is invalid, stopping: ${ctx.args.query} - ${err.render}" - ) + items.flatMap(cont) } def withEventContext[F[_]]( diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicQueryArgs.scala b/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicQueryArgs.scala index 3f79bf90..e8ffd089 100644 --- a/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicQueryArgs.scala +++ b/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicQueryArgs.scala @@ -15,7 +15,8 @@ import io.circe.{Decoder, Encoder} final case class PeriodicQueryArgs( account: AccountId, channel: ChannelOrRef, - query: ItemQueryString, + query: Option[ItemQueryString], + bookmark: Option[String], baseUrl: Option[LenientUri] ) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 6c8f5530..135fa85e 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1880,18 +1880,146 @@ paths: application/json: schema: {} + /sec/querybookmark: + get: + operationId: "sec-querybookmark-get-all" + tags: [Query Bookmarks] + summary: Return all query bookmarks + description: | + Returns all query bookmarks of the current user. + + Bookmarks can be "global", where they belong to the whole + collective or personal, so they are only for the user. This + returns both. + security: + - authTokenHeader: [] + responses: + 422: + description: BadRequest + 200: + description: Ok + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BookmarkedQuery" + post: + operationId: "sec-querybookmark-post" + tags: [Query Bookmarks] + summary: Create a new query bookmark + description: | + Creates a new query bookmark. + + A bookmark must have a unique name (within both collective and + personal scope). If a name already exists, a failure is + returned - use PUT instead for changing existing bookmarks. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/BookmarkedQuery" + responses: + 422: + description: BadRequest + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + put: + operationId: "sec-querybookmark-put" + tags: [Query Bookmarks] + summary: Change a query bookmark + description: | + Changes an existing query bookmark. + + A bookmark must have a unique name within the collective + (considering collective and personal scope). The bookmark is + identified by its id, which must exist. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/BookmarkedQuery" + responses: + 422: + description: BadRequest + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/querybookmark/{bookmarkId}: + parameters: + - $ref: "#/components/parameters/bookmarkId" + delete: + operationId: "sec-querybookmark-delete" + tags: [Query Bookmark] + summary: Delete a bookmark. + description: | + Deletes a bookmarks by its id. + responses: + 422: + description: BadRequest + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/clientSettings/{clientId}: parameters: - $ref: "#/components/parameters/clientId" get: operationId: "sec-clientsettings-get" tags: [ Client Settings ] - summary: Return the current user settings + summary: Return the client settings of current user + description: | + Returns the settings for the current user by merging the + client settings for the collective and the user's own client + settings into one json structure. + + Null, Array, Boolean, String and Number are treated as values, + and values from the users settings completely replace values + from the collective's settings. + + The `clientId` is an identifier to a client application. It + returns a JSON structure. The server doesn't care about the + actual data, since it is meant to be interpreted by clients. + security: + - authTokenHeader: [] + responses: + 422: + description: BadRequest + 200: + description: Ok + content: + application/json: + schema: {} + + /sec/clientSettings/user/{clientId}: + parameters: + - $ref: "#/components/parameters/clientId" + get: + operationId: "sec-clientsettings-user-get" + tags: [ Client Settings ] + summary: Return the user's own client settings description: | Returns the settings for the current user. The `clientId` is an identifier to a client application. It returns a JSON structure. The server doesn't care about the actual data, since it is meant to be interpreted by clients. + + If there is nothing stored for the given `clientId` an empty + JSON object (`{}`) is returned! security: - authTokenHeader: [] responses: @@ -1903,9 +2031,9 @@ paths: application/json: schema: {} put: - operationId: "sec-clientsettings-update" + operationId: "sec-clientsettings-user-update" tags: [ Client Settings ] - summary: Update current user settings + summary: Update client settings of current user description: | Updates (replaces or creates) the current user's settings with the given data. The `clientId` is an identifier to a client @@ -1933,9 +2061,9 @@ paths: schema: $ref: "#/components/schemas/BasicResult" delete: - operationId: "sec-clientsettings-delete" + operationId: "sec-clientsettings-user-delete" tags: [ Client Settings ] - summary: Clears the current user settings + summary: Clears client settings of current user description: | Removes all stored user settings for the client identified by `clientId`. @@ -1951,6 +2079,80 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/clientSettings/collective/{clientId}: + parameters: + - $ref: "#/components/parameters/clientId" + get: + operationId: "sec-clientsettings-collective-get" + tags: [ Client Settings ] + summary: Return collective client settings + description: | + Returns the settings for the current collective. The + `clientId` is an identifier to a client application. It + returns a JSON structure. The server doesn't care about the + actual data, since it is meant to be interpreted by clients. + + If there is nothing stored for the given `clientId` an empty + JSON object (`{}`) is returned! + security: + - authTokenHeader: [] + responses: + 422: + description: BadRequest + 200: + description: Ok + content: + application/json: + schema: {} + put: + operationId: "sec-clientsettings-collective-update" + tags: [ Client Settings ] + summary: Update collective client settings + description: | + Updates (replaces or creates) the current collective's + settings with the given data. The `clientId` is an identifier + to a client application. The request body is expected to be + JSON, the structure is not important to the server. + + The data is stored for the current user and given `clientId`. + + The data is only saved without being checked in any way + (besides being valid JSON). It is returned "as is" to the + client in the corresponding GET endpoint. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: {} + responses: + 422: + description: BadRequest + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + delete: + operationId: "sec-clientsettings-collective-delete" + tags: [ Client Settings ] + summary: Clears collective's client settings + description: | + Removes all stored client settings of id `clientId` for + collective. + security: + - authTokenHeader: [] + responses: + 422: + description: BadRequest + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /share/search/query: post: operationId: "share-search-query" @@ -5206,6 +5408,32 @@ paths: components: schemas: + BookmarkedQuery: + description: | + A query bookmark. + required: + - id + - name + - query + - personal + - created + properties: + id: + type: string + format: ident + name: + type: string + label: + type: string + query: + type: string + format: itemquery + personal: + type: boolean + created: + type: integer + format: date-time + StringValue: description: | A generic string value @@ -7755,6 +7983,14 @@ components: some identifier for a client application schema: type: string + bookmarkId: + name: bookmarkId + in: path + required: true + description: | + An identifier for a bookmark + schema: + type: string providerId: name: providerId in: path @@ -7811,12 +8047,12 @@ extraSchemas: PeriodicQuerySettings: description: | - Settings for the periodc-query task. + Settings for the periodc-query task. At least one of `query` and + `bookmark` is required! required: - id - enabled - channel - - query - schedule properties: id: @@ -7839,6 +8075,10 @@ extraSchemas: query: type: string format: itemquery + bookmark: + type: string + description: | + Name or ID of bookmark to use. PeriodicDueItemsSettings: description: | diff --git a/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicQuerySettings.scala b/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicQuerySettings.scala index 5045c62f..964ad125 100644 --- a/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicQuerySettings.scala +++ b/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicQuerySettings.scala @@ -21,7 +21,8 @@ final case class PeriodicQuerySettings( summary: Option[String], enabled: Boolean, channel: NotificationChannel, - query: ItemQuery, + query: Option[ItemQuery], + bookmark: Option[String], schedule: CalEvent ) {} diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index aaecc0dd..dd36286e 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -172,7 +172,8 @@ object RestServer { "folder" -> FolderRoutes(restApp.backend, token), "customfield" -> CustomFieldRoutes(restApp.backend, token), "clientSettings" -> ClientSettingsRoutes(restApp.backend, token), - "notification" -> NotificationRoutes(cfg, restApp.backend, token) + "notification" -> NotificationRoutes(cfg, restApp.backend, token), + "querybookmark" -> BookmarkRoutes(restApp.backend, token) ) def openRoutes[F[_]: Async]( diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/BookmarkRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/BookmarkRoutes.scala new file mode 100644 index 00000000..744c7370 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/BookmarkRoutes.scala @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.routes + +import cats.effect.Async +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.backend.ops.OQueryBookmarks +import docspell.common.Ident +import docspell.restapi.model.BookmarkedQuery +import docspell.restserver.conv.Conversions + +import org.http4s.HttpRoutes +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +object BookmarkRoutes { + + def apply[F[_]: Async](backend: BackendApp[F], token: AuthToken): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case GET -> Root => + for { + all <- backend.bookmarks.getAll(token.account) + resp <- Ok(all.map(convert.toApi)) + } yield resp + + case req @ POST -> Root => + for { + data <- req.as[BookmarkedQuery] + res <- backend.bookmarks.create(token.account, convert.toModel(data)) + resp <- Ok(Conversions.basicResult(res, "Bookmark created")) + } yield resp + + case req @ PUT -> Root => + for { + data <- req.as[BookmarkedQuery] + res <- backend.bookmarks.update(token.account, data.id, convert.toModel(data)) + resp <- Ok(Conversions.basicResult(res, "Bookmark updated")) + } yield resp + + case DELETE -> Root / Ident(id) => + for { + res <- backend.bookmarks.delete(token.account, id).attempt + resp <- Ok(Conversions.basicResult(res, "Bookmark deleted")) + } yield resp + } + } + + object convert { + def toApi(b: OQueryBookmarks.Bookmark): BookmarkedQuery = + BookmarkedQuery(b.id, b.name, b.label, b.query, b.personal, b.created) + + def toModel(b: BookmarkedQuery): OQueryBookmarks.NewBookmark = + OQueryBookmarks.NewBookmark(b.name, b.label, b.query, b.personal) + } +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala index 962f6f13..aef0b386 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala @@ -8,6 +8,7 @@ package docspell.restserver.routes import cats.effect._ import cats.implicits._ +import cats.kernel.Semigroup import docspell.backend.BackendApp import docspell.backend.auth.AuthToken @@ -27,25 +28,65 @@ object ClientSettingsRoutes { import dsl._ HttpRoutes.of { - case req @ PUT -> Root / Ident(clientId) => - for { - data <- req.as[Json] - _ <- backend.clientSettings.save(clientId, user.account, data) - res <- Ok(BasicResult(true, "Settings stored")) - } yield res - case GET -> Root / Ident(clientId) => for { - data <- backend.clientSettings.load(clientId, user.account) - res <- data match { - case Some(d) => Ok(d.settingsData) + collData <- backend.clientSettings.loadCollective(clientId, user.account) + userData <- backend.clientSettings.loadUser(clientId, user.account) + + mergedData = collData.map(_.settingsData) |+| userData.map(_.settingsData) + + res <- mergedData match { + case Some(j) => Ok(j) case None => NotFound() } } yield res - case DELETE -> Root / Ident(clientId) => + case req @ PUT -> Root / "user" / Ident(clientId) => for { - flag <- backend.clientSettings.delete(clientId, user.account) + data <- req.as[Json] + _ <- backend.clientSettings.saveUser(clientId, user.account, data) + res <- Ok(BasicResult(true, "Settings stored")) + } yield res + + case GET -> Root / "user" / Ident(clientId) => + for { + data <- backend.clientSettings.loadUser(clientId, user.account) + res <- data match { + case Some(d) => Ok(d.settingsData) + case None => Ok(Map.empty[String, String]) + } + } yield res + + case DELETE -> Root / "user" / Ident(clientId) => + for { + flag <- backend.clientSettings.deleteUser(clientId, user.account) + res <- Ok( + BasicResult( + flag, + if (flag) "Settings deleted" else "Deleting settings failed" + ) + ) + } yield res + + case req @ PUT -> Root / "collective" / Ident(clientId) => + for { + data <- req.as[Json] + _ <- backend.clientSettings.saveCollective(clientId, user.account, data) + res <- Ok(BasicResult(true, "Settings stored")) + } yield res + + case GET -> Root / "collective" / Ident(clientId) => + for { + data <- backend.clientSettings.loadCollective(clientId, user.account) + res <- data match { + case Some(d) => Ok(d.settingsData) + case None => Ok(Map.empty[String, String]) + } + } yield res + + case DELETE -> Root / "collective" / Ident(clientId) => + for { + flag <- backend.clientSettings.deleteCollective(clientId, user.account) res <- Ok( BasicResult( flag, @@ -55,4 +96,7 @@ object ClientSettingsRoutes { } yield res } } + + implicit def jsonSemigroup: Semigroup[Json] = + Semigroup.instance((a1, a2) => a1.deepMerge(a2)) } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/PeriodicQueryRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/PeriodicQueryRoutes.scala index b55e59ad..93b90b6e 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/PeriodicQueryRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/PeriodicQueryRoutes.scala @@ -117,11 +117,20 @@ object PeriodicQueryRoutes extends MailAddressCodec { Sync[F] .pure(for { ch <- NotificationChannel.convert(settings.channel) - qstr <- ItemQueryParser - .asString(settings.query.expr) - .left - .map(err => new IllegalArgumentException(s"Query not renderable: $err")) - } yield (ch, ItemQueryString(qstr))) + qstr <- settings.query match { + case Some(q) => + ItemQueryParser + .asString(q.expr) + .left + .map(err => new IllegalArgumentException(s"Query not renderable: $err")) + .map(Option.apply) + case None => + Right(None) + } + _ <- + if (qstr.nonEmpty || settings.bookmark.nonEmpty) Right(()) + else Left(new IllegalArgumentException("No query or bookmark provided")) + } yield (ch, qstr.map(ItemQueryString.apply))) .rethrow .map { case (channel, qstr) => UserTask( @@ -134,6 +143,7 @@ object PeriodicQueryRoutes extends MailAddressCodec { user, Right(channel), qstr, + settings.bookmark, Some(baseUrl / "app" / "item") ) ) @@ -155,7 +165,8 @@ object PeriodicQueryRoutes extends MailAddressCodec { task.summary, task.enabled, ch, - ItemQueryParser.parseUnsafe(task.args.query.query), + task.args.query.map(_.query).map(ItemQueryParser.parseUnsafe), + task.args.bookmark, task.timer ) } diff --git a/modules/store/src/main/resources/db/migration/h2/V1.30.0__clientsettings_collective.sql b/modules/store/src/main/resources/db/migration/h2/V1.30.0__clientsettings_collective.sql new file mode 100644 index 00000000..c2806a43 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.30.0__clientsettings_collective.sql @@ -0,0 +1,12 @@ +ALTER TABLE "client_settings" RENAME TO "client_settings_user"; + +CREATE TABLE "client_settings_collective" ( + "id" varchar(254) not null primary key, + "client_id" varchar(254) not null, + "cid" varchar(254) not null, + "settings_data" text not null, + "created" timestamp not null, + "updated" timestamp not null, + foreign key ("cid") references "collective"("cid") on delete cascade, + unique ("client_id", "cid") +); diff --git a/modules/store/src/main/resources/db/migration/h2/V1.31.0__query_bookmark.sql b/modules/store/src/main/resources/db/migration/h2/V1.31.0__query_bookmark.sql new file mode 100644 index 00000000..c98efc01 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.31.0__query_bookmark.sql @@ -0,0 +1,13 @@ +CREATE TABLE "query_bookmark" ( + "id" varchar(254) not null primary key, + "name" varchar(254) not null, + "label" varchar(254), + "user_id" varchar(254), + "cid" varchar(254) not null, + "query" varchar(2000) not null, + "created" timestamp, + "__user_id" varchar(254) not null, + foreign key ("user_id") references "user_"("uid") on delete cascade, + foreign key ("cid") references "collective"("cid") on delete cascade, + unique("cid", "__user_id", "name") +) diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.30.0__clientsettings_collective.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.30.0__clientsettings_collective.sql new file mode 100644 index 00000000..d0563042 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.30.0__clientsettings_collective.sql @@ -0,0 +1,12 @@ +RENAME TABLE `client_settings` TO `client_settings_user`; + +CREATE TABLE `client_settings_collective` ( + `id` varchar(254) not null primary key, + `client_id` varchar(254) not null, + `cid` varchar(254) not null, + `settings_data` text not null, + `created` timestamp not null, + `updated` timestamp not null, + foreign key (`cid`) references `collective`(`cid`) on delete cascade, + unique (`client_id`, `cid`) +); diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.31.0__query_bookmark.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.31.0__query_bookmark.sql new file mode 100644 index 00000000..73b83810 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.31.0__query_bookmark.sql @@ -0,0 +1,13 @@ +CREATE TABLE `query_bookmark` ( + `id` varchar(254) not null primary key, + `name` varchar(254) not null, + `label` varchar(254), + `user_id` varchar(254), + `cid` varchar(254) not null, + `query` varchar(2000) not null, + `created` timestamp, + `__user_id` varchar(254) not null, + foreign key (`user_id`) references `user_`(`uid`) on delete cascade, + foreign key (`cid`) references `collective`(`cid`) on delete cascade, + unique(`cid`, `__user_id`, `name`) +) diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.30.0__clientsettings_collective.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.30.0__clientsettings_collective.sql new file mode 100644 index 00000000..c2806a43 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.30.0__clientsettings_collective.sql @@ -0,0 +1,12 @@ +ALTER TABLE "client_settings" RENAME TO "client_settings_user"; + +CREATE TABLE "client_settings_collective" ( + "id" varchar(254) not null primary key, + "client_id" varchar(254) not null, + "cid" varchar(254) not null, + "settings_data" text not null, + "created" timestamp not null, + "updated" timestamp not null, + foreign key ("cid") references "collective"("cid") on delete cascade, + unique ("client_id", "cid") +); diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.31.0__query_bookmark.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.31.0__query_bookmark.sql new file mode 100644 index 00000000..c98efc01 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.31.0__query_bookmark.sql @@ -0,0 +1,13 @@ +CREATE TABLE "query_bookmark" ( + "id" varchar(254) not null primary key, + "name" varchar(254) not null, + "label" varchar(254), + "user_id" varchar(254), + "cid" varchar(254) not null, + "query" varchar(2000) not null, + "created" timestamp, + "__user_id" varchar(254) not null, + foreign key ("user_id") references "user_"("uid") on delete cascade, + foreign key ("cid") references "collective"("cid") on delete cascade, + unique("cid", "__user_id", "name") +) diff --git a/modules/store/src/main/scala/docspell/store/records/RClientSettingsCollective.scala b/modules/store/src/main/scala/docspell/store/records/RClientSettingsCollective.scala new file mode 100644 index 00000000..659fde30 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RClientSettingsCollective.scala @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.NonEmptyList +import cats.implicits._ + +import docspell.common._ +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ +import io.circe.Json + +case class RClientSettingsCollective( + id: Ident, + clientId: Ident, + cid: Ident, + settingsData: Json, + updated: Timestamp, + created: Timestamp +) {} + +object RClientSettingsCollective { + + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "client_settings_collective" + + val id = Column[Ident]("id", this) + val clientId = Column[Ident]("client_id", this) + val cid = Column[Ident]("cid", this) + val settingsData = Column[Json]("settings_data", this) + val updated = Column[Timestamp]("updated", this) + val created = Column[Timestamp]("created", this) + val all = + NonEmptyList.of[Column[_]](id, clientId, cid, settingsData, updated, created) + } + + def as(alias: String): Table = Table(Some(alias)) + val T = Table(None) + + def insert(v: RClientSettingsCollective): ConnectionIO[Int] = { + val t = Table(None) + DML.insert( + t, + t.all, + fr"${v.id},${v.clientId},${v.cid},${v.settingsData},${v.updated},${v.created}" + ) + } + + def updateSettings( + clientId: Ident, + cid: Ident, + data: Json, + updateTs: Timestamp + ): ConnectionIO[Int] = + DML.update( + T, + T.clientId === clientId && T.cid === cid, + DML.set(T.settingsData.setTo(data), T.updated.setTo(updateTs)) + ) + + def upsert(clientId: Ident, cid: Ident, data: Json): ConnectionIO[Int] = + for { + id <- Ident.randomId[ConnectionIO] + now <- Timestamp.current[ConnectionIO] + nup <- updateSettings(clientId, cid, data, now) + nin <- + if (nup <= 0) insert(RClientSettingsCollective(id, clientId, cid, data, now, now)) + else 0.pure[ConnectionIO] + } yield nup + nin + + def delete(clientId: Ident, cid: Ident): ConnectionIO[Int] = + DML.delete(T, T.clientId === clientId && T.cid === cid) + + def find(clientId: Ident, cid: Ident): ConnectionIO[Option[RClientSettingsCollective]] = + run(select(T.all), from(T), T.clientId === clientId && T.cid === cid) + .query[RClientSettingsCollective] + .option +} diff --git a/modules/store/src/main/scala/docspell/store/records/RClientSettings.scala b/modules/store/src/main/scala/docspell/store/records/RClientSettingsUser.scala similarity index 86% rename from modules/store/src/main/scala/docspell/store/records/RClientSettings.scala rename to modules/store/src/main/scala/docspell/store/records/RClientSettingsUser.scala index 94ef0026..5dfbc323 100644 --- a/modules/store/src/main/scala/docspell/store/records/RClientSettings.scala +++ b/modules/store/src/main/scala/docspell/store/records/RClientSettingsUser.scala @@ -17,7 +17,7 @@ import doobie._ import doobie.implicits._ import io.circe.Json -case class RClientSettings( +case class RClientSettingsUser( id: Ident, clientId: Ident, userId: Ident, @@ -26,10 +26,10 @@ case class RClientSettings( created: Timestamp ) {} -object RClientSettings { +object RClientSettingsUser { final case class Table(alias: Option[String]) extends TableDef { - val tableName = "client_settings" + val tableName = "client_settings_user" val id = Column[Ident]("id", this) val clientId = Column[Ident]("client_id", this) @@ -44,7 +44,7 @@ object RClientSettings { def as(alias: String): Table = Table(Some(alias)) val T = Table(None) - def insert(v: RClientSettings): ConnectionIO[Int] = { + def insert(v: RClientSettingsUser): ConnectionIO[Int] = { val t = Table(None) DML.insert( t, @@ -71,15 +71,15 @@ object RClientSettings { now <- Timestamp.current[ConnectionIO] nup <- updateSettings(clientId, userId, data, now) nin <- - if (nup <= 0) insert(RClientSettings(id, clientId, userId, data, now, now)) + if (nup <= 0) insert(RClientSettingsUser(id, clientId, userId, data, now, now)) else 0.pure[ConnectionIO] } yield nup + nin def delete(clientId: Ident, userId: Ident): ConnectionIO[Int] = DML.delete(T, T.clientId === clientId && T.userId === userId) - def find(clientId: Ident, userId: Ident): ConnectionIO[Option[RClientSettings]] = + def find(clientId: Ident, userId: Ident): ConnectionIO[Option[RClientSettingsUser]] = run(select(T.all), from(T), T.clientId === clientId && T.userId === userId) - .query[RClientSettings] + .query[RClientSettingsUser] .option } diff --git a/modules/store/src/main/scala/docspell/store/records/RQueryBookmark.scala b/modules/store/src/main/scala/docspell/store/records/RQueryBookmark.scala new file mode 100644 index 00000000..b16d5dd1 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RQueryBookmark.scala @@ -0,0 +1,177 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.NonEmptyList +import cats.syntax.all._ + +import docspell.common._ +import docspell.query.ItemQuery +import docspell.store.AddResult +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ + +final case class RQueryBookmark( + id: Ident, + name: String, + label: Option[String], + userId: Option[Ident], + cid: Ident, + query: ItemQuery, + created: Timestamp +) { + def isPersonal: Boolean = + userId.isDefined + + def asGlobal: RQueryBookmark = + copy(userId = None) + + def asPersonal(userId: Ident): RQueryBookmark = + copy(userId = userId.some) +} + +object RQueryBookmark { + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "query_bookmark"; + + val id = Column[Ident]("id", this) + val name = Column[String]("name", this) + val label = Column[String]("label", this) + val userId = Column[Ident]("user_id", this) + val cid = Column[Ident]("cid", this) + val query = Column[ItemQuery]("query", this) + val created = Column[Timestamp]("created", this) + + val internUserId = Column[String]("__user_id", this) + + val all: NonEmptyList[Column[_]] = + NonEmptyList.of(id, name, label, userId, cid, query, created) + } + + val T: Table = Table(None) + def as(alias: String): Table = Table(Some(alias)) + + def createNew( + account: AccountId, + name: String, + label: Option[String], + query: ItemQuery, + personal: Boolean + ): ConnectionIO[RQueryBookmark] = + for { + userId <- RUser.getIdByAccount(account) + curTime <- Timestamp.current[ConnectionIO] + id <- Ident.randomId[ConnectionIO] + } yield RQueryBookmark( + id, + name, + label, + if (personal) userId.some else None, + account.collective, + query, + curTime + ) + + def insert(r: RQueryBookmark): ConnectionIO[Int] = { + val userIdDummy = r.userId.getOrElse(Ident.unsafe("-")) + DML.insert( + T, + T.all.append(T.internUserId), + sql"${r.id},${r.name},${r.label},${r.userId},${r.cid},${r.query},${r.created},$userIdDummy" + ) + } + + def update(r: RQueryBookmark): ConnectionIO[Int] = + DML.update( + T, + T.id === r.id, + DML.set( + T.name.setTo(r.name), + T.label.setTo(r.label), + T.query.setTo(r.query) + ) + ) + + def deleteById(cid: Ident, id: Ident): ConnectionIO[Int] = + DML.delete(T, T.id === id && T.cid === cid) + + def nameExists(account: AccountId, name: String): ConnectionIO[Boolean] = { + val user = RUser.as("u") + val bm = RQueryBookmark.as("bm") + + val users = Select( + user.uid.s, + from(user), + user.cid === account.collective && user.login === account.user + ) + Select( + select(count(bm.id)), + from(bm), + bm.name === name && bm.cid === account.collective && (bm.userId.isNull || bm.userId + .in(users)) + ).build.query[Int].unique.map(_ > 0) + } + + // impl note: store.add doesn't work, because it checks for duplicate + // after trying to insert the check is necessary because a name + // should be unique across personal *and* collective bookmarks + def insertIfNotExists( + account: AccountId, + r: ConnectionIO[RQueryBookmark] + ): ConnectionIO[AddResult] = + for { + bm <- r + res <- + nameExists(account, bm.name).flatMap { + case true => + AddResult + .entityExists(s"A bookmark '${bm.name}' already exists.") + .pure[ConnectionIO] + case false => insert(bm).attempt.map(AddResult.fromUpdate) + } + } yield res + + def allForUser(account: AccountId): ConnectionIO[Vector[RQueryBookmark]] = { + val user = RUser.as("u") + val bm = RQueryBookmark.as("bm") + + val users = Select( + user.uid.s, + from(user), + user.cid === account.collective && user.login === account.user + ) + Select( + select(bm.all), + from(bm), + bm.cid === account.collective && (bm.userId.isNull || bm.userId.in(users)) + ).build.query[RQueryBookmark].to[Vector] + } + + def findByNameOrId( + account: AccountId, + nameOrId: String + ): ConnectionIO[Option[RQueryBookmark]] = { + val user = RUser.as("u") + val bm = RQueryBookmark.as("bm") + + val users = Select( + user.uid.s, + from(user), + user.cid === account.collective && user.login === account.user + ) + Select( + select(bm.all), + from(bm), + bm.cid === account.collective && + (bm.userId.isNull || bm.userId.in(users)) && + (bm.name === nameOrId || bm.id ==== nameOrId) + ).build.query[RQueryBookmark].option + } +} diff --git a/modules/store/src/main/scala/docspell/store/records/RShare.scala b/modules/store/src/main/scala/docspell/store/records/RShare.scala index 5ddfdb6b..3aca8f81 100644 --- a/modules/store/src/main/scala/docspell/store/records/RShare.scala +++ b/modules/store/src/main/scala/docspell/store/records/RShare.scala @@ -138,6 +138,23 @@ object RShare { .option }) + def findOneByCollective( + cid: Ident, + enabled: Option[Boolean], + nameOrId: String + ): ConnectionIO[Option[RShare]] = { + val s = RShare.as("s") + val u = RUser.as("u") + + Select( + select(s.all), + from(s).innerJoin(u, u.uid === s.userId), + u.cid === cid && + (s.name === nameOrId || s.id ==== nameOrId) &&? + enabled.map(e => s.enabled === e) + ).build.query[RShare].option + } + def findAllByCollective( cid: Ident, ownerLogin: Option[Ident], diff --git a/modules/store/src/main/scala/docspell/store/records/RUser.scala b/modules/store/src/main/scala/docspell/store/records/RUser.scala index af0c2224..c100f19d 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUser.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUser.scala @@ -7,6 +7,8 @@ package docspell.store.records import cats.data.NonEmptyList +import cats.data.OptionT +import cats.effect.Sync import docspell.common._ import docspell.store.qb.DSL._ @@ -150,6 +152,13 @@ object RUser { .query[Ident] .option + def getIdByAccount(account: AccountId): ConnectionIO[Ident] = + OptionT(findIdByAccount(account)).getOrElseF( + Sync[ConnectionIO].raiseError( + new Exception(s"No user found for: ${account.asString}") + ) + ) + def updateLogin(accountId: AccountId): ConnectionIO[Int] = { val t = Table(None) def stmt(now: Timestamp) = diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 1d719f5c..d99c60c3 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -6,7 +6,8 @@ module Api exposing - ( addConcEquip + ( addBookmark + , addConcEquip , addConcPerson , addCorrOrg , addCorrPerson @@ -15,6 +16,7 @@ module Api exposing , addTag , addTagsMultiple , attachmentPreviewURL + , bookmarkNameExists , cancelJob , changeFolderName , changePassword @@ -31,6 +33,7 @@ module Api exposing , deleteAllItems , deleteAttachment , deleteAttachments + , deleteBookmark , deleteCustomField , deleteCustomValue , deleteCustomValueMultiple @@ -52,6 +55,7 @@ module Api exposing , disableOtp , fileURL , getAttachmentMeta + , getBookmarks , getClientSettings , getCollective , getCollectiveSettings @@ -166,6 +170,7 @@ module Api exposing , toggleTags , twoFactor , unconfirmMultiple + , updateBookmark , updateHook , updateNotifyDueItems , updatePeriodicQuery @@ -182,6 +187,7 @@ module Api exposing import Api.Model.AttachmentMeta exposing (AttachmentMeta) import Api.Model.AuthResult exposing (AuthResult) import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.BookmarkedQuery exposing (BookmarkedQuery) import Api.Model.CalEventCheck exposing (CalEventCheck) import Api.Model.CalEventCheckResult exposing (CalEventCheckResult) import Api.Model.Collective exposing (Collective) @@ -260,6 +266,7 @@ import Api.Model.User exposing (User) import Api.Model.UserList exposing (UserList) import Api.Model.UserPass exposing (UserPass) import Api.Model.VersionInfo exposing (VersionInfo) +import Data.Bookmarks exposing (AllBookmarks, Bookmarks) import Data.ContactType exposing (ContactType) import Data.CustomFieldOrder exposing (CustomFieldOrder) import Data.EquipmentOrder exposing (EquipmentOrder) @@ -2261,7 +2268,7 @@ getClientSettings flags receive = Data.UiSettings.storedUiSettingsDecoder in Http2.authGet - { url = flags.config.baseUrl ++ "/api/v1/sec/clientSettings/webClient" + { url = flags.config.baseUrl ++ "/api/v1/sec/clientSettings/user/webClient" , account = getAccount flags , expect = Http.expectJson receive decoder } @@ -2277,7 +2284,7 @@ saveClientSettings flags settings receive = Data.UiSettings.storedUiSettingsEncode storedSettings in Http2.authPut - { url = flags.config.baseUrl ++ "/api/v1/sec/clientSettings/webClient" + { url = flags.config.baseUrl ++ "/api/v1/sec/clientSettings/user/webClient" , account = getAccount flags , body = Http.jsonBody encode , expect = Http.expectJson receive Api.Model.BasicResult.decoder @@ -2285,6 +2292,93 @@ saveClientSettings flags settings receive = +--- Query Bookmarks + + +bookmarkUri : Flags -> String +bookmarkUri flags = + flags.config.baseUrl ++ "/api/v1/sec/querybookmark" + + +getBookmarksTask : Flags -> Task.Task Http.Error Bookmarks +getBookmarksTask flags = + Http2.authTask + { method = "GET" + , url = bookmarkUri flags + , account = getAccount flags + , body = Http.emptyBody + , resolver = Http2.jsonResolver Data.Bookmarks.bookmarksDecoder + , headers = [] + , timeout = Nothing + } + + +getBookmarks : Flags -> (Result Http.Error AllBookmarks -> msg) -> Cmd msg +getBookmarks flags receive = + let + bms = + getBookmarksTask flags + + shares = + getSharesTask flags "" False + + activeShare s = + s.enabled && s.name /= Nothing + + combine bm bs = + AllBookmarks (Data.Bookmarks.sort bm) (List.filter activeShare bs.items) + in + Task.map2 combine bms shares + |> Task.attempt receive + + +addBookmark : Flags -> BookmarkedQuery -> (Result Http.Error BasicResult -> msg) -> Cmd msg +addBookmark flags model receive = + Http2.authPost + { url = bookmarkUri flags + , account = getAccount flags + , body = Http.jsonBody (Api.Model.BookmarkedQuery.encode model) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +updateBookmark : Flags -> BookmarkedQuery -> (Result Http.Error BasicResult -> msg) -> Cmd msg +updateBookmark flags model receive = + Http2.authPut + { url = bookmarkUri flags + , account = getAccount flags + , body = Http.jsonBody (Api.Model.BookmarkedQuery.encode model) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +bookmarkNameExistsTask : Flags -> String -> Task.Task Http.Error Bool +bookmarkNameExistsTask flags name = + let + load = + getBookmarksTask flags + + exists current = + Data.Bookmarks.exists name current + in + Task.map exists load + + +bookmarkNameExists : Flags -> String -> (Result Http.Error Bool -> msg) -> Cmd msg +bookmarkNameExists flags name receive = + bookmarkNameExistsTask flags name |> Task.attempt receive + + +deleteBookmark : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg +deleteBookmark flags id receive = + Http2.authDelete + { url = bookmarkUri flags ++ "/" ++ id + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + + --- OTP @@ -2331,10 +2425,12 @@ disableOtp flags otp receive = --- Share -getShares : Flags -> String -> Bool -> (Result Http.Error ShareList -> msg) -> Cmd msg -getShares flags query owning receive = - Http2.authGet - { url = +getSharesTask : Flags -> String -> Bool -> Task.Task Http.Error ShareList +getSharesTask flags query owning = + Http2.authTask + { method = + "GET" + , url = flags.config.baseUrl ++ "/api/v1/sec/share?q=" ++ Url.percentEncode query @@ -2345,10 +2441,18 @@ getShares flags query owning receive = "" ) , account = getAccount flags - , expect = Http.expectJson receive Api.Model.ShareList.decoder + , body = Http.emptyBody + , resolver = Http2.jsonResolver Api.Model.ShareList.decoder + , headers = [] + , timeout = Nothing } +getShares : Flags -> String -> Bool -> (Result Http.Error ShareList -> msg) -> Cmd msg +getShares flags query owning receive = + getSharesTask flags query owning |> Task.attempt receive + + getShare : Flags -> String -> (Result Http.Error ShareDetail -> msg) -> Cmd msg getShare flags id receive = Http2.authGet diff --git a/modules/webapp/src/main/elm/App/Update.elm b/modules/webapp/src/main/elm/App/Update.elm index 20e51359..a37368d2 100644 --- a/modules/webapp/src/main/elm/App/Update.elm +++ b/modules/webapp/src/main/elm/App/Update.elm @@ -592,12 +592,12 @@ updateHome texts lmsg model = updateManageData : Page.ManageData.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) updateManageData lmsg model = let - ( lm, lc ) = + ( lm, lc, ls ) = Page.ManageData.Update.update model.flags lmsg model.manageDataModel in ( { model | manageDataModel = lm } , Cmd.map ManageDataMsg lc - , Sub.none + , Sub.map ManageDataMsg ls ) diff --git a/modules/webapp/src/main/elm/Comp/BookmarkChooser.elm b/modules/webapp/src/main/elm/Comp/BookmarkChooser.elm new file mode 100644 index 00000000..d888a65d --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/BookmarkChooser.elm @@ -0,0 +1,208 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.BookmarkChooser exposing + ( Model + , Msg + , Selection + , emptySelection + , getQueries + , init + , isEmpty + , isEmptySelection + , update + , view + ) + +import Api.Model.BookmarkedQuery exposing (BookmarkedQuery) +import Api.Model.ShareDetail exposing (ShareDetail) +import Data.Bookmarks exposing (AllBookmarks) +import Data.Icons as Icons +import Html exposing (Html, a, div, i, label, span, text) +import Html.Attributes exposing (class, classList, href) +import Html.Events exposing (onClick) +import Messages.Comp.BookmarkChooser exposing (Texts) +import Set exposing (Set) + + +type alias Model = + { all : AllBookmarks + } + + +init : AllBookmarks -> Model +init all = + { all = all + } + + +isEmpty : Model -> Bool +isEmpty model = + model.all == Data.Bookmarks.empty + + +type alias Selection = + { bookmarks : Set String + , shares : Set String + } + + +emptySelection : Selection +emptySelection = + { bookmarks = Set.empty, shares = Set.empty } + + +isEmptySelection : Selection -> Bool +isEmptySelection sel = + sel == emptySelection + + +type Kind + = Bookmark + | Share + + +type Msg + = Toggle Kind String + + +getQueries : Model -> Selection -> List BookmarkedQuery +getQueries model sel = + let + member set bm = + Set.member bm.id set + + filterBookmarks f bms = + List.filter f bms |> List.map identity + in + List.concat + [ filterBookmarks (member sel.bookmarks) model.all.bookmarks + , List.map shareToBookmark model.all.shares + |> List.filter (member sel.shares) + ] + + + +--- Update + + +update : Msg -> Model -> Selection -> ( Model, Selection ) +update msg model current = + let + toggle name set = + if Set.member name set then + Set.remove name set + + else + Set.insert name set + in + case msg of + Toggle kind id -> + case kind of + Bookmark -> + ( model, { current | bookmarks = toggle id current.bookmarks } ) + + Share -> + ( model, { current | shares = toggle id current.shares } ) + + + +--- View + + +view : Texts -> Model -> Selection -> Html Msg +view texts model selection = + let + ( user, coll ) = + List.partition .personal model.all.bookmarks + in + div [ class "flex flex-col" ] + [ userBookmarks texts user selection + , collBookmarks texts coll selection + , shares texts model selection + ] + + +titleDiv : String -> Html msg +titleDiv label = + div [ class "text-sm opacity-75 py-0.5 italic" ] + [ text label + + --, text " ──" + ] + + +userBookmarks : Texts -> List BookmarkedQuery -> Selection -> Html Msg +userBookmarks texts model sel = + div + [ class "mb-2" + , classList [ ( "hidden", model == [] ) ] + ] + [ titleDiv texts.userLabel + , div [ class "flex flex-col space-y-2 md:space-y-1" ] + (List.map (mkItem "fa fa-bookmark" sel Bookmark) model) + ] + + +collBookmarks : Texts -> List BookmarkedQuery -> Selection -> Html Msg +collBookmarks texts model sel = + div + [ class "mb-2" + , classList [ ( "hidden", [] == model ) ] + ] + [ titleDiv texts.collectiveLabel + , div [ class "flex flex-col space-y-2 md:space-y-1" ] + (List.map (mkItem "fa fa-bookmark font-light" sel Bookmark) model) + ] + + +shares : Texts -> Model -> Selection -> Html Msg +shares texts model sel = + let + bms = + List.map shareToBookmark model.all.shares + in + div + [ class "" + , classList [ ( "hidden", List.isEmpty bms ) ] + ] + [ titleDiv texts.shareLabel + , div [ class "flex flex-col space-y-2 md:space-y-1" ] + (List.map (mkItem Icons.share sel Share) bms) + ] + + +mkItem : String -> Selection -> Kind -> BookmarkedQuery -> Html Msg +mkItem icon sel kind bm = + a + [ class "flex flex-row items-center rounded px-1 py-1 hover:bg-blue-100 dark:hover:bg-slate-600" + , href "#" + , onClick (Toggle kind bm.id) + ] + [ if isSelected sel kind bm.id then + i [ class "fa fa-check" ] [] + + else + i [ class icon ] [] + , span [ class "ml-2" ] [ text bm.name ] + ] + + +isSelected : Selection -> Kind -> String -> Bool +isSelected sel kind id = + Set.member id <| + case kind of + Bookmark -> + sel.bookmarks + + Share -> + sel.shares + + +shareToBookmark : ShareDetail -> BookmarkedQuery +shareToBookmark share = + BookmarkedQuery share.id (Maybe.withDefault "-" share.name) share.name share.query False 0 diff --git a/modules/webapp/src/main/elm/Comp/BookmarkDropdown.elm b/modules/webapp/src/main/elm/Comp/BookmarkDropdown.elm new file mode 100644 index 00000000..134743ea --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/BookmarkDropdown.elm @@ -0,0 +1,177 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.BookmarkDropdown exposing (Item(..), Model, Msg, getSelected, getSelectedId, init, initWith, update, view) + +import Api +import Api.Model.BookmarkedQuery exposing (BookmarkedQuery) +import Api.Model.ShareDetail exposing (ShareDetail) +import Comp.Dropdown exposing (Option) +import Data.Bookmarks exposing (AllBookmarks) +import Data.DropdownStyle +import Data.Flags exposing (Flags) +import Data.UiSettings exposing (UiSettings) +import Html exposing (Html) +import Http +import Messages.Comp.BookmarkDropdown exposing (Texts) +import Util.List + + +type Model + = Model (Comp.Dropdown.Model Item) + + +type Msg + = DropdownMsg (Comp.Dropdown.Msg Item) + | GetBookmarksResp (Maybe String) (Result Http.Error AllBookmarks) + + +initCmd : Flags -> Maybe String -> Cmd Msg +initCmd flags selected = + Api.getBookmarks flags (GetBookmarksResp selected) + + +type Item + = BM BookmarkedQuery + | Share ShareDetail + + +toItems : AllBookmarks -> List Item +toItems all = + List.map BM all.bookmarks + ++ List.map Share all.shares + + +initWith : AllBookmarks -> Maybe String -> Model +initWith bms selected = + let + items = + toItems bms + + findSel id = + Util.List.find + (\b -> + case b of + BM m -> + m.id == id + + Share s -> + s.id == id + ) + items + in + Model <| + Comp.Dropdown.makeSingleList + { options = items, selected = Maybe.andThen findSel selected } + + +init : Flags -> Maybe String -> ( Model, Cmd Msg ) +init flags selected = + ( Model Comp.Dropdown.makeSingle, initCmd flags selected ) + + +getSelected : Model -> Maybe Item +getSelected model = + case model of + Model dm -> + Comp.Dropdown.getSelected dm + |> List.head + + +getSelectedId : Model -> Maybe String +getSelectedId model = + let + id item = + case item of + BM b -> + b.id + + Share s -> + s.id + in + getSelected model |> Maybe.map id + + + +--- Update + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + let + dmodel = + case model of + Model a -> + a + in + case msg of + GetBookmarksResp sel (Ok all) -> + ( initWith all sel, Cmd.none ) + + GetBookmarksResp _ (Err err) -> + ( model, Cmd.none ) + + DropdownMsg lm -> + let + ( dm, dc ) = + Comp.Dropdown.update lm dmodel + in + ( Model dm, Cmd.map DropdownMsg dc ) + + + +--- View + + +itemOption : Texts -> Item -> Option +itemOption texts item = + case item of + BM b -> + { text = b.name + , additional = + if b.personal then + texts.personal + + else + texts.collective + } + + Share s -> + { text = Maybe.withDefault "-" s.name, additional = texts.share } + + +itemColor : Item -> String +itemColor item = + case item of + BM b -> + if b.personal then + "text-cyan-600 dark:text-indigo-300" + + else + "text-sky-600 dark:text-violet-300" + + Share _ -> + "text-blue-600 dark:text-purple-300" + + +view : Texts -> UiSettings -> Model -> Html Msg +view texts settings model = + let + viewSettings = + { makeOption = itemOption texts + , placeholder = texts.placeholder + , labelColor = \a -> \_ -> itemColor a + , style = Data.DropdownStyle.mainStyle + } + + dm = + case model of + Model a -> + a + in + Html.map DropdownMsg + (Comp.Dropdown.viewSingle2 viewSettings settings dm) diff --git a/modules/webapp/src/main/elm/Comp/BookmarkManage.elm b/modules/webapp/src/main/elm/Comp/BookmarkManage.elm new file mode 100644 index 00000000..bbec3aa7 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/BookmarkManage.elm @@ -0,0 +1,391 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.BookmarkManage exposing (Model, Msg, init, loadBookmarks, update, view) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Comp.Basic as B +import Comp.BookmarkQueryForm +import Comp.BookmarkTable +import Comp.ItemDetail.Model exposing (Msg(..)) +import Comp.MenuBar as MB +import Data.Bookmarks exposing (AllBookmarks) +import Data.Flags exposing (Flags) +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Http +import Messages.Comp.BookmarkManage exposing (Texts) +import Page exposing (Page(..)) +import Styles as S + + +type FormError + = FormErrorNone + | FormErrorHttp Http.Error + | FormErrorInvalid + | FormErrorSubmit String + + +type ViewMode + = Table + | Form + + +type DeleteConfirm + = DeleteConfirmOff + | DeleteConfirmOn + + +type alias Model = + { viewMode : ViewMode + , bookmarks : AllBookmarks + , formModel : Comp.BookmarkQueryForm.Model + , loading : Bool + , formError : FormError + , deleteConfirm : DeleteConfirm + } + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + let + ( fm, fc ) = + Comp.BookmarkQueryForm.init + in + ( { viewMode = Table + , bookmarks = Data.Bookmarks.empty + , formModel = fm + , loading = False + , formError = FormErrorNone + , deleteConfirm = DeleteConfirmOff + } + , Cmd.batch + [ Cmd.map FormMsg fc + , Api.getBookmarks flags LoadBookmarksResp + ] + ) + + +type Msg + = LoadBookmarks + | TableMsg Comp.BookmarkTable.Msg + | FormMsg Comp.BookmarkQueryForm.Msg + | InitNewBookmark + | SetViewMode ViewMode + | Submit + | RequestDelete + | CancelDelete + | DeleteBookmarkNow String + | LoadBookmarksResp (Result Http.Error AllBookmarks) + | AddBookmarkResp (Result Http.Error BasicResult) + | UpdateBookmarkResp (Result Http.Error BasicResult) + | DeleteBookmarkResp (Result Http.Error BasicResult) + + +loadBookmarks : Msg +loadBookmarks = + LoadBookmarks + + + +--- update + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +update flags msg model = + case msg of + InitNewBookmark -> + let + ( bm, bc ) = + Comp.BookmarkQueryForm.init + + nm = + { model + | viewMode = Form + , formError = FormErrorNone + , formModel = bm + } + in + ( nm, Cmd.map FormMsg bc, Sub.none ) + + SetViewMode vm -> + ( { model | viewMode = vm, formError = FormErrorNone } + , if vm == Table then + Api.getBookmarks flags LoadBookmarksResp + + else + Cmd.none + , Sub.none + ) + + FormMsg lm -> + let + ( fm, fc, fs ) = + Comp.BookmarkQueryForm.update flags lm model.formModel + in + ( { model | formModel = fm, formError = FormErrorNone } + , Cmd.map FormMsg fc + , Sub.map FormMsg fs + ) + + TableMsg lm -> + let + action = + Comp.BookmarkTable.update lm + in + case action of + Comp.BookmarkTable.Edit bookmark -> + let + ( bm, bc ) = + Comp.BookmarkQueryForm.initWith bookmark + in + ( { model + | viewMode = Form + , formError = FormErrorNone + , formModel = bm + } + , Cmd.map FormMsg bc + , Sub.none + ) + + RequestDelete -> + ( { model | deleteConfirm = DeleteConfirmOn }, Cmd.none, Sub.none ) + + CancelDelete -> + ( { model | deleteConfirm = DeleteConfirmOff }, Cmd.none, Sub.none ) + + DeleteBookmarkNow id -> + ( { model | deleteConfirm = DeleteConfirmOff, loading = True } + , Api.deleteBookmark flags id DeleteBookmarkResp + , Sub.none + ) + + LoadBookmarks -> + ( { model | loading = True } + , Api.getBookmarks flags LoadBookmarksResp + , Sub.none + ) + + LoadBookmarksResp (Ok list) -> + ( { model | loading = False, bookmarks = list, formError = FormErrorNone } + , Cmd.none + , Sub.none + ) + + LoadBookmarksResp (Err err) -> + ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none ) + + Submit -> + case Comp.BookmarkQueryForm.get model.formModel of + Just data -> + if data.id /= "" then + ( { model | loading = True }, Api.updateBookmark flags data AddBookmarkResp, Sub.none ) + + else + ( { model | loading = True }, Api.addBookmark flags data AddBookmarkResp, Sub.none ) + + Nothing -> + ( { model | formError = FormErrorInvalid }, Cmd.none, Sub.none ) + + AddBookmarkResp (Ok res) -> + if res.success then + ( { model | loading = True, viewMode = Table }, Api.getBookmarks flags LoadBookmarksResp, Sub.none ) + + else + ( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none, Sub.none ) + + AddBookmarkResp (Err err) -> + ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none ) + + UpdateBookmarkResp (Ok res) -> + if res.success then + ( model, Api.getBookmarks flags LoadBookmarksResp, Sub.none ) + + else + ( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none, Sub.none ) + + UpdateBookmarkResp (Err err) -> + ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none ) + + DeleteBookmarkResp (Ok res) -> + if res.success then + update flags (SetViewMode Table) { model | loading = False } + + else + ( { model | formError = FormErrorSubmit res.message, loading = False }, Cmd.none, Sub.none ) + + DeleteBookmarkResp (Err err) -> + ( { model | formError = FormErrorHttp err, loading = False }, Cmd.none, Sub.none ) + + + +--- view + + +view : Texts -> UiSettings -> Flags -> Model -> Html Msg +view texts settings flags model = + if model.viewMode == Table then + viewTable texts model + + else + viewForm texts settings flags model + + +viewTable : Texts -> Model -> Html Msg +viewTable texts model = + let + ( user, coll ) = + List.partition .personal model.bookmarks.bookmarks + in + div [ class "flex flex-col" ] + [ MB.view + { start = + [] + , end = + [ MB.PrimaryButton + { tagger = InitNewBookmark + , title = texts.createNewBookmark + , icon = Just "fa fa-plus" + , label = texts.newBookmark + } + ] + , rootClasses = "mb-4" + } + , div + [ class "flex flex-col" + , classList [ ( "hidden", user == [] ) ] + ] + [ h3 [ class S.header3 ] + [ text texts.userBookmarks ] + , Html.map TableMsg + (Comp.BookmarkTable.view texts.bookmarkTable user) + ] + , div + [ class "flex flex-col mt-3" + , classList [ ( "hidden", coll == [] ) ] + ] + [ h3 [ class S.header3 ] + [ text texts.collectiveBookmarks ] + , Html.map TableMsg + (Comp.BookmarkTable.view texts.bookmarkTable coll) + ] + , B.loadingDimmer + { label = "" + , active = model.loading + } + ] + + +viewForm : Texts -> UiSettings -> Flags -> Model -> Html Msg +viewForm texts _ _ model = + let + newBookmark = + model.formModel.bookmark.id == "" + + isValid = + Comp.BookmarkQueryForm.get model.formModel /= Nothing + in + div [] + [ Html.form [] + [ if newBookmark then + h1 [ class S.header2 ] + [ text texts.createNewBookmark + ] + + else + h1 [ class S.header2 ] + [ text (Maybe.withDefault "" model.formModel.name) + ] + , MB.view + { start = + [ MB.CustomElement <| + B.primaryButton + { handler = onClick Submit + , title = texts.basics.submitThisForm + , icon = "fa fa-save" + , label = texts.basics.submit + , disabled = not isValid + , attrs = [ href "#" ] + } + , MB.SecondaryButton + { tagger = SetViewMode Table + , title = texts.basics.backToList + , icon = Just "fa fa-arrow-left" + , label = texts.basics.cancel + } + ] + , end = + if not newBookmark then + [ MB.DeleteButton + { tagger = RequestDelete + , title = texts.deleteThisBookmark + , icon = Just "fa fa-trash" + , label = texts.basics.delete + } + ] + + else + [] + , rootClasses = "mb-4" + } + , div + [ classList + [ ( "hidden", model.formError == FormErrorNone ) + ] + , class "my-2" + , class S.errorMessage + ] + [ case model.formError of + FormErrorNone -> + text "" + + FormErrorHttp err -> + text (texts.httpError err) + + FormErrorInvalid -> + text texts.correctFormErrors + + FormErrorSubmit m -> + text m + ] + , div [] + [ Html.map FormMsg (Comp.BookmarkQueryForm.view texts.bookmarkForm model.formModel) + ] + , B.loadingDimmer + { active = model.loading + , label = texts.basics.loading + } + , B.contentDimmer + (model.deleteConfirm == DeleteConfirmOn) + (div [ class "flex flex-col" ] + [ div [ class "text-lg" ] + [ i [ class "fa fa-info-circle mr-2" ] [] + , text texts.reallyDeleteBookmark + ] + , div [ class "mt-4 flex flex-row items-center" ] + [ B.deleteButton + { label = texts.basics.yes + , icon = "fa fa-check" + , disabled = False + , handler = onClick (DeleteBookmarkNow model.formModel.bookmark.id) + , attrs = [ href "#" ] + } + , B.secondaryButton + { label = texts.basics.no + , icon = "fa fa-times" + , disabled = False + , handler = onClick CancelDelete + , attrs = [ href "#", class "ml-2" ] + } + ] + ] + ) + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/BookmarkQueryForm.elm b/modules/webapp/src/main/elm/Comp/BookmarkQueryForm.elm new file mode 100644 index 00000000..1cf6d8ea --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/BookmarkQueryForm.elm @@ -0,0 +1,252 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.BookmarkQueryForm exposing (Model, Msg, get, init, initQuery, initWith, update, view) + +import Api +import Api.Model.BookmarkedQuery exposing (BookmarkedQuery) +import Comp.Basic as B +import Comp.PowerSearchInput +import Data.Flags exposing (Flags) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onCheck, onInput) +import Http +import Messages.Comp.BookmarkQueryForm exposing (Texts) +import Styles as S +import Throttle exposing (Throttle) +import Time +import Util.Maybe + + +type alias Model = + { bookmark : BookmarkedQuery + , name : Maybe String + , nameExists : Bool + , queryModel : Comp.PowerSearchInput.Model + , isPersonal : Bool + , nameExistsThrottle : Throttle Msg + } + + +initQuery : String -> ( Model, Cmd Msg ) +initQuery q = + let + res = + Comp.PowerSearchInput.update + (Comp.PowerSearchInput.setSearchString q) + Comp.PowerSearchInput.init + in + ( { bookmark = Api.Model.BookmarkedQuery.empty + , name = Nothing + , nameExists = False + , queryModel = res.model + , isPersonal = True + , nameExistsThrottle = Throttle.create 1 + } + , Cmd.batch + [ Cmd.map QueryMsg res.cmd + ] + ) + + +init : ( Model, Cmd Msg ) +init = + initQuery "" + + +initWith : BookmarkedQuery -> ( Model, Cmd Msg ) +initWith bm = + let + ( m, c ) = + initQuery bm.query + in + ( { m + | name = Just bm.name + , isPersonal = bm.personal + , bookmark = bm + } + , c + ) + + +isValid : Model -> Bool +isValid model = + List.all identity + [ Comp.PowerSearchInput.isValid model.queryModel + , model.name /= Nothing + , not model.nameExists + ] + + +get : Model -> Maybe BookmarkedQuery +get model = + let + qStr = + Maybe.withDefault "" model.queryModel.input + + bm = + model.bookmark + in + if isValid model then + Just + { bm + | query = qStr + , name = Maybe.withDefault "" model.name + , personal = model.isPersonal + } + + else + Nothing + + +type Msg + = SetName String + | QueryMsg Comp.PowerSearchInput.Msg + | SetPersonal Bool + | NameExistsResp (Result Http.Error Bool) + | UpdateThrottle + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +update flags msg model = + let + nameCheck1 name = + Api.bookmarkNameExists flags name NameExistsResp + + throttleSub = + Throttle.ifNeeded + (Time.every 150 (\_ -> UpdateThrottle)) + model.nameExistsThrottle + in + case msg of + SetName n -> + let + ( newThrottle, cmd ) = + Throttle.try (nameCheck1 n) model.nameExistsThrottle + in + ( { model | name = Util.Maybe.fromString n, nameExistsThrottle = newThrottle } + , cmd + , throttleSub + ) + + SetPersonal flag -> + ( { model | isPersonal = flag }, Cmd.none, Sub.none ) + + QueryMsg lm -> + let + res = + Comp.PowerSearchInput.update lm model.queryModel + in + ( { model | queryModel = res.model } + , Cmd.map QueryMsg res.cmd + , Sub.map QueryMsg res.subs + ) + + NameExistsResp (Ok flag) -> + ( { model | nameExists = flag } + , Cmd.none + , Sub.none + ) + + NameExistsResp (Err err) -> + ( model, Cmd.none, Sub.none ) + + UpdateThrottle -> + let + ( newThrottle, cmd ) = + Throttle.update model.nameExistsThrottle + in + ( { model | nameExistsThrottle = newThrottle } + , cmd + , throttleSub + ) + + + +--- View + + +view : Texts -> Model -> Html Msg +view texts model = + let + queryInput = + div + [ class "relative flex flex-grow flex-row" ] + [ Html.map QueryMsg + (Comp.PowerSearchInput.viewInput + { placeholder = texts.queryLabel + , extraAttrs = [] + } + model.queryModel + ) + , Html.map QueryMsg + (Comp.PowerSearchInput.viewResult [] model.queryModel) + ] + in + div + [ class "flex flex-col" ] + [ div [ class "mb-2" ] + [ label + [ for "bookmark-name" + , class S.inputLabel + ] + [ text texts.basics.name + , B.inputRequired + ] + , input + [ type_ "text" + , onInput SetName + , placeholder texts.basics.name + , value <| Maybe.withDefault "" model.name + , id "bookmark-name" + , class S.textInput + ] + [] + , span + [ class S.warnMessagePlain + , class "font-medium text-sm" + , classList [ ( "invisible", not model.nameExists ) ] + ] + [ text texts.nameExistsWarning + ] + ] + , div [ class "flex flex-col mb-4 " ] + [ label [ class "inline-flex items-center" ] + [ input + [ type_ "radio" + , checked model.isPersonal + , onCheck (\_ -> SetPersonal True) + , class S.radioInput + ] + [] + , span [ class "ml-2" ] [ text texts.userLocation ] + , span [ class "ml-3 opacity-75 text-sm" ] [ text texts.userLocationText ] + ] + , label [ class "inline-flex items-center" ] + [ input + [ type_ "radio" + , checked (not model.isPersonal) + , class S.radioInput + , onCheck (\_ -> SetPersonal False) + ] + [] + , span [ class "ml-2" ] [ text texts.collectiveLocation ] + , span [ class "ml-3 opacity-75 text-sm" ] [ text texts.collectiveLocationText ] + ] + ] + , div [ class "mb-4" ] + [ label + [ for "sharequery" + , class S.inputLabel + ] + [ text texts.queryLabel + , B.inputRequired + ] + , queryInput + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/BookmarkQueryManage.elm b/modules/webapp/src/main/elm/Comp/BookmarkQueryManage.elm new file mode 100644 index 00000000..deec76c4 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/BookmarkQueryManage.elm @@ -0,0 +1,180 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.BookmarkQueryManage exposing (..) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.BookmarkedQuery exposing (BookmarkedQuery) +import Comp.Basic as B +import Comp.BookmarkQueryForm +import Data.Flags exposing (Flags) +import Html exposing (Html, div, text) +import Html.Attributes exposing (class, href) +import Html.Events exposing (onClick) +import Http +import Messages.Comp.BookmarkQueryManage exposing (Texts) +import Styles as S + + +type alias Model = + { formModel : Comp.BookmarkQueryForm.Model + , loading : Bool + , formState : FormState + } + + +type FormState + = FormStateNone + | FormStateError Http.Error + | FormStateSaveError String + | FormStateInvalid + | FormStateSaved + + +init : String -> ( Model, Cmd Msg ) +init query = + let + ( fm, fc ) = + Comp.BookmarkQueryForm.initQuery query + in + ( { formModel = fm + , loading = False + , formState = FormStateNone + } + , Cmd.map FormMsg fc + ) + + +type Msg + = Submit + | Cancel + | FormMsg Comp.BookmarkQueryForm.Msg + | SaveResp (Result Http.Error BasicResult) + + + +--- Update + + +type FormResult + = Submitted BookmarkedQuery + | Cancelled + | Done + | None + + +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , sub : Sub Msg + , outcome : FormResult + } + + +update : Flags -> Msg -> Model -> UpdateResult +update flags msg model = + let + empty = + { model = model + , cmd = Cmd.none + , sub = Sub.none + , outcome = None + } + in + case msg of + FormMsg lm -> + let + ( fm, fc, fs ) = + Comp.BookmarkQueryForm.update flags lm model.formModel + in + { model = { model | formModel = fm } + , cmd = Cmd.map FormMsg fc + , sub = Sub.map FormMsg fs + , outcome = None + } + + Submit -> + case Comp.BookmarkQueryForm.get model.formModel of + Just data -> + { empty | cmd = save flags data, outcome = Submitted data, model = { model | loading = True } } + + Nothing -> + { empty | model = { model | formState = FormStateInvalid } } + + Cancel -> + { model = model + , cmd = Cmd.none + , sub = Sub.none + , outcome = Cancelled + } + + SaveResp (Ok res) -> + if res.success then + { empty | model = { model | loading = False, formState = FormStateSaved }, outcome = Done } + + else + { empty | model = { model | loading = False, formState = FormStateSaveError res.message } } + + SaveResp (Err err) -> + { empty | model = { model | loading = False, formState = FormStateError err } } + + +save : Flags -> BookmarkedQuery -> Cmd Msg +save flags model = + Api.addBookmark flags model SaveResp + + + +--- View + + +view : Texts -> Model -> Html Msg +view texts model = + div [ class "relative" ] + [ B.loadingDimmer { label = "", active = model.loading } + , Html.map FormMsg (Comp.BookmarkQueryForm.view texts.form model.formModel) + , case model.formState of + FormStateNone -> + div [ class "hidden" ] [] + + FormStateError err -> + div [ class S.errorMessage ] + [ text <| texts.httpError err + ] + + FormStateInvalid -> + div [ class S.errorMessage ] + [ text texts.formInvalid + ] + + FormStateSaveError m -> + div [ class S.errorMessage ] + [ text m + ] + + FormStateSaved -> + div [ class S.successMessage ] + [ text texts.saved + ] + , div [ class "flex flex-row space-x-2 py-2" ] + [ B.primaryButton + { label = texts.basics.submit + , icon = "fa fa-save" + , disabled = False + , handler = onClick Submit + , attrs = [ href "#" ] + } + , B.secondaryButton + { label = texts.basics.cancel + , icon = "fa fa-times" + , disabled = False + , handler = onClick Cancel + , attrs = [ href "#" ] + } + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/BookmarkTable.elm b/modules/webapp/src/main/elm/Comp/BookmarkTable.elm new file mode 100644 index 00000000..c29f4c2a --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/BookmarkTable.elm @@ -0,0 +1,67 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.BookmarkTable exposing + ( Msg(..) + , SelectAction(..) + , update + , view + ) + +import Api.Model.BookmarkedQuery exposing (BookmarkedQuery) +import Comp.Basic as B +import Html exposing (..) +import Html.Attributes exposing (..) +import Messages.Comp.BookmarkTable exposing (Texts) +import Styles as S + + +type Msg + = Select BookmarkedQuery + + +type SelectAction + = Edit BookmarkedQuery + + +update : Msg -> SelectAction +update msg = + case msg of + Select share -> + Edit share + + + +--- View + + +view : Texts -> List BookmarkedQuery -> Html Msg +view texts bms = + table [ class S.tableMain ] + [ thead [] + [ tr [] + [ th [ class "" ] [] + , th [ class "text-left" ] + [ text texts.basics.name + ] + ] + ] + , tbody [] + (List.map (renderBookmarkLine texts) bms) + ] + + +renderBookmarkLine : Texts -> BookmarkedQuery -> Html Msg +renderBookmarkLine texts bm = + tr + [ class S.tableRow + ] + [ B.editLinkTableCell texts.basics.edit (Select bm) + , td [ class "text-left py-4 md:py-2" ] + [ text bm.name + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/ChannelMenu.elm b/modules/webapp/src/main/elm/Comp/ChannelMenu.elm index 7c948d2d..ad162001 100644 --- a/modules/webapp/src/main/elm/Comp/ChannelMenu.elm +++ b/modules/webapp/src/main/elm/Comp/ChannelMenu.elm @@ -41,6 +41,7 @@ menuItem : Texts -> Model msg -> ChannelType -> MB.DropdownMenu msg menuItem texts model ct = { icon = Data.ChannelType.icon ct "w-6 h-6 text-center inline-block" , label = texts ct + , disabled = False , attrs = [ href "" , onClick (model.onItem ct) diff --git a/modules/webapp/src/main/elm/Comp/DetailEdit.elm b/modules/webapp/src/main/elm/Comp/DetailEdit.elm index e9d9c759..43b5231a 100644 --- a/modules/webapp/src/main/elm/Comp/DetailEdit.elm +++ b/modules/webapp/src/main/elm/Comp/DetailEdit.elm @@ -834,7 +834,7 @@ viewIntern2 texts settings withButtons model = ] , case model.form of TM tm -> - Html.map TagMsg (Comp.TagForm.view2 texts.tagForm tm) + Html.map TagMsg (Comp.TagForm.view2 texts.tagForm settings tm) PMR pm -> Html.map PersonMsg (Comp.PersonForm.view2 texts.personForm True settings pm) diff --git a/modules/webapp/src/main/elm/Comp/Dropdown.elm b/modules/webapp/src/main/elm/Comp/Dropdown.elm index 75ce2fb3..f8e039b7 100644 --- a/modules/webapp/src/main/elm/Comp/Dropdown.elm +++ b/modules/webapp/src/main/elm/Comp/Dropdown.elm @@ -462,16 +462,17 @@ view2 cfg settings model = viewMultiple2 cfg settings model else - viewSingle2 cfg model + viewSingle2 cfg settings model -viewSingle2 : ViewSettings a -> Model a -> Html (Msg a) -viewSingle2 cfg model = +viewSingle2 : ViewSettings a -> UiSettings -> Model a -> Html (Msg a) +viewSingle2 cfg settings model = let renderItem item = a [ href "#" , class cfg.style.item + , class (cfg.labelColor item.value settings) , classList [ ( cfg.style.itemActive, item.active ) , ( "font-semibold", item.selected ) @@ -480,7 +481,7 @@ viewSingle2 cfg model = , onKeyUp KeyPress ] [ text <| (.value >> cfg.makeOption >> .text) item - , span [ class "text-gray-400 float-right" ] + , span [ class "text-gray-400 opacity-75 float-right" ] [ text <| (.value >> cfg.makeOption >> .additional) item ] ] diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/SingleAttachment.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/SingleAttachment.elm index 7c8bd0fb..52942e49 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/SingleAttachment.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/SingleAttachment.elm @@ -228,6 +228,7 @@ attachHeader texts settings model _ attach = , items = [ { icon = i [ class "fa fa-download" ] [] , label = texts.downloadFile + , disabled = False , attrs = [ download attachName , href fileUrl @@ -235,6 +236,7 @@ attachHeader texts settings model _ attach = } , { icon = i [ class "fa fa-file" ] [] , label = texts.renameFile + , disabled = False , attrs = [ href "#" , onClick (EditAttachNameStart attach.id) @@ -242,6 +244,7 @@ attachHeader texts settings model _ attach = } , { icon = i [ class "fa fa-file-archive" ] [] , label = texts.downloadOriginalArchiveFile + , disabled = False , attrs = [ href (fileUrl ++ "/archive") , target "_new" @@ -250,6 +253,7 @@ attachHeader texts settings model _ attach = } , { icon = i [ class "fa fa-external-link-alt" ] [] , label = texts.originalFile + , disabled = False , attrs = [ href (fileUrl ++ "/original") , target "_new" @@ -263,6 +267,7 @@ attachHeader texts settings model _ attach = else i [ class "fa fa-toggle-off" ] [] , label = texts.viewExtractedData + , disabled = False , attrs = [ onClick (AttachMetaClick attach.id) , href "#" @@ -270,6 +275,7 @@ attachHeader texts settings model _ attach = } , { icon = i [ class "fa fa-redo-alt" ] [] , label = texts.reprocessFile + , disabled = False , attrs = [ onClick (RequestReprocessFile attach.id) , href "#" @@ -277,6 +283,7 @@ attachHeader texts settings model _ attach = } , { icon = i [ class Icons.showQr ] [] , label = texts.showQrCode + , disabled = False , attrs = [ onClick (ToggleShowQrAttach attach.id) , href "#" @@ -284,6 +291,7 @@ attachHeader texts settings model _ attach = } , { icon = i [ class "fa fa-trash" ] [] , label = texts.deleteThisFile + , disabled = False , attrs = [ onClick (RequestDeleteAttachment attach.id) , href "#" diff --git a/modules/webapp/src/main/elm/Comp/MenuBar.elm b/modules/webapp/src/main/elm/Comp/MenuBar.elm index df7cceea..c25fa281 100644 --- a/modules/webapp/src/main/elm/Comp/MenuBar.elm +++ b/modules/webapp/src/main/elm/Comp/MenuBar.elm @@ -96,6 +96,7 @@ type alias DropdownData msg = type alias DropdownMenu msg = { icon : Html msg , label : String + , disabled : Bool , attrs : List (Attribute msg) } @@ -171,10 +172,16 @@ makeDropdown model = menuStyle = "absolute right-0 bg-white dark:bg-slate-800 border dark:border-slate-700 z-50 dark:text-slate-300 shadow-lg transition duration-200 min-w-max " - itemStyle = - "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" + itemStyleBase = + "transition-colors duration-200 items-center block px-4 py-2 text-normal z-50 " - menuItem m = + itemStyleHover = + " hover:bg-gray-200 dark:hover:bg-slate-700 dark:hover:text-slate-50" + + itemStyle = + itemStyleBase ++ itemStyleHover + + menuLink m = a (class itemStyle :: m.attrs) [ m.icon @@ -185,6 +192,34 @@ makeDropdown model = [ text m.label ] ] + + disabledLink m = + a + ([ href "#" + , disabled True + , class itemStyleBase + , class "disabled" + ] + ++ m.attrs + ) + [ m.icon + , span + [ class "ml-2" + , classList [ ( "hidden", m.label == "" ) ] + ] + [ text m.label + ] + ] + + menuItem m = + if m.label == "separator" then + div [ class "py-1" ] [ hr [ class S.border ] [] ] + + else if m.disabled then + disabledLink m + + else + menuLink m in div [ class "relative" ] [ a diff --git a/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskForm.elm b/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskForm.elm index 098a61aa..e5fe3b67 100644 --- a/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskForm.elm +++ b/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskForm.elm @@ -17,6 +17,7 @@ module Comp.PeriodicQueryTaskForm exposing ) import Comp.Basic as B +import Comp.BookmarkDropdown import Comp.CalEventInput import Comp.ChannelForm import Comp.MenuBar as MB @@ -44,6 +45,7 @@ type alias Model = , scheduleModel : Comp.CalEventInput.Model , queryModel : Comp.PowerSearchInput.Model , channelModel : Comp.ChannelForm.Model + , bookmarkDropdown : Comp.BookmarkDropdown.Model , formState : FormState , loading : Int } @@ -75,6 +77,7 @@ type Msg | CalEventMsg Comp.CalEventInput.Msg | QueryMsg Comp.PowerSearchInput.Msg | ChannelMsg Comp.ChannelForm.Msg + | BookmarkMsg Comp.BookmarkDropdown.Msg | StartOnce | Cancel | RequestDelete @@ -93,11 +96,14 @@ initWith flags s = res = Comp.PowerSearchInput.update - (Comp.PowerSearchInput.setSearchString s.query) + (Comp.PowerSearchInput.setSearchString (Maybe.withDefault "" s.query)) Comp.PowerSearchInput.init ( cfm, cfc ) = Comp.ChannelForm.initWith flags s.channel + + ( bm, bc ) = + Comp.BookmarkDropdown.init flags s.bookmark in ( { settings = s , enabled = s.enabled @@ -105,6 +111,7 @@ initWith flags s = , scheduleModel = sm , queryModel = res.model , channelModel = cfm + , bookmarkDropdown = bm , formState = FormStateInitial , loading = 0 , summary = s.summary @@ -113,6 +120,7 @@ initWith flags s = [ Cmd.map CalEventMsg sc , Cmd.map QueryMsg res.cmd , Cmd.map ChannelMsg cfc + , Cmd.map BookmarkMsg bc ] ) @@ -128,6 +136,9 @@ init flags ct = ( cfm, cfc ) = Comp.ChannelForm.init flags ct + + ( bm, bc ) = + Comp.BookmarkDropdown.init flags Nothing in ( { settings = Data.PeriodicQuerySettings.empty ct , enabled = False @@ -135,6 +146,7 @@ init flags ct = , scheduleModel = sm , queryModel = Comp.PowerSearchInput.init , channelModel = cfm + , bookmarkDropdown = bm , formState = FormStateInitial , loading = 0 , summary = Nothing @@ -142,6 +154,7 @@ init flags ct = , Cmd.batch [ Cmd.map CalEventMsg scmd , Cmd.map ChannelMsg cfc + , Cmd.map BookmarkMsg bc ] ) @@ -172,27 +185,46 @@ makeSettings model = Nothing -> Err ValidateCalEventInvalid - queryString = - Result.fromMaybe ValidateQueryStringRequired model.queryModel.input + query = + let + qstr = + model.queryModel.input + + bm = + Comp.BookmarkDropdown.getSelectedId model.bookmarkDropdown + in + case ( qstr, bm ) of + ( Just _, Just _ ) -> + Result.Ok ( qstr, bm ) + + ( Just _, Nothing ) -> + Result.Ok ( qstr, bm ) + + ( Nothing, Just _ ) -> + Result.Ok ( qstr, bm ) + + ( Nothing, Nothing ) -> + Result.Err ValidateQueryStringRequired channelM = Result.fromMaybe ValidateChannelRequired (Comp.ChannelForm.getChannel model.channelModel) - make timer channel query = + make timer channel q = { prev | enabled = model.enabled , schedule = Data.CalEvent.makeEvent timer , summary = model.summary , channel = channel - , query = query + , query = Tuple.first q + , bookmark = Tuple.second q } in Result.map3 make schedule_ channelM - queryString + query withValidSettings : (PeriodicQuerySettings -> Action) -> Model -> UpdateResult @@ -257,6 +289,17 @@ update flags msg model = , sub = Sub.none } + BookmarkMsg lm -> + let + ( bm, bc ) = + Comp.BookmarkDropdown.update lm model.bookmarkDropdown + in + { model = { model | bookmarkDropdown = bm } + , action = NoAction + , cmd = Cmd.map BookmarkMsg bc + , sub = Sub.none + } + ToggleEnabled -> { model = { model @@ -344,9 +387,14 @@ view texts extraClasses settings model = (Comp.PowerSearchInput.viewResult [] model.queryModel) ] - formHeader txt = + formHeader txt req = h2 [ class S.formHeader, class "mt-2" ] [ text txt + , if req then + B.inputRequired + + else + span [] [] ] in div @@ -438,23 +486,29 @@ view texts extraClasses settings model = ] ] , div [ class "mb-4" ] - [ formHeader (texts.channelHeader (Comp.ChannelForm.channelType model.channelModel)) + [ formHeader (texts.channelHeader (Comp.ChannelForm.channelType model.channelModel)) False , Html.map ChannelMsg (Comp.ChannelForm.view texts.channelForm settings model.channelModel) ] , div [ class "mb-4" ] - [ formHeader texts.queryLabel - , label - [ for "sharequery" - , class S.inputLabel + [ formHeader texts.queryLabel True + , div [ class "mb-3" ] + [ label [ class S.inputLabel ] + [ text "Bookmark" ] + , Html.map BookmarkMsg (Comp.BookmarkDropdown.view texts.bookmarkDropdown settings model.bookmarkDropdown) ] - [ text texts.queryLabel - , B.inputRequired + , div [ class "mb-3" ] + [ label + [ for "sharequery" + , class S.inputLabel + ] + [ text texts.queryLabel + ] + , queryInput ] - , queryInput ] , div [ class "mb-4" ] - [ formHeader texts.schedule + [ formHeader texts.schedule False , label [ class S.inputLabel ] [ text texts.schedule , B.inputRequired diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index 019987d7..aa52bc4c 100644 --- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm @@ -16,6 +16,7 @@ module Comp.SearchMenu exposing , isFulltextSearch , isNamesSearch , linkTargetMsg + , refreshBookmarks , setFromStats , textSearchString , update @@ -33,6 +34,7 @@ import Api.Model.ItemQuery exposing (ItemQuery) import Api.Model.PersonList exposing (PersonList) import Api.Model.ReferenceList exposing (ReferenceList) import Api.Model.SearchStats exposing (SearchStats) +import Comp.BookmarkChooser import Comp.CustomFieldMultiInput import Comp.DatePicker import Comp.Dropdown exposing (isDropdownChangeMsg) @@ -41,6 +43,7 @@ import Comp.LinkTarget exposing (LinkTarget) import Comp.MenuBar as MB import Comp.Tabs import Comp.TagSelect +import Data.Bookmarks exposing (AllBookmarks) import Data.CustomFieldChange exposing (CustomFieldValueCollect) import Data.Direction exposing (Direction) import Data.DropdownStyle as DS @@ -96,6 +99,8 @@ type alias Model = , customFieldModel : Comp.CustomFieldMultiInput.Model , customValues : CustomFieldValueCollect , sourceModel : Maybe String + , allBookmarks : Comp.BookmarkChooser.Model + , selectedBookmarks : Comp.BookmarkChooser.Selection , openTabs : Set String , searchMode : SearchMode } @@ -141,6 +146,8 @@ init flags = , customFieldModel = Comp.CustomFieldMultiInput.initWith [] , customValues = Data.CustomFieldChange.emptyCollect , sourceModel = Nothing + , allBookmarks = Comp.BookmarkChooser.init Data.Bookmarks.empty + , selectedBookmarks = Comp.BookmarkChooser.emptySelection , openTabs = Set.fromList [ "Tags", "Inbox" ] , searchMode = Data.SearchMode.Normal } @@ -243,6 +250,10 @@ getItemQuery model = textSearch = textSearchValue model.textSearchModel + + bookmarks = + List.map .query (Comp.BookmarkChooser.getQueries model.allBookmarks model.selectedBookmarks) + |> List.map Q.Fragment in Q.and [ when model.inboxCheckbox (Q.Inbox True) @@ -289,6 +300,7 @@ getItemQuery model = |> Maybe.map Q.Dir , textSearch.fullText |> Maybe.map Q.Contents + , whenNotEmpty bookmarks Q.And ] @@ -333,6 +345,7 @@ resetModel model = model.customFieldModel , customValues = Data.CustomFieldChange.emptyCollect , sourceModel = Nothing + , selectedBookmarks = Comp.BookmarkChooser.emptySelection , searchMode = Data.SearchMode.Normal } @@ -380,6 +393,8 @@ type Msg | GetAllTagsResp (Result Http.Error SearchStats) | ToggleAkkordionTab String | ToggleOpenAllAkkordionTabs + | AllBookmarksResp (Result Http.Error AllBookmarks) + | SelectBookmarkMsg Comp.BookmarkChooser.Msg setFromStats : SearchStats -> Msg @@ -426,6 +441,11 @@ type alias NextState = } +refreshBookmarks : Flags -> Cmd Msg +refreshBookmarks flags = + Api.getBookmarks flags AllBookmarksResp + + update : Flags -> UiSettings -> Msg -> Model -> NextState update = updateDrop DD.init @@ -488,6 +508,7 @@ updateDrop ddm flags settings msg model = , Api.getPersons flags "" Data.PersonOrder.NameAsc GetPersonResp , Cmd.map CustomFieldMsg (Comp.CustomFieldMultiInput.initCmd flags) , cdp + , Api.getBookmarks flags AllBookmarksResp ] , stateChange = False , dragDrop = DD.DragDropData ddm Nothing @@ -1040,6 +1061,31 @@ updateDrop ddm flags settings msg model = , dragDrop = DD.DragDropData ddm Nothing } + AllBookmarksResp (Ok bm) -> + { model = { model | allBookmarks = Comp.BookmarkChooser.init bm } + , cmd = Cmd.none + , stateChange = False + , dragDrop = DD.DragDropData ddm Nothing + } + + AllBookmarksResp (Err err) -> + { model = model + , cmd = Cmd.none + , stateChange = False + , dragDrop = DD.DragDropData ddm Nothing + } + + SelectBookmarkMsg lm -> + let + ( next, sel ) = + Comp.BookmarkChooser.update lm model.allBookmarks model.selectedBookmarks + in + { model = { model | allBookmarks = next, selectedBookmarks = sel } + , cmd = Cmd.none + , stateChange = sel /= model.selectedBookmarks + , dragDrop = DD.DragDropData ddm Nothing + } + --- View2 @@ -1064,6 +1110,7 @@ viewDrop2 texts ddd flags cfg settings model = type SearchTab = TabInbox + | TabBookmarks | TabTags | TabTagCategories | TabFolder @@ -1080,6 +1127,7 @@ type SearchTab allTabs : List SearchTab allTabs = [ TabInbox + , TabBookmarks , TabTags , TabTagCategories , TabFolder @@ -1100,6 +1148,9 @@ tabName tab = TabInbox -> "inbox" + TabBookmarks -> + "bookmarks" + TabTags -> "tags" @@ -1140,6 +1191,9 @@ findTab tab = "inbox" -> Just TabInbox + "bookmarks" -> + Just TabBookmarks + "tags" -> Just TabTags @@ -1215,6 +1269,16 @@ tabLook settings model tab = TabInbox -> activeWhen model.inboxCheckbox + TabBookmarks -> + if Comp.BookmarkChooser.isEmpty model.allBookmarks then + Comp.Tabs.Hidden + + else if not <| Comp.BookmarkChooser.isEmptySelection model.selectedBookmarks then + Comp.Tabs.Active + + else + Comp.Tabs.Normal + TabTags -> hiddenOr [ Data.Fields.Tag ] (activeWhenNotEmpty model.tagSelection.includeTags model.tagSelection.excludeTags) @@ -1329,52 +1393,15 @@ searchTabs texts ddd flags settings model = , label = texts.inbox , tagger = \_ -> ToggleInbox } - , div [ class "mt-2 hidden" ] - [ label [ class S.inputLabel ] - [ text - (case model.textSearchModel of - Fulltext _ -> - texts.fulltextSearch - - Names _ -> - texts.searchInNames - ) - , a - [ classList - [ ( "hidden", not flags.config.fullTextSearchEnabled ) - ] - , class "float-right" - , class S.link - , href "#" - , onClick SwapTextSearch - , title texts.switchSearchModes - ] - [ i [ class "fa fa-exchange-alt" ] [] - ] - ] - , input - [ type_ "text" - , onInput SetTextSearch - , Util.Html.onKeyUpCode KeyUpMsg - , textSearchString model.textSearchModel |> Maybe.withDefault "" |> value - , case model.textSearchModel of - Fulltext _ -> - placeholder texts.contentSearch - - Names _ -> - placeholder texts.searchInNamesPlaceholder - , class S.textInputSidebar - ] - [] - , span [ class "opacity-50 text-sm" ] - [ case model.textSearchModel of - Fulltext _ -> - text texts.fulltextSearchInfo - - Names _ -> - text texts.nameSearchInfo - ] - ] + ] + } + , { name = tabName TabBookmarks + , title = texts.bookmarks + , titleRight = [] + , info = Nothing + , body = + [ Html.map SelectBookmarkMsg + (Comp.BookmarkChooser.view texts.bookmarkChooser model.allBookmarks model.selectedBookmarks) ] } , { name = tabName TabTags diff --git a/modules/webapp/src/main/elm/Comp/TagForm.elm b/modules/webapp/src/main/elm/Comp/TagForm.elm index 1ec2280e..015645f9 100644 --- a/modules/webapp/src/main/elm/Comp/TagForm.elm +++ b/modules/webapp/src/main/elm/Comp/TagForm.elm @@ -20,6 +20,7 @@ import Comp.Basic as B import Comp.Dropdown import Data.DropdownStyle as DS import Data.Flags exposing (Flags) +import Data.UiSettings exposing (UiSettings) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onInput) @@ -126,8 +127,8 @@ update _ msg model = --- View2 -view2 : Texts -> Model -> Html Msg -view2 texts model = +view2 : Texts -> UiSettings -> Model -> Html Msg +view2 texts settings model = let categoryCfg = { makeOption = \s -> Comp.Dropdown.mkOption s @@ -170,6 +171,7 @@ view2 texts model = , Html.map CatMsg (Comp.Dropdown.viewSingle2 categoryCfg + settings model.catDropdown ) ] diff --git a/modules/webapp/src/main/elm/Comp/TagManage.elm b/modules/webapp/src/main/elm/Comp/TagManage.elm index 3c067d20..422e3264 100644 --- a/modules/webapp/src/main/elm/Comp/TagManage.elm +++ b/modules/webapp/src/main/elm/Comp/TagManage.elm @@ -24,6 +24,7 @@ import Comp.TagTable import Comp.YesNoDimmer import Data.Flags exposing (Flags) import Data.TagOrder exposing (TagOrder) +import Data.UiSettings exposing (UiSettings) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onSubmit) @@ -247,13 +248,13 @@ update flags msg model = --- View2 -view2 : Texts -> Model -> Html Msg -view2 texts model = +view2 : Texts -> UiSettings -> Model -> Html Msg +view2 texts settings model = if model.viewMode == Table then viewTable2 texts model else - viewForm2 texts model + viewForm2 texts settings model viewTable2 : Texts -> Model -> Html Msg @@ -290,8 +291,8 @@ viewTable2 texts model = ] -viewForm2 : Texts -> Model -> Html Msg -viewForm2 texts model = +viewForm2 : Texts -> UiSettings -> Model -> Html Msg +viewForm2 texts settings model = let newTag = model.tagFormModel.tag.id == "" @@ -373,7 +374,7 @@ viewForm2 texts model = FormErrorSubmit m -> text m ] - , Html.map FormMsg (Comp.TagForm.view2 texts.tagForm model.tagFormModel) + , Html.map FormMsg (Comp.TagForm.view2 texts.tagForm settings model.tagFormModel) , B.loadingDimmer { active = model.loading , label = texts.basics.loading diff --git a/modules/webapp/src/main/elm/Data/Bookmarks.elm b/modules/webapp/src/main/elm/Data/Bookmarks.elm new file mode 100644 index 00000000..e9c83c5c --- /dev/null +++ b/modules/webapp/src/main/elm/Data/Bookmarks.elm @@ -0,0 +1,55 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Data.Bookmarks exposing + ( AllBookmarks + , Bookmarks + , bookmarksDecoder + , empty + , exists + , sort + ) + +import Api.Model.BookmarkedQuery exposing (BookmarkedQuery) +import Api.Model.ShareDetail exposing (ShareDetail) +import Json.Decode as D + + +type alias AllBookmarks = + { bookmarks : List BookmarkedQuery + , shares : List ShareDetail + } + + +empty : AllBookmarks +empty = + AllBookmarks [] [] + + +type alias Bookmarks = + List BookmarkedQuery + + +{-| Checks wether a bookmark of this name already exists. +-} +exists : String -> Bookmarks -> Bool +exists name bookmarks = + List.any (\b -> b.name == name) bookmarks + + +sort : Bookmarks -> Bookmarks +sort bms = + let + labelName b = + Maybe.withDefault b.name b.label + in + List.sortBy labelName bms + + +bookmarksDecoder : D.Decoder Bookmarks +bookmarksDecoder = + D.list Api.Model.BookmarkedQuery.decoder diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm index bf6d3979..36370a6b 100644 --- a/modules/webapp/src/main/elm/Data/ItemQuery.elm +++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm @@ -71,7 +71,7 @@ and list = Nothing es -> - Just (And es) + Just (unwrap (And es)) request : SearchMode -> Maybe ItemQuery -> RQ.ItemQuery @@ -90,6 +90,32 @@ renderMaybe mq = |> Maybe.withDefault "" +unwrap : ItemQuery -> ItemQuery +unwrap query = + case query of + And inner -> + case inner of + first :: [] -> + unwrap first + + _ -> + And (List.map unwrap inner) + + Or inner -> + case inner of + first :: [] -> + unwrap first + + _ -> + Or (List.map unwrap inner) + + Not (Not inner) -> + unwrap inner + + _ -> + query + + render : ItemQuery -> String render q = let @@ -118,7 +144,7 @@ render q = String.replace "\"" "\\\"" >> surround "\"" in - case q of + case unwrap q of And inner -> List.map render inner |> String.join " " diff --git a/modules/webapp/src/main/elm/Data/PeriodicQuerySettings.elm b/modules/webapp/src/main/elm/Data/PeriodicQuerySettings.elm index 63db0087..6a3a5b1e 100644 --- a/modules/webapp/src/main/elm/Data/PeriodicQuerySettings.elm +++ b/modules/webapp/src/main/elm/Data/PeriodicQuerySettings.elm @@ -18,7 +18,8 @@ type alias PeriodicQuerySettings = , enabled : Bool , summary : Maybe String , channel : NotificationChannel - , query : String + , query : Maybe String + , bookmark : Maybe String , schedule : String } @@ -29,19 +30,21 @@ empty ct = , enabled = False , summary = Nothing , channel = Data.NotificationChannel.empty ct - , query = "" + , query = Nothing + , bookmark = Nothing , schedule = "" } decoder : D.Decoder PeriodicQuerySettings decoder = - D.map6 PeriodicQuerySettings + D.map7 PeriodicQuerySettings (D.field "id" D.string) (D.field "enabled" D.bool) - (D.field "summary" (D.maybe D.string)) + (D.maybe (D.field "summary" D.string)) (D.field "channel" Data.NotificationChannel.decoder) - (D.field "query" D.string) + (D.maybe (D.field "query" D.string)) + (D.maybe (D.field "bookmark" D.string)) (D.field "schedule" D.string) @@ -52,6 +55,7 @@ encode s = , ( "enabled", E.bool s.enabled ) , ( "summary", Maybe.map E.string s.summary |> Maybe.withDefault E.null ) , ( "channel", Data.NotificationChannel.encode s.channel ) - , ( "query", E.string s.query ) + , ( "query", Maybe.map E.string s.query |> Maybe.withDefault E.null ) + , ( "bookmark", Maybe.map E.string s.bookmark |> Maybe.withDefault E.null ) , ( "schedule", E.string s.schedule ) ] diff --git a/modules/webapp/src/main/elm/Messages/Comp/BookmarkChooser.elm b/modules/webapp/src/main/elm/Messages/Comp/BookmarkChooser.elm new file mode 100644 index 00000000..a878a8e0 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/BookmarkChooser.elm @@ -0,0 +1,40 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.BookmarkChooser exposing + ( Texts + , de + , gb + ) + +import Messages.Basics + + +type alias Texts = + { basics : Messages.Basics.Texts + , userLabel : String + , collectiveLabel : String + , shareLabel : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , userLabel = "Personal" + , collectiveLabel = "Collective" + , shareLabel = "Shares" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , userLabel = "Persönlich" + , collectiveLabel = "Kollektiv" + , shareLabel = "Freigaben" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/BookmarkDropdown.elm b/modules/webapp/src/main/elm/Messages/Comp/BookmarkDropdown.elm new file mode 100644 index 00000000..4934a839 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/BookmarkDropdown.elm @@ -0,0 +1,43 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.BookmarkDropdown exposing + ( Texts + , de + , gb + ) + +import Messages.Basics + + +type alias Texts = + { basics : Messages.Basics.Texts + , placeholder : String + , personal : String + , collective : String + , share : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , placeholder = "Bookmark…" + , personal = "Personal" + , collective = "Collective" + , share = "Share" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , placeholder = "Bookmark…" + , personal = "Persönlich" + , collective = "Kollektiv" + , share = "Freigabe" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/BookmarkManage.elm b/modules/webapp/src/main/elm/Messages/Comp/BookmarkManage.elm new file mode 100644 index 00000000..b5c0da71 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/BookmarkManage.elm @@ -0,0 +1,65 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.BookmarkManage exposing + ( Texts + , de + , gb + ) + +import Http +import Messages.Basics +import Messages.Comp.BookmarkQueryForm +import Messages.Comp.BookmarkTable +import Messages.Comp.HttpError + + +type alias Texts = + { basics : Messages.Basics.Texts + , bookmarkTable : Messages.Comp.BookmarkTable.Texts + , bookmarkForm : Messages.Comp.BookmarkQueryForm.Texts + , httpError : Http.Error -> String + , newBookmark : String + , reallyDeleteBookmark : String + , createNewBookmark : String + , deleteThisBookmark : String + , correctFormErrors : String + , userBookmarks : String + , collectiveBookmarks : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , bookmarkTable = Messages.Comp.BookmarkTable.gb + , bookmarkForm = Messages.Comp.BookmarkQueryForm.gb + , httpError = Messages.Comp.HttpError.gb + , newBookmark = "New bookmark" + , reallyDeleteBookmark = "Really delete this bookmark?" + , createNewBookmark = "Create new bookmark" + , deleteThisBookmark = "Delete this bookmark" + , correctFormErrors = "Please correct the errors in the form." + , userBookmarks = "Personal bookmarks" + , collectiveBookmarks = "Collective bookmarks" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , bookmarkTable = Messages.Comp.BookmarkTable.de + , bookmarkForm = Messages.Comp.BookmarkQueryForm.de + , httpError = Messages.Comp.HttpError.de + , newBookmark = "Neue Freigabe" + , reallyDeleteBookmark = "Diese Freigabe wirklich entfernen?" + , createNewBookmark = "Neue Freigabe erstellen" + , deleteThisBookmark = "Freigabe löschen" + , correctFormErrors = "Bitte korrigiere die Fehler im Formular." + , userBookmarks = "Persönliche Bookmarks" + , collectiveBookmarks = "Kollektivbookmarks" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/BookmarkQueryForm.elm b/modules/webapp/src/main/elm/Messages/Comp/BookmarkQueryForm.elm new file mode 100644 index 00000000..4547ab70 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/BookmarkQueryForm.elm @@ -0,0 +1,49 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.BookmarkQueryForm exposing + ( Texts + , de + , gb + ) + +import Messages.Basics + + +type alias Texts = + { basics : Messages.Basics.Texts + , queryLabel : String + , userLocation : String + , userLocationText : String + , collectiveLocation : String + , collectiveLocationText : String + , nameExistsWarning : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , queryLabel = "Query" + , userLocation = "User scope" + , userLocationText = "The bookmarked query is just for you" + , collectiveLocation = "Collective scope" + , collectiveLocationText = "The bookmarked query can be used and edited by all users" + , nameExistsWarning = "A bookmark with this name exists! Choose another name." + } + + +de : Texts +de = + { basics = Messages.Basics.de + , queryLabel = "Abfrage" + , userLocation = "Persönliches Bookmark" + , userLocationText = "Der Bookmark ist nur für dich" + , collectiveLocation = "Kollektiv-Bookmark" + , collectiveLocationText = "Der Bookmark kann von allen Benutzer verwendet werden" + , nameExistsWarning = "Der Bookmark existiert bereits! Verwende einen anderen Namen." + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/BookmarkQueryManage.elm b/modules/webapp/src/main/elm/Messages/Comp/BookmarkQueryManage.elm new file mode 100644 index 00000000..db568072 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/BookmarkQueryManage.elm @@ -0,0 +1,46 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.BookmarkQueryManage exposing + ( Texts + , de + , gb + ) + +import Http +import Messages.Basics +import Messages.Comp.BookmarkQueryForm +import Messages.Comp.HttpError + + +type alias Texts = + { basics : Messages.Basics.Texts + , form : Messages.Comp.BookmarkQueryForm.Texts + , httpError : Http.Error -> String + , formInvalid : String + , saved : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , form = Messages.Comp.BookmarkQueryForm.gb + , httpError = Messages.Comp.HttpError.gb + , formInvalid = "Please correct errors in the form" + , saved = "Bookmark saved" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , form = Messages.Comp.BookmarkQueryForm.de + , httpError = Messages.Comp.HttpError.de + , formInvalid = "Bitte korrigiere das Formular" + , saved = "Bookmark gespeichert" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/BookmarkTable.elm b/modules/webapp/src/main/elm/Messages/Comp/BookmarkTable.elm new file mode 100644 index 00000000..5f9be8df --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/BookmarkTable.elm @@ -0,0 +1,34 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.BookmarkTable exposing + ( Texts + , de + , gb + ) + +import Messages.Basics + + +type alias Texts = + { basics : Messages.Basics.Texts + , user : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , user = "User" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , user = "Benutzer" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskForm.elm b/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskForm.elm index 96f8cfac..e988ce72 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskForm.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskForm.elm @@ -14,6 +14,7 @@ module Messages.Comp.PeriodicQueryTaskForm exposing import Data.ChannelType exposing (ChannelType) import Http import Messages.Basics +import Messages.Comp.BookmarkDropdown import Messages.Comp.CalEventInput import Messages.Comp.ChannelForm import Messages.Comp.HttpError @@ -24,6 +25,7 @@ type alias Texts = { basics : Messages.Basics.Texts , calEventInput : Messages.Comp.CalEventInput.Texts , channelForm : Messages.Comp.ChannelForm.Texts + , bookmarkDropdown : Messages.Comp.BookmarkDropdown.Texts , httpError : Http.Error -> String , reallyDeleteTask : String , startOnce : String @@ -49,6 +51,7 @@ gb = , calEventInput = Messages.Comp.CalEventInput.gb , channelForm = Messages.Comp.ChannelForm.gb , httpError = Messages.Comp.HttpError.gb + , bookmarkDropdown = Messages.Comp.BookmarkDropdown.gb , reallyDeleteTask = "Really delete this notification task?" , startOnce = "Start Once" , startTaskNow = "Start this task now" @@ -66,7 +69,7 @@ gb = , invalidCalEvent = "The calendar event is not valid." , queryLabel = "Query" , channelRequired = "A valid channel must be given." - , queryStringRequired = "A query string must be supplied" + , queryStringRequired = "A query string and/or bookmark must be supplied" , channelHeader = \ct -> "Connection details for " ++ Messages.Data.ChannelType.gb ct } @@ -77,6 +80,7 @@ de = , calEventInput = Messages.Comp.CalEventInput.de , channelForm = Messages.Comp.ChannelForm.de , httpError = Messages.Comp.HttpError.de + , bookmarkDropdown = Messages.Comp.BookmarkDropdown.de , reallyDeleteTask = "Diesen Benachrichtigungsauftrag wirklich löschen?" , startOnce = "Jetzt starten" , startTaskNow = "Starte den Auftrag sofort" @@ -94,6 +98,6 @@ de = , invalidCalEvent = "Das Kalenderereignis ist nicht gültig." , queryLabel = "Abfrage" , channelRequired = "Ein Versandkanal muss angegeben werden." - , queryStringRequired = "Eine Suchabfrage muss angegeben werden." + , queryStringRequired = "Eine Suchabfrage und/oder ein Bookmark muss angegeben werden." , channelHeader = \ct -> "Details für " ++ Messages.Data.ChannelType.de ct } diff --git a/modules/webapp/src/main/elm/Messages/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Messages/Comp/SearchMenu.elm index 23ee8342..e526ac19 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/SearchMenu.elm @@ -13,6 +13,7 @@ module Messages.Comp.SearchMenu exposing import Data.Direction exposing (Direction) import Messages.Basics +import Messages.Comp.BookmarkChooser import Messages.Comp.CustomFieldMultiInput import Messages.Comp.FolderSelect import Messages.Comp.TagSelect @@ -24,6 +25,7 @@ type alias Texts = , customFieldMultiInput : Messages.Comp.CustomFieldMultiInput.Texts , tagSelect : Messages.Comp.TagSelect.Texts , folderSelect : Messages.Comp.FolderSelect.Texts + , bookmarkChooser : Messages.Comp.BookmarkChooser.Texts , chooseDirection : String , choosePerson : String , chooseEquipment : String @@ -47,6 +49,7 @@ type alias Texts = , searchInItemSource : String , direction : Direction -> String , trashcan : String + , bookmarks : String } @@ -56,6 +59,7 @@ gb = , customFieldMultiInput = Messages.Comp.CustomFieldMultiInput.gb , tagSelect = Messages.Comp.TagSelect.gb , folderSelect = Messages.Comp.FolderSelect.gb + , bookmarkChooser = Messages.Comp.BookmarkChooser.gb , chooseDirection = "Choose a direction…" , choosePerson = "Choose a person" , chooseEquipment = "Choose an equipment" @@ -79,6 +83,7 @@ gb = , searchInItemSource = "Search in item source…" , direction = Messages.Data.Direction.gb , trashcan = "Trash" + , bookmarks = "Bookmarks" } @@ -88,6 +93,7 @@ de = , customFieldMultiInput = Messages.Comp.CustomFieldMultiInput.de , tagSelect = Messages.Comp.TagSelect.de , folderSelect = Messages.Comp.FolderSelect.de + , bookmarkChooser = Messages.Comp.BookmarkChooser.de , chooseDirection = "Wähle eine Richtung…" , choosePerson = "Wähle eine Person…" , chooseEquipment = "Wähle eine Ausstattung" @@ -111,4 +117,5 @@ de = , searchInItemSource = "Suche in Dokumentquelle…" , direction = Messages.Data.Direction.de , trashcan = "Papierkorb" + , bookmarks = "Bookmarks" } diff --git a/modules/webapp/src/main/elm/Messages/Page/Home.elm b/modules/webapp/src/main/elm/Messages/Page/Home.elm index 3269cad8..38b5386f 100644 --- a/modules/webapp/src/main/elm/Messages/Page/Home.elm +++ b/modules/webapp/src/main/elm/Messages/Page/Home.elm @@ -12,6 +12,7 @@ module Messages.Page.Home exposing ) import Messages.Basics +import Messages.Comp.BookmarkQueryManage import Messages.Comp.ItemCardList import Messages.Comp.ItemMerge import Messages.Comp.PublishItems @@ -26,6 +27,7 @@ type alias Texts = , sideMenu : Messages.Page.HomeSideMenu.Texts , itemMerge : Messages.Comp.ItemMerge.Texts , publishItems : Messages.Comp.PublishItems.Texts + , bookmarkManage : Messages.Comp.BookmarkQueryManage.Texts , contentSearch : String , searchInNames : String , selectModeTitle : String @@ -46,6 +48,7 @@ type alias Texts = , mergeItemsTitle : Int -> String , publishItemsTitle : Int -> String , publishCurrentQueryTitle : String + , shareResults : String , nothingSelectedToShare : String , loadMore : String , thatsAll : String @@ -53,6 +56,8 @@ type alias Texts = , listView : String , tileView : String , expandCollapseRows : String + , bookmarkQuery : String + , nothingToBookmark : String } @@ -64,6 +69,7 @@ gb = , sideMenu = Messages.Page.HomeSideMenu.gb , itemMerge = Messages.Comp.ItemMerge.gb , publishItems = Messages.Comp.PublishItems.gb + , bookmarkManage = Messages.Comp.BookmarkQueryManage.gb , contentSearch = "Content search…" , searchInNames = "Search in names…" , selectModeTitle = "Select Mode" @@ -84,6 +90,7 @@ gb = , mergeItemsTitle = \n -> "Merge " ++ String.fromInt n ++ " selected items" , publishItemsTitle = \n -> "Publish " ++ String.fromInt n ++ " selected items" , publishCurrentQueryTitle = "Publish current results" + , shareResults = "Share Results" , nothingSelectedToShare = "Sharing everything doesn't work. You need to apply some criteria." , loadMore = "Load more…" , thatsAll = "That's all" @@ -91,6 +98,8 @@ gb = , listView = "List view" , tileView = "Tile view" , expandCollapseRows = "Expand/Collapse all" + , bookmarkQuery = "Bookmark query" + , nothingToBookmark = "Nothing selected to bookmark" } @@ -102,6 +111,7 @@ de = , sideMenu = Messages.Page.HomeSideMenu.de , itemMerge = Messages.Comp.ItemMerge.de , publishItems = Messages.Comp.PublishItems.de + , bookmarkManage = Messages.Comp.BookmarkQueryManage.de , contentSearch = "Volltextsuche…" , searchInNames = "Suche in Namen…" , selectModeTitle = "Auswahlmodus" @@ -122,6 +132,7 @@ de = , mergeItemsTitle = \n -> String.fromInt n ++ " gewählte Dokumente zusammenführen" , publishItemsTitle = \n -> String.fromInt n ++ " gewählte Dokumente publizieren" , publishCurrentQueryTitle = "Aktuelle Ansicht publizieren" + , shareResults = "Ergebnisse teilen" , nothingSelectedToShare = "Alles kann nicht geteilt werden; es muss etwas gesucht werden." , loadMore = "Mehr laden…" , thatsAll = "Mehr gibt es nicht" @@ -129,4 +140,6 @@ de = , listView = "Listenansicht" , tileView = "Kachelansicht" , expandCollapseRows = "Alle ein-/ausklappen" + , bookmarkQuery = "Abfrage merken" + , nothingToBookmark = "Keine Abfrage vorhanden" } diff --git a/modules/webapp/src/main/elm/Messages/Page/ManageData.elm b/modules/webapp/src/main/elm/Messages/Page/ManageData.elm index ab824afd..feda298b 100644 --- a/modules/webapp/src/main/elm/Messages/Page/ManageData.elm +++ b/modules/webapp/src/main/elm/Messages/Page/ManageData.elm @@ -12,6 +12,7 @@ module Messages.Page.ManageData exposing ) import Messages.Basics +import Messages.Comp.BookmarkManage import Messages.Comp.CustomFieldManage import Messages.Comp.EquipmentManage import Messages.Comp.FolderManage @@ -28,7 +29,9 @@ type alias Texts = , personManage : Messages.Comp.PersonManage.Texts , folderManage : Messages.Comp.FolderManage.Texts , customFieldManage : Messages.Comp.CustomFieldManage.Texts + , bookmarkManage : Messages.Comp.BookmarkManage.Texts , manageData : String + , bookmarks : String } @@ -41,7 +44,9 @@ gb = , personManage = Messages.Comp.PersonManage.gb , folderManage = Messages.Comp.FolderManage.gb , customFieldManage = Messages.Comp.CustomFieldManage.gb + , bookmarkManage = Messages.Comp.BookmarkManage.gb , manageData = "Manage Data" + , bookmarks = "Bookmarks" } @@ -54,5 +59,7 @@ de = , personManage = Messages.Comp.PersonManage.de , folderManage = Messages.Comp.FolderManage.de , customFieldManage = Messages.Comp.CustomFieldManage.de + , bookmarkManage = Messages.Comp.BookmarkManage.de , manageData = "Daten verwalten" + , bookmarks = "Bookmarks" } diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index 17fddc42..b55d31aa 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -13,6 +13,7 @@ module Page.Home.Data exposing , SearchType(..) , SelectActionMode(..) , SelectViewModel + , TopWidgetModel(..) , ViewMode(..) , createQuery , doSearchCmd @@ -30,6 +31,7 @@ import Api.Model.BasicResult exposing (BasicResult) import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.SearchStats exposing (SearchStats) import Browser.Dom as Dom +import Comp.BookmarkQueryManage import Comp.ItemCardList import Comp.ItemDetail.FormChange exposing (FormChange) import Comp.ItemDetail.MultiEditMenu exposing (SaveNameState(..)) @@ -68,9 +70,15 @@ type alias Model = , powerSearchInput : Comp.PowerSearchInput.Model , viewMenuOpen : Bool , itemRowsOpen : Set String + , topWidgetModel : TopWidgetModel } +type TopWidgetModel + = TopWidgetHidden + | BookmarkQuery Comp.BookmarkQueryManage.Model + + type ConfirmModalValue = ConfirmReprocessItems | ConfirmDelete @@ -137,6 +145,7 @@ init flags viewMode = , powerSearchInput = Comp.PowerSearchInput.init , viewMenuOpen = False , itemRowsOpen = Set.empty + , topWidgetModel = TopWidgetHidden } @@ -238,6 +247,8 @@ type Msg | ToggleShowGroups | ToggleArrange ItemArrange | ToggleExpandCollapseRows + | ToggleBookmarkCurrentQueryView + | BookmarkQueryMsg Comp.BookmarkQueryManage.Msg type SearchType diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index 182495ba..aa99403a 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -13,6 +13,7 @@ module Page.Home.Update exposing import Api import Api.Model.ItemLightList exposing (ItemLightList) import Browser.Navigation as Nav +import Comp.BookmarkQueryManage import Comp.ItemCardList import Comp.ItemDetail.FormChange exposing (FormChange(..)) import Comp.ItemDetail.MultiEditMenu exposing (SaveNameState(..)) @@ -872,7 +873,7 @@ update mId key flags texts settings msg model = cmd = Api.saveClientSettings flags newSettings (ClientSettingsSaveResp newSettings) in - noSub ( model, cmd ) + noSub ( { model | viewMenuOpen = False }, cmd ) ClientSettingsSaveResp newSettings (Ok res) -> if res.success then @@ -922,11 +923,69 @@ update mId key flags texts settings msg model = ( pm, pc ) = Comp.PublishItems.initQuery flags q in - noSub ( { model | viewMode = PublishView pm }, Cmd.map PublishViewMsg pc ) + noSub ( { model | viewMode = PublishView pm, viewMenuOpen = False }, Cmd.map PublishViewMsg pc ) Nothing -> noSub ( model, Cmd.none ) + ToggleBookmarkCurrentQueryView -> + case createQuery model of + Just q -> + case model.topWidgetModel of + BookmarkQuery _ -> + noSub ( { model | topWidgetModel = TopWidgetHidden, viewMenuOpen = False }, Cmd.none ) + + TopWidgetHidden -> + let + ( qm, qc ) = + Comp.BookmarkQueryManage.init (Q.render q) + in + noSub + ( { model | topWidgetModel = BookmarkQuery qm, viewMenuOpen = False } + , Cmd.map BookmarkQueryMsg qc + ) + + Nothing -> + noSub ( model, Cmd.none ) + + BookmarkQueryMsg lm -> + case model.topWidgetModel of + BookmarkQuery bm -> + let + res = + Comp.BookmarkQueryManage.update flags lm bm + + nextModel = + if + res.outcome + == Comp.BookmarkQueryManage.Cancelled + || res.outcome + == Comp.BookmarkQueryManage.Done + then + TopWidgetHidden + + else + BookmarkQuery res.model + + refreshCmd = + if res.outcome == Comp.BookmarkQueryManage.Done then + Cmd.map SearchMenuMsg (Comp.SearchMenu.refreshBookmarks flags) + + else + Cmd.none + in + makeResult + ( { model | topWidgetModel = nextModel } + , Cmd.batch + [ Cmd.map BookmarkQueryMsg res.cmd + , refreshCmd + ] + , Sub.map BookmarkQueryMsg res.sub + ) + + TopWidgetHidden -> + noSub ( model, Cmd.none ) + PublishViewMsg lmsg -> case model.viewMode of PublishView inPM -> @@ -942,7 +1001,10 @@ update mId key flags texts settings msg model = ) Comp.PublishItems.OutcomeDone -> - noSub ( { model | viewMode = SearchView }, Cmd.none ) + noSub + ( { model | viewMode = SearchView } + , Cmd.map SearchMenuMsg (Comp.SearchMenu.refreshBookmarks flags) + ) _ -> noSub ( model, Cmd.none ) diff --git a/modules/webapp/src/main/elm/Page/Home/View2.elm b/modules/webapp/src/main/elm/Page/Home/View2.elm index 3f4bf2b1..e75788b5 100644 --- a/modules/webapp/src/main/elm/Page/Home/View2.elm +++ b/modules/webapp/src/main/elm/Page/Home/View2.elm @@ -9,6 +9,7 @@ module Page.Home.View2 exposing (viewContent, viewSidebar) import Api import Comp.Basic as B +import Comp.BookmarkQueryManage import Comp.ConfirmModal import Comp.ItemCardList import Comp.ItemMerge @@ -103,7 +104,21 @@ mainView texts flags settings model = body Nothing -> - itemCardList texts flags settings model + bookmarkQueryWidget texts settings flags model + ++ itemCardList texts flags settings model + + +bookmarkQueryWidget : Texts -> UiSettings -> Flags -> Model -> List (Html Msg) +bookmarkQueryWidget texts settings flags model = + case model.topWidgetModel of + BookmarkQuery m -> + [ div [ class "px-2 mb-4 border-l border-r border-b dark:border-slate-600" ] + [ Html.map BookmarkQueryMsg (Comp.BookmarkQueryManage.view texts.bookmarkManage m) + ] + ] + + TopWidgetHidden -> + [] itemPublishView : Texts -> UiSettings -> Flags -> SelectViewModel -> List (Html Msg) @@ -254,29 +269,26 @@ defaultMenuBar texts flags settings model = isListView = settings.itemSearchArrange == Data.ItemArrange.List + + menuSep = + { icon = i [] [] + , label = "separator" + , disabled = False + , attrs = + [] + } in MB.view - { end = + { start = [ MB.CustomElement <| - B.secondaryBasicButton - { label = "" - , icon = Icons.share - , disabled = createQuery model == Nothing - , handler = onClick TogglePublishCurrentQueryView - , attrs = - [ title <| - if createQuery model == Nothing then - texts.nothingSelectedToShare + if settings.powerSearchEnabled then + powerSearchBar - else - texts.publishCurrentQueryTitle - , classList - [ ( btnStyle, True ) - ] - , href "#" - ] - } - , MB.CustomElement <| + else + simpleSearchBar + ] + , end = + [ MB.CustomElement <| B.secondaryBasicButton { label = "" , icon = @@ -300,7 +312,7 @@ defaultMenuBar texts flags settings model = ] } , MB.Dropdown - { linkIcon = "fa fa-grip-vertical" + { linkIcon = "fa fa-bars" , label = "" , linkClass = [ ( S.secondaryBasicButton, True ) @@ -314,6 +326,7 @@ defaultMenuBar texts flags settings model = else i [ class "fa fa-square font-thin" ] [] + , disabled = False , label = texts.showItemGroups , attrs = [ href "#" @@ -326,6 +339,7 @@ defaultMenuBar texts flags settings model = else i [ class "fa fa-list" ] [] + , disabled = False , label = texts.listView , attrs = [ href "#" @@ -338,6 +352,7 @@ defaultMenuBar texts flags settings model = else i [ class "fa fa-th-large" ] [] + , disabled = False , label = texts.tileView , attrs = [ href "#" @@ -346,36 +361,67 @@ defaultMenuBar texts flags settings model = } , { icon = i [ class "fa fa-compress" ] [] , label = texts.expandCollapseRows + , disabled = False , attrs = [ href "#" , classList [ ( "hidden", not isListView ) ] , onClick ToggleExpandCollapseRows ] } - ] - } - ] - , start = - [ MB.CustomElement <| - if settings.powerSearchEnabled then - powerSearchBar + , menuSep + , { label = texts.shareResults + , icon = Icons.shareIcon "" + , disabled = createQuery model == Nothing + , attrs = + [ title <| + if createQuery model == Nothing then + texts.nothingSelectedToShare - else - simpleSearchBar - , MB.CustomButton - { tagger = TogglePreviewFullWidth - , label = "" - , icon = Just "fa fa-expand" - , title = - if settings.cardPreviewFullWidth then - texts.fullHeightPreviewTitle + else + texts.publishCurrentQueryTitle + , href "#" + , if createQuery model == Nothing then + class "" - else - texts.fullWidthPreviewTitle - , inputClass = - [ ( btnStyle, True ) - , ( "hidden sm:inline-block", False ) - , ( "bg-gray-200 dark:bg-slate-600", settings.cardPreviewFullWidth ) + else + onClick TogglePublishCurrentQueryView + ] + } + , { label = texts.bookmarkQuery + , icon = i [ class "fa fa-bookmark" ] [] + , disabled = createQuery model == Nothing + , attrs = + [ title <| + if createQuery model == Nothing then + texts.nothingToBookmark + + else + texts.bookmarkQuery + , href "#" + , if createQuery model == Nothing then + class "" + + else + onClick ToggleBookmarkCurrentQueryView + ] + } + , { label = + if settings.cardPreviewFullWidth then + texts.fullHeightPreviewTitle + + else + texts.fullWidthPreviewTitle + , icon = i [ class "fa fa-expand" ] [] + , disabled = False + , attrs = + [ href "#" + , onClick TogglePreviewFullWidth + , classList + [ ( "hidden sm:inline-block", False ) + , ( "bg-gray-200 dark:bg-slate-600", settings.cardPreviewFullWidth ) + ] + ] + } ] } ] diff --git a/modules/webapp/src/main/elm/Page/ManageData/Data.elm b/modules/webapp/src/main/elm/Page/ManageData/Data.elm index e59fc380..f56b8702 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/Data.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/Data.elm @@ -12,6 +12,7 @@ module Page.ManageData.Data exposing , init ) +import Comp.BookmarkManage import Comp.CustomFieldManage import Comp.EquipmentManage import Comp.FolderManage @@ -29,6 +30,7 @@ type alias Model = , personManageModel : Comp.PersonManage.Model , folderManageModel : Comp.FolderManage.Model , fieldManageModel : Comp.CustomFieldManage.Model + , bookmarkModel : Comp.BookmarkManage.Model } @@ -37,6 +39,9 @@ init flags = let ( m2, c2 ) = Comp.TagManage.update flags Comp.TagManage.LoadTags Comp.TagManage.emptyModel + + ( bm, bc ) = + Comp.BookmarkManage.init flags in ( { currentTab = Just TagTab , tagManageModel = m2 @@ -45,8 +50,12 @@ init flags = , personManageModel = Comp.PersonManage.emptyModel , folderManageModel = Comp.FolderManage.empty , fieldManageModel = Comp.CustomFieldManage.empty + , bookmarkModel = bm } - , Cmd.map TagManageMsg c2 + , Cmd.batch + [ Cmd.map TagManageMsg c2 + , Cmd.map BookmarkMsg bc + ] ) @@ -57,6 +66,7 @@ type Tab | PersonTab | FolderTab | CustomFieldTab + | BookmarkTab type Msg @@ -67,3 +77,4 @@ type Msg | PersonManageMsg Comp.PersonManage.Msg | FolderMsg Comp.FolderManage.Msg | CustomFieldMsg Comp.CustomFieldManage.Msg + | BookmarkMsg Comp.BookmarkManage.Msg diff --git a/modules/webapp/src/main/elm/Page/ManageData/Update.elm b/modules/webapp/src/main/elm/Page/ManageData/Update.elm index a124108d..b2c61dd3 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/Update.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/Update.elm @@ -7,6 +7,7 @@ module Page.ManageData.Update exposing (update) +import Comp.BookmarkManage import Comp.CustomFieldManage import Comp.EquipmentManage import Comp.FolderManage @@ -17,7 +18,7 @@ import Data.Flags exposing (Flags) import Page.ManageData.Data exposing (..) -update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) update flags msg model = case msg of SetTab t -> @@ -43,42 +44,49 @@ update flags msg model = ( sm, sc ) = Comp.FolderManage.init flags in - ( { m | folderManageModel = sm }, Cmd.map FolderMsg sc ) + ( { m | folderManageModel = sm }, Cmd.map FolderMsg sc, Sub.none ) CustomFieldTab -> let ( cm, cc ) = Comp.CustomFieldManage.init flags in - ( { m | fieldManageModel = cm }, Cmd.map CustomFieldMsg cc ) + ( { m | fieldManageModel = cm }, Cmd.map CustomFieldMsg cc, Sub.none ) + + BookmarkTab -> + let + ( bm, bc ) = + Comp.BookmarkManage.init flags + in + ( { m | bookmarkModel = bm }, Cmd.map BookmarkMsg bc, Sub.none ) TagManageMsg m -> let ( m2, c2 ) = Comp.TagManage.update flags m model.tagManageModel in - ( { model | tagManageModel = m2 }, Cmd.map TagManageMsg c2 ) + ( { model | tagManageModel = m2 }, Cmd.map TagManageMsg c2, Sub.none ) EquipManageMsg m -> let ( m2, c2 ) = Comp.EquipmentManage.update flags m model.equipManageModel in - ( { model | equipManageModel = m2 }, Cmd.map EquipManageMsg c2 ) + ( { model | equipManageModel = m2 }, Cmd.map EquipManageMsg c2, Sub.none ) OrgManageMsg m -> let ( m2, c2 ) = Comp.OrgManage.update flags m model.orgManageModel in - ( { model | orgManageModel = m2 }, Cmd.map OrgManageMsg c2 ) + ( { model | orgManageModel = m2 }, Cmd.map OrgManageMsg c2, Sub.none ) PersonManageMsg m -> let ( m2, c2 ) = Comp.PersonManage.update flags m model.personManageModel in - ( { model | personManageModel = m2 }, Cmd.map PersonManageMsg c2 ) + ( { model | personManageModel = m2 }, Cmd.map PersonManageMsg c2, Sub.none ) FolderMsg lm -> let @@ -87,6 +95,7 @@ update flags msg model = in ( { model | folderManageModel = m2 } , Cmd.map FolderMsg c2 + , Sub.none ) CustomFieldMsg lm -> @@ -96,4 +105,15 @@ update flags msg model = in ( { model | fieldManageModel = m2 } , Cmd.map CustomFieldMsg c2 + , Sub.none + ) + + BookmarkMsg lm -> + let + ( m2, c2, s2 ) = + Comp.BookmarkManage.update flags lm model.bookmarkModel + in + ( { model | bookmarkModel = m2 } + , Cmd.map BookmarkMsg c2 + , Sub.map BookmarkMsg s2 ) diff --git a/modules/webapp/src/main/elm/Page/ManageData/View2.elm b/modules/webapp/src/main/elm/Page/ManageData/View2.elm index aacc8ca2..f4947bf1 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/View2.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/View2.elm @@ -7,6 +7,7 @@ module Page.ManageData.View2 exposing (viewContent, viewSidebar) +import Comp.BookmarkManage import Comp.CustomFieldManage import Comp.EquipmentManage import Comp.FolderManage @@ -121,6 +122,18 @@ viewSidebar texts visible _ settings model = [ text texts.basics.customFields ] ] + , a + [ href "#" + , onClick (SetTab BookmarkTab) + , menuEntryActive model BookmarkTab + , class S.sidebarLink + ] + [ i [ class "fa fa-bookmark" ] [] + , span + [ class "ml-3" ] + [ text texts.bookmarks + ] + ] ] ] @@ -133,7 +146,7 @@ viewContent texts flags settings model = ] (case model.currentTab of Just TagTab -> - viewTags texts model + viewTags texts settings model Just EquipTab -> viewEquip texts model @@ -150,6 +163,9 @@ viewContent texts flags settings model = Just CustomFieldTab -> viewCustomFields texts flags settings model + Just BookmarkTab -> + viewBookmarks texts flags settings model + Nothing -> [] ) @@ -164,8 +180,8 @@ menuEntryActive model tab = class "" -viewTags : Texts -> Model -> List (Html Msg) -viewTags texts model = +viewTags : Texts -> UiSettings -> Model -> List (Html Msg) +viewTags texts settings model = [ h2 [ class S.header1 , class "inline-flex items-center" @@ -178,6 +194,7 @@ viewTags texts model = , Html.map TagManageMsg (Comp.TagManage.view2 texts.tagManage + settings model.tagManageModel ) ] @@ -274,3 +291,18 @@ viewCustomFields texts flags _ model = model.fieldManageModel ) ] + + +viewBookmarks : Texts -> Flags -> UiSettings -> Model -> List (Html Msg) +viewBookmarks texts flags settings model = + [ h2 + [ class S.header1 + , class "inline-flex items-center" + ] + [ i [ class "fa fa-bookmark" ] [] + , div [ class "ml-2" ] + [ text texts.bookmarks + ] + ] + , Html.map BookmarkMsg (Comp.BookmarkManage.view texts.bookmarkManage settings flags model.bookmarkModel) + ] diff --git a/modules/webapp/src/main/elm/Page/Share/Menubar.elm b/modules/webapp/src/main/elm/Page/Share/Menubar.elm index c2ae67c2..5df6db84 100644 --- a/modules/webapp/src/main/elm/Page/Share/Menubar.elm +++ b/modules/webapp/src/main/elm/Page/Share/Menubar.elm @@ -125,6 +125,7 @@ view texts flags model = else i [ class "fa fa-square font-thin" ] [] , label = texts.showItemGroups + , disabled = False , attrs = [ href "#" , onClick ToggleShowGroups @@ -132,6 +133,7 @@ view texts flags model = } , { icon = i [ class "fa fa-list" ] [] , label = texts.listView + , disabled = False , attrs = [ href "#" , onClick (ToggleArrange Data.ItemArrange.List) @@ -139,6 +141,7 @@ view texts flags model = } , { icon = i [ class "fa fa-th-large" ] [] , label = texts.tileView + , disabled = False , attrs = [ href "#" , onClick (ToggleArrange Data.ItemArrange.Cards) diff --git a/modules/webapp/src/main/elm/Styles.elm b/modules/webapp/src/main/elm/Styles.elm index abea7a25..314f8dc7 100644 --- a/modules/webapp/src/main/elm/Styles.elm +++ b/modules/webapp/src/main/elm/Styles.elm @@ -55,6 +55,11 @@ errorText = " text-red-600 dark:text-orange-800 " +warnMessagePlain : String +warnMessagePlain = + " text-yellow-800 dark:text-amber-200 " + + warnMessage : String warnMessage = warnMessageColors ++ " border dark:bg-opacity-25 px-2 py-2 rounded " @@ -62,12 +67,17 @@ warnMessage = warnMessageColors : String warnMessageColors = - " border-yellow-800 bg-yellow-50 text-yellow-800 dark:border-amber-200 dark:bg-amber-800 dark:text-amber-200 " + warnMessagePlain ++ " border-yellow-800 bg-yellow-50 dark:border-amber-200 dark:bg-amber-800 " + + +infoMessagePlain : String +infoMessagePlain = + " text-blue-800 dark:text-sky-200 " infoMessageBase : String infoMessageBase = - " border border-blue-800 bg-blue-100 text-blue-800 dark:border-sky-200 dark:bg-sky-800 dark:text-sky-200 dark:bg-opacity-25 " + infoMessagePlain ++ " border border-blue-800 bg-blue-100 dark:border-sky-200 dark:bg-sky-800 dark:bg-opacity-25 " infoMessage : String diff --git a/website/site/content/docs/features/_index.md b/website/site/content/docs/features/_index.md index 23fa40d9..03db3604 100644 --- a/website/site/content/docs/features/_index.md +++ b/website/site/content/docs/features/_index.md @@ -19,6 +19,7 @@ description = "A list of features and limitations." into searchable PDF/A pdfs. - A powerful [query language](@/docs/query/_index.md) to find documents +- use [bookmarks](@/docs/webapp/bookmarks.md) to save more complex queries - Non-destructive: all your uploaded files are never modified and can always be downloaded untouched - Organize files using tags, folders, [Custom diff --git a/website/site/content/docs/jsonminiquery/_index.md b/website/site/content/docs/jsonminiquery/_index.md index b48b0db4..2669e8b6 100644 --- a/website/site/content/docs/jsonminiquery/_index.md +++ b/website/site/content/docs/jsonminiquery/_index.md @@ -140,8 +140,8 @@ list. When using `&`, results are only concatenated if all lists are not empty; otherwise the result is the empty list. This example filters all `count` equal to `6` and all `name` equal to -`max`. Since there are now `count`s with value `6`, the final result -is empty. +`max`. Since there are no `count`s with value `6`, the final result is +empty. ``` query: [count=6 & name=max] diff --git a/website/site/content/docs/webapp/bookmarks-01.png b/website/site/content/docs/webapp/bookmarks-01.png new file mode 100644 index 00000000..2aeadfcd Binary files /dev/null and b/website/site/content/docs/webapp/bookmarks-01.png differ diff --git a/website/site/content/docs/webapp/bookmarks-02.png b/website/site/content/docs/webapp/bookmarks-02.png new file mode 100644 index 00000000..46309d26 Binary files /dev/null and b/website/site/content/docs/webapp/bookmarks-02.png differ diff --git a/website/site/content/docs/webapp/bookmarks-03.png b/website/site/content/docs/webapp/bookmarks-03.png new file mode 100644 index 00000000..bc0f1999 Binary files /dev/null and b/website/site/content/docs/webapp/bookmarks-03.png differ diff --git a/website/site/content/docs/webapp/bookmarks-04.png b/website/site/content/docs/webapp/bookmarks-04.png new file mode 100644 index 00000000..535a582c Binary files /dev/null and b/website/site/content/docs/webapp/bookmarks-04.png differ diff --git a/website/site/content/docs/webapp/bookmarks.md b/website/site/content/docs/webapp/bookmarks.md new file mode 100644 index 00000000..e18c6b0c --- /dev/null +++ b/website/site/content/docs/webapp/bookmarks.md @@ -0,0 +1,54 @@ ++++ +title = "Bookmarks" +weight = 35 +[extra] +mktoc = true ++++ + +Bookmarks allow you to save queries under a name and refer to it from the search menu. + +## Creating bookmarks + +Bookmarks can be created from the search view. Apply some criteria to +select items and then click on the top left menu. + +{{ figure(file="bookmarks-02.png") }} + +This opens a small form right below the search bar where you can +adjust the query and enter the name. You can also decide whether this +bookmark is for all users or just for you. + +{{ figure(file="bookmarks-03.png") }} + +The other way is to go to *Manage Data* where you can edit and delete +existing bookmarks and also create new ones. + + +## Using bookmarks + +Once you save a bookmark, the search menu will display a new section +that shows you all your bookmarks as well as your shares. Clicking one +"enables" it, meaning the query is used in conjunction with other +criteria. + +