From 0f45e1b097deea920447b9321c51446e1129fd2d Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 28 Sep 2020 14:16:02 +0200 Subject: [PATCH 01/29] Add adr for how custom fields could work --- .../docs/dev/adr/0016_custom_fields.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 website/site/content/docs/dev/adr/0016_custom_fields.md diff --git a/website/site/content/docs/dev/adr/0016_custom_fields.md b/website/site/content/docs/dev/adr/0016_custom_fields.md new file mode 100644 index 00000000..f975945a --- /dev/null +++ b/website/site/content/docs/dev/adr/0016_custom_fields.md @@ -0,0 +1,159 @@ ++++ +title = "Custom Fields" +weight = 170 ++++ + +# Context and Problem Statement + +Users want to add custom metadata to items. For example, for invoices +fields like `invoice-number` or `total`/`amount` make sense. When +using a pagination stamp, every item gets a pagination number. + +This is currently not possible to realize in docspell. But it is an +essential part when organizing and archiving documents. It should be +supported. + + +# Considered Options + +## Requirements + +- Fields have simple types: There is a difference in presenting a + date, string or number. At least some simple types should be + distinguishable for the UI to make it more convenient to use. +- An item can have at most one value of a field: The typical example + is `invoice number` – it doesn't make sense to be able to specify + two invoice-numbers on an item. If still necessary, one can create + artificial fields like `invoice-number-1` and `invoice-number-2`. +- Fulltext Index: should custom field values be sent to the full-text + index? + - This is not required, imho. At least not for a start. +- Fields are stored per collective. When creating a new field, user + can select from existing ones to avoid creating same fields with + different names. +- Fields can be managed: Rename, change type, delete. Show fields that + don't have any value associated and could be deleted. + +## Ideas + +### Database + +Initial sketch: + +``` sql +CREATE TABLE custom_field ( + id varchar(244) not null primary key, + name varchar(100) not null, + cid varchar(254) not null, + ftype varchar(100) not null, + foreign key "cid" references collective(cid), + unique (cid, name) +); + +CREATE TABLE custom_field_item_value ( + id varchar(254) not null primary key, + item_id varchar(254) not null, + field varchar(254) not null, + field_value varchar(254), + foreign key item_id references item(id), + foreign key field references custom_field(id), + unique (item_id, field) -- only one value allowed per item +) +``` + +- field carries the type in the column `ftype`. type is an enum: + `text`, `numeric`, `date`, `money`, `bool` +- the type is just some constant, the database doesn't care and can't + enforce anything +- the field name is unique per collective +- a value to a field can only exist on an item +- only one value per item can be created for one field +- the values are represented as a string in the database +- the application is responsible for converting into a string +- date is a local date, the iso format is used (e.g. `2020-08-11`) +- Why not each type a separate column, like `value_str`, `value_date` + etc? + - making them different requires to fetch all fields first before + running a query, in order to know which columns to check + - usually the query would look like this: `my_field_1 == "test"`; + in order to know what column to check for `my_field_1`, a query + to fetch the field must be done first. Only then the type is + known and its clear what column to use for the value. This + complicates searching and increases query count. + - The value must be present (or converted) into the target type + - It's a lot simpler for the implementation to reduce every custom + field to a string value at the database. Type-specific queries + (like avg, sum etc) can still be done using sql `CAST` function. + +Changing Type: +- change the type on the `custom_field` table +- the string must be convertible to the new type, which must be + ensured by the application + +Adding more types: +- ammend the `ftype` enum with a new value and provide conversion + functions + +### REST Api + +- the known field types must be transferred to the ui +- the ui creates custom presentation for date, money etc + +Input 1: +- setting one value for a specific field. The server knows its type + and converts accordingly (e.g. string->date) +- json only knows number, strings and bools (and null). +- make a structure to allow to specify all json types: + ``` elm + { value_str: Maybe String + , value_num: Maybe Float + , value_bool: Maybe Bool + } + ``` +- con: setting all to `Nothing` is an error as well as using the wrong + field with some type (e.g. setting `value_str` for setting a + `numeric` field) +- con: very confusing – what to use for fields of type "date" or + "money"? +- client needs some parsing anyways to show errors + +Input 2: +- send one value as a string + ```elm + { value: String + } + ``` +- string must be in a specific format according to its type. server + may convert (like `12.4999` → `12.49`), or report an error +- client must create the correct string + + +Output: +- server sends field name, type and value per custom field. Return an + array of objects per item. + +Searching: +- UI knows all fields of a collective: user selects one in a dropdown + and specifies the value + + +# Decision Outcome + +- values are strings at the database +- values are strings when transported from/to server +- client must provide the correct formatted strings per type + - numeric: some decimal number + - money: decimal number + - text: no restrictions + - date: a local date as iso string, e.g. `2011-10-09` + - bool: either `"true"` or `"false"`, case insensitive + +## Initial Version + +- create the database structure and a REST api to work with custom + fields +- create a UI on item detail to add/set custom fields +- show custom fields on item detail +- create a page to manage fields: only rename and deletion +- extend the search for custom fields +- show custom fields in search results From 248ad04dd0645589528f834da5190f897029070e Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 28 Sep 2020 22:54:35 +0200 Subject: [PATCH 02/29] Prepare custom fields --- build.sbt | 2 + .../scala/docspell/backend/BackendApp.scala | 43 ++++++++-------- .../docspell/backend/ops/OCustomFields.scala | 25 +++++++++ .../docspell/common/CustomFieldType.scala | 51 +++++++++++++++++++ .../src/main/resources/docspell-openapi.yml | 50 ++++++++++++++++++ .../docspell/restserver/RestServer.scala | 3 +- .../restserver/routes/CustomFieldRoutes.scala | 35 +++++++++++++ .../postgresql/V1.13.0__custom_fields.sql | 20 ++++++++ .../docspell/store/impl/DoobieMeta.scala | 3 ++ .../docspell/store/records/RCustomField.scala | 44 ++++++++++++++++ .../store/records/RCustomFieldValue.scala | 42 +++++++++++++++ 11 files changed, 297 insertions(+), 21 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala create mode 100644 modules/common/src/main/scala/docspell/common/CustomFieldType.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql create mode 100644 modules/store/src/main/scala/docspell/store/records/RCustomField.scala create mode 100644 modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala diff --git a/build.sbt b/build.sbt index 32ed4d0b..88b9e48b 100644 --- a/build.sbt +++ b/build.sbt @@ -183,6 +183,8 @@ val openapiScalaSettings = Seq( ) case "glob" => field => field.copy(typeDef = TypeDef("Glob", Imports("docspell.common.Glob"))) + case "customfieldtype" => + field => field.copy(typeDef = TypeDef("CustomFieldType", Imports("docspell.common.CustomFieldType"))) })) ) diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index be76d45b..81328296 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -36,6 +36,7 @@ trait BackendApp[F[_]] { def joex: OJoex[F] def userTask: OUserTask[F] def folder: OFolder[F] + def customFields: OCustomFields[F] } object BackendApp { @@ -66,27 +67,29 @@ object BackendApp { fulltextImpl <- OFulltext(itemSearchImpl, ftsClient, store, queue, joexImpl) javaEmil = JavaMailEmil(blocker, Settings.defaultSettings.copy(debug = cfg.mailDebug)) - mailImpl <- OMail(store, javaEmil) - userTaskImpl <- OUserTask(utStore, queue, joexImpl) - folderImpl <- OFolder(store) + mailImpl <- OMail(store, javaEmil) + userTaskImpl <- OUserTask(utStore, queue, joexImpl) + folderImpl <- OFolder(store) + customFieldsImpl <- OCustomFields(store) } yield new BackendApp[F] { - val login: Login[F] = loginImpl - val signup: OSignup[F] = signupImpl - val collective: OCollective[F] = collImpl - val source = sourceImpl - val tag = tagImpl - val equipment = equipImpl - val organization = orgImpl - val upload = uploadImpl - val node = nodeImpl - val job = jobImpl - val item = itemImpl - val itemSearch = itemSearchImpl - val fulltext = fulltextImpl - val mail = mailImpl - val joex = joexImpl - val userTask = userTaskImpl - val folder = folderImpl + val login = loginImpl + val signup = signupImpl + val collective = collImpl + val source = sourceImpl + val tag = tagImpl + val equipment = equipImpl + val organization = orgImpl + val upload = uploadImpl + val node = nodeImpl + val job = jobImpl + val item = itemImpl + val itemSearch = itemSearchImpl + val fulltext = fulltextImpl + val mail = mailImpl + val joex = joexImpl + val userTask = userTaskImpl + val folder = folderImpl + val customFields = customFieldsImpl } def apply[F[_]: ConcurrentEffect: ContextShift]( diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala new file mode 100644 index 00000000..e8058b17 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala @@ -0,0 +1,25 @@ +package docspell.backend.ops + +import cats.effect.{Effect, Resource} + +import docspell.common._ +import docspell.store.Store +import docspell.store.records.RCustomField + +trait OCustomFields[F[_]] { + + def findAll(coll: Ident): F[Vector[RCustomField]] + +} + +object OCustomFields { + + def apply[F[_]: Effect]( + store: Store[F] + ): Resource[F, OCustomFields[F]] = + Resource.pure[F, OCustomFields[F]](new OCustomFields[F] { + + def findAll(coll: Ident): F[Vector[RCustomField]] = + store.transact(RCustomField.findAll(coll)) + }) +} diff --git a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala new file mode 100644 index 00000000..021bbe28 --- /dev/null +++ b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala @@ -0,0 +1,51 @@ +package docspell.common + +import io.circe._ + +sealed trait CustomFieldType { self: Product => + + final def name: String = + self.productPrefix.toLowerCase() + +} + +object CustomFieldType { + + case object Text extends CustomFieldType + + case object Numeric extends CustomFieldType + + case object Date extends CustomFieldType + + case object Bool extends CustomFieldType + + case object Money extends CustomFieldType + + def text: CustomFieldType = Text + def numeric: CustomFieldType = Numeric + def date: CustomFieldType = Date + def bool: CustomFieldType = Bool + def money: CustomFieldType = Money + + val all: List[CustomFieldType] = List(Text, Numeric, Date, Bool, Money) + + def fromString(str: String): Either[String, CustomFieldType] = + str.toLowerCase match { + case "text" => Right(text) + case "numeric" => Right(numeric) + case "date" => Right(date) + case "bool" => Right(bool) + case "money" => Right(money) + case _ => Left(s"Unknown custom field: $str") + } + + def unsafe(str: String): CustomFieldType = + fromString(str).fold(sys.error, identity) + + + implicit val jsonDecoder: Decoder[CustomFieldType] = + Decoder.decodeString.emap(fromString) + + implicit val jsonEncoder: Encoder[CustomFieldType] = + Encoder.encodeString.contramap(_.name) +} diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index c41a6877..8acefbec 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -3202,6 +3202,23 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/customfields: + get: + tags: [ Custom Fields ] + summary: Get all defined custom fields. + description: | + Get all custom fields defined for the current collective. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/CustomFieldList" + + components: schemas: ItemsAndRefs: @@ -3282,6 +3299,38 @@ components: format: date-time + CustomFieldList: + description: | + A list of known custom fields. + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/CustomField" + + CustomField: + description: | + A custom field definition. + required: + - id + - name + - ftype + - created + properties: + id: + type: string + format: ident + name: + type: string + ftype: + type: string + format: customfieldtype + created: + type: integer + format: date-time + JobPriority: description: | Transfer the priority of a job. @@ -4372,6 +4421,7 @@ components: type: array items: $ref: "#/components/schemas/SourceAndTags" + Source: description: | Data about a Source. A source defines the endpoint where diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 9dbba2b0..9c23e589 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -85,7 +85,8 @@ object RestServer { "usertask/scanmailbox" -> ScanMailboxRoutes(restApp.backend, token), "calevent/check" -> CalEventCheckRoutes(), "fts" -> FullTextIndexRoutes.secured(cfg, restApp.backend, token), - "folder" -> FolderRoutes(restApp.backend, token) + "folder" -> FolderRoutes(restApp.backend, token), + "customfield" -> CustomFieldRoutes(restApp.backend, token) ) def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala new file mode 100644 index 00000000..14313262 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala @@ -0,0 +1,35 @@ +package docspell.restserver.routes + +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.restapi.model._ +import docspell.restserver.http4s._ + +import org.http4s.HttpRoutes +//import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl +import docspell.store.records.RCustomField + +object CustomFieldRoutes { + + def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] with ResponseGenerator[F] {} + import dsl._ + + HttpRoutes.of { + case GET -> Root => + for { + fs <- backend.customFields.findAll(user.account.collective) + res <- Ok(CustomFieldList(fs.map(convertField).toList)) + } yield res + } + } + + + private def convertField(f: RCustomField): CustomField = + CustomField(f.id, f.name, f.ftype, f.created) +} diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql new file mode 100644 index 00000000..2614e9e5 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql @@ -0,0 +1,20 @@ +CREATE TABLE "custom_field" ( + "id" varchar(254) not null primary key, + "name" varchar(254) not null, + "cid" varchar(254) not null, + "ftype" varchar(100) not null, + "created" timestamp not null, + foreign key ("cid") references "collective"("cid"), + unique ("cid", "name") +); + +CREATE TABLE "custom_field_value" ( + "id" varchar(254) not null primary key, + "item_id" varchar(254) not null, + "field" varchar(254) not null, + "value_text" varchar(300), + "value_numeric" numeric, + foreign key ("item_id") references "item"("itemid"), + foreign key ("field") references "custom_field"("id"), + unique ("item_id", "field") +) diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index 0e2ed027..cbe3ab0f 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -94,6 +94,9 @@ trait DoobieMeta extends EmilDoobieMeta { implicit val metaGlob: Meta[Glob] = Meta[String].timap(Glob.apply)(_.asString) + + implicit val metaCustomFieldType: Meta[CustomFieldType] = + Meta[String].timap(CustomFieldType.unsafe)(_.name) } object DoobieMeta extends DoobieMeta { diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomField.scala b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala new file mode 100644 index 00000000..5b4492e2 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala @@ -0,0 +1,44 @@ +package docspell.store.records + +import docspell.common._ +import docspell.store.impl.Column +import docspell.store.impl.Implicits._ + +import doobie._ +import doobie.implicits._ + +case class RCustomField( + id: Ident, + name: String, + cid: Ident, + ftype: CustomFieldType, + created: Timestamp +) + +object RCustomField { + + val table = fr"custom_field" + + object Columns { + + val id = Column("id") + val name = Column("name") + val cid = Column("cid") + val ftype = Column("ftype") + val created = Column("created") + + val all = List(id, name, cid, ftype, created) + } + + def insert(value: RCustomField): ConnectionIO[Int] = { + val sql = insertRow( + table, + Columns.all, + fr"${value.id},${value.name},${value.cid},${value.ftype},${value.created}" + ) + sql.update.run + } + + def findAll(coll: Ident): ConnectionIO[Vector[RCustomField]] = + selectSimple(Columns.all, table, Columns.cid.is(coll)).query[RCustomField].to[Vector] +} diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala new file mode 100644 index 00000000..1bc90d66 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala @@ -0,0 +1,42 @@ +package docspell.store.records + +import docspell.common._ +import docspell.store.impl.Column +import docspell.store.impl.Implicits._ + +import doobie._ +import doobie.implicits._ + +case class RCustomFieldValue( + id: Ident, + itemId: Ident, + field: Ident, + valueText: Option[String], + valueNumeric: Option[BigDecimal] +) + +object RCustomFieldValue { + + val table = fr"custom_field_value" + + object Columns { + + val id = Column("id") + val itemId = Column("item_id") + val field = Column("field") + val valueText = Column("value_text") + val valueNumeric = Column("value_numeric") + + val all = List(id, itemId, field, valueText, valueNumeric) + } + + def insert(value: RCustomFieldValue): ConnectionIO[Int] = { + val sql = insertRow( + table, + Columns.all, + fr"${value.id},${value.itemId},${value.field},${value.valueText},${value.valueNumeric}" + ) + sql.update.run + } + +} From 62313ab03ad26531b9397c06c1a56660c74f05b0 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 16 Nov 2020 12:39:49 +0100 Subject: [PATCH 03/29] Add and change custom fields --- .../docspell/backend/ops/OCustomFields.scala | 63 +++++++++++- .../docspell/common/CustomFieldType.scala | 1 - .../src/main/resources/docspell-openapi.yml | 98 +++++++++++++++++++ .../restserver/routes/CustomFieldRoutes.scala | 82 ++++++++++++++-- .../postgresql/V1.13.0__custom_fields.sql | 1 + .../docspell/store/impl/DoobieSyntax.scala | 8 ++ .../docspell/store/queries/QCustomField.scala | 64 ++++++++++++ .../scala/docspell/store/queries/QItem.scala | 19 +--- .../store/queries/QueryWildcard.scala | 17 ++++ .../docspell/store/records/RCustomField.scala | 36 ++++++- .../store/records/RCustomFieldValue.scala | 8 ++ .../store/queries/QueryWildcardTest.scala | 21 ++++ 12 files changed, 388 insertions(+), 30 deletions(-) create mode 100644 modules/store/src/main/scala/docspell/store/queries/QCustomField.scala create mode 100644 modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala create mode 100644 modules/store/src/test/scala/docspell/store/queries/QueryWildcardTest.scala diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala index e8058b17..70bd0f49 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala @@ -1,25 +1,80 @@ package docspell.backend.ops -import cats.effect.{Effect, Resource} +import cats.data.OptionT +import cats.effect._ +import docspell.backend.ops.OCustomFields.CustomFieldData +import docspell.backend.ops.OCustomFields.NewCustomField import docspell.common._ +import docspell.store.AddResult import docspell.store.Store +import docspell.store.UpdateResult +import docspell.store.queries.QCustomField import docspell.store.records.RCustomField +import docspell.store.records.RCustomFieldValue + +import doobie._ trait OCustomFields[F[_]] { - def findAll(coll: Ident): F[Vector[RCustomField]] + def findAll(coll: Ident, nameQuery: Option[String]): F[Vector[CustomFieldData]] + def findById(coll: Ident, fieldId: Ident): F[Option[CustomFieldData]] + + def create(field: NewCustomField): F[AddResult] + + def change(field: RCustomField): F[UpdateResult] + + def delete(coll: Ident, fieldIdOrName: Ident): F[UpdateResult] } object OCustomFields { + type CustomFieldData = QCustomField.CustomFieldData + val CustomFieldData = QCustomField.CustomFieldData + + case class NewCustomField( + name: Ident, + label: Option[String], + ftype: CustomFieldType, + cid: Ident + ) + def apply[F[_]: Effect]( store: Store[F] ): Resource[F, OCustomFields[F]] = Resource.pure[F, OCustomFields[F]](new OCustomFields[F] { - def findAll(coll: Ident): F[Vector[RCustomField]] = - store.transact(RCustomField.findAll(coll)) + def findAll(coll: Ident, nameQuery: Option[String]): F[Vector[CustomFieldData]] = + store.transact(QCustomField.findAllLike(coll, nameQuery)) + + def findById(coll: Ident, field: Ident): F[Option[CustomFieldData]] = + store.transact(QCustomField.findById(field, coll)) + + def create(field: NewCustomField): F[AddResult] = { + val exists = RCustomField.exists(field.name, field.cid) + val insert = for { + id <- Ident.randomId[ConnectionIO] + now <- Timestamp.current[ConnectionIO] + rec = RCustomField(id, field.name, field.label, field.cid, field.ftype, now) + n <- RCustomField.insert(rec) + } yield n + + store.add(insert, exists) + } + + def change(field: RCustomField): F[UpdateResult] = + UpdateResult.fromUpdate(store.transact(RCustomField.update(field))) + + def delete(coll: Ident, fieldIdOrName: Ident): F[UpdateResult] = { + val update = + for { + field <- OptionT(RCustomField.findByIdOrName(fieldIdOrName, coll)) + n <- OptionT.liftF(RCustomFieldValue.deleteByField(field.id)) + k <- OptionT.liftF(RCustomField.deleteById(field.id, coll)) + } yield n + k + + UpdateResult.fromUpdate(store.transact(update.getOrElse(0))) + } }) } diff --git a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala index 021bbe28..9ff08bbf 100644 --- a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala +++ b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala @@ -42,7 +42,6 @@ object CustomFieldType { def unsafe(str: String): CustomFieldType = fromString(str).fold(sys.error, identity) - implicit val jsonDecoder: Decoder[CustomFieldType] = Decoder.decodeString.emap(fromString) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 8acefbec..ea6addb8 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -3210,6 +3210,8 @@ paths: Get all custom fields defined for the current collective. security: - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/q" responses: 200: description: Ok @@ -3217,6 +3219,79 @@ paths: application/json: schema: $ref: "#/components/schemas/CustomFieldList" + post: + tags: [ Custom Fields ] + summary: Create a new custom field + description: | + Creates a new custom field. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/NewCustomField" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/customfields/{id}: + get: + tags: [ Custom Fields ] + summary: Get details about a custom field. + description: | + Returns the details about a custom field. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/CustomField" + put: + tags: [ Custom Fields ] + summary: Change a custom field + description: | + Change properties of a custom field. + + Changing the label has no further impliciations, since it is + only used for displaying. The name and type on the other hand + have consequences: name must be unique and the type determines + how the value is stored internally. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/NewCustomField" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + delete: + tags: [ Custom Fields ] + summary: Deletes a custom field. + description: | + Deletes the custom field and all its relations. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" components: @@ -3310,6 +3385,22 @@ components: items: $ref: "#/components/schemas/CustomField" + NewCustomField: + description: | + Data for creating a custom field. + required: + - name + - ftype + properties: + name: + type: string + format: ident + label: + type: string + ftype: + type: string + format: customfieldtype + CustomField: description: | A custom field definition. @@ -3317,6 +3408,7 @@ components: - id - name - ftype + - usages - created properties: id: @@ -3324,9 +3416,15 @@ components: format: ident name: type: string + format: ident + label: + type: string ftype: type: string format: customfieldtype + usages: + type: integer + format: int32 created: type: integer format: date-time diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala index 14313262..ceab2aea 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala @@ -1,18 +1,25 @@ package docspell.restserver.routes +import cats.data.OptionT import cats.effect._ import cats.implicits._ import docspell.backend.BackendApp import docspell.backend.auth.AuthToken +import docspell.backend.ops.OCustomFields +import docspell.backend.ops.OCustomFields.CustomFieldData +import docspell.common._ import docspell.restapi.model._ +import docspell.restserver.conv.Conversions import docspell.restserver.http4s._ +import docspell.store.AddResult +import docspell.store.UpdateResult +import docspell.store.records.RCustomField import org.http4s.HttpRoutes -//import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe.CirceEntityEncoder._ import org.http4s.dsl.Http4sDsl -import docspell.store.records.RCustomField object CustomFieldRoutes { @@ -21,15 +28,78 @@ object CustomFieldRoutes { import dsl._ HttpRoutes.of { - case GET -> Root => + case GET -> Root :? QueryParam.QueryOpt(param) => for { - fs <- backend.customFields.findAll(user.account.collective) + fs <- backend.customFields.findAll(user.account.collective, param.map(_.q)) res <- Ok(CustomFieldList(fs.map(convertField).toList)) } yield res + + case req @ POST -> Root => + for { + data <- req.as[NewCustomField] + res <- backend.customFields.create(convertNewField(user, data)) + resp <- Ok(convertResult(res)) + } yield resp + + case GET -> Root / Ident(id) => + (for { + field <- OptionT(backend.customFields.findById(user.account.collective, id)) + res <- OptionT.liftF(Ok(convertField(field))) + } yield res).getOrElseF(NotFound(BasicResult(false, "Not found"))) + + case req @ PUT -> Root / Ident(id) => + for { + data <- req.as[NewCustomField] + res <- backend.customFields.change(convertChangeField(id, user, data)) + resp <- Ok(convertResult(res)) + } yield resp + + case DELETE -> Root / Ident(id) => + for { + res <- backend.customFields.delete(id, user.account.collective) + resp <- Ok(convertResult(res)) + } yield resp } } + private def convertResult(r: AddResult): BasicResult = + Conversions.basicResult(r, "New field created.") - private def convertField(f: RCustomField): CustomField = - CustomField(f.id, f.name, f.ftype, f.created) + private def convertResult(r: UpdateResult): BasicResult = + Conversions.basicResult(r, "Field updated.") + + private def convertChangeField( + id: Ident, + user: AuthToken, + in: NewCustomField + ): RCustomField = + RCustomField( + id, + in.name, + in.label, + user.account.collective, + in.ftype, + Timestamp.Epoch + ) + + private def convertNewField( + user: AuthToken, + in: NewCustomField + ): OCustomFields.NewCustomField = + OCustomFields.NewCustomField( + in.name, + in.label, + in.ftype, + user.account.collective + ) + + private def convertField(f: CustomFieldData): CustomField = + CustomField( + f.field.id, + f.field.name, + f.field.label, + f.field.ftype, + f.usageCount, + f.field.created + ) } diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql index 2614e9e5..4a18bcaa 100644 --- a/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql @@ -1,6 +1,7 @@ CREATE TABLE "custom_field" ( "id" varchar(254) not null primary key, "name" varchar(254) not null, + "label" varchar(254), "cid" varchar(254) not null, "ftype" varchar(100) not null, "created" timestamp not null, diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieSyntax.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieSyntax.scala index 310e1eec..b4ac5b7f 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieSyntax.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieSyntax.scala @@ -1,5 +1,7 @@ package docspell.store.impl +import cats.data.NonEmptyList + import docspell.common.Timestamp import doobie._ @@ -7,6 +9,12 @@ import doobie.implicits._ trait DoobieSyntax { + def groupBy(c0: Column, cs: Column*): Fragment = + groupBy(NonEmptyList.of(c0, cs: _*)) + + def groupBy(cs: NonEmptyList[Column]): Fragment = + fr" GROUP BY (" ++ commas(cs.toList.map(_.f)) ++ fr")" + def coalesce(f0: Fragment, fs: Fragment*): Fragment = sql" coalesce(" ++ commas(f0 :: fs.toList) ++ sql") " diff --git a/modules/store/src/main/scala/docspell/store/queries/QCustomField.scala b/modules/store/src/main/scala/docspell/store/queries/QCustomField.scala new file mode 100644 index 00000000..92cc0b1f --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QCustomField.scala @@ -0,0 +1,64 @@ +package docspell.store.queries + +import cats.data.NonEmptyList +import cats.implicits._ + +import docspell.common._ +import docspell.store.impl.Column +import docspell.store.impl.Implicits._ +import docspell.store.records._ + +import doobie._ +import doobie.implicits._ + +object QCustomField { + + case class CustomFieldData(field: RCustomField, usageCount: Int) + + def findAllLike( + coll: Ident, + nameQuery: Option[String] + ): ConnectionIO[Vector[CustomFieldData]] = + findFragment(coll, nameQuery, None).query[CustomFieldData].to[Vector] + + def findById(field: Ident, collective: Ident): ConnectionIO[Option[CustomFieldData]] = + findFragment(collective, None, field.some).query[CustomFieldData].option + + private def findFragment( + coll: Ident, + nameQuery: Option[String], + fieldId: Option[Ident] + ): Fragment = { + val fId = RCustomField.Columns.id.prefix("f") + val fColl = RCustomField.Columns.cid.prefix("f") + val fName = RCustomField.Columns.name.prefix("f") + val fLabel = RCustomField.Columns.label.prefix("f") + val vField = RCustomFieldValue.Columns.field.prefix("v") + + val join = RCustomField.table ++ fr"f LEFT OUTER JOIN" ++ + RCustomFieldValue.table ++ fr"v ON" ++ fId.is(vField) + + val cols = RCustomField.Columns.all.map(_.prefix("f")) :+ Column("COUNT(v.id)") + + val nameCond = nameQuery.map(QueryWildcard.apply) match { + case Some(q) => + or(fName.lowerLike(q), fLabel.lowerLike(q)) + case None => + Fragment.empty + } + val fieldCond = fieldId match { + case Some(id) => + fId.is(id) + case None => + Fragment.empty + } + val cond = and(fColl.is(coll), nameCond, fieldCond) + + val group = NonEmptyList.fromList(RCustomField.Columns.all) match { + case Some(nel) => groupBy(nel.map(_.prefix("f"))) + case None => Fragment.empty + } + + selectSimple(cols, join, cond) ++ group + } +} diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index 7f768f93..8f771827 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -342,8 +342,8 @@ object QItem { TagItemName.itemsWithTagOrCategory(q.tagsExclude, q.tagCategoryExcl) val iFolder = IC.folder.prefix("i") - val name = q.name.map(_.toLowerCase).map(queryWildcard) - val allNames = q.allNames.map(_.toLowerCase).map(queryWildcard) + val name = q.name.map(_.toLowerCase).map(QueryWildcard.apply) + val allNames = q.allNames.map(_.toLowerCase).map(QueryWildcard.apply) val cond = and( IC.cid.prefix("i").is(q.account.collective), IC.state.prefix("i").isOneOf(q.states), @@ -516,8 +516,9 @@ object QItem { rn <- QAttachment.deleteItemAttachments(store)(itemId, collective) tn <- store.transact(RTagItem.deleteItemTags(itemId)) mn <- store.transact(RSentMail.deleteByItem(itemId)) + cf <- store.transact(RCustomFieldValue.deleteByItem(itemId)) n <- store.transact(RItem.deleteByIdAndCollective(itemId, collective)) - } yield tn + rn + n + mn + } yield tn + rn + n + mn + cf private def findByFileIdsQuery( fileMetaIds: NonEmptyList[Ident], @@ -618,18 +619,6 @@ object QItem { .to[Vector] } - private def queryWildcard(value: String): String = { - def prefix(n: String) = - if (n.startsWith("*")) s"%${n.substring(1)}" - else n - - def suffix(n: String) = - if (n.endsWith("*")) s"${n.dropRight(1)}%" - else n - - prefix(suffix(value)) - } - final case class NameAndNotes( id: Ident, collective: Ident, diff --git a/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala b/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala new file mode 100644 index 00000000..b11df4ec --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala @@ -0,0 +1,17 @@ +package docspell.store.queries + +object QueryWildcard { + + def apply(value: String): String = { + def prefix(n: String) = + if (n.startsWith("*")) s"%${n.substring(1)}" + else n + + def suffix(n: String) = + if (n.endsWith("*")) s"${n.dropRight(1)}%" + else n + + prefix(suffix(value)) + } + +} diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomField.scala b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala index 5b4492e2..497506e7 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCustomField.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala @@ -9,7 +9,8 @@ import doobie.implicits._ case class RCustomField( id: Ident, - name: String, + name: Ident, + label: Option[String], cid: Ident, ftype: CustomFieldType, created: Timestamp @@ -23,22 +24,49 @@ object RCustomField { val id = Column("id") val name = Column("name") + val label = Column("label") val cid = Column("cid") val ftype = Column("ftype") val created = Column("created") - val all = List(id, name, cid, ftype, created) + val all = List(id, name, label, cid, ftype, created) } + import Columns._ def insert(value: RCustomField): ConnectionIO[Int] = { val sql = insertRow( table, Columns.all, - fr"${value.id},${value.name},${value.cid},${value.ftype},${value.created}" + fr"${value.id},${value.name},${value.label},${value.cid},${value.ftype},${value.created}" ) sql.update.run } + def exists(fname: Ident, coll: Ident): ConnectionIO[Boolean] = + ??? + + def findById(fid: Ident, coll: Ident): ConnectionIO[Option[RCustomField]] = + selectSimple(all, table, and(id.is(fid), cid.is(coll))).query[RCustomField].option + + def findByIdOrName(idOrName: Ident, coll: Ident): ConnectionIO[Option[RCustomField]] = + selectSimple(all, table, and(cid.is(coll), or(id.is(idOrName), name.is(idOrName)))) + .query[RCustomField] + .option + + def deleteById(fid: Ident, coll: Ident): ConnectionIO[Int] = + deleteFrom(table, and(id.is(fid), cid.is(coll))).update.run + def findAll(coll: Ident): ConnectionIO[Vector[RCustomField]] = - selectSimple(Columns.all, table, Columns.cid.is(coll)).query[RCustomField].to[Vector] + selectSimple(all, table, cid.is(coll)).query[RCustomField].to[Vector] + + def update(value: RCustomField): ConnectionIO[Int] = + updateRow( + table, + and(id.is(value.id), cid.is(value.cid)), + commas( + name.setTo(value.name), + label.setTo(value.label), + ftype.setTo(value.ftype) + ) + ).update.run } diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala index 1bc90d66..85c7f58c 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala @@ -39,4 +39,12 @@ object RCustomFieldValue { sql.update.run } + def countField(fieldId: Ident): ConnectionIO[Int] = + selectCount(Columns.id, table, Columns.field.is(fieldId)).query[Int].unique + + def deleteByField(fieldId: Ident): ConnectionIO[Int] = + deleteFrom(table, Columns.field.is(fieldId)).update.run + + def deleteByItem(item: Ident): ConnectionIO[Int] = + deleteFrom(table, Columns.itemId.is(item)).update.run } diff --git a/modules/store/src/test/scala/docspell/store/queries/QueryWildcardTest.scala b/modules/store/src/test/scala/docspell/store/queries/QueryWildcardTest.scala new file mode 100644 index 00000000..688a4bc6 --- /dev/null +++ b/modules/store/src/test/scala/docspell/store/queries/QueryWildcardTest.scala @@ -0,0 +1,21 @@ +package docspell.store.queries + +import minitest._ + +object QueryWildcardTest extends SimpleTestSuite { + + test("replace prefix") { + assertEquals("%name", QueryWildcard("*name")) + assertEquals("%some more", QueryWildcard("*some more")) + } + + test("replace suffix") { + assertEquals("name%", QueryWildcard("name*")) + assertEquals("some other name%", QueryWildcard("some other name*")) + } + + test("replace both sides") { + assertEquals("%name%", QueryWildcard("*name*")) + assertEquals("%some other name%", QueryWildcard("*some other name*")) + } +} From 93295d63a5d0011c41976de3767928f367f35ea3 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 16 Nov 2020 22:06:48 +0100 Subject: [PATCH 04/29] Change custom field values for a single item --- .../docspell/backend/ops/OCustomFields.scala | 77 +++++++++++++++++++ .../docspell/common/CustomFieldType.scala | 68 ++++++++++++++-- .../src/main/resources/docspell-openapi.yml | 59 ++++++++++++++ .../restserver/conv/Conversions.scala | 13 ++++ .../restserver/routes/ItemRoutes.scala | 19 +++++ .../postgresql/V1.13.0__custom_fields.sql | 3 +- .../docspell/store/records/RCustomField.scala | 19 ++++- .../store/records/RCustomFieldValue.scala | 30 +++++--- .../scala/docspell/store/records/RItem.scala | 3 + 9 files changed, 274 insertions(+), 17 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala index 70bd0f49..4792a4d9 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala @@ -1,10 +1,15 @@ package docspell.backend.ops +import cats.data.EitherT import cats.data.OptionT import cats.effect._ +import cats.implicits._ import docspell.backend.ops.OCustomFields.CustomFieldData import docspell.backend.ops.OCustomFields.NewCustomField +import docspell.backend.ops.OCustomFields.RemoveValue +import docspell.backend.ops.OCustomFields.SetValue +import docspell.backend.ops.OCustomFields.SetValueResult import docspell.common._ import docspell.store.AddResult import docspell.store.Store @@ -12,20 +17,32 @@ import docspell.store.UpdateResult import docspell.store.queries.QCustomField import docspell.store.records.RCustomField import docspell.store.records.RCustomFieldValue +import docspell.store.records.RItem import doobie._ trait OCustomFields[F[_]] { + /** Find all fields using an optional query on the name and label */ def findAll(coll: Ident, nameQuery: Option[String]): F[Vector[CustomFieldData]] + /** Find one field by its id */ def findById(coll: Ident, fieldId: Ident): F[Option[CustomFieldData]] + /** Create a new non-existing field. */ def create(field: NewCustomField): F[AddResult] + /** Change an existing field. */ def change(field: RCustomField): F[UpdateResult] + /** Deletes the field by name or id. */ def delete(coll: Ident, fieldIdOrName: Ident): F[UpdateResult] + + /** Sets a value given a field an an item. Existing values are overwritten. */ + def setValue(item: Ident, value: SetValue): F[SetValueResult] + + /** Deletes a value for a given field an item. */ + def deleteValue(in: RemoveValue): F[UpdateResult] } object OCustomFields { @@ -40,6 +57,32 @@ object OCustomFields { cid: Ident ) + case class SetValue( + field: Ident, + value: String, + collective: Ident + ) + + sealed trait SetValueResult + object SetValueResult { + + case object ItemNotFound extends SetValueResult + case object FieldNotFound extends SetValueResult + case class ValueInvalid(msg: String) extends SetValueResult + case object Success extends SetValueResult + + def itemNotFound: SetValueResult = ItemNotFound + def fieldNotFound: SetValueResult = FieldNotFound + def valueInvalid(msg: String): SetValueResult = ValueInvalid(msg) + def success: SetValueResult = Success + } + + case class RemoveValue( + field: Ident, + item: Ident, + collective: Ident + ) + def apply[F[_]: Effect]( store: Store[F] ): Resource[F, OCustomFields[F]] = @@ -76,5 +119,39 @@ object OCustomFields { UpdateResult.fromUpdate(store.transact(update.getOrElse(0))) } + + def setValue(item: Ident, value: SetValue): F[SetValueResult] = + (for { + field <- EitherT.fromOptionF( + store.transact(RCustomField.findByIdOrName(value.field, value.collective)), + SetValueResult.fieldNotFound + ) + _ <- EitherT( + store + .transact(RItem.existsByIdAndCollective(item, value.collective)) + .map(flag => if (flag) Right(()) else Left(SetValueResult.itemNotFound)) + ) + fval <- EitherT.fromEither[F]( + field.ftype + .parseValue(value.value) + .leftMap(SetValueResult.valueInvalid) + .map(field.ftype.valueString) + ) + nu <- EitherT.right[SetValueResult]( + store.transact(RCustomField.setValue(field, item, fval)) + ) + } yield nu).fold(identity, _ => SetValueResult.success) + + def deleteValue(in: RemoveValue): F[UpdateResult] = { + val update = + for { + field <- OptionT(RCustomField.findByIdOrName(in.field, in.collective)) + n <- OptionT.liftF(RCustomFieldValue.deleteValue(field.id, in.item)) + } yield n + + UpdateResult.fromUpdate(store.transact(update.getOrElse(0))) + } + }) + } diff --git a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala index 9ff08bbf..1505233f 100644 --- a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala +++ b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala @@ -1,25 +1,83 @@ package docspell.common +import java.time.LocalDate + +import cats.implicits._ + import io.circe._ sealed trait CustomFieldType { self: Product => + type ValueType + final def name: String = self.productPrefix.toLowerCase() + def valueString(value: ValueType): String + + def parseValue(value: String): Either[String, ValueType] } object CustomFieldType { - case object Text extends CustomFieldType + case object Text extends CustomFieldType { - case object Numeric extends CustomFieldType + type ValueType = String - case object Date extends CustomFieldType + def valueString(value: String): String = + value - case object Bool extends CustomFieldType + def parseValue(value: String): Either[String, String] = + Right(value) + } - case object Money extends CustomFieldType + case object Numeric extends CustomFieldType { + type ValueType = BigDecimal + + def valueString(value: BigDecimal): String = + value.toString + + def parseValue(value: String): Either[String, BigDecimal] = + Either + .catchNonFatal(BigDecimal.exact(value)) + .leftMap(_ => s"Could not parse decimal value from: $value") + } + + case object Date extends CustomFieldType { + type ValueType = LocalDate + + def valueString(value: LocalDate): String = + value.toString + + def parseValue(value: String): Either[String, LocalDate] = + Either + .catchNonFatal(LocalDate.parse(value)) + .leftMap(_.getMessage) + } + + case object Bool extends CustomFieldType { + type ValueType = Boolean + + def valueString(value: Boolean): String = + value.toString + + def parseValue(value: String): Either[String, Boolean] = + Right(value.equalsIgnoreCase("true")) + + } + + case object Money extends CustomFieldType { + type ValueType = BigDecimal + + def valueString(value: BigDecimal): String = + Numeric.valueString(value) + + def parseValue(value: String): Either[String, BigDecimal] = + Numeric.parseValue(value).map(round) + + def round(v: BigDecimal): BigDecimal = + v.setScale(2, BigDecimal.RoundingMode.HALF_EVEN) + } def text: CustomFieldType = Text def numeric: CustomFieldType = Numeric diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index ea6addb8..c1a849b1 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1914,6 +1914,50 @@ paths: type: string format: binary + /sec/item/{id}/customfield: + put: + tags: [ Item ] + summary: Set the value of a custom field. + description: | + Sets the value for a custom field to this item. If a value + already exists, it is overwritten. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CustomFieldValue" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/item/{itemId}/customfield/{id}: + delete: + tags: [ Item ] + summary: Removes the value for a custom field + description: | + Removes the value for the given custom field. The `id` may be + the id of a custom field or its name. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/itemId" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/item/{itemId}/reprocess: post: tags: [ Item ] @@ -3240,6 +3284,8 @@ paths: $ref: "#/components/schemas/BasicResult" /sec/customfields/{id}: + parameters: + - $ref: "#/components/parameters/id" get: tags: [ Custom Fields ] summary: Get details about a custom field. @@ -3385,6 +3431,19 @@ components: items: $ref: "#/components/schemas/CustomField" + CustomFieldValue: + description: | + Data structure to update the value of a custom field. + required: + - field + - value + properties: + field: + type: string + format: ident + value: + type: string + NewCustomField: description: | Data for creating a custom field. diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 8c889f6b..2762b3ae 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -7,6 +7,7 @@ import cats.implicits._ import fs2.Stream import docspell.backend.ops.OCollective.{InsightData, PassChangeResult} +import docspell.backend.ops.OCustomFields.SetValueResult import docspell.backend.ops.OJob.JobCancelResult import docspell.backend.ops.OUpload.{UploadData, UploadMeta, UploadResult} import docspell.backend.ops._ @@ -589,6 +590,18 @@ trait Conversions { // basic result + def basicResult(r: SetValueResult): BasicResult = + r match { + case SetValueResult.FieldNotFound => + BasicResult(false, "The given field is unknown") + case SetValueResult.ItemNotFound => + BasicResult(false, "The given item is unknown") + case SetValueResult.ValueInvalid(msg) => + BasicResult(false, s"The value is invalid: $msg") + case SetValueResult.Success => + BasicResult(true, "Custom field value set successfully.") + } + def basicResult(cr: JobCancelResult): BasicResult = cr match { case JobCancelResult.JobNotFound => BasicResult(false, "Job not found") diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala index ba0c8c08..8f25eb08 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -6,6 +6,7 @@ import cats.implicits._ import docspell.backend.BackendApp import docspell.backend.auth.AuthToken +import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue} import docspell.backend.ops.OFulltext import docspell.backend.ops.OItemSearch.Batch import docspell.common.syntax.all._ @@ -358,6 +359,24 @@ object ItemRoutes { resp <- Ok(Conversions.basicResult(res, "Re-process task submitted.")) } yield resp + case req @ PUT -> Root / Ident(id) / "customfield" => + for { + data <- req.as[CustomFieldValue] + res <- backend.customFields.setValue( + id, + SetValue(data.field, data.value, user.account.collective) + ) + resp <- Ok(Conversions.basicResult(res)) + } yield resp + + case DELETE -> Root / Ident(id) / "customfield" / Ident(fieldId) => + for { + res <- backend.customFields.deleteValue( + RemoveValue(fieldId, id, user.account.collective) + ) + resp <- Ok(Conversions.basicResult(res, "Custom field value removed.")) + } yield resp + case DELETE -> Root / Ident(id) => for { n <- backend.item.deleteItem(id, user.account.collective) diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql index 4a18bcaa..708989bf 100644 --- a/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql @@ -13,8 +13,7 @@ CREATE TABLE "custom_field_value" ( "id" varchar(254) not null primary key, "item_id" varchar(254) not null, "field" varchar(254) not null, - "value_text" varchar(300), - "value_numeric" numeric, + "field_value" varchar(300) not null, foreign key ("item_id") references "item"("itemid"), foreign key ("field") references "custom_field"("id"), unique ("item_id", "field") diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomField.scala b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala index 497506e7..f74c7cc3 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCustomField.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala @@ -1,5 +1,7 @@ package docspell.store.records +import cats.implicits._ + import docspell.common._ import docspell.store.impl.Column import docspell.store.impl.Implicits._ @@ -43,7 +45,7 @@ object RCustomField { } def exists(fname: Ident, coll: Ident): ConnectionIO[Boolean] = - ??? + selectCount(id, table, and(name.is(fname), cid.is(coll))).query[Int].unique.map(_ > 0) def findById(fid: Ident, coll: Ident): ConnectionIO[Option[RCustomField]] = selectSimple(all, table, and(id.is(fid), cid.is(coll))).query[RCustomField].option @@ -69,4 +71,19 @@ object RCustomField { ftype.setTo(value.ftype) ) ).update.run + + def setValue(f: RCustomField, item: Ident, fval: String): ConnectionIO[Int] = + for { + n <- RCustomFieldValue.updateValue(f.id, item, fval) + k <- + if (n == 0) + Ident + .randomId[ConnectionIO] + .flatMap(nId => + RCustomFieldValue + .insert(RCustomFieldValue(nId, item, f.id, fval)) + ) + else 0.pure[ConnectionIO] + } yield n + k + } diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala index 85c7f58c..7789143c 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala @@ -11,8 +11,7 @@ case class RCustomFieldValue( id: Ident, itemId: Ident, field: Ident, - valueText: Option[String], - valueNumeric: Option[BigDecimal] + value: String ) object RCustomFieldValue { @@ -21,24 +20,34 @@ object RCustomFieldValue { object Columns { - val id = Column("id") - val itemId = Column("item_id") - val field = Column("field") - val valueText = Column("value_text") - val valueNumeric = Column("value_numeric") + val id = Column("id") + val itemId = Column("item_id") + val field = Column("field") + val value = Column("field_value") - val all = List(id, itemId, field, valueText, valueNumeric) + val all = List(id, itemId, field, value) } def insert(value: RCustomFieldValue): ConnectionIO[Int] = { val sql = insertRow( table, Columns.all, - fr"${value.id},${value.itemId},${value.field},${value.valueText},${value.valueNumeric}" + fr"${value.id},${value.itemId},${value.field},${value.value}" ) sql.update.run } + def updateValue( + fieldId: Ident, + item: Ident, + value: String + ): ConnectionIO[Int] = + updateRow( + table, + and(Columns.itemId.is(item), Columns.field.is(fieldId)), + Columns.value.setTo(value) + ).update.run + def countField(fieldId: Ident): ConnectionIO[Int] = selectCount(Columns.id, table, Columns.field.is(fieldId)).query[Int].unique @@ -47,4 +56,7 @@ object RCustomFieldValue { def deleteByItem(item: Ident): ConnectionIO[Int] = deleteFrom(table, Columns.itemId.is(item)).update.run + + def deleteValue(fieldId: Ident, item: Ident): ConnectionIO[Int] = + deleteFrom(table, and(Columns.id.is(fieldId), Columns.itemId.is(item))).update.run } diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index a023e136..89f78e2a 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -323,6 +323,9 @@ object RItem { def existsById(itemId: Ident): ConnectionIO[Boolean] = selectCount(id, table, id.is(itemId)).query[Int].unique.map(_ > 0) + def existsByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Boolean] = + selectCount(id, table, and(id.is(itemId), cid.is(coll))).query[Int].unique.map(_ > 0) + def findByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Option[RItem]] = selectSimple(all, table, and(id.is(itemId), cid.is(coll))).query[RItem].option From 8d35d100d6265706e956cf1cece90eb3efe4564e Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 16 Nov 2020 23:27:26 +0100 Subject: [PATCH 05/29] Change custom fields for multiple items --- .../docspell/backend/ops/OCustomFields.scala | 31 ++++++-- .../src/main/resources/docspell-openapi.yml | 77 ++++++++++++++++++- .../restserver/routes/CustomFieldRoutes.scala | 2 +- .../restserver/routes/ItemMultiRoutes.scala | 24 ++++++ .../restserver/routes/ItemRoutes.scala | 2 +- .../store/records/RCustomFieldValue.scala | 6 +- .../scala/docspell/store/records/RItem.scala | 9 +++ 7 files changed, 137 insertions(+), 14 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala index 4792a4d9..8f6179d0 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala @@ -1,6 +1,7 @@ package docspell.backend.ops import cats.data.EitherT +import cats.data.NonEmptyList import cats.data.OptionT import cats.effect._ import cats.implicits._ @@ -20,6 +21,7 @@ import docspell.store.records.RCustomFieldValue import docspell.store.records.RItem import doobie._ +import org.log4s.getLogger trait OCustomFields[F[_]] { @@ -41,6 +43,8 @@ trait OCustomFields[F[_]] { /** Sets a value given a field an an item. Existing values are overwritten. */ def setValue(item: Ident, value: SetValue): F[SetValueResult] + def setValueMultiple(items: NonEmptyList[Ident], value: SetValue): F[SetValueResult] + /** Deletes a value for a given field an item. */ def deleteValue(in: RemoveValue): F[UpdateResult] } @@ -79,7 +83,7 @@ object OCustomFields { case class RemoveValue( field: Ident, - item: Ident, + item: NonEmptyList[Ident], collective: Ident ) @@ -88,8 +92,10 @@ object OCustomFields { ): Resource[F, OCustomFields[F]] = Resource.pure[F, OCustomFields[F]](new OCustomFields[F] { + private[this] val logger = Logger.log4s[ConnectionIO](getLogger) + def findAll(coll: Ident, nameQuery: Option[String]): F[Vector[CustomFieldData]] = - store.transact(QCustomField.findAllLike(coll, nameQuery)) + store.transact(QCustomField.findAllLike(coll, nameQuery.filter(_.nonEmpty))) def findById(coll: Ident, field: Ident): F[Option[CustomFieldData]] = store.transact(QCustomField.findById(field, coll)) @@ -113,6 +119,7 @@ object OCustomFields { val update = for { field <- OptionT(RCustomField.findByIdOrName(fieldIdOrName, coll)) + _ <- OptionT.liftF(logger.info(s"Deleting field: $field")) n <- OptionT.liftF(RCustomFieldValue.deleteByField(field.id)) k <- OptionT.liftF(RCustomField.deleteById(field.id, coll)) } yield n + k @@ -121,24 +128,32 @@ object OCustomFields { } def setValue(item: Ident, value: SetValue): F[SetValueResult] = + setValueMultiple(NonEmptyList.of(item), value) + + def setValueMultiple( + items: NonEmptyList[Ident], + value: SetValue + ): F[SetValueResult] = (for { field <- EitherT.fromOptionF( store.transact(RCustomField.findByIdOrName(value.field, value.collective)), SetValueResult.fieldNotFound ) - _ <- EitherT( - store - .transact(RItem.existsByIdAndCollective(item, value.collective)) - .map(flag => if (flag) Right(()) else Left(SetValueResult.itemNotFound)) - ) fval <- EitherT.fromEither[F]( field.ftype .parseValue(value.value) .leftMap(SetValueResult.valueInvalid) .map(field.ftype.valueString) ) + _ <- EitherT( + store + .transact(RItem.existsByIdsAndCollective(items, value.collective)) + .map(flag => if (flag) Right(()) else Left(SetValueResult.itemNotFound)) + ) nu <- EitherT.right[SetValueResult]( - store.transact(RCustomField.setValue(field, item, fval)) + items + .traverse(item => store.transact(RCustomField.setValue(field, item, fval))) + .map(_.toList.sum) ) } yield nu).fold(identity, _ => SetValueResult.success) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index c1a849b1..8774a2f7 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -2383,6 +2383,52 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/items/customfield: + put: + tags: [ Item (Multi Edit) ] + summary: Set the value of a custom field for multiple items + description: | + Sets the value for a custom field to multiple given items. If + a value already exists, it is overwritten. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndFieldValue" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/customfieldremove: + post: + tags: [ Item (Multi Edit) ] + summary: Removes the value for a custom field on multiple items + description: | + Removes the value for the given custom field from multiple + items. The field may be specified by its id or name. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/itemId" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndName" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/attachment/{id}: delete: @@ -3246,7 +3292,7 @@ paths: schema: $ref: "#/components/schemas/BasicResult" - /sec/customfields: + /sec/customfield: get: tags: [ Custom Fields ] summary: Get all defined custom fields. @@ -3283,7 +3329,7 @@ paths: schema: $ref: "#/components/schemas/BasicResult" - /sec/customfields/{id}: + /sec/customfield/{id}: parameters: - $ref: "#/components/parameters/id" get: @@ -3342,6 +3388,21 @@ paths: components: schemas: + ItemsAndFieldValue: + description: | + Holds a list of item ids and a custom field value. + required: + - items + - field + properties: + items: + type: array + items: + type: string + format: ident + field: + $ref: "#/components/schemas/CustomFieldValue" + ItemsAndRefs: description: | Holds a list of item ids and a list of ids of some other @@ -3459,6 +3520,12 @@ components: ftype: type: string format: customfieldtype + enum: + - text + - numeric + - date + - bool + - money CustomField: description: | @@ -3481,6 +3548,12 @@ components: ftype: type: string format: customfieldtype + enum: + - text + - numeric + - date + - bool + - money usages: type: integer format: int32 diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala index ceab2aea..0aab3c17 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala @@ -56,7 +56,7 @@ object CustomFieldRoutes { case DELETE -> Root / Ident(id) => for { - res <- backend.customFields.delete(id, user.account.collective) + res <- backend.customFields.delete(user.account.collective, id) resp <- Ok(convertResult(res)) } yield resp } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala index 7b1dd931..4b15689c 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -8,6 +8,7 @@ import cats.implicits._ import docspell.backend.BackendApp import docspell.backend.auth.AuthToken +import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue} import docspell.common.{Ident, ItemState} import docspell.restapi.model._ import docspell.restserver.conv.Conversions @@ -180,6 +181,29 @@ object ItemMultiRoutes { ) resp <- Ok(res) } yield resp + + case req @ PUT -> Root / "customfield" => + for { + json <- req.as[ItemsAndFieldValue] + items <- readIds[F](json.items) + res <- backend.customFields.setValueMultiple( + items, + SetValue(json.field.field, json.field.value, user.account.collective) + ) + resp <- Ok(Conversions.basicResult(res)) + } yield resp + + case req @ POST -> Root / "customfieldremove" => + for { + json <- req.as[ItemsAndName] + items <- readIds[F](json.items) + field <- readId[F](json.name) + res <- backend.customFields.deleteValue( + RemoveValue(field, items, user.account.collective) + ) + resp <- Ok(Conversions.basicResult(res, "Custom fields removed.")) + } yield resp + } } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala index 8f25eb08..c6606667 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -372,7 +372,7 @@ object ItemRoutes { case DELETE -> Root / Ident(id) / "customfield" / Ident(fieldId) => for { res <- backend.customFields.deleteValue( - RemoveValue(fieldId, id, user.account.collective) + RemoveValue(fieldId, NonEmptyList.of(id), user.account.collective) ) resp <- Ok(Conversions.basicResult(res, "Custom field value removed.")) } yield resp diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala index 7789143c..53356233 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala @@ -1,5 +1,7 @@ package docspell.store.records +import cats.data.NonEmptyList + import docspell.common._ import docspell.store.impl.Column import docspell.store.impl.Implicits._ @@ -57,6 +59,6 @@ object RCustomFieldValue { def deleteByItem(item: Ident): ConnectionIO[Int] = deleteFrom(table, Columns.itemId.is(item)).update.run - def deleteValue(fieldId: Ident, item: Ident): ConnectionIO[Int] = - deleteFrom(table, and(Columns.id.is(fieldId), Columns.itemId.is(item))).update.run + def deleteValue(fieldId: Ident, items: NonEmptyList[Ident]): ConnectionIO[Int] = + deleteFrom(table, and(Columns.id.is(fieldId), Columns.itemId.isIn(items))).update.run } diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index 89f78e2a..980cd324 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -326,6 +326,15 @@ object RItem { def existsByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Boolean] = selectCount(id, table, and(id.is(itemId), cid.is(coll))).query[Int].unique.map(_ > 0) + def existsByIdsAndCollective( + itemIds: NonEmptyList[Ident], + coll: Ident + ): ConnectionIO[Boolean] = + selectCount(id, table, and(id.isIn(itemIds), cid.is(coll))) + .query[Int] + .unique + .map(_ == itemIds.size) + def findByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Option[RItem]] = selectSimple(all, table, and(id.is(itemId), cid.is(coll))).query[RItem].option From e90f65f94148b9e52a60abb3b18a1e24077e0255 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 17 Nov 2020 23:06:06 +0100 Subject: [PATCH 06/29] Manage custom fields in webui --- modules/webapp/src/main/elm/Api.elm | 63 +++- .../src/main/elm/Comp/CustomFieldDetail.elm | 284 ++++++++++++++++++ .../src/main/elm/Comp/CustomFieldManage.elm | 190 ++++++++++++ .../src/main/elm/Comp/CustomFieldTable.elm | 90 ++++++ .../src/main/elm/Comp/ItemDetail/Update.elm | 3 + .../src/main/elm/Data/CustomFieldType.elm | 80 +++++ modules/webapp/src/main/elm/Data/Fields.elm | 11 + modules/webapp/src/main/elm/Data/Icons.elm | 12 + .../src/main/elm/Page/ManageData/Data.elm | 5 + .../src/main/elm/Page/ManageData/Update.elm | 17 ++ .../src/main/elm/Page/ManageData/View.elm | 28 ++ 11 files changed, 781 insertions(+), 2 deletions(-) create mode 100644 modules/webapp/src/main/elm/Comp/CustomFieldDetail.elm create mode 100644 modules/webapp/src/main/elm/Comp/CustomFieldManage.elm create mode 100644 modules/webapp/src/main/elm/Comp/CustomFieldTable.elm create mode 100644 modules/webapp/src/main/elm/Data/CustomFieldType.elm diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 5af07dc1..22270101 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -19,6 +19,7 @@ module Api exposing , createScanMailbox , deleteAllItems , deleteAttachment + , deleteCustomField , deleteEquip , deleteFolder , deleteImapSettings @@ -36,6 +37,7 @@ module Api exposing , getCollective , getCollectiveSettings , getContacts + , getCustomFields , getEquipment , getEquipments , getFolderDetail @@ -68,12 +70,14 @@ module Api exposing , logout , moveAttachmentBefore , newInvite + , postCustomField , postEquipment , postNewUser , postOrg , postPerson , postSource , postTag + , putCustomField , putUser , refreshSession , register @@ -129,6 +133,7 @@ import Api.Model.CalEventCheckResult exposing (CalEventCheckResult) import Api.Model.Collective exposing (Collective) import Api.Model.CollectiveSettings exposing (CollectiveSettings) import Api.Model.ContactList exposing (ContactList) +import Api.Model.CustomFieldList exposing (CustomFieldList) import Api.Model.DirectionValue exposing (DirectionValue) import Api.Model.EmailSettings exposing (EmailSettings) import Api.Model.EmailSettingsList exposing (EmailSettingsList) @@ -145,7 +150,6 @@ import Api.Model.InviteResult exposing (InviteResult) import Api.Model.ItemDetail exposing (ItemDetail) import Api.Model.ItemFtsSearch exposing (ItemFtsSearch) import Api.Model.ItemInsights exposing (ItemInsights) -import Api.Model.ItemLight exposing (ItemLight) import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ItemSearch exposing (ItemSearch) @@ -158,6 +162,7 @@ import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) import Api.Model.JobPriority exposing (JobPriority) import Api.Model.JobQueueState exposing (JobQueueState) import Api.Model.MoveAttachment exposing (MoveAttachment) +import Api.Model.NewCustomField exposing (NewCustomField) import Api.Model.NewFolder exposing (NewFolder) import Api.Model.NotificationSettings exposing (NotificationSettings) import Api.Model.NotificationSettingsList exposing (NotificationSettingsList) @@ -177,7 +182,7 @@ import Api.Model.SentMails exposing (SentMails) import Api.Model.SimpleMail exposing (SimpleMail) import Api.Model.SourceAndTags exposing (SourceAndTags) import Api.Model.SourceList exposing (SourceList) -import Api.Model.SourceTagIn exposing (SourceTagIn) +import Api.Model.SourceTagIn import Api.Model.StringList exposing (StringList) import Api.Model.Tag exposing (Tag) import Api.Model.TagCloud exposing (TagCloud) @@ -200,6 +205,60 @@ import Util.Http as Http2 +--- Custom Fields + + +getCustomFields : Flags -> String -> (Result Http.Error CustomFieldList -> msg) -> Cmd msg +getCustomFields flags query receive = + Http2.authGet + { url = + flags.config.baseUrl + ++ "/api/v1/sec/customfield?q=" + ++ Url.percentEncode query + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.CustomFieldList.decoder + } + + +postCustomField : + Flags + -> NewCustomField + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +postCustomField flags field receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/customfield" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.NewCustomField.encode field) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +putCustomField : + Flags + -> String + -> NewCustomField + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +putCustomField flags id field receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/customfield/" ++ id + , account = getAccount flags + , body = Http.jsonBody (Api.Model.NewCustomField.encode field) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +deleteCustomField : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg +deleteCustomField flags id receive = + Http2.authDelete + { url = flags.config.baseUrl ++ "/api/v1/sec/customfield/" ++ id + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + + --- Folders diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldDetail.elm b/modules/webapp/src/main/elm/Comp/CustomFieldDetail.elm new file mode 100644 index 00000000..e424aaeb --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/CustomFieldDetail.elm @@ -0,0 +1,284 @@ +module Comp.CustomFieldDetail exposing + ( Model + , Msg + , init + , initEmpty + , update + , view + ) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.CustomField exposing (CustomField) +import Api.Model.NewCustomField exposing (NewCustomField) +import Comp.FixedDropdown +import Comp.YesNoDimmer +import Data.CustomFieldType exposing (CustomFieldType) +import Data.Flags exposing (Flags) +import Data.Validated exposing (Validated) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput) +import Http +import Util.Http +import Util.Maybe + + +type alias Model = + { result : Maybe BasicResult + , field : CustomField + , name : Maybe String + , label : Maybe String + , ftype : Maybe CustomFieldType + , ftypeModel : Comp.FixedDropdown.Model CustomFieldType + , loading : Bool + , deleteDimmer : Comp.YesNoDimmer.Model + } + + +type Msg + = SetName String + | SetLabel String + | FTypeMsg (Comp.FixedDropdown.Msg CustomFieldType) + | RequestDelete + | DeleteMsg Comp.YesNoDimmer.Msg + | UpdateResp (Result Http.Error BasicResult) + | GoBack + | SubmitForm + + +init : CustomField -> Model +init field = + { result = Nothing + , field = field + , name = Util.Maybe.fromString field.name + , label = field.label + , ftype = Data.CustomFieldType.fromString field.ftype + , ftypeModel = + Comp.FixedDropdown.initMap Data.CustomFieldType.label + Data.CustomFieldType.all + , loading = False + , deleteDimmer = Comp.YesNoDimmer.emptyModel + } + + +initEmpty : Model +initEmpty = + init Api.Model.CustomField.empty + + + +--- Update + + +makeField : Model -> Validated NewCustomField +makeField model = + let + name = + Maybe.map Data.Validated.Valid model.name + |> Maybe.withDefault (Data.Validated.Invalid [ "A name is required." ] "") + + ftype = + Maybe.map Data.CustomFieldType.asString model.ftype + |> Maybe.map Data.Validated.Valid + |> Maybe.withDefault (Data.Validated.Invalid [ "A field type is required." ] "") + + make n ft = + NewCustomField n model.label ft + in + Data.Validated.map2 make name ftype + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Bool ) +update flags msg model = + case msg of + GoBack -> + ( model, Cmd.none, True ) + + FTypeMsg lm -> + let + ( m2, sel ) = + Comp.FixedDropdown.update lm model.ftypeModel + in + ( { model | ftype = Util.Maybe.or [ sel, model.ftype ], ftypeModel = m2 } + , Cmd.none + , False + ) + + SetName str -> + ( { model | name = Util.Maybe.fromString str } + , Cmd.none + , False + ) + + SetLabel str -> + ( { model | label = Util.Maybe.fromString str } + , Cmd.none + , False + ) + + SubmitForm -> + let + newField = + makeField model + in + case newField of + Data.Validated.Valid f -> + ( model + , if model.field.id == "" then + Api.postCustomField flags f UpdateResp + + else + Api.putCustomField flags model.field.id f UpdateResp + , False + ) + + Data.Validated.Invalid msgs _ -> + let + combined = + String.join "; " msgs + in + ( { model | result = Just (BasicResult False combined) } + , Cmd.none + , False + ) + + Data.Validated.Unknown _ -> + ( model, Cmd.none, False ) + + RequestDelete -> + let + ( dm, _ ) = + Comp.YesNoDimmer.update Comp.YesNoDimmer.activate model.deleteDimmer + in + ( { model | deleteDimmer = dm }, Cmd.none, False ) + + DeleteMsg lm -> + let + ( dm, flag ) = + Comp.YesNoDimmer.update lm model.deleteDimmer + + cmd = + if flag then + Api.deleteCustomField flags model.field.id UpdateResp + + else + Cmd.none + in + ( { model | deleteDimmer = dm }, cmd, False ) + + UpdateResp (Ok r) -> + ( { model | result = Just r }, Cmd.none, r.success ) + + UpdateResp (Err err) -> + ( { model | result = Just (BasicResult False (Util.Http.errorToString err)) } + , Cmd.none + , False + ) + + + +--- View + + +view : Flags -> Model -> Html Msg +view _ model = + let + mkItem cft = + Comp.FixedDropdown.Item cft (Data.CustomFieldType.label cft) + in + div [ class "ui error form segment" ] + ([ Html.map DeleteMsg (Comp.YesNoDimmer.view model.deleteDimmer) + , if model.field.id == "" then + div [] + [ text "Create a new custom field." + ] + + else + div [] + [ text "Modify this custom field. Note that changing the type may result in data loss!" + ] + , div + [ classList + [ ( "ui message", True ) + , ( "invisible hidden", model.result == Nothing ) + , ( "error", Maybe.map .success model.result == Just False ) + , ( "success", Maybe.map .success model.result == Just True ) + ] + ] + [ Maybe.map .message model.result + |> Maybe.withDefault "" + |> text + ] + , div [ class "required field" ] + [ label [] [ text "Name" ] + , input + [ type_ "text" + , onInput SetName + , model.name + |> Maybe.withDefault "" + |> value + ] + [] + , div [ class "small-info" ] + [ text "The name uniquely identifies this field. It must be a valid " + , text "identifier, not contain spaces or weird characters." + ] + ] + , div [ class "field" ] + [ label [] [ text "Label" ] + , input + [ type_ "text" + , onInput SetLabel + , model.label + |> Maybe.withDefault "" + |> value + ] + [] + , div [ class "small-info" ] + [ text "The user defined label for this field. This is used to represent " + , text "this field in the ui. If not present, the name is used." + ] + ] + , div [ class "required field" ] + [ label [] [ text "Field Type" ] + , Html.map FTypeMsg + (Comp.FixedDropdown.view + (Maybe.map mkItem model.ftype) + model.ftypeModel + ) + , div [ class "small-info" ] + [ text "A field must have a type. This defines how to input values and " + , text "the server validates it according to this type." + ] + ] + ] + ++ viewButtons model + ) + + +viewButtons : Model -> List (Html Msg) +viewButtons model = + [ div [ class "ui divider" ] [] + , button + [ class "ui primary button" + , onClick SubmitForm + ] + [ text "Submit" + ] + , button + [ class "ui button" + , onClick GoBack + ] + [ text "Back" + ] + , button + [ classList + [ ( "ui red button", True ) + , ( "invisible hidden", model.field.id == "" ) + ] + , onClick RequestDelete + ] + [ text "Delete" + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldManage.elm b/modules/webapp/src/main/elm/Comp/CustomFieldManage.elm new file mode 100644 index 00000000..00ba5c7d --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/CustomFieldManage.elm @@ -0,0 +1,190 @@ +module Comp.CustomFieldManage exposing + ( Model + , Msg + , empty + , init + , update + , view + ) + +import Api +import Api.Model.CustomField exposing (CustomField) +import Api.Model.CustomFieldList exposing (CustomFieldList) +import Comp.CustomFieldDetail +import Comp.CustomFieldTable +import Data.Flags exposing (Flags) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput) +import Http + + +type alias Model = + { tableModel : Comp.CustomFieldTable.Model + , detailModel : Maybe Comp.CustomFieldDetail.Model + , fields : List CustomField + , query : String + , loading : Bool + } + + +type Msg + = TableMsg Comp.CustomFieldTable.Msg + | DetailMsg Comp.CustomFieldDetail.Msg + | CustomFieldListResp (Result Http.Error CustomFieldList) + | SetQuery String + | InitNewCustomField + + +empty : Model +empty = + { tableModel = Comp.CustomFieldTable.init + , detailModel = Nothing + , fields = [] + , query = "" + , loading = False + } + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + ( empty + , Api.getCustomFields flags empty.query CustomFieldListResp + ) + + + +--- Update + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update flags msg model = + case msg of + TableMsg lm -> + let + ( tm, action ) = + Comp.CustomFieldTable.update lm model.tableModel + + detail = + case action of + Comp.CustomFieldTable.EditAction item -> + Comp.CustomFieldDetail.init item |> Just + + Comp.CustomFieldTable.NoAction -> + model.detailModel + in + ( { model | tableModel = tm, detailModel = detail }, Cmd.none ) + + DetailMsg lm -> + case model.detailModel of + Just detail -> + let + ( dm, dc, back ) = + Comp.CustomFieldDetail.update flags lm detail + + cmd = + if back then + Api.getCustomFields flags model.query CustomFieldListResp + + else + Cmd.none + in + ( { model + | detailModel = + if back then + Nothing + + else + Just dm + } + , Cmd.batch + [ Cmd.map DetailMsg dc + , cmd + ] + ) + + Nothing -> + ( model, Cmd.none ) + + SetQuery str -> + ( { model | query = str } + , Api.getCustomFields flags str CustomFieldListResp + ) + + CustomFieldListResp (Ok sl) -> + ( { model | fields = sl.items }, Cmd.none ) + + CustomFieldListResp (Err _) -> + ( model, Cmd.none ) + + InitNewCustomField -> + let + sd = + Comp.CustomFieldDetail.initEmpty + in + ( { model | detailModel = Just sd } + , Cmd.none + ) + + + +--- View + + +view : Flags -> Model -> Html Msg +view flags model = + case model.detailModel of + Just dm -> + viewDetail flags dm + + Nothing -> + viewTable model + + +viewDetail : Flags -> Comp.CustomFieldDetail.Model -> Html Msg +viewDetail flags detailModel = + div [] + [ Html.map DetailMsg (Comp.CustomFieldDetail.view flags detailModel) + ] + + +viewTable : Model -> Html Msg +viewTable model = + div [] + [ div [ class "ui secondary menu" ] + [ div [ class "horizontally fitted item" ] + [ div [ class "ui icon input" ] + [ input + [ type_ "text" + , onInput SetQuery + , value model.query + , placeholder "Search…" + ] + [] + , i [ class "ui search icon" ] + [] + ] + ] + , div [ class "right menu" ] + [ div [ class "item" ] + [ a + [ class "ui primary button" + , href "#" + , onClick InitNewCustomField + ] + [ i [ class "plus icon" ] [] + , text "New CustomField" + ] + ] + ] + ] + , Html.map TableMsg (Comp.CustomFieldTable.view model.tableModel model.fields) + , div + [ classList + [ ( "ui dimmer", True ) + , ( "active", model.loading ) + ] + ] + [ div [ class "ui loader" ] [] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldTable.elm b/modules/webapp/src/main/elm/Comp/CustomFieldTable.elm new file mode 100644 index 00000000..a0e5ef92 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/CustomFieldTable.elm @@ -0,0 +1,90 @@ +module Comp.CustomFieldTable exposing + ( Action(..) + , Model + , Msg + , init + , update + , view + ) + +import Api.Model.CustomField exposing (CustomField) +import Api.Model.CustomFieldList exposing (CustomFieldList) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Util.Html +import Util.Time + + +type alias Model = + {} + + +type Msg + = EditItem CustomField + + +type Action + = NoAction + | EditAction CustomField + + +init : Model +init = + {} + + +update : Msg -> Model -> ( Model, Action ) +update msg model = + case msg of + EditItem item -> + ( model, EditAction item ) + + +view : Model -> List CustomField -> Html Msg +view _ items = + div [] + [ table [ class "ui very basic center aligned table" ] + [ thead [] + [ tr [] + [ th [ class "collapsing" ] [] + , th [] [ text "Name/Label" ] + , th [] [ text "Type" ] + , th [] [ text "#Usage" ] + , th [] [ text "Created" ] + ] + ] + , tbody [] + (List.map viewItem items) + ] + ] + + +viewItem : CustomField -> Html Msg +viewItem item = + tr [] + [ td [ class "collapsing" ] + [ a + [ href "#" + , class "ui basic small blue label" + , onClick (EditItem item) + ] + [ i [ class "edit icon" ] [] + , text "Edit" + ] + ] + , td [] + [ text <| Maybe.withDefault item.name item.label + ] + , td [] + [ text item.ftype + ] + , td [] + [ String.fromInt item.usages + |> text + ] + , td [] + [ Util.Time.formatDateShort item.created + |> text + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm index 19ad419d..f998f158 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -1449,6 +1449,9 @@ resetField flags item tagger field = Data.Fields.PreviewImage -> Cmd.none + Data.Fields.CustomFields -> + Cmd.none + resetHiddenFields : UiSettings diff --git a/modules/webapp/src/main/elm/Data/CustomFieldType.elm b/modules/webapp/src/main/elm/Data/CustomFieldType.elm new file mode 100644 index 00000000..a9b89719 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/CustomFieldType.elm @@ -0,0 +1,80 @@ +module Data.CustomFieldType exposing + ( CustomFieldType(..) + , all + , asString + , fromString + , label + ) + + +type CustomFieldType + = Text + | Numeric + | Date + | Boolean + | Money + + +all : List CustomFieldType +all = + [ Text, Numeric, Date, Boolean, Money ] + + +asString : CustomFieldType -> String +asString ft = + case ft of + Text -> + "text" + + Numeric -> + "numeric" + + Date -> + "date" + + Boolean -> + "bool" + + Money -> + "money" + + +label : CustomFieldType -> String +label ft = + case ft of + Text -> + "Text" + + Numeric -> + "Numeric" + + Date -> + "Date" + + Boolean -> + "Boolean" + + Money -> + "Money" + + +fromString : String -> Maybe CustomFieldType +fromString str = + case String.toLower str of + "text" -> + Just Text + + "numeric" -> + Just Numeric + + "date" -> + Just Date + + "bool" -> + Just Boolean + + "money" -> + Just Money + + _ -> + Nothing diff --git a/modules/webapp/src/main/elm/Data/Fields.elm b/modules/webapp/src/main/elm/Data/Fields.elm index 4a0244d2..e7b71aa8 100644 --- a/modules/webapp/src/main/elm/Data/Fields.elm +++ b/modules/webapp/src/main/elm/Data/Fields.elm @@ -20,6 +20,7 @@ type Field | DueDate | Direction | PreviewImage + | CustomFields all : List Field @@ -35,6 +36,7 @@ all = , DueDate , Direction , PreviewImage + , CustomFields ] @@ -76,6 +78,9 @@ fromString str = "preview" -> Just PreviewImage + "customfields" -> + Just CustomFields + _ -> Nothing @@ -113,6 +118,9 @@ toString field = PreviewImage -> "preview" + CustomFields -> + "customfields" + label : Field -> String label field = @@ -147,6 +155,9 @@ label field = PreviewImage -> "Preview Image" + CustomFields -> + "Custom Fields" + fromList : List String -> List Field fromList strings = diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm index 72c78be0..f970ae2f 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -5,6 +5,8 @@ module Data.Icons exposing , concernedIcon , correspondent , correspondentIcon + , customField + , customFieldIcon , date , dateIcon , direction @@ -33,6 +35,16 @@ import Html exposing (Html, i) import Html.Attributes exposing (class) +customField : String +customField = + "highlighter icon" + + +customFieldIcon : String -> Html msg +customFieldIcon classes = + i [ class (customField ++ " " ++ classes) ] [] + + search : String search = "search icon" diff --git a/modules/webapp/src/main/elm/Page/ManageData/Data.elm b/modules/webapp/src/main/elm/Page/ManageData/Data.elm index 69178dac..ac7cdf96 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/Data.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/Data.elm @@ -5,6 +5,7 @@ module Page.ManageData.Data exposing , init ) +import Comp.CustomFieldManage import Comp.EquipmentManage import Comp.FolderManage import Comp.OrgManage @@ -20,6 +21,7 @@ type alias Model = , orgManageModel : Comp.OrgManage.Model , personManageModel : Comp.PersonManage.Model , folderManageModel : Comp.FolderManage.Model + , fieldManageModel : Comp.CustomFieldManage.Model } @@ -31,6 +33,7 @@ init _ = , orgManageModel = Comp.OrgManage.emptyModel , personManageModel = Comp.PersonManage.emptyModel , folderManageModel = Comp.FolderManage.empty + , fieldManageModel = Comp.CustomFieldManage.empty } , Cmd.none ) @@ -42,6 +45,7 @@ type Tab | OrgTab | PersonTab | FolderTab + | CustomFieldTab type Msg @@ -51,3 +55,4 @@ type Msg | OrgManageMsg Comp.OrgManage.Msg | PersonManageMsg Comp.PersonManage.Msg | FolderMsg Comp.FolderManage.Msg + | CustomFieldMsg Comp.CustomFieldManage.Msg diff --git a/modules/webapp/src/main/elm/Page/ManageData/Update.elm b/modules/webapp/src/main/elm/Page/ManageData/Update.elm index f229e2ad..f684e8b5 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/Update.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/Update.elm @@ -1,5 +1,6 @@ module Page.ManageData.Update exposing (update) +import Comp.CustomFieldManage import Comp.EquipmentManage import Comp.FolderManage import Comp.OrgManage @@ -37,6 +38,13 @@ update flags msg model = in ( { m | folderManageModel = sm }, Cmd.map FolderMsg sc ) + CustomFieldTab -> + let + ( cm, cc ) = + Comp.CustomFieldManage.init flags + in + ( { m | fieldManageModel = cm }, Cmd.map CustomFieldMsg cc ) + TagManageMsg m -> let ( m2, c2 ) = @@ -73,3 +81,12 @@ update flags msg model = ( { model | folderManageModel = m2 } , Cmd.map FolderMsg c2 ) + + CustomFieldMsg lm -> + let + ( m2, c2 ) = + Comp.CustomFieldManage.update flags lm model.fieldManageModel + in + ( { model | fieldManageModel = m2 } + , Cmd.map CustomFieldMsg c2 + ) diff --git a/modules/webapp/src/main/elm/Page/ManageData/View.elm b/modules/webapp/src/main/elm/Page/ManageData/View.elm index d927fd48..2f3535b2 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/View.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/View.elm @@ -1,5 +1,6 @@ module Page.ManageData.View exposing (view) +import Comp.CustomFieldManage import Comp.EquipmentManage import Comp.FolderManage import Comp.OrgManage @@ -65,6 +66,18 @@ view flags settings model = [ Icons.folderIcon "" , text "Folder" ] + , div + [ classActive (model.currentTab == Just CustomFieldTab) "link icon item" + , classList + [ ( "invisible hidden" + , Data.UiSettings.fieldHidden settings Data.Fields.CustomFields + ) + ] + , onClick (SetTab CustomFieldTab) + ] + [ Icons.customFieldIcon "" + , text "Custom Fields" + ] ] ] ] @@ -86,6 +99,9 @@ view flags settings model = Just FolderTab -> viewFolder flags settings model + Just CustomFieldTab -> + viewCustomFields flags settings model + Nothing -> [] ) @@ -93,6 +109,18 @@ view flags settings model = ] +viewCustomFields : Flags -> UiSettings -> Model -> List (Html Msg) +viewCustomFields flags _ model = + [ h2 [ class "ui header" ] + [ Icons.customFieldIcon "" + , div [ class "content" ] + [ text "Custom Fields" + ] + ] + , Html.map CustomFieldMsg (Comp.CustomFieldManage.view flags model.fieldManageModel) + ] + + viewFolder : Flags -> UiSettings -> Model -> List (Html Msg) viewFolder flags _ model = [ h2 From 4059ef31c1d2508fae3308e1f26d85441df2c89c Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 19 Nov 2020 23:37:00 +0100 Subject: [PATCH 07/29] Allow providing values for custom fields in item detail --- .../src/main/elm/Comp/CustomFieldInput.elm | 343 ++++++++++++++++++ .../main/elm/Comp/CustomFieldMultiInput.elm | 273 ++++++++++++++ .../src/main/elm/Comp/ItemDetail/Model.elm | 4 + .../src/main/elm/Comp/ItemDetail/Update.elm | 33 +- .../src/main/elm/Comp/ItemDetail/View.elm | 56 ++- modules/webapp/src/main/webjar/docspell.css | 11 + 6 files changed, 699 insertions(+), 21 deletions(-) create mode 100644 modules/webapp/src/main/elm/Comp/CustomFieldInput.elm create mode 100644 modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm new file mode 100644 index 00000000..86bf321f --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm @@ -0,0 +1,343 @@ +module Comp.CustomFieldInput exposing + ( FieldResult(..) + , Model + , Msg + , UpdateResult + , init + , update + , view + ) + +import Api.Model.CustomField exposing (CustomField) +import Comp.DatePicker +import Data.CustomFieldType exposing (CustomFieldType) +import Date exposing (Date) +import DatePicker exposing (DatePicker) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onCheck, onClick, onInput) + + +type alias Model = + { fieldModel : FieldModel + , field : CustomField + } + + +type alias FloatModel = + { input : String + , result : Result String Float + } + + +type FieldModel + = TextField (Maybe String) + | NumberField FloatModel + | MoneyField FloatModel + | BoolField Bool + | DateField (Maybe Date) DatePicker + + +type Msg + = NumberMsg String + | MoneyMsg String + | DateMsg DatePicker.Msg + | SetText String + | ToggleBool + | Remove + + +fieldType : CustomField -> CustomFieldType +fieldType field = + Data.CustomFieldType.fromString field.ftype + |> Maybe.withDefault Data.CustomFieldType.Text + + +errorMsg : Model -> Maybe String +errorMsg model = + let + floatModel = + case model.fieldModel of + NumberField fm -> + Just fm + + MoneyField fm -> + Just fm + + _ -> + Nothing + + getMsg res = + case res of + Ok _ -> + Nothing + + Err m -> + Just m + in + Maybe.andThen getMsg (Maybe.map .result floatModel) + + +init : CustomField -> ( Model, Cmd Msg ) +init field = + let + ( dm, dc ) = + Comp.DatePicker.init + in + ( { field = field + , fieldModel = + case fieldType field of + Data.CustomFieldType.Text -> + TextField Nothing + + Data.CustomFieldType.Numeric -> + NumberField (FloatModel "" (Err "No number given")) + + Data.CustomFieldType.Money -> + MoneyField (FloatModel "" (Err "No amount given")) + + Data.CustomFieldType.Boolean -> + BoolField False + + Data.CustomFieldType.Date -> + DateField Nothing dm + } + , if fieldType field == Data.CustomFieldType.Date then + Cmd.map DateMsg dc + + else + Cmd.none + ) + + +type FieldResult + = NoResult + | RemoveField + | Value String + + +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , result : FieldResult + , subs : Sub Msg + } + + +updateFloatModel : String -> (Float -> Float) -> ( FloatModel, FieldResult ) +updateFloatModel msg rounding = + case String.toFloat msg of + Just n -> + let + fieldVal = + if String.endsWith "." msg || String.endsWith ".0" msg then + msg + + else + String.fromFloat (rounding n) + in + ( { input = fieldVal + , result = Ok (rounding n) + } + , Value (String.fromFloat (rounding n)) + ) + + Nothing -> + ( { input = msg + , result = Err ("Not a number: " ++ msg) + } + , NoResult + ) + + +roundScale2 : Float -> Float +roundScale2 input = + (round (input * 100) |> toFloat) / 100 + + +update : Msg -> Model -> UpdateResult +update msg model = + case ( msg, model.fieldModel ) of + ( SetText str, TextField _ ) -> + let + model_ = + { model | fieldModel = TextField (Just str) } + in + UpdateResult model_ Cmd.none (Value str) Sub.none + + ( NumberMsg str, NumberField _ ) -> + let + ( fm, res ) = + updateFloatModel str identity + + model_ = + { model | fieldModel = NumberField fm } + in + UpdateResult model_ Cmd.none res Sub.none + + ( MoneyMsg str, MoneyField _ ) -> + let + ( fm, res ) = + updateFloatModel str roundScale2 + + model_ = + { model | fieldModel = MoneyField fm } + in + UpdateResult model_ Cmd.none res Sub.none + + ( ToggleBool, BoolField b ) -> + let + notb = + not b + + model_ = + { model | fieldModel = BoolField notb } + + value = + if notb then + "true" + + else + "false" + in + UpdateResult model_ Cmd.none (Value value) Sub.none + + ( DateMsg lm, DateField _ picker ) -> + let + ( picker_, event ) = + Comp.DatePicker.updateDefault lm picker + + ( newDate, value ) = + case event of + DatePicker.Picked date -> + ( Just date, Value (Date.toIsoString date) ) + + DatePicker.None -> + ( Nothing, NoResult ) + + DatePicker.FailedInput _ -> + ( Nothing, NoResult ) + + model_ = + { model | fieldModel = DateField newDate picker_ } + in + UpdateResult model_ Cmd.none value Sub.none + + ( Remove, _ ) -> + UpdateResult model Cmd.none RemoveField Sub.none + + -- no other possibilities, not well encoded here + _ -> + UpdateResult model Cmd.none NoResult Sub.none + + +mkLabel : Model -> String +mkLabel model = + Maybe.withDefault model.field.name model.field.label + + +removeButton : String -> Html Msg +removeButton classes = + a + [ class "ui icon button" + , class classes + , href "#" + , title "Remove this value" + , onClick Remove + ] + [ i [ class "trash alternate outline icon" ] [] + ] + + +view : String -> Maybe String -> Model -> Html Msg +view classes icon model = + let + error = + errorMsg model + in + div + [ class classes + , classList + [ ( "error", error /= Nothing ) + ] + ] + [ label [] + [ mkLabel model |> text + ] + , makeInput icon model + , div + [ class "ui red pointing basic label" + , classList + [ ( "invisible hidden", error == Nothing ) + ] + ] + [ Maybe.withDefault "" error |> text + ] + ] + + +makeInput : Maybe String -> Model -> Html Msg +makeInput icon model = + let + iconOr c = + Maybe.withDefault c icon + in + case model.fieldModel of + TextField v -> + div [ class "ui action left icon input" ] + [ input + [ type_ "text" + , Maybe.withDefault "" v |> value + , onInput SetText + ] + [] + , removeButton "" + , i [ class (iconOr "pen icon") ] [] + ] + + NumberField nm -> + div [ class "ui action left icon input" ] + [ input + [ type_ "text" + , value nm.input + , onInput NumberMsg + ] + [] + , removeButton "" + , i [ class (iconOr "hashtag icon") ] [] + ] + + MoneyField nm -> + div [ class "ui action left icon input" ] + [ input + [ type_ "text" + , value nm.input + , onInput MoneyMsg + ] + [] + , removeButton "" + , i [ class (iconOr "money bill icon") ] [] + ] + + BoolField b -> + div [ class "ui container" ] + [ div [ class "ui checkbox" ] + [ input + [ type_ "checkbox" + , onCheck (\_ -> ToggleBool) + , checked b + ] + [] + , label [] + [ text (mkLabel model) + ] + ] + , removeButton "right floated" + ] + + DateField v dp -> + div [ class "ui action left icon input" ] + [ Html.map DateMsg (Comp.DatePicker.view v Comp.DatePicker.defaultSettings dp) + , removeButton "" + , i [ class (iconOr "calendar icon") ] [] + ] diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm new file mode 100644 index 00000000..1a783b72 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm @@ -0,0 +1,273 @@ +module Comp.CustomFieldMultiInput exposing + ( Model + , Msg + , UpdateResult + , init + , initWith + , update + , view + ) + +import Api +import Api.Model.CustomField exposing (CustomField) +import Api.Model.CustomFieldList exposing (CustomFieldList) +import Comp.CustomFieldInput +import Comp.FixedDropdown +import Data.Flags exposing (Flags) +import Dict exposing (Dict) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Http +import Util.Maybe + + +type alias Model = + { fieldModels : Dict String Comp.CustomFieldInput.Model + , fieldSelect : + { selected : Maybe CustomField + , dropdown : Comp.FixedDropdown.Model CustomField + } + , visibleFields : List CustomField + , availableFields : List CustomField + } + + +type Msg + = CustomFieldInputMsg CustomField Comp.CustomFieldInput.Msg + | ApplyField CustomField + | RemoveField CustomField + | CreateNewField + | CustomFieldResp (Result Http.Error CustomFieldList) + | FieldSelectMsg (Comp.FixedDropdown.Msg CustomField) + + +type FieldResult + = NoResult + | FieldValueRemove CustomField + | FieldValueChange CustomField String + | FieldCreateNew + + +initWith : List CustomField -> Model +initWith fields = + { fieldModels = Dict.empty + , fieldSelect = + { selected = List.head fields + , dropdown = Comp.FixedDropdown.init (List.map mkItem fields) + } + , visibleFields = [] + , availableFields = fields + } + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + ( initWith [] + , Api.getCustomFields flags "" CustomFieldResp + ) + + + +--- Update + + +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , subs : Sub Msg + , result : FieldResult + } + + +mkItem : CustomField -> Comp.FixedDropdown.Item CustomField +mkItem f = + Comp.FixedDropdown.Item f (Maybe.withDefault f.name f.label) + + +update : Msg -> Model -> UpdateResult +update msg model = + case msg of + CreateNewField -> + UpdateResult model Cmd.none Sub.none FieldCreateNew + + CustomFieldResp (Ok list) -> + let + model_ = + { model + | availableFields = list.items + , fieldSelect = + { selected = List.head list.items + , dropdown = Comp.FixedDropdown.init (List.map mkItem list.items) + } + } + in + UpdateResult model_ Cmd.none Sub.none NoResult + + CustomFieldResp (Err _) -> + UpdateResult model Cmd.none Sub.none NoResult + + FieldSelectMsg lm -> + let + ( dm_, sel ) = + Comp.FixedDropdown.update lm model.fieldSelect.dropdown + + newF = + Util.Maybe.or [ sel, model.fieldSelect.selected ] + + model_ = + { model + | fieldSelect = + { selected = newF + , dropdown = dm_ + } + } + in + UpdateResult model_ Cmd.none Sub.none NoResult + + ApplyField f -> + let + notSelected e = + e /= f + + ( fm, fc ) = + Comp.CustomFieldInput.init f + + avail = + List.filter notSelected model.availableFields + + visible = + f :: model.visibleFields + + model_ = + { model + | fieldSelect = + { selected = List.head avail + , dropdown = Comp.FixedDropdown.init (List.map mkItem avail) + } + , availableFields = avail + , visibleFields = visible + , fieldModels = Dict.insert f.name fm model.fieldModels + } + + cmd_ = + Cmd.map (CustomFieldInputMsg f) fc + in + UpdateResult model_ cmd_ Sub.none NoResult + + RemoveField f -> + let + avail = + f :: model.availableFields + + visible = + List.filter (\e -> e /= f) model.visibleFields + + model_ = + { model + | availableFields = avail + , visibleFields = visible + , fieldSelect = + { selected = List.head avail + , dropdown = Comp.FixedDropdown.init (List.map mkItem avail) + } + } + in + UpdateResult model_ Cmd.none Sub.none (FieldValueRemove f) + + CustomFieldInputMsg field lm -> + let + fieldModel = + Dict.get field.name model.fieldModels + in + case fieldModel of + Just fm -> + let + res = + Comp.CustomFieldInput.update lm fm + + model_ = + { model | fieldModels = Dict.insert field.name res.model model.fieldModels } + + cmd_ = + Cmd.map (CustomFieldInputMsg field) res.cmd + + result = + case res.result of + Comp.CustomFieldInput.Value str -> + FieldValueChange field str + + Comp.CustomFieldInput.RemoveField -> + FieldValueRemove field + + Comp.CustomFieldInput.NoResult -> + NoResult + in + if res.result == Comp.CustomFieldInput.RemoveField then + update (RemoveField field) model_ + + else + UpdateResult model_ cmd_ Sub.none result + + Nothing -> + UpdateResult model Cmd.none Sub.none NoResult + + +view : String -> Model -> Html Msg +view classes model = + div [ class classes ] + (viewMenuBar model + :: List.map (viewCustomField model) model.visibleFields + ) + + +viewMenuBar : Model -> Html Msg +viewMenuBar model = + let + { dropdown, selected } = + model.fieldSelect + in + div [ class "ui action input field" ] + [ Html.map FieldSelectMsg + (Comp.FixedDropdown.viewStyled "fluid" (Maybe.map mkItem selected) dropdown) + , a + [ class "ui primary icon button" + , href "#" + , case selected of + Just f -> + onClick (ApplyField f) + + Nothing -> + class "disabled" + ] + [ i [ class "check icon" ] [] + ] + , addFieldLink "" model + ] + + +viewCustomField : Model -> CustomField -> Html Msg +viewCustomField model field = + let + fieldModel = + Dict.get field.name model.fieldModels + in + case fieldModel of + Just fm -> + Html.map (CustomFieldInputMsg field) + (Comp.CustomFieldInput.view "field" Nothing fm) + + Nothing -> + span [] [] + + +addFieldLink : String -> Model -> Html Msg +addFieldLink classes _ = + a + [ class ("ui icon button " ++ classes) + , href "#" + , onClick CreateNewField + , title "Create a new custom field" + ] + [ i [ class "plus link icon" ] [] + ] diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm index 57625afc..c6d79e5a 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm @@ -24,6 +24,7 @@ import Api.Model.SentMails exposing (SentMails) import Api.Model.Tag exposing (Tag) import Api.Model.TagList exposing (TagList) import Comp.AttachmentMeta +import Comp.CustomFieldMultiInput import Comp.DatePicker import Comp.DetailEdit import Comp.Dropdown @@ -93,6 +94,7 @@ type alias Model = , modalEdit : Maybe Comp.DetailEdit.Model , attachRename : Maybe AttachmentRename , keyInputModel : Comp.KeyInput.Model + , customFieldsModel : Comp.CustomFieldMultiInput.Model } @@ -194,6 +196,7 @@ emptyModel = , modalEdit = Nothing , attachRename = Nothing , keyInputModel = Comp.KeyInput.init + , customFieldsModel = Comp.CustomFieldMultiInput.initWith [] } @@ -279,6 +282,7 @@ type Msg | ToggleAttachMenu | UiSettingsUpdated | SetLinkTarget LinkTarget + | CustomFieldMsg Comp.CustomFieldMultiInput.Msg type SaveNameState diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm index f998f158..3e0b30e4 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -13,6 +13,7 @@ import Api.Model.ReferenceList exposing (ReferenceList) import Api.Model.Tag exposing (Tag) import Browser.Navigation as Nav import Comp.AttachmentMeta +import Comp.CustomFieldMultiInput import Comp.DatePicker import Comp.DetailEdit import Comp.Dropdown exposing (isDropdownChangeMsg) @@ -72,14 +73,24 @@ update key flags inav settings msg model = ( im, ic ) = Comp.ItemMail.init flags + + ( cm, cc ) = + Comp.CustomFieldMultiInput.init flags in resultModelCmd - ( { model | itemDatePicker = dp, dueDatePicker = dp, itemMail = im, visibleAttach = 0 } + ( { model + | itemDatePicker = dp + , dueDatePicker = dp + , itemMail = im + , visibleAttach = 0 + , customFieldsModel = cm + } , Cmd.batch [ getOptions flags , Cmd.map ItemDatePickerMsg dpc , Cmd.map DueDatePickerMsg dpc , Cmd.map ItemMailMsg ic + , Cmd.map CustomFieldMsg cc , Api.getSentMails flags model.item.id SentMailsResp ] ) @@ -1270,6 +1281,26 @@ update key flags inav settings msg model = , linkTarget = lt } + CustomFieldMsg lm -> + let + result = + Comp.CustomFieldMultiInput.update lm model.customFieldsModel + + model_ = + { model | customFieldsModel = result.model } + + cmd_ = + Cmd.map CustomFieldMsg result.cmd + + sub_ = + Sub.map CustomFieldMsg result.subs + in + { model = model_ + , cmd = cmd_ + , sub = sub_ + , linkTarget = Comp.LinkTarget.LinkNone + } + --- Helper diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm index 27eef314..7b4e43ec 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm @@ -4,6 +4,7 @@ import Api import Api.Model.Attachment exposing (Attachment) import Api.Model.IdName exposing (IdName) import Comp.AttachmentMeta +import Comp.CustomFieldMultiInput import Comp.DatePicker import Comp.DetailEdit import Comp.Dropdown @@ -730,16 +731,7 @@ renderEditForm settings model = in div [ class "ui attached segment" ] [ div [ class "ui form warning" ] - [ optional [ Data.Fields.Tag ] <| - div [ class "field" ] - [ label [] - [ Icons.tagsIcon "grey" - , text "Tags" - , addIconLink "Add new tag" StartTagModal - ] - , Html.map TagDropdownMsg (Comp.Dropdown.view settings model.tagModel) - ] - , div [ class " field" ] + [ div [ class " field" ] [ label [] [ text "Name" ] , div [ class "ui icon input" ] [ input [ type_ "text", value model.nameModel, onInput SetName ] [] @@ -753,6 +745,15 @@ renderEditForm settings model = [] ] ] + , optional [ Data.Fields.Tag ] <| + div [ class "field" ] + [ label [] + [ Icons.tagsIcon "grey" + , text "Tags" + , addIconLink "Add new tag" StartTagModal + ] + , Html.map TagDropdownMsg (Comp.Dropdown.view settings model.tagModel) + ] , optional [ Data.Fields.Folder ] <| div [ class "field" ] [ label [] @@ -773,21 +774,26 @@ item visible. This message will disappear then. """ ] ] - , optional [ Data.Fields.Direction ] <| - div [ class "field" ] - [ label [] - [ Icons.directionIcon "grey" - , text "Direction" - ] - , Html.map DirDropdownMsg (Comp.Dropdown.view settings model.directionModel) + , optional [ Data.Fields.CustomFields ] <| + h4 [ class "ui dividing header" ] + [ Icons.customFieldIcon "" + , text "Custom Fields" + ] + , optional [ Data.Fields.CustomFields ] <| + Html.map CustomFieldMsg + (Comp.CustomFieldMultiInput.view "field" model.customFieldsModel) + , optional [ Data.Fields.DueDate, Data.Fields.Date ] <| + h4 [ class "ui dividing header" ] + [ Icons.dateIcon "" + , text "Dates" ] , optional [ Data.Fields.Date ] <| div [ class "field" ] [ label [] [ Icons.dateIcon "grey" - , text "Date" + , text "Item Date" ] - , div [ class "ui action input" ] + , div [ class "ui left icon action input" ] [ Html.map ItemDatePickerMsg (Comp.DatePicker.viewTime model.itemDate @@ -797,6 +803,7 @@ item visible. This message will disappear then. , a [ class "ui icon button", href "", onClick RemoveDate ] [ i [ class "trash alternate outline icon" ] [] ] + , Icons.dateIcon "" ] , renderItemDateSuggestions model ] @@ -806,7 +813,7 @@ item visible. This message will disappear then. [ Icons.dueDateIcon "grey" , text "Due Date" ] - , div [ class "ui action input" ] + , div [ class "ui left icon action input" ] [ Html.map DueDatePickerMsg (Comp.DatePicker.viewTime model.dueDate @@ -815,6 +822,7 @@ item visible. This message will disappear then. ) , a [ class "ui icon button", href "", onClick RemoveDueDate ] [ i [ class "trash alternate outline icon" ] [] ] + , Icons.dueDateIcon "" ] , renderDueDateSuggestions model ] @@ -878,6 +886,14 @@ item visible. This message will disappear then. , Html.map ConcEquipMsg (Comp.Dropdown.view settings model.concEquipModel) , renderConcEquipSuggestions model ] + , optional [ Data.Fields.Direction ] <| + div [ class "field" ] + [ label [] + [ Icons.directionIcon "grey" + , text "Direction" + ] + , Html.map DirDropdownMsg (Comp.Dropdown.view settings model.directionModel) + ] ] ] diff --git a/modules/webapp/src/main/webjar/docspell.css b/modules/webapp/src/main/webjar/docspell.css index 88f3575b..1d4381e7 100644 --- a/modules/webapp/src/main/webjar/docspell.css +++ b/modules/webapp/src/main/webjar/docspell.css @@ -235,6 +235,17 @@ textarea.markdown-editor { background-color: aliceblue; } +.default-layout .ui.action.input .elm-datepicker--container { + width: 100%; +} +.default-layout .ui.action.input .elm-datepicker--container input.elm-datepicker--input { + width: 100%; + padding-left: 2.67142857em; + padding-right: 1em; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + .ui.dimmer.keep-small { justify-content: start; } From af1cca7d83486aa823f9e0f212e2726fc393b139 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 21 Nov 2020 21:22:12 +0100 Subject: [PATCH 08/29] Fix condition for deleting custom field value --- .../src/main/scala/docspell/backend/ops/OCustomFields.scala | 1 + .../scala/docspell/store/records/RCustomFieldValue.scala | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala index 8f6179d0..bdec0212 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala @@ -161,6 +161,7 @@ object OCustomFields { val update = for { field <- OptionT(RCustomField.findByIdOrName(in.field, in.collective)) + _ <- OptionT.liftF(logger.debug(s"Field found by '${in.field}': $field")) n <- OptionT.liftF(RCustomFieldValue.deleteValue(field.id, in.item)) } yield n diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala index 53356233..8830dc58 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala @@ -60,5 +60,8 @@ object RCustomFieldValue { deleteFrom(table, Columns.itemId.is(item)).update.run def deleteValue(fieldId: Ident, items: NonEmptyList[Ident]): ConnectionIO[Int] = - deleteFrom(table, and(Columns.id.is(fieldId), Columns.itemId.isIn(items))).update.run + deleteFrom( + table, + and(Columns.field.is(fieldId), Columns.itemId.isIn(items)) + ).update.run } From cc6db61a3ace4c4050343e512bb4dc7f590a25b0 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 21 Nov 2020 22:53:06 +0100 Subject: [PATCH 09/29] Allow to create fields when editing items --- modules/webapp/src/main/elm/Api.elm | 32 ++++++++ ...tomFieldDetail.elm => CustomFieldForm.elm} | 31 +++++-- .../src/main/elm/Comp/CustomFieldManage.elm | 20 +++-- .../main/elm/Comp/CustomFieldMultiInput.elm | 11 ++- .../webapp/src/main/elm/Comp/DetailEdit.elm | 82 ++++++++++++++++++- .../src/main/elm/Comp/ItemDetail/Update.elm | 33 ++++++-- 6 files changed, 188 insertions(+), 21 deletions(-) rename modules/webapp/src/main/elm/Comp/{CustomFieldDetail.elm => CustomFieldForm.elm} (93%) diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 22270101..2ef008d5 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -20,6 +20,7 @@ module Api exposing , deleteAllItems , deleteAttachment , deleteCustomField + , deleteCustomValue , deleteEquip , deleteFolder , deleteImapSettings @@ -78,6 +79,7 @@ module Api exposing , postSource , postTag , putCustomField + , putCustomValue , putUser , refreshSession , register @@ -134,6 +136,7 @@ import Api.Model.Collective exposing (Collective) import Api.Model.CollectiveSettings exposing (CollectiveSettings) import Api.Model.ContactList exposing (ContactList) import Api.Model.CustomFieldList exposing (CustomFieldList) +import Api.Model.CustomFieldValue exposing (CustomFieldValue) import Api.Model.DirectionValue exposing (DirectionValue) import Api.Model.EmailSettings exposing (EmailSettings) import Api.Model.EmailSettingsList exposing (EmailSettingsList) @@ -208,6 +211,35 @@ import Util.Http as Http2 --- Custom Fields +deleteCustomValue : + Flags + -> String + -> String + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +deleteCustomValue flags item field receive = + Http2.authDelete + { url = flags.config.baseUrl ++ "/api/v1/sec/item/" ++ item ++ "/customfield/" ++ field + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +putCustomValue : + Flags + -> String + -> CustomFieldValue + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +putCustomValue flags item fieldValue receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/item/" ++ item ++ "/customfield" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.CustomFieldValue.encode fieldValue) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + getCustomFields : Flags -> String -> (Result Http.Error CustomFieldList -> msg) -> Cmd msg getCustomFields flags query receive = Http2.authGet diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldDetail.elm b/modules/webapp/src/main/elm/Comp/CustomFieldForm.elm similarity index 93% rename from modules/webapp/src/main/elm/Comp/CustomFieldDetail.elm rename to modules/webapp/src/main/elm/Comp/CustomFieldForm.elm index e424aaeb..2945f054 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldDetail.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldForm.elm @@ -1,8 +1,11 @@ -module Comp.CustomFieldDetail exposing +module Comp.CustomFieldForm exposing ( Model , Msg + , ViewSettings + , fullViewSettings , init , initEmpty + , makeField , update , view ) @@ -181,13 +184,26 @@ update flags msg model = --- View -view : Flags -> Model -> Html Msg -view _ model = +type alias ViewSettings = + { classes : String + , showControls : Bool + } + + +fullViewSettings : ViewSettings +fullViewSettings = + { classes = "ui error form segment" + , showControls = True + } + + +view : ViewSettings -> Model -> Html Msg +view viewSettings model = let mkItem cft = Comp.FixedDropdown.Item cft (Data.CustomFieldType.label cft) in - div [ class "ui error form segment" ] + div [ class viewSettings.classes ] ([ Html.map DeleteMsg (Comp.YesNoDimmer.view model.deleteDimmer) , if model.field.id == "" then div [] @@ -253,7 +269,12 @@ view _ model = ] ] ] - ++ viewButtons model + ++ (if viewSettings.showControls then + viewButtons model + + else + [] + ) ) diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldManage.elm b/modules/webapp/src/main/elm/Comp/CustomFieldManage.elm index 00ba5c7d..f4915a85 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldManage.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldManage.elm @@ -10,7 +10,7 @@ module Comp.CustomFieldManage exposing import Api import Api.Model.CustomField exposing (CustomField) import Api.Model.CustomFieldList exposing (CustomFieldList) -import Comp.CustomFieldDetail +import Comp.CustomFieldForm import Comp.CustomFieldTable import Data.Flags exposing (Flags) import Html exposing (..) @@ -21,7 +21,7 @@ import Http type alias Model = { tableModel : Comp.CustomFieldTable.Model - , detailModel : Maybe Comp.CustomFieldDetail.Model + , detailModel : Maybe Comp.CustomFieldForm.Model , fields : List CustomField , query : String , loading : Bool @@ -30,7 +30,7 @@ type alias Model = type Msg = TableMsg Comp.CustomFieldTable.Msg - | DetailMsg Comp.CustomFieldDetail.Msg + | DetailMsg Comp.CustomFieldForm.Msg | CustomFieldListResp (Result Http.Error CustomFieldList) | SetQuery String | InitNewCustomField @@ -68,7 +68,7 @@ update flags msg model = detail = case action of Comp.CustomFieldTable.EditAction item -> - Comp.CustomFieldDetail.init item |> Just + Comp.CustomFieldForm.init item |> Just Comp.CustomFieldTable.NoAction -> model.detailModel @@ -80,7 +80,7 @@ update flags msg model = Just detail -> let ( dm, dc, back ) = - Comp.CustomFieldDetail.update flags lm detail + Comp.CustomFieldForm.update flags lm detail cmd = if back then @@ -120,7 +120,7 @@ update flags msg model = InitNewCustomField -> let sd = - Comp.CustomFieldDetail.initEmpty + Comp.CustomFieldForm.initEmpty in ( { model | detailModel = Just sd } , Cmd.none @@ -141,10 +141,14 @@ view flags model = viewTable model -viewDetail : Flags -> Comp.CustomFieldDetail.Model -> Html Msg +viewDetail : Flags -> Comp.CustomFieldForm.Model -> Html Msg viewDetail flags detailModel = + let + viewSettings = + Comp.CustomFieldForm.fullViewSettings + in div [] - [ Html.map DetailMsg (Comp.CustomFieldDetail.view flags detailModel) + [ Html.map DetailMsg (Comp.CustomFieldForm.view viewSettings detailModel) ] diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm index 1a783b72..e0926d1b 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm @@ -1,8 +1,10 @@ module Comp.CustomFieldMultiInput exposing - ( Model + ( FieldResult(..) + , Model , Msg , UpdateResult , init + , initCmd , initWith , update , view @@ -64,10 +66,15 @@ initWith fields = init : Flags -> ( Model, Cmd Msg ) init flags = ( initWith [] - , Api.getCustomFields flags "" CustomFieldResp + , initCmd flags ) +initCmd : Flags -> Cmd Msg +initCmd flags = + Api.getCustomFields flags "" CustomFieldResp + + --- Update diff --git a/modules/webapp/src/main/elm/Comp/DetailEdit.elm b/modules/webapp/src/main/elm/Comp/DetailEdit.elm index 5330da9c..d4275052 100644 --- a/modules/webapp/src/main/elm/Comp/DetailEdit.elm +++ b/modules/webapp/src/main/elm/Comp/DetailEdit.elm @@ -7,6 +7,7 @@ module Comp.DetailEdit exposing , editPerson , initConcPerson , initCorrPerson + , initCustomField , initEquip , initOrg , initTag @@ -26,9 +27,11 @@ rendered in a modal. import Api import Api.Model.BasicResult exposing (BasicResult) import Api.Model.Equipment exposing (Equipment) +import Api.Model.NewCustomField exposing (NewCustomField) import Api.Model.Organization exposing (Organization) import Api.Model.Person exposing (Person) import Api.Model.Tag exposing (Tag) +import Comp.CustomFieldForm import Comp.EquipmentForm import Comp.OrgForm import Comp.PersonForm @@ -36,6 +39,7 @@ import Comp.TagForm import Data.Flags exposing (Flags) import Data.Icons as Icons import Data.UiSettings exposing (UiSettings) +import Data.Validated import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onClick) @@ -58,6 +62,7 @@ type FormModel | PMC Comp.PersonForm.Model | OM Comp.OrgForm.Model | EM Comp.EquipmentForm.Model + | CFM Comp.CustomFieldForm.Model fold : @@ -65,9 +70,10 @@ fold : -> (Comp.PersonForm.Model -> a) -> (Comp.OrgForm.Model -> a) -> (Comp.EquipmentForm.Model -> a) + -> (Comp.CustomFieldForm.Model -> a) -> FormModel -> a -fold ft fp fo fe model = +fold ft fp fo fe fcf model = case model of TM tm -> ft tm @@ -84,6 +90,9 @@ fold ft fp fo fe model = EM em -> fe em + CFM fm -> + fcf fm + init : String -> FormModel -> Model init itemId fm = @@ -168,11 +177,21 @@ initTagByName itemId name = initTag itemId tm_ +initCustomField : String -> Model +initCustomField itemId = + let + cfm = + Comp.CustomFieldForm.initEmpty + in + init itemId (CFM cfm) + + type Msg = TagMsg Comp.TagForm.Msg | PersonMsg Comp.PersonForm.Msg | OrgMsg Comp.OrgForm.Msg | EquipMsg Comp.EquipmentForm.Msg + | CustomFieldMsg Comp.CustomFieldForm.Msg | Submit | Cancel | SubmitResp (Result Http.Error BasicResult) @@ -186,6 +205,7 @@ type Value | SubmitPerson Person | SubmitOrg Organization | SubmitEquip Equipment + | SubmitCustomField NewCustomField | CancelForm @@ -207,6 +227,18 @@ makeValue fm = EM em -> SubmitEquip (Comp.EquipmentForm.getEquipment em) + CFM fieldModel -> + let + cfield = + Comp.CustomFieldForm.makeField fieldModel + in + case cfield of + Data.Validated.Valid field -> + SubmitCustomField field + + _ -> + CancelForm + --- Update @@ -432,6 +464,24 @@ update flags msg model = , Nothing ) + CFM fm -> + let + cfield = + Comp.CustomFieldForm.makeField fm + in + case cfield of + Data.Validated.Valid newField -> + ( { model | submitting = True } + , Api.postCustomField flags newField SubmitResp + , Nothing + ) + + _ -> + ( { model | result = failMsg } + , Cmd.none + , Nothing + ) + TagMsg lm -> case model.form of TM tm -> @@ -517,11 +567,36 @@ update flags msg model = _ -> ( model, Cmd.none, Nothing ) + CustomFieldMsg lm -> + case model.form of + CFM fm -> + let + ( fm_, fc_, _ ) = + Comp.CustomFieldForm.update flags lm fm + in + ( { model + | form = CFM fm_ + , result = Nothing + } + , Cmd.map CustomFieldMsg fc_ + , Nothing + ) + + _ -> + ( model, Cmd.none, Nothing ) + --- View +customFieldFormSettings : Comp.CustomFieldForm.ViewSettings +customFieldFormSettings = + { classes = "ui error form" + , showControls = False + } + + viewButtons : Model -> List (Html Msg) viewButtons model = [ button @@ -575,6 +650,9 @@ viewIntern settings withButtons model = EM em -> Html.map EquipMsg (Comp.EquipmentForm.view em) + + CFM fm -> + Html.map CustomFieldMsg (Comp.CustomFieldForm.view customFieldFormSettings fm) ] ++ (if withButtons then div [ class "ui divider" ] [] :: viewButtons model @@ -601,12 +679,14 @@ viewModal settings mm = (\_ -> "Add Person") (\_ -> "Add Organization") (\_ -> "Add Equipment") + (\_ -> "Add Custom Field") headIcon = fold (\_ -> Icons.tagIcon "") (\_ -> Icons.personIcon "") (\_ -> Icons.organizationIcon "") (\_ -> Icons.equipmentIcon "") + (\_ -> Icons.customFieldIcon "") in div [ classList diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm index 3e0b30e4..25fe1a93 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -2,6 +2,7 @@ module Comp.ItemDetail.Update exposing (update) import Api import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.CustomFieldValue exposing (CustomFieldValue) import Api.Model.DirectionValue exposing (DirectionValue) import Api.Model.IdName exposing (IdName) import Api.Model.ItemDetail exposing (ItemDetail) @@ -13,7 +14,7 @@ import Api.Model.ReferenceList exposing (ReferenceList) import Api.Model.Tag exposing (Tag) import Browser.Navigation as Nav import Comp.AttachmentMeta -import Comp.CustomFieldMultiInput +import Comp.CustomFieldMultiInput exposing (FieldResult(..)) import Comp.DatePicker import Comp.DetailEdit import Comp.Dropdown exposing (isDropdownChangeMsg) @@ -238,6 +239,7 @@ update key flags inav settings msg model = , getOptions flags , proposalCmd , Api.getSentMails flags item.id SentMailsResp + , Cmd.map CustomFieldMsg (Comp.CustomFieldMultiInput.initCmd flags) ] , sub = Sub.batch @@ -1286,17 +1288,38 @@ update key flags inav settings msg model = result = Comp.CustomFieldMultiInput.update lm model.customFieldsModel - model_ = - { model | customFieldsModel = result.model } - cmd_ = Cmd.map CustomFieldMsg result.cmd + action = + case result.result of + NoResult -> + Cmd.none + + FieldValueRemove field -> + Api.deleteCustomValue flags model.item.id field.id SaveResp + + FieldValueChange field value -> + Api.putCustomValue flags model.item.id (CustomFieldValue field.id value) SaveResp + + FieldCreateNew -> + Cmd.none + sub_ = Sub.map CustomFieldMsg result.subs + + modalEdit = + if result.result == FieldCreateNew then + Just (Comp.DetailEdit.initCustomField model.item.id) + + else + Nothing + + model_ = + { model | customFieldsModel = result.model, modalEdit = modalEdit } in { model = model_ - , cmd = cmd_ + , cmd = Cmd.batch [ cmd_, action ] , sub = sub_ , linkTarget = Comp.LinkTarget.LinkNone } From 1ee36cef8f2e42553adb9e620631ad9a5d720d49 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 22 Nov 2020 00:07:03 +0100 Subject: [PATCH 10/29] Add fields when clicking in the dropdown Remove the additional button --- .../main/elm/Comp/CustomFieldMultiInput.elm | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm index e0926d1b..205aba81 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm @@ -26,10 +26,7 @@ import Util.Maybe type alias Model = { fieldModels : Dict String Comp.CustomFieldInput.Model - , fieldSelect : - { selected : Maybe CustomField - , dropdown : Comp.FixedDropdown.Model CustomField - } + , fieldSelect : FieldSelect , visibleFields : List CustomField , availableFields : List CustomField } @@ -51,13 +48,16 @@ type FieldResult | FieldCreateNew +type alias FieldSelect = + { selected : Maybe CustomField + , dropdown : Comp.FixedDropdown.Model CustomField + } + + initWith : List CustomField -> Model initWith fields = { fieldModels = Dict.empty - , fieldSelect = - { selected = List.head fields - , dropdown = Comp.FixedDropdown.init (List.map mkItem fields) - } + , fieldSelect = mkFieldSelect fields , visibleFields = [] , availableFields = fields } @@ -75,6 +75,13 @@ initCmd flags = Api.getCustomFields flags "" CustomFieldResp +mkFieldSelect : List CustomField -> FieldSelect +mkFieldSelect fields = + { selected = Nothing + , dropdown = Comp.FixedDropdown.init (List.map mkItem fields) + } + + --- Update @@ -103,10 +110,7 @@ update msg model = model_ = { model | availableFields = list.items - , fieldSelect = - { selected = List.head list.items - , dropdown = Comp.FixedDropdown.init (List.map mkItem list.items) - } + , fieldSelect = mkFieldSelect list.items } in UpdateResult model_ Cmd.none Sub.none NoResult @@ -130,7 +134,12 @@ update msg model = } } in - UpdateResult model_ Cmd.none Sub.none NoResult + case sel of + Just field -> + update (ApplyField field) model + + Nothing -> + UpdateResult model_ Cmd.none Sub.none NoResult ApplyField f -> let @@ -146,12 +155,20 @@ update msg model = visible = f :: model.visibleFields + fSelect = + mkFieldSelect avail + + -- have to re-state the open menu when this is invoked + -- from a click in the dropdown + fSelectDropdown = + fSelect.dropdown + + dropdownOpen = + { fSelectDropdown | menuOpen = True } + model_ = { model - | fieldSelect = - { selected = List.head avail - , dropdown = Comp.FixedDropdown.init (List.map mkItem avail) - } + | fieldSelect = { fSelect | dropdown = dropdownOpen } , availableFields = avail , visibleFields = visible , fieldModels = Dict.insert f.name fm model.fieldModels @@ -174,10 +191,7 @@ update msg model = { model | availableFields = avail , visibleFields = visible - , fieldSelect = - { selected = List.head avail - , dropdown = Comp.FixedDropdown.init (List.map mkItem avail) - } + , fieldSelect = mkFieldSelect avail } in UpdateResult model_ Cmd.none Sub.none (FieldValueRemove f) @@ -237,18 +251,6 @@ viewMenuBar model = div [ class "ui action input field" ] [ Html.map FieldSelectMsg (Comp.FixedDropdown.viewStyled "fluid" (Maybe.map mkItem selected) dropdown) - , a - [ class "ui primary icon button" - , href "#" - , case selected of - Just f -> - onClick (ApplyField f) - - Nothing -> - class "disabled" - ] - [ i [ class "check icon" ] [] - ] , addFieldLink "" model ] From 1aefff37aab2027ee0ad16696363fd79f65d21c7 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 22 Nov 2020 01:21:21 +0100 Subject: [PATCH 11/29] Return custom field values with item details --- .../docspell/backend/ops/OItemSearch.scala | 3 ++ .../src/main/resources/docspell-openapi.yml | 34 ++++++++++++++ .../restserver/conv/Conversions.scala | 6 ++- .../scala/docspell/store/queries/QItem.scala | 44 ++++++++++++++++--- 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala index 6a5cb49b..41870dce 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala @@ -65,6 +65,9 @@ object OItemSearch { type ListItemWithTags = QItem.ListItemWithTags val ListItemWithTags = QItem.ListItemWithTags + type ItemFieldValue = QItem.ItemFieldValue + val ItemFieldValue = QItem.ItemFieldValue + type ItemData = QItem.ItemData val ItemData = QItem.ItemData diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 8774a2f7..8426f129 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -3492,6 +3492,35 @@ components: items: $ref: "#/components/schemas/CustomField" + ItemFieldValue: + description: | + Information about a custom field on an item. + required: + - id + - name + - ftype + - value + properties: + id: + type: string + format: ident + name: + type: string + format: ident + label: + type: string + ftype: + type: string + format: customfieldtype + enum: + - text + - numeric + - date + - bool + - money + value: + type: string + CustomFieldValue: description: | Data structure to update the value of a custom field. @@ -4253,6 +4282,7 @@ components: - sources - archives - tags + - customfields properties: id: type: string @@ -4312,6 +4342,10 @@ components: type: array items: $ref: "#/components/schemas/Tag" + customfields: + type: array + items: + $ref: "#/components/schemas/ItemFieldValue" Attachment: description: | Information about an attachment to an item. diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 2762b3ae..2f7f0378 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -96,9 +96,13 @@ trait Conversions { data.attachments.map((mkAttachment(data) _).tupled).toList, data.sources.map((mkAttachmentSource _).tupled).toList, data.archives.map((mkAttachmentArchive _).tupled).toList, - data.tags.map(mkTag).toList + data.tags.map(mkTag).toList, + data.customFields.map(mkItemFieldValue).toList ) + def mkItemFieldValue(v: OItemSearch.ItemFieldValue): ItemFieldValue = + ItemFieldValue(v.fieldId, v.fieldName, v.fieldLabel, v.fieldType, v.value) + def mkAttachment( item: OItemSearch.ItemData )(ra: RAttachment, m: FileMeta): Attachment = { diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index 8f771827..91deca4d 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -60,6 +60,13 @@ object QItem { } + case class ItemFieldValue( + fieldId: Ident, + fieldName: Ident, + fieldLabel: Option[String], + fieldType: CustomFieldType, + value: String + ) case class ItemData( item: RItem, corrOrg: Option[ROrganization], @@ -71,7 +78,8 @@ object QItem { tags: Vector[RTag], attachments: Vector[(RAttachment, FileMeta)], sources: Vector[(RAttachmentSource, FileMeta)], - archives: Vector[(RAttachmentArchive, FileMeta)] + archives: Vector[(RAttachmentArchive, FileMeta)], + customFields: Vector[ItemFieldValue] ) { def filterCollective(coll: Ident): Option[ItemData] = @@ -126,11 +134,12 @@ object QItem { ) ] .option - val attachs = RAttachment.findByItemWithMeta(id) - val sources = RAttachmentSource.findByItemWithMeta(id) - val archives = RAttachmentArchive.findByItemWithMeta(id) - - val tags = RTag.findByItem(id) + logger.trace(s"Find item query: $cq") + val attachs = RAttachment.findByItemWithMeta(id) + val sources = RAttachmentSource.findByItemWithMeta(id) + val archives = RAttachmentArchive.findByItemWithMeta(id) + val tags = RTag.findByItem(id) + val customfields = findCustomFieldValues(id) for { data <- q @@ -138,11 +147,32 @@ object QItem { srcs <- sources arch <- archives ts <- tags + cfs <- customfields } yield data.map(d => - ItemData(d._1, d._2, d._3, d._4, d._5, d._6, d._7, ts, att, srcs, arch) + ItemData(d._1, d._2, d._3, d._4, d._5, d._6, d._7, ts, att, srcs, arch, cfs) ) } + def findCustomFieldValues(itemId: Ident): ConnectionIO[Vector[ItemFieldValue]] = { + val cfId = RCustomField.Columns.id.prefix("cf") + val cfName = RCustomField.Columns.name.prefix("cf") + val cfLabel = RCustomField.Columns.label.prefix("cf") + val cfType = RCustomField.Columns.ftype.prefix("cf") + val cvItem = RCustomFieldValue.Columns.itemId.prefix("cvf") + val cvValue = RCustomFieldValue.Columns.value.prefix("cvf") + val cvField = RCustomFieldValue.Columns.field.prefix("cvf") + + val cfFrom = + RCustomFieldValue.table ++ fr"cvf INNER JOIN" ++ RCustomField.table ++ fr"cf ON" ++ cvField + .is(cfId) + + selectSimple( + Seq(cfId, cfName, cfLabel, cfType, cvValue), + cfFrom, + cvItem.is(itemId) + ).query[ItemFieldValue].to[Vector] + } + case class ListItem( id: Ident, name: String, From 76647d132f3c6872915a21ec8893e59b0e96bf76 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 22 Nov 2020 02:12:26 +0100 Subject: [PATCH 12/29] Show custom field values in detail view --- .../src/main/elm/Comp/CustomFieldInput.elm | 50 +++++++++++++++ .../main/elm/Comp/CustomFieldMultiInput.elm | 61 +++++++++++++++++-- .../src/main/elm/Comp/ItemDetail/Update.elm | 10 ++- 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm index 86bf321f..df57d58e 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm @@ -4,11 +4,13 @@ module Comp.CustomFieldInput exposing , Msg , UpdateResult , init + , initWith , update , view ) import Api.Model.CustomField exposing (CustomField) +import Api.Model.ItemFieldValue exposing (ItemFieldValue) import Comp.DatePicker import Data.CustomFieldType exposing (CustomFieldType) import Date exposing (Date) @@ -110,6 +112,54 @@ init field = ) +initWith : ItemFieldValue -> ( Model, Cmd Msg ) +initWith value = + let + field = + CustomField value.id value.name value.label value.ftype 0 0 + + ( dm, dc ) = + Comp.DatePicker.init + in + ( { field = field + , fieldModel = + case fieldType field of + Data.CustomFieldType.Text -> + TextField (Just value.value) + + Data.CustomFieldType.Numeric -> + let + ( fm, _ ) = + updateFloatModel value.value identity + in + NumberField fm + + Data.CustomFieldType.Money -> + let + ( fm, _ ) = + updateFloatModel value.value identity + in + MoneyField fm + + Data.CustomFieldType.Boolean -> + BoolField (value.value == "true") + + Data.CustomFieldType.Date -> + case Date.fromIsoString value.value of + Ok d -> + DateField (Just d) dm + + Err _ -> + DateField Nothing dm + } + , if fieldType field == Data.CustomFieldType.Date then + Cmd.map DateMsg dc + + else + Cmd.none + ) + + type FieldResult = NoResult | RemoveField diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm index 205aba81..4eb85ced 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm @@ -6,6 +6,7 @@ module Comp.CustomFieldMultiInput exposing , init , initCmd , initWith + , setValues , update , view ) @@ -13,6 +14,7 @@ module Comp.CustomFieldMultiInput exposing import Api import Api.Model.CustomField exposing (CustomField) import Api.Model.CustomFieldList exposing (CustomFieldList) +import Api.Model.ItemFieldValue exposing (ItemFieldValue) import Comp.CustomFieldInput import Comp.FixedDropdown import Data.Flags exposing (Flags) @@ -21,6 +23,7 @@ import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onClick) import Http +import Util.List import Util.Maybe @@ -39,6 +42,7 @@ type Msg | CreateNewField | CustomFieldResp (Result Http.Error CustomFieldList) | FieldSelectMsg (Comp.FixedDropdown.Msg CustomField) + | SetValues (List ItemFieldValue) type FieldResult @@ -75,6 +79,11 @@ initCmd flags = Api.getCustomFields flags "" CustomFieldResp +setValues : List ItemFieldValue -> Msg +setValues values = + SetValues values + + mkFieldSelect : List CustomField -> FieldSelect mkFieldSelect fields = { selected = Nothing @@ -107,10 +116,15 @@ update msg model = CustomFieldResp (Ok list) -> let + avail = + List.filter + (\e -> not <| Dict.member e.name model.fieldModels) + list.items + model_ = { model - | availableFields = list.items - , fieldSelect = mkFieldSelect list.items + | availableFields = avail + , fieldSelect = mkFieldSelect avail } in UpdateResult model_ Cmd.none Sub.none NoResult @@ -144,7 +158,7 @@ update msg model = ApplyField f -> let notSelected e = - e /= f + e /= f && (not <| Dict.member e.name model.fieldModels) ( fm, fc ) = Comp.CustomFieldInput.init f @@ -153,7 +167,9 @@ update msg model = List.filter notSelected model.availableFields visible = - f :: model.visibleFields + f + :: model.visibleFields + |> List.sortBy .name fSelect = mkFieldSelect avail @@ -233,6 +249,43 @@ update msg model = Nothing -> UpdateResult model Cmd.none Sub.none NoResult + SetValues values -> + let + field value = + CustomField value.id value.name value.label value.ftype 0 0 + + merge fv ( dict, cmds ) = + let + ( fim, fic ) = + Comp.CustomFieldInput.initWith fv + in + ( Dict.insert fv.name fim dict + , Cmd.map (CustomFieldInputMsg (field fv)) fic :: cmds + ) + + ( modelDict, cmdList ) = + List.foldl merge ( Dict.empty, [] ) values + + avail = + List.filter + (\e -> not <| Dict.member e.name modelDict) + (model.availableFields ++ model.visibleFields) + + model_ = + { model + | fieldModels = modelDict + , availableFields = avail + , fieldSelect = mkFieldSelect avail + , visibleFields = + model.visibleFields + ++ model.availableFields + |> List.filter (\e -> Dict.member e.name modelDict) + |> Util.List.distinct + |> List.sortBy .name + } + in + UpdateResult model_ (Cmd.batch cmdList) Sub.none NoResult + view : String -> Model -> Html Msg view classes model = diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm index 25fe1a93..cdca4c34 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -199,6 +199,14 @@ update key flags inav settings msg model = ) res7.model + res9 = + update key + flags + inav + settings + (CustomFieldMsg (Comp.CustomFieldMultiInput.setValues item.customfields)) + res8.model + proposalCmd = if item.state == "created" then Api.getItemProposals flags item.id GetProposalResp @@ -207,7 +215,7 @@ update key flags inav settings msg model = Cmd.none lastModel = - res8.model + res9.model in { model = { lastModel From ff30ed555851890923ea81d4957bd425f219a92a Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 22 Nov 2020 12:03:28 +0100 Subject: [PATCH 13/29] Add custom fields to multi-edit form --- modules/webapp/src/main/elm/Api.elm | 31 ++++++++ .../main/elm/Comp/CustomFieldMultiInput.elm | 42 ++++++++--- .../src/main/elm/Comp/ItemDetail/EditMenu.elm | 71 ++++++++++++++++--- .../main/elm/Comp/ItemDetail/FormChange.elm | 19 +++++ .../src/main/elm/Comp/ItemDetail/View.elm | 9 ++- modules/webapp/src/main/elm/Data/Icons.elm | 15 ++++ 6 files changed, 165 insertions(+), 22 deletions(-) diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 2ef008d5..c36c709f 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -21,6 +21,7 @@ module Api exposing , deleteAttachment , deleteCustomField , deleteCustomValue + , deleteCustomValueMultiple , deleteEquip , deleteFolder , deleteImapSettings @@ -80,6 +81,7 @@ module Api exposing , postTag , putCustomField , putCustomValue + , putCustomValueMultiple , putUser , refreshSession , register @@ -159,6 +161,7 @@ import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) import Api.Model.ItemsAndDate exposing (ItemsAndDate) import Api.Model.ItemsAndDirection exposing (ItemsAndDirection) +import Api.Model.ItemsAndFieldValue exposing (ItemsAndFieldValue) import Api.Model.ItemsAndName exposing (ItemsAndName) import Api.Model.ItemsAndRef exposing (ItemsAndRef) import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) @@ -211,6 +214,34 @@ import Util.Http as Http2 --- Custom Fields +putCustomValueMultiple : + Flags + -> ItemsAndFieldValue + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +putCustomValueMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/customfield" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndFieldValue.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +deleteCustomValueMultiple : + Flags + -> ItemsAndName + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +deleteCustomValueMultiple flags data receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/items/customfieldremove" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndName.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + deleteCustomValue : Flags -> String diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm index 4eb85ced..a64413d8 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm @@ -3,6 +3,7 @@ module Comp.CustomFieldMultiInput exposing , Model , Msg , UpdateResult + , ViewSettings , init , initCmd , initWith @@ -287,25 +288,46 @@ update msg model = UpdateResult model_ (Cmd.batch cmdList) Sub.none NoResult -view : String -> Model -> Html Msg -view classes model = - div [ class classes ] - (viewMenuBar model + +--- View + + +type alias ViewSettings = + { showAddButton : Bool + , classes : String + } + + +view : ViewSettings -> Model -> Html Msg +view viewSettings model = + div [ class viewSettings.classes ] + (viewMenuBar viewSettings model :: List.map (viewCustomField model) model.visibleFields ) -viewMenuBar : Model -> Html Msg -viewMenuBar model = +viewMenuBar : ViewSettings -> Model -> Html Msg +viewMenuBar viewSettings model = let { dropdown, selected } = model.fieldSelect in - div [ class "ui action input field" ] - [ Html.map FieldSelectMsg - (Comp.FixedDropdown.viewStyled "fluid" (Maybe.map mkItem selected) dropdown) - , addFieldLink "" model + div + [ classList + [ ( "field", True ) + , ( "ui action input", viewSettings.showAddButton ) + ] ] + (Html.map FieldSelectMsg + (Comp.FixedDropdown.viewStyled "fluid" (Maybe.map mkItem selected) dropdown) + :: (if viewSettings.showAddButton then + [ addFieldLink "" model + ] + + else + [] + ) + ) viewCustomField : Model -> CustomField -> Html Msg diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm index 161b5871..c919fe92 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm @@ -18,6 +18,7 @@ import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ReferenceList exposing (ReferenceList) import Api.Model.Tag exposing (Tag) import Api.Model.TagList exposing (TagList) +import Comp.CustomFieldMultiInput exposing (FieldResult(..)) import Comp.DatePicker import Comp.DetailEdit import Comp.Dropdown exposing (isDropdownChangeMsg) @@ -77,6 +78,7 @@ type alias Model = , concEquipModel : Comp.Dropdown.Model IdName , modalEdit : Maybe Comp.DetailEdit.Model , tagEditMode : TagEditMode + , customFieldModel : Comp.CustomFieldMultiInput.Model } @@ -102,6 +104,7 @@ type Msg | GetPersonResp (Result Http.Error ReferenceList) | GetEquipResp (Result Http.Error EquipmentList) | GetFolderResp (Result Http.Error FolderList) + | CustomFieldMsg Comp.CustomFieldMultiInput.Msg init : Model @@ -155,6 +158,7 @@ init = , dueDatePicker = Comp.DatePicker.emptyModel , modalEdit = Nothing , tagEditMode = AddTags + , customFieldModel = Comp.CustomFieldMultiInput.initWith [] } @@ -170,6 +174,7 @@ loadModel flags = , Api.getPersonsLight flags GetPersonResp , Api.getEquipments flags "" GetEquipResp , Api.getFolders flags "" False GetFolderResp + , Cmd.map CustomFieldMsg (Comp.CustomFieldMultiInput.initCmd flags) , Cmd.map ItemDatePickerMsg dpc , Cmd.map DueDatePickerMsg dpc ] @@ -547,6 +552,36 @@ update flags msg model = in UpdateResult newModel cmd sub NoFormChange + CustomFieldMsg lm -> + let + res = + Comp.CustomFieldMultiInput.update lm model.customFieldModel + + model_ = + { model | customFieldModel = res.model } + + cmd_ = + Cmd.map CustomFieldMsg res.cmd + + sub_ = + Sub.map CustomFieldMsg res.subs + + change = + case res.result of + NoResult -> + NoFormChange + + FieldValueRemove cf -> + RemoveCustomValue cf + + FieldValueChange cf value -> + CustomValueChange cf value + + FieldCreateNew -> + NoFormChange + in + UpdateResult model_ cmd_ sub_ change + nameThrottleSub : Model -> Sub Msg nameThrottleSub model = @@ -614,6 +649,9 @@ renderEditForm cfg settings model = ReplaceTags -> "Tags chosen here *replace* those on selected items." + + customFieldSettings = + Comp.CustomFieldMultiInput.ViewSettings False "field" in div [ class cfg.menuClass ] [ div [ class "ui form warning" ] @@ -687,13 +725,18 @@ item visible. This message will disappear then. """ ] ] - , optional [ Data.Fields.Direction ] <| - div [ class "field" ] - [ label [] - [ Icons.directionIcon "grey" - , text "Direction" - ] - , Html.map DirDropdownMsg (Comp.Dropdown.view settings model.directionModel) + , optional [ Data.Fields.CustomFields ] <| + h4 [ class "ui dividing header" ] + [ Icons.customFieldIcon "" + , text "Custom Fields" + ] + , optional [ Data.Fields.CustomFields ] <| + Html.map CustomFieldMsg + (Comp.CustomFieldMultiInput.view customFieldSettings model.customFieldModel) + , optional [ Data.Fields.Date, Data.Fields.DueDate ] <| + h4 [ class "ui dividing header" ] + [ Icons.itemDatesIcon "" + , text "Item Dates" ] , optional [ Data.Fields.Date ] <| div [ class "field" ] @@ -701,7 +744,7 @@ item visible. This message will disappear then. [ Icons.dateIcon "grey" , text "Date" ] - , div [ class "ui action input" ] + , div [ class "ui left icon action input" ] [ Html.map ItemDatePickerMsg (Comp.DatePicker.viewTime model.itemDate @@ -711,6 +754,7 @@ item visible. This message will disappear then. , a [ class "ui icon button", href "", onClick RemoveDate ] [ i [ class "trash alternate outline icon" ] [] ] + , Icons.dateIcon "" ] ] , optional [ Data.Fields.DueDate ] <| @@ -719,7 +763,7 @@ item visible. This message will disappear then. [ Icons.dueDateIcon "grey" , text "Due Date" ] - , div [ class "ui action input" ] + , div [ class "ui left icon action input" ] [ Html.map DueDatePickerMsg (Comp.DatePicker.viewTime model.dueDate @@ -728,6 +772,7 @@ item visible. This message will disappear then. ) , a [ class "ui icon button", href "", onClick RemoveDueDate ] [ i [ class "trash alternate outline icon" ] [] ] + , Icons.dueDateIcon "" ] ] , optional [ Data.Fields.CorrOrg, Data.Fields.CorrPerson ] <| @@ -772,6 +817,14 @@ item visible. This message will disappear then. ] , Html.map ConcEquipMsg (Comp.Dropdown.view settings model.concEquipModel) ] + , optional [ Data.Fields.Direction ] <| + div [ class "field" ] + [ label [] + [ Icons.directionIcon "grey" + , text "Direction" + ] + , Html.map DirDropdownMsg (Comp.Dropdown.view settings model.directionModel) + ] ] ] diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm index b7521ba4..2bb39e91 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm @@ -5,9 +5,12 @@ module Comp.ItemDetail.FormChange exposing import Api import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.CustomField exposing (CustomField) +import Api.Model.CustomFieldValue exposing (CustomFieldValue) import Api.Model.IdName exposing (IdName) import Api.Model.ItemsAndDate exposing (ItemsAndDate) import Api.Model.ItemsAndDirection exposing (ItemsAndDirection) +import Api.Model.ItemsAndFieldValue exposing (ItemsAndFieldValue) import Api.Model.ItemsAndName exposing (ItemsAndName) import Api.Model.ItemsAndRef exposing (ItemsAndRef) import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) @@ -33,6 +36,8 @@ type FormChange | DueDateChange (Maybe Int) | NameChange String | ConfirmChange Bool + | CustomValueChange CustomField String + | RemoveCustomValue CustomField multiUpdate : @@ -47,6 +52,20 @@ multiUpdate flags ids change receive = Set.toList ids in case change of + CustomValueChange field value -> + let + data = + ItemsAndFieldValue items (CustomFieldValue field.id value) + in + Api.putCustomValueMultiple flags data receive + + RemoveCustomValue field -> + let + data = + ItemsAndName items field.id + in + Api.deleteCustomValueMultiple flags data receive + ReplaceTagChange tags -> let data = diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm index 7b4e43ec..ddff5af6 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm @@ -728,6 +728,9 @@ renderEditForm settings model = else span [ class "invisible hidden" ] [] + + customFieldSettings = + Comp.CustomFieldMultiInput.ViewSettings True "field" in div [ class "ui attached segment" ] [ div [ class "ui form warning" ] @@ -781,11 +784,11 @@ item visible. This message will disappear then. ] , optional [ Data.Fields.CustomFields ] <| Html.map CustomFieldMsg - (Comp.CustomFieldMultiInput.view "field" model.customFieldsModel) + (Comp.CustomFieldMultiInput.view customFieldSettings model.customFieldsModel) , optional [ Data.Fields.DueDate, Data.Fields.Date ] <| h4 [ class "ui dividing header" ] - [ Icons.dateIcon "" - , text "Dates" + [ Icons.itemDatesIcon "" + , text "Item Dates" ] , optional [ Data.Fields.Date ] <| div [ class "field" ] diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm index f970ae2f..375f11ef 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -19,6 +19,7 @@ module Data.Icons exposing , equipmentIcon , folder , folderIcon + , itemDatesIcon , organization , organizationIcon , person @@ -85,6 +86,20 @@ correspondentIcon classes = i [ class (correspondent ++ " " ++ classes) ] [] +itemDates : String +itemDates = + "calendar alternate outline icon" + + +itemDatesIcon : String -> Html msg +itemDatesIcon classes = + i + [ class classes + , class itemDates + ] + [] + + date : String date = "calendar outline icon" From bb19e02c668cf61c5566587a1f3a6319011c22c4 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 22 Nov 2020 13:25:24 +0100 Subject: [PATCH 14/29] Ui improvements - don't show custom fields in edit menu if there are none. This reduces load of ui elements. The first custom field must be created in manage-data page. - Add more validation to the money type --- .../src/main/elm/Comp/CustomFieldInput.elm | 48 +++++++++---------- .../main/elm/Comp/CustomFieldMultiInput.elm | 6 +++ .../src/main/elm/Comp/ItemDetail/View.elm | 14 +++++- modules/webapp/src/main/elm/Data/Money.elm | 43 +++++++++++++++++ 4 files changed, 85 insertions(+), 26 deletions(-) create mode 100644 modules/webapp/src/main/elm/Data/Money.elm diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm index df57d58e..deb3dc81 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm @@ -13,6 +13,7 @@ import Api.Model.CustomField exposing (CustomField) import Api.Model.ItemFieldValue exposing (ItemFieldValue) import Comp.DatePicker import Data.CustomFieldType exposing (CustomFieldType) +import Data.Money import Date exposing (Date) import DatePicker exposing (DatePicker) import Html exposing (..) @@ -130,14 +131,14 @@ initWith value = Data.CustomFieldType.Numeric -> let ( fm, _ ) = - updateFloatModel value.value identity + updateFloatModel value.value string2Float in NumberField fm Data.CustomFieldType.Money -> let ( fm, _ ) = - updateFloatModel value.value identity + updateFloatModel value.value Data.Money.fromString in MoneyField fm @@ -174,35 +175,32 @@ type alias UpdateResult = } -updateFloatModel : String -> (Float -> Float) -> ( FloatModel, FieldResult ) -updateFloatModel msg rounding = - case String.toFloat msg of - Just n -> - let - fieldVal = - if String.endsWith "." msg || String.endsWith ".0" msg then - msg - - else - String.fromFloat (rounding n) - in - ( { input = fieldVal - , result = Ok (rounding n) +updateFloatModel : String -> (String -> Result String Float) -> ( FloatModel, FieldResult ) +updateFloatModel msg parse = + case parse msg of + Ok n -> + ( { input = msg + , result = Ok n } - , Value (String.fromFloat (rounding n)) + , Value msg ) - Nothing -> + Err err -> ( { input = msg - , result = Err ("Not a number: " ++ msg) + , result = Err err } , NoResult ) -roundScale2 : Float -> Float -roundScale2 input = - (round (input * 100) |> toFloat) / 100 +string2Float : String -> Result String Float +string2Float str = + case String.toFloat str of + Just n -> + Ok n + + Nothing -> + Err ("Not a number: " ++ str) update : Msg -> Model -> UpdateResult @@ -218,7 +216,7 @@ update msg model = ( NumberMsg str, NumberField _ ) -> let ( fm, res ) = - updateFloatModel str identity + updateFloatModel str string2Float model_ = { model | fieldModel = NumberField fm } @@ -228,7 +226,9 @@ update msg model = ( MoneyMsg str, MoneyField _ ) -> let ( fm, res ) = - updateFloatModel str roundScale2 + updateFloatModel + str + Data.Money.fromString model_ = { model | fieldModel = MoneyField fm } diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm index a64413d8..b5f7f57f 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm @@ -7,6 +7,7 @@ module Comp.CustomFieldMultiInput exposing , init , initCmd , initWith + , nonEmpty , setValues , update , view @@ -59,6 +60,11 @@ type alias FieldSelect = } +nonEmpty : Model -> Bool +nonEmpty model = + not (List.isEmpty model.availableFields && List.isEmpty model.visibleFields) + + initWith : List CustomField -> Model initWith fields = { fieldModels = Dict.empty diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm index ddff5af6..d819a368 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm @@ -729,6 +729,10 @@ renderEditForm settings model = else span [ class "invisible hidden" ] [] + showCustomFields = + fieldVisible Data.Fields.CustomFields + && Comp.CustomFieldMultiInput.nonEmpty model.customFieldsModel + customFieldSettings = Comp.CustomFieldMultiInput.ViewSettings True "field" in @@ -777,14 +781,20 @@ item visible. This message will disappear then. """ ] ] - , optional [ Data.Fields.CustomFields ] <| + , if showCustomFields then h4 [ class "ui dividing header" ] [ Icons.customFieldIcon "" , text "Custom Fields" ] - , optional [ Data.Fields.CustomFields ] <| + + else + span [ class "hidden invisible" ] [] + , if showCustomFields then Html.map CustomFieldMsg (Comp.CustomFieldMultiInput.view customFieldSettings model.customFieldsModel) + + else + span [ class "hidden invisible" ] [] , optional [ Data.Fields.DueDate, Data.Fields.Date ] <| h4 [ class "ui dividing header" ] [ Icons.itemDatesIcon "" diff --git a/modules/webapp/src/main/elm/Data/Money.elm b/modules/webapp/src/main/elm/Data/Money.elm new file mode 100644 index 00000000..c62c3144 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/Money.elm @@ -0,0 +1,43 @@ +module Data.Money exposing + ( Money + , format + , fromString + , roundMoney + ) + + +type alias Money = + Float + + +fromString : String -> Result String Money +fromString str = + let + points = + String.indexes "." str + + len = + String.length str + in + case points of + index :: [] -> + if index == (len - 3) then + String.toFloat str + |> Maybe.map Ok + |> Maybe.withDefault (Err "Two digits required after the dot.") + + else + Err ("Two digits required after the dot: " ++ str) + + _ -> + Err "One single dot + digits required for money." + + +format : Float -> String +format money = + String.fromFloat (roundMoney money) + + +roundMoney : Float -> Float +roundMoney input = + (round (input * 100) |> toFloat) / 100 From c5ab663091250a45005af90d781054b3726178d3 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 22 Nov 2020 15:24:15 +0100 Subject: [PATCH 15/29] Allow a comma and a point for money values --- .../src/main/elm/Comp/CustomFieldInput.elm | 22 +++++++++++++------ modules/webapp/src/main/elm/Data/Money.elm | 13 +++++++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm index deb3dc81..b0ff5137 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm @@ -131,14 +131,17 @@ initWith value = Data.CustomFieldType.Numeric -> let ( fm, _ ) = - updateFloatModel value.value string2Float + updateFloatModel value.value string2Float identity in NumberField fm Data.CustomFieldType.Money -> let ( fm, _ ) = - updateFloatModel value.value Data.Money.fromString + updateFloatModel + value.value + Data.Money.fromString + Data.Money.normalizeInput in MoneyField fm @@ -175,14 +178,18 @@ type alias UpdateResult = } -updateFloatModel : String -> (String -> Result String Float) -> ( FloatModel, FieldResult ) -updateFloatModel msg parse = +updateFloatModel : + String + -> (String -> Result String Float) + -> (String -> String) + -> ( FloatModel, FieldResult ) +updateFloatModel msg parse normalize = case parse msg of Ok n -> - ( { input = msg + ( { input = normalize msg , result = Ok n } - , Value msg + , Value (normalize msg) ) Err err -> @@ -216,7 +223,7 @@ update msg model = ( NumberMsg str, NumberField _ ) -> let ( fm, res ) = - updateFloatModel str string2Float + updateFloatModel str string2Float identity model_ = { model | fieldModel = NumberField fm } @@ -229,6 +236,7 @@ update msg model = updateFloatModel str Data.Money.fromString + Data.Money.normalizeInput model_ = { model | fieldModel = MoneyField fm } diff --git a/modules/webapp/src/main/elm/Data/Money.elm b/modules/webapp/src/main/elm/Data/Money.elm index c62c3144..7856f517 100644 --- a/modules/webapp/src/main/elm/Data/Money.elm +++ b/modules/webapp/src/main/elm/Data/Money.elm @@ -2,6 +2,7 @@ module Data.Money exposing ( Money , format , fromString + , normalizeInput , roundMoney ) @@ -13,8 +14,11 @@ type alias Money = fromString : String -> Result String Money fromString str = let + input = + normalizeInput str + points = - String.indexes "." str + String.indexes "." input len = String.length str @@ -22,7 +26,7 @@ fromString str = case points of index :: [] -> if index == (len - 3) then - String.toFloat str + String.toFloat input |> Maybe.map Ok |> Maybe.withDefault (Err "Two digits required after the dot.") @@ -41,3 +45,8 @@ format money = roundMoney : Float -> Float roundMoney input = (round (input * 100) |> toFloat) / 100 + + +normalizeInput : String -> String +normalizeInput str = + String.replace "," "." str From 066c856981a697f33b97d31f6939f3218eb7e67e Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 22 Nov 2020 18:25:27 +0100 Subject: [PATCH 16/29] Allow to search for custom field values --- .../docspell/backend/ops/OItemSearch.scala | 3 ++ .../src/main/resources/docspell-openapi.yml | 5 ++ .../restserver/conv/Conversions.scala | 4 ++ .../scala/docspell/store/queries/QItem.scala | 50 +++++++++++++++++-- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala index 41870dce..c546a184 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala @@ -53,6 +53,9 @@ trait OItemSearch[F[_]] { object OItemSearch { + type CustomValue = QItem.CustomValue + val CustomValue = QItem.CustomValue + type Query = QItem.Query val Query = QItem.Query diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 8426f129..ed0df51b 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -4952,6 +4952,7 @@ components: - inbox - offset - limit + - customValues properties: tagsInclude: type: array @@ -5031,6 +5032,10 @@ components: format: date-time itemSubset: $ref: "#/components/schemas/IdList" + customValues: + type: array + items: + $ref: "#/components/schemas/CustomFieldValue" ItemLight: description: | An item with only a few important properties. diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 2f7f0378..e5d49fc2 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -143,9 +143,13 @@ trait Conversions { m.itemSubset .map(_.ids.flatMap(i => Ident.fromString(i).toOption).toSet) .filter(_.nonEmpty), + m.customValues.map(mkCustomValue), None ) + def mkCustomValue(v: CustomFieldValue): OItemSearch.CustomValue = + OItemSearch.CustomValue(v.field, v.value) + def mkItemList(v: Vector[OItemSearch.ListItem]): ItemLightList = { val groups = v.groupBy(item => item.date.toUtcDate.toString.substring(0, 7)) diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index 91deca4d..f587ae9f 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -139,7 +139,7 @@ object QItem { val sources = RAttachmentSource.findByItemWithMeta(id) val archives = RAttachmentArchive.findByItemWithMeta(id) val tags = RTag.findByItem(id) - val customfields = findCustomFieldValues(id) + val customfields = findCustomFieldValuesForItem(id) for { data <- q @@ -153,7 +153,9 @@ object QItem { ) } - def findCustomFieldValues(itemId: Ident): ConnectionIO[Vector[ItemFieldValue]] = { + def findCustomFieldValuesForItem( + itemId: Ident + ): ConnectionIO[Vector[ItemFieldValue]] = { val cfId = RCustomField.Columns.id.prefix("cf") val cfName = RCustomField.Columns.name.prefix("cf") val cfLabel = RCustomField.Columns.label.prefix("cf") @@ -191,6 +193,8 @@ object QItem { notes: Option[String] ) + case class CustomValue(field: Ident, value: String) + case class Query( account: AccountId, name: Option[String], @@ -211,6 +215,7 @@ object QItem { dueDateTo: Option[Timestamp], allNames: Option[String], itemIds: Option[Set[Ident]], + customValues: Seq[CustomValue], orderAsc: Option[RItem.Columns.type => Column] ) @@ -236,6 +241,7 @@ object QItem { None, None, None, + Seq.empty, None ) } @@ -261,6 +267,35 @@ object QItem { Batch(0, c) } + private def findCustomFieldValuesForColl( + coll: Ident, + cv: Seq[CustomValue] + ): Seq[(String, Fragment)] = { + val cfId = RCustomField.Columns.id.prefix("cf") + val cfName = RCustomField.Columns.name.prefix("cf") + val cfColl = RCustomField.Columns.cid.prefix("cf") + val cvValue = RCustomFieldValue.Columns.value.prefix("cvf") + val cvField = RCustomFieldValue.Columns.field.prefix("cvf") + val cvItem = RCustomFieldValue.Columns.itemId.prefix("cvf") + + val cfFrom = + RCustomFieldValue.table ++ fr"cvf INNER JOIN" ++ RCustomField.table ++ fr"cf ON" ++ cvField + .is(cfId) + + def singleSelect(v: CustomValue) = + selectSimple( + Seq(cvItem), + cfFrom, + and( + cfColl.is(coll), + or(cfName.is(v.field), cfId.is(v.field)), + cvValue.is(v.value) + ) + ) + if (cv.isEmpty) Seq.empty + else Seq("customvalues" -> cv.map(singleSelect).reduce(_ ++ fr"INTERSECT" ++ _)) + } + private def findItemsBase( q: Query, distinct: Boolean, @@ -279,6 +314,7 @@ object QItem { val orgCols = List(OC.oid, OC.name) val equipCols = List(EC.eid, EC.name) val folderCols = List(FC.id, FC.name) + val cvItem = RCustomFieldValue.Columns.itemId.prefix("cv") val finalCols = commas( Seq( @@ -325,6 +361,9 @@ object QItem { val withAttach = fr"SELECT COUNT(" ++ AC.id.f ++ fr") as num, " ++ AC.itemId.f ++ fr"from" ++ RAttachment.table ++ fr"GROUP BY (" ++ AC.itemId.f ++ fr")" + val withCustomValues = + findCustomFieldValuesForColl(q.account.collective, q.customValues) + val selectKW = if (distinct) fr"SELECT DISTINCT" else fr"SELECT" withCTE( (Seq( @@ -334,7 +373,7 @@ object QItem { "equips" -> withEquips, "attachs" -> withAttach, "folders" -> withFolder - ) ++ ctes): _* + ) ++ withCustomValues ++ ctes): _* ) ++ selectKW ++ finalCols ++ fr" FROM items i" ++ fr"LEFT JOIN attachs a ON" ++ IC.id.prefix("i").is(AC.itemId.prefix("a")) ++ @@ -344,7 +383,10 @@ object QItem { fr"LEFT JOIN equips e1 ON" ++ IC.concEquipment .prefix("i") .is(EC.eid.prefix("e1")) ++ - fr"LEFT JOIN folders f1 ON" ++ IC.folder.prefix("i").is(FC.id.prefix("f1")) + fr"LEFT JOIN folders f1 ON" ++ IC.folder.prefix("i").is(FC.id.prefix("f1")) ++ + (if (q.customValues.isEmpty) Fragment.empty + else + fr"INNER JOIN customvalues cv ON" ++ cvItem.is(IC.id.prefix("i"))) } def findItems( From 23b343649c7565c834174c415365e8a581739ee3 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 22 Nov 2020 20:07:35 +0100 Subject: [PATCH 17/29] Fix and enhance custom-multi-input field --- .../main/elm/Comp/CustomFieldMultiInput.elm | 180 +++++++++--------- .../src/main/elm/Comp/ItemDetail/EditMenu.elm | 5 +- .../src/main/elm/Comp/ItemDetail/Update.elm | 5 +- .../src/main/elm/Data/CustomFieldChange.elm | 77 ++++++++ 4 files changed, 175 insertions(+), 92 deletions(-) create mode 100644 modules/webapp/src/main/elm/Data/CustomFieldChange.elm diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm index b5f7f57f..e0aadf4e 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm @@ -1,13 +1,14 @@ module Comp.CustomFieldMultiInput exposing - ( FieldResult(..) - , Model + ( Model , Msg , UpdateResult , ViewSettings , init , initCmd , initWith + , isEmpty , nonEmpty + , reset , setValues , update , view @@ -19,24 +20,49 @@ import Api.Model.CustomFieldList exposing (CustomFieldList) import Api.Model.ItemFieldValue exposing (ItemFieldValue) import Comp.CustomFieldInput import Comp.FixedDropdown +import Data.CustomFieldChange exposing (CustomFieldChange(..)) import Data.Flags exposing (Flags) import Dict exposing (Dict) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onClick) import Http -import Util.List import Util.Maybe type alias Model = - { fieldModels : Dict String Comp.CustomFieldInput.Model - , fieldSelect : FieldSelect - , visibleFields : List CustomField - , availableFields : List CustomField + { fieldSelect : FieldSelect + , visibleFields : Dict String VisibleField + , allFields : List CustomField } +type alias FieldSelect = + { selected : Maybe CustomField + , dropdown : Comp.FixedDropdown.Model CustomField + } + + +type alias VisibleField = + { field : CustomField + , inputModel : Comp.CustomFieldInput.Model + } + + +visibleFields : Model -> List CustomField +visibleFields model = + Dict.toList model.visibleFields + |> List.map (Tuple.second >> .field) + |> List.sortBy .name + + +currentOptions : List CustomField -> Dict String VisibleField -> List CustomField +currentOptions all visible = + List.filter + (\e -> not <| Dict.member e.name visible) + all + + type Msg = CustomFieldInputMsg CustomField Comp.CustomFieldInput.Msg | ApplyField CustomField @@ -47,30 +73,21 @@ type Msg | SetValues (List ItemFieldValue) -type FieldResult - = NoResult - | FieldValueRemove CustomField - | FieldValueChange CustomField String - | FieldCreateNew - - -type alias FieldSelect = - { selected : Maybe CustomField - , dropdown : Comp.FixedDropdown.Model CustomField - } - - nonEmpty : Model -> Bool nonEmpty model = - not (List.isEmpty model.availableFields && List.isEmpty model.visibleFields) + not (isEmpty model) + + +isEmpty : Model -> Bool +isEmpty model = + List.isEmpty model.allFields initWith : List CustomField -> Model initWith fields = - { fieldModels = Dict.empty - , fieldSelect = mkFieldSelect fields - , visibleFields = [] - , availableFields = fields + { fieldSelect = mkFieldSelect (currentOptions fields Dict.empty) + , visibleFields = Dict.empty + , allFields = fields } @@ -91,6 +108,18 @@ setValues values = SetValues values +reset : Model -> Model +reset model = + let + opts = + currentOptions model.allFields Dict.empty + in + { model + | fieldSelect = mkFieldSelect opts + , visibleFields = Dict.empty + } + + mkFieldSelect : List CustomField -> FieldSelect mkFieldSelect fields = { selected = Nothing @@ -106,7 +135,7 @@ type alias UpdateResult = { model : Model , cmd : Cmd Msg , subs : Sub Msg - , result : FieldResult + , result : CustomFieldChange } @@ -123,21 +152,16 @@ update msg model = CustomFieldResp (Ok list) -> let - avail = - List.filter - (\e -> not <| Dict.member e.name model.fieldModels) - list.items - model_ = { model - | availableFields = avail - , fieldSelect = mkFieldSelect avail + | allFields = list.items + , fieldSelect = mkFieldSelect (currentOptions list.items model.visibleFields) } in - UpdateResult model_ Cmd.none Sub.none NoResult + UpdateResult model_ Cmd.none Sub.none NoFieldChange CustomFieldResp (Err _) -> - UpdateResult model Cmd.none Sub.none NoResult + UpdateResult model Cmd.none Sub.none NoFieldChange FieldSelectMsg lm -> let @@ -160,26 +184,18 @@ update msg model = update (ApplyField field) model Nothing -> - UpdateResult model_ Cmd.none Sub.none NoResult + UpdateResult model_ Cmd.none Sub.none NoFieldChange ApplyField f -> let - notSelected e = - e /= f && (not <| Dict.member e.name model.fieldModels) - ( fm, fc ) = Comp.CustomFieldInput.init f - avail = - List.filter notSelected model.availableFields - visible = - f - :: model.visibleFields - |> List.sortBy .name + Dict.insert f.name (VisibleField f fm) model.visibleFields fSelect = - mkFieldSelect avail + mkFieldSelect (currentOptions model.allFields visible) -- have to re-state the open menu when this is invoked -- from a click in the dropdown @@ -192,46 +208,43 @@ update msg model = model_ = { model | fieldSelect = { fSelect | dropdown = dropdownOpen } - , availableFields = avail , visibleFields = visible - , fieldModels = Dict.insert f.name fm model.fieldModels } cmd_ = Cmd.map (CustomFieldInputMsg f) fc in - UpdateResult model_ cmd_ Sub.none NoResult + UpdateResult model_ cmd_ Sub.none NoFieldChange RemoveField f -> let - avail = - f :: model.availableFields - visible = - List.filter (\e -> e /= f) model.visibleFields + Dict.remove f.name model.visibleFields model_ = { model - | availableFields = avail - , visibleFields = visible - , fieldSelect = mkFieldSelect avail + | visibleFields = visible + , fieldSelect = mkFieldSelect (currentOptions model.allFields visible) } in UpdateResult model_ Cmd.none Sub.none (FieldValueRemove f) - CustomFieldInputMsg field lm -> + CustomFieldInputMsg f lm -> let - fieldModel = - Dict.get field.name model.fieldModels + visibleField = + Dict.get f.name model.visibleFields in - case fieldModel of - Just fm -> + case visibleField of + Just { field, inputModel } -> let res = - Comp.CustomFieldInput.update lm fm + Comp.CustomFieldInput.update lm inputModel model_ = - { model | fieldModels = Dict.insert field.name res.model model.fieldModels } + { model + | visibleFields = + Dict.insert field.name (VisibleField field res.model) model.visibleFields + } cmd_ = Cmd.map (CustomFieldInputMsg field) res.cmd @@ -245,7 +258,7 @@ update msg model = FieldValueRemove field Comp.CustomFieldInput.NoResult -> - NoResult + NoFieldChange in if res.result == Comp.CustomFieldInput.RemoveField then update (RemoveField field) model_ @@ -254,7 +267,7 @@ update msg model = UpdateResult model_ cmd_ Sub.none result Nothing -> - UpdateResult model Cmd.none Sub.none NoResult + UpdateResult model Cmd.none Sub.none NoFieldChange SetValues values -> let @@ -265,33 +278,24 @@ update msg model = let ( fim, fic ) = Comp.CustomFieldInput.initWith fv + + f = + field fv in - ( Dict.insert fv.name fim dict - , Cmd.map (CustomFieldInputMsg (field fv)) fic :: cmds + ( Dict.insert fv.name (VisibleField f fim) dict + , Cmd.map (CustomFieldInputMsg f) fic :: cmds ) ( modelDict, cmdList ) = List.foldl merge ( Dict.empty, [] ) values - avail = - List.filter - (\e -> not <| Dict.member e.name modelDict) - (model.availableFields ++ model.visibleFields) - model_ = { model - | fieldModels = modelDict - , availableFields = avail - , fieldSelect = mkFieldSelect avail - , visibleFields = - model.visibleFields - ++ model.availableFields - |> List.filter (\e -> Dict.member e.name modelDict) - |> Util.List.distinct - |> List.sortBy .name + | fieldSelect = mkFieldSelect (currentOptions model.allFields modelDict) + , visibleFields = modelDict } in - UpdateResult model_ (Cmd.batch cmdList) Sub.none NoResult + UpdateResult model_ (Cmd.batch cmdList) Sub.none NoFieldChange @@ -308,7 +312,7 @@ view : ViewSettings -> Model -> Html Msg view viewSettings model = div [ class viewSettings.classes ] (viewMenuBar viewSettings model - :: List.map (viewCustomField model) model.visibleFields + :: List.map (viewCustomField model) (visibleFields model) ) @@ -339,13 +343,13 @@ viewMenuBar viewSettings model = viewCustomField : Model -> CustomField -> Html Msg viewCustomField model field = let - fieldModel = - Dict.get field.name model.fieldModels + visibleField = + Dict.get field.name model.visibleFields in - case fieldModel of - Just fm -> + case visibleField of + Just vf -> Html.map (CustomFieldInputMsg field) - (Comp.CustomFieldInput.view "field" Nothing fm) + (Comp.CustomFieldInput.view "field" Nothing vf.inputModel) Nothing -> span [] [] diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm index c919fe92..37d1316f 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm @@ -18,11 +18,12 @@ import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ReferenceList exposing (ReferenceList) import Api.Model.Tag exposing (Tag) import Api.Model.TagList exposing (TagList) -import Comp.CustomFieldMultiInput exposing (FieldResult(..)) +import Comp.CustomFieldMultiInput import Comp.DatePicker import Comp.DetailEdit import Comp.Dropdown exposing (isDropdownChangeMsg) import Comp.ItemDetail.FormChange exposing (FormChange(..)) +import Data.CustomFieldChange exposing (CustomFieldChange(..)) import Data.Direction exposing (Direction) import Data.Fields import Data.Flags exposing (Flags) @@ -568,7 +569,7 @@ update flags msg model = change = case res.result of - NoResult -> + NoFieldChange -> NoFormChange FieldValueRemove cf -> diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm index cdca4c34..f610f1ce 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -14,7 +14,7 @@ import Api.Model.ReferenceList exposing (ReferenceList) import Api.Model.Tag exposing (Tag) import Browser.Navigation as Nav import Comp.AttachmentMeta -import Comp.CustomFieldMultiInput exposing (FieldResult(..)) +import Comp.CustomFieldMultiInput import Comp.DatePicker import Comp.DetailEdit import Comp.Dropdown exposing (isDropdownChangeMsg) @@ -41,6 +41,7 @@ import Comp.OrgForm import Comp.PersonForm import Comp.SentMails import Comp.YesNoDimmer +import Data.CustomFieldChange exposing (CustomFieldChange(..)) import Data.Direction import Data.Fields exposing (Field) import Data.Flags exposing (Flags) @@ -1301,7 +1302,7 @@ update key flags inav settings msg model = action = case result.result of - NoResult -> + NoFieldChange -> Cmd.none FieldValueRemove field -> diff --git a/modules/webapp/src/main/elm/Data/CustomFieldChange.elm b/modules/webapp/src/main/elm/Data/CustomFieldChange.elm new file mode 100644 index 00000000..7a12dbc2 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/CustomFieldChange.elm @@ -0,0 +1,77 @@ +module Data.CustomFieldChange exposing + ( CustomFieldChange(..) + , CustomFieldValueCollect + , collectValues + , emptyCollect + , isValueChange + , toFieldValues + ) + +import Api.Model.CustomField exposing (CustomField) +import Api.Model.CustomFieldValue exposing (CustomFieldValue) +import Dict exposing (Dict) + + +type CustomFieldChange + = NoFieldChange + | FieldValueRemove CustomField + | FieldValueChange CustomField String + | FieldCreateNew + + +type CustomFieldValueCollect + = CustomFieldValueCollect (Dict String String) + + +emptyCollect : CustomFieldValueCollect +emptyCollect = + CustomFieldValueCollect Dict.empty + + +collectValues : + CustomFieldChange + -> CustomFieldValueCollect + -> CustomFieldValueCollect +collectValues change collector = + let + dict = + case collector of + CustomFieldValueCollect d -> + d + in + case change of + NoFieldChange -> + collector + + FieldValueRemove f -> + CustomFieldValueCollect (Dict.remove f.id dict) + + FieldValueChange f v -> + CustomFieldValueCollect (Dict.insert f.id v dict) + + FieldCreateNew -> + collector + + +toFieldValues : CustomFieldValueCollect -> List CustomFieldValue +toFieldValues dict = + case dict of + CustomFieldValueCollect d -> + Dict.toList d + |> List.map (\( k, v ) -> CustomFieldValue k v) + + +isValueChange : CustomFieldChange -> Bool +isValueChange change = + case change of + NoFieldChange -> + False + + FieldValueRemove _ -> + True + + FieldValueChange _ _ -> + True + + FieldCreateNew -> + False From a2e0c23644e7f7af4f8be7ead2fa2e84e6257ace Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 22 Nov 2020 21:09:09 +0100 Subject: [PATCH 18/29] Amend search form for custom fields --- .../webapp/src/main/elm/Comp/SearchMenu.elm | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index 07af0830..ff385da8 100644 --- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm @@ -19,10 +19,12 @@ import Api.Model.IdName exposing (IdName) import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ReferenceList exposing (ReferenceList) import Api.Model.TagCloud exposing (TagCloud) +import Comp.CustomFieldMultiInput import Comp.DatePicker import Comp.Dropdown exposing (isDropdownChangeMsg) import Comp.FolderSelect import Comp.TagSelect +import Data.CustomFieldChange exposing (CustomFieldValueCollect) import Data.Direction exposing (Direction) import Data.Fields import Data.Flags exposing (Flags) @@ -67,6 +69,8 @@ type alias Model = , fulltextModel : Maybe String , datePickerInitialized : Bool , showNameHelp : Bool + , customFieldModel : Comp.CustomFieldMultiInput.Model + , customValues : CustomFieldValueCollect } @@ -128,6 +132,8 @@ init = , fulltextModel = Nothing , datePickerInitialized = False , showNameHelp = False + , customFieldModel = Comp.CustomFieldMultiInput.initWith [] + , customValues = Data.CustomFieldChange.emptyCollect } @@ -188,6 +194,7 @@ getItemSearch model = , fullText = model.fulltextModel , tagCategoriesInclude = model.tagSelection.includeCats |> List.map .name , tagCategoriesExclude = model.tagSelection.excludeCats |> List.map .name + , customValues = Data.CustomFieldChange.toFieldValues model.customValues } @@ -222,6 +229,10 @@ resetModel model = , nameModel = Nothing , allNameModel = Nothing , fulltextModel = Nothing + , customFieldModel = + Comp.CustomFieldMultiInput.reset + model.customFieldModel + , customValues = Data.CustomFieldChange.emptyCollect } @@ -260,6 +271,7 @@ type Msg | SetConcEquip IdName | SetFolder IdName | SetTag String + | CustomFieldMsg Comp.CustomFieldMultiInput.Msg type alias NextState = @@ -331,6 +343,7 @@ updateDrop ddm flags settings msg model = , Api.getEquipments flags "" GetEquipResp , Api.getPersonsLight flags GetPersonResp , Api.getFolders flags "" False GetFolderResp + , Cmd.map CustomFieldMsg (Comp.CustomFieldMultiInput.initCmd flags) , cdp ] , stateChange = False @@ -694,6 +707,22 @@ updateDrop ddm flags settings msg model = , dragDrop = ddd } + CustomFieldMsg lm -> + let + res = + Comp.CustomFieldMultiInput.update lm model.customFieldModel + in + { model = + { model + | customFieldModel = res.model + , customValues = Data.CustomFieldChange.collectValues res.result model.customValues + } + , cmd = Cmd.map CustomFieldMsg res.cmd + , stateChange = + Data.CustomFieldChange.isValueChange res.result + , dragDrop = DD.DragDropData ddm Nothing + } + -- View @@ -813,6 +842,22 @@ viewDrop ddd flags settings model = , Html.map ConcEquipmentMsg (Comp.Dropdown.view settings model.concEquipmentModel) ] ] + , div + [ classList + [ ( segmentClass, True ) + , ( "hidden invisible" + , fieldHidden Data.Fields.CustomFields + || Comp.CustomFieldMultiInput.isEmpty model.customFieldModel + ) + ] + ] + [ formHeader (Icons.customFieldIcon "") "Custom Fields" + , Html.map CustomFieldMsg + (Comp.CustomFieldMultiInput.view + (Comp.CustomFieldMultiInput.ViewSettings False "field") + model.customFieldModel + ) + ] , div [ class segmentClass ] [ formHeader (Icons.searchIcon "") "Text Search" , div From bcdb2fc0fee6a50cbb92a7dceb1b8b62011d96af Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 22 Nov 2020 22:08:52 +0100 Subject: [PATCH 19/29] Show custom field values in item detail header --- .../src/main/elm/Comp/CustomFieldInput.elm | 9 ++- .../main/elm/Comp/CustomFieldMultiInput.elm | 6 +- .../src/main/elm/Comp/ItemDetail/View.elm | 73 ++++++++++++++----- modules/webapp/src/main/elm/Data/Icons.elm | 28 +++++++ 4 files changed, 94 insertions(+), 22 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm index b0ff5137..d9c4fa48 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm @@ -13,6 +13,7 @@ import Api.Model.CustomField exposing (CustomField) import Api.Model.ItemFieldValue exposing (ItemFieldValue) import Comp.DatePicker import Data.CustomFieldType exposing (CustomFieldType) +import Data.Icons as Icons import Data.Money import Date exposing (Date) import DatePicker exposing (DatePicker) @@ -350,7 +351,7 @@ makeInput icon model = ] [] , removeButton "" - , i [ class (iconOr "pen icon") ] [] + , i [ class (iconOr <| Icons.customFieldType Data.CustomFieldType.Text) ] [] ] NumberField nm -> @@ -362,7 +363,7 @@ makeInput icon model = ] [] , removeButton "" - , i [ class (iconOr "hashtag icon") ] [] + , i [ class (iconOr <| Icons.customFieldType Data.CustomFieldType.Numeric) ] [] ] MoneyField nm -> @@ -374,7 +375,7 @@ makeInput icon model = ] [] , removeButton "" - , i [ class (iconOr "money bill icon") ] [] + , i [ class (iconOr <| Icons.customFieldType Data.CustomFieldType.Money) ] [] ] BoolField b -> @@ -397,5 +398,5 @@ makeInput icon model = div [ class "ui action left icon input" ] [ Html.map DateMsg (Comp.DatePicker.view v Comp.DatePicker.defaultSettings dp) , removeButton "" - , i [ class (iconOr "calendar icon") ] [] + , i [ class (iconOr <| Icons.customFieldType Data.CustomFieldType.Date) ] [] ] diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm index e0aadf4e..e443b472 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm @@ -51,9 +51,13 @@ type alias VisibleField = visibleFields : Model -> List CustomField visibleFields model = + let + labelThenName cv = + Maybe.withDefault cv.name cv.label + in Dict.toList model.visibleFields |> List.map (Tuple.second >> .field) - |> List.sortBy .name + |> List.sortBy labelThenName currentOptions : List CustomField -> Dict String VisibleField -> List CustomField diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm index d819a368..d5dadbf1 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm @@ -17,6 +17,7 @@ import Comp.LinkTarget import Comp.MarkdownInput import Comp.SentMails import Comp.YesNoDimmer +import Data.CustomFieldType import Data.Direction import Data.Fields import Data.Icons as Icons @@ -599,34 +600,72 @@ renderItemInfo settings model = ] ] ] - :: renderTags settings model + :: renderTagsAndFields settings model ) +renderTagsAndFields : UiSettings -> Model -> List (Html Msg) +renderTagsAndFields settings model = + [ div [ class "ui fluid right aligned container" ] + (renderTags settings model ++ renderCustomValues settings model) + ] + + renderTags : UiSettings -> Model -> List (Html Msg) renderTags settings model = - if Data.UiSettings.fieldHidden settings Data.Fields.Tag then + let + tagView t = + Comp.LinkTarget.makeTagLink + (IdName t.id t.name) + [ ( "ui tag label", True ) + , ( Data.UiSettings.tagColorString t settings, True ) + ] + SetLinkTarget + in + if Data.UiSettings.fieldHidden settings Data.Fields.Tag || model.item.tags == [] then [] else - case model.item.tags of - [] -> - [] + List.map tagView model.item.tags - _ -> - [ div [ class "ui right aligned fluid container" ] <| - List.map - (\t -> - Comp.LinkTarget.makeTagLink - (IdName t.id t.name) - [ ( "ui tag label", True ) - , ( Data.UiSettings.tagColorString t settings, True ) - ] - SetLinkTarget - ) - model.item.tags + +renderCustomValues : UiSettings -> Model -> List (Html Msg) +renderCustomValues settings model = + let + cfIcon cv = + Data.CustomFieldType.fromString cv.ftype + |> Maybe.map (Icons.customFieldTypeIcon "") + |> Maybe.withDefault (i [ class "question circle outline icon" ] []) + + renderBool cv = + if cv.value == "true" then + i [ class "check icon" ] [] + + else + i [ class "minus icon" ] [] + + fieldView cv = + div [ class "ui secondary basic label" ] + [ cfIcon cv + , Maybe.withDefault cv.name cv.label |> text + , div [ class "detail" ] + [ if Data.CustomFieldType.fromString cv.ftype == Just Data.CustomFieldType.Boolean then + renderBool cv + + else + text cv.value + ] ] + labelThenName cv = + Maybe.withDefault cv.name cv.label + in + if Data.UiSettings.fieldHidden settings Data.Fields.CustomFields || model.item.customfields == [] then + [] + + else + List.map fieldView (List.sortBy labelThenName model.item.customfields) + renderEditMenu : UiSettings -> Model -> List (Html Msg) renderEditMenu settings model = diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm index 375f11ef..cc5b364f 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -7,6 +7,8 @@ module Data.Icons exposing , correspondentIcon , customField , customFieldIcon + , customFieldType + , customFieldTypeIcon , date , dateIcon , direction @@ -32,10 +34,36 @@ module Data.Icons exposing , tagsIcon ) +import Data.CustomFieldType exposing (CustomFieldType) import Html exposing (Html, i) import Html.Attributes exposing (class) +customFieldType : CustomFieldType -> String +customFieldType ftype = + case ftype of + Data.CustomFieldType.Text -> + "pen icon" + + Data.CustomFieldType.Numeric -> + "hashtag icon" + + Data.CustomFieldType.Date -> + "calendar icon" + + Data.CustomFieldType.Boolean -> + "question icon" + + Data.CustomFieldType.Money -> + "money bill icon" + + +customFieldTypeIcon : String -> CustomFieldType -> Html msg +customFieldTypeIcon classes ftype = + i [ class (customFieldType ftype ++ " " ++ classes) ] + [] + + customField : String customField = "highlighter icon" From 7026852123b4454642d38e8c20d7fad5fc7685b3 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 23 Nov 2020 00:16:50 +0100 Subject: [PATCH 20/29] Indicate saving custom field values --- .../main/elm/Comp/CustomFieldMultiInput.elm | 12 +++-- .../src/main/elm/Comp/ItemDetail/EditMenu.elm | 18 +++++++- .../src/main/elm/Comp/ItemDetail/Model.elm | 3 ++ .../src/main/elm/Comp/ItemDetail/Update.elm | 46 ++++++++++++++++--- .../src/main/elm/Comp/ItemDetail/View.elm | 5 +- .../webapp/src/main/elm/Comp/SearchMenu.elm | 2 +- .../webapp/src/main/elm/Page/Home/Data.elm | 2 + .../webapp/src/main/elm/Page/Home/Update.elm | 26 +++++++++++ .../webapp/src/main/elm/Page/Home/View.elm | 11 ++++- 9 files changed, 111 insertions(+), 14 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm index e443b472..21ee078f 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm @@ -309,6 +309,7 @@ update msg model = type alias ViewSettings = { showAddButton : Bool , classes : String + , fieldIcon : CustomField -> Maybe String } @@ -316,7 +317,7 @@ view : ViewSettings -> Model -> Html Msg view viewSettings model = div [ class viewSettings.classes ] (viewMenuBar viewSettings model - :: List.map (viewCustomField model) (visibleFields model) + :: List.map (viewCustomField viewSettings model) (visibleFields model) ) @@ -344,8 +345,8 @@ viewMenuBar viewSettings model = ) -viewCustomField : Model -> CustomField -> Html Msg -viewCustomField model field = +viewCustomField : ViewSettings -> Model -> CustomField -> Html Msg +viewCustomField viewSettings model field = let visibleField = Dict.get field.name model.visibleFields @@ -353,7 +354,10 @@ viewCustomField model field = case visibleField of Just vf -> Html.map (CustomFieldInputMsg field) - (Comp.CustomFieldInput.view "field" Nothing vf.inputModel) + (Comp.CustomFieldInput.view "field" + (viewSettings.fieldIcon vf.field) + vf.inputModel + ) Nothing -> span [] [] diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm index 37d1316f..40f0c13a 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm @@ -598,6 +598,7 @@ nameThrottleSub model = type alias ViewConfig = { menuClass : String , nameState : SaveNameState + , customFieldState : String -> SaveNameState } @@ -605,6 +606,7 @@ defaultViewConfig : ViewConfig defaultViewConfig = { menuClass = "ui vertical segment" , nameState = SaveSuccess + , customFieldState = \_ -> SaveSuccess } @@ -651,8 +653,22 @@ renderEditForm cfg settings model = ReplaceTags -> "Tags chosen here *replace* those on selected items." + customFieldIcon field = + case cfg.customFieldState field.id of + SaveSuccess -> + Nothing + + SaveFailed -> + Just "red exclamation triangle icon" + + Saving -> + Just "refresh loading icon" + customFieldSettings = - Comp.CustomFieldMultiInput.ViewSettings False "field" + Comp.CustomFieldMultiInput.ViewSettings + False + "field" + customFieldIcon in div [ class cfg.menuClass ] [ div [ class "ui form warning" ] diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm index c6d79e5a..8f5bbf67 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm @@ -95,6 +95,7 @@ type alias Model = , attachRename : Maybe AttachmentRename , keyInputModel : Comp.KeyInput.Model , customFieldsModel : Comp.CustomFieldMultiInput.Model + , customFieldSavingIcon : Dict String String } @@ -197,6 +198,7 @@ emptyModel = , attachRename = Nothing , keyInputModel = Comp.KeyInput.init , customFieldsModel = Comp.CustomFieldMultiInput.initWith [] + , customFieldSavingIcon = Dict.empty } @@ -283,6 +285,7 @@ type Msg | UiSettingsUpdated | SetLinkTarget LinkTarget | CustomFieldMsg Comp.CustomFieldMultiInput.Msg + | CustomFieldSaveResp String (Result Http.Error BasicResult) type SaveNameState diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm index f610f1ce..2dd84c8d 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -1300,19 +1300,32 @@ update key flags inav settings msg model = cmd_ = Cmd.map CustomFieldMsg result.cmd - action = + loadingIcon = + "refresh loading icon" + + ( action, icons ) = case result.result of NoFieldChange -> - Cmd.none + ( Cmd.none, model.customFieldSavingIcon ) FieldValueRemove field -> - Api.deleteCustomValue flags model.item.id field.id SaveResp + ( Api.deleteCustomValue flags + model.item.id + field.id + (CustomFieldSaveResp field.id) + , Dict.insert field.id loadingIcon model.customFieldSavingIcon + ) FieldValueChange field value -> - Api.putCustomValue flags model.item.id (CustomFieldValue field.id value) SaveResp + ( Api.putCustomValue flags + model.item.id + (CustomFieldValue field.id value) + (CustomFieldSaveResp field.id) + , Dict.insert field.id loadingIcon model.customFieldSavingIcon + ) FieldCreateNew -> - Cmd.none + ( Cmd.none, model.customFieldSavingIcon ) sub_ = Sub.map CustomFieldMsg result.subs @@ -1325,7 +1338,11 @@ update key flags inav settings msg model = Nothing model_ = - { model | customFieldsModel = result.model, modalEdit = modalEdit } + { model + | customFieldsModel = result.model + , modalEdit = modalEdit + , customFieldSavingIcon = icons + } in { model = model_ , cmd = Cmd.batch [ cmd_, action ] @@ -1333,6 +1350,23 @@ update key flags inav settings msg model = , linkTarget = Comp.LinkTarget.LinkNone } + CustomFieldSaveResp fieldId (Ok res) -> + let + model_ = + { model | customFieldSavingIcon = Dict.remove fieldId model.customFieldSavingIcon } + in + if res.success then + resultModelCmd + ( model_ + , Api.itemDetail flags model.item.id GetItemResp + ) + + else + resultModel model_ + + CustomFieldSaveResp fieldId (Err _) -> + resultModel { model | customFieldSavingIcon = Dict.remove fieldId model.customFieldSavingIcon } + --- Helper diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm index d5dadbf1..b5fbd555 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm @@ -773,7 +773,10 @@ renderEditForm settings model = && Comp.CustomFieldMultiInput.nonEmpty model.customFieldsModel customFieldSettings = - Comp.CustomFieldMultiInput.ViewSettings True "field" + Comp.CustomFieldMultiInput.ViewSettings + True + "field" + (\f -> Dict.get f.id model.customFieldSavingIcon) in div [ class "ui attached segment" ] [ div [ class "ui form warning" ] diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index ff385da8..406cbf9a 100644 --- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm @@ -854,7 +854,7 @@ viewDrop ddd flags settings model = [ formHeader (Icons.customFieldIcon "") "Custom Fields" , Html.map CustomFieldMsg (Comp.CustomFieldMultiInput.view - (Comp.CustomFieldMultiInput.ViewSettings False "field") + (Comp.CustomFieldMultiInput.ViewSettings False "field" (\_ -> Nothing)) model.customFieldModel ) ] diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index dcd14300..333d1442 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -63,6 +63,7 @@ type alias SelectViewModel = , deleteAllConfirm : Comp.YesNoDimmer.Model , editModel : Comp.ItemDetail.EditMenu.Model , saveNameState : SaveNameState + , saveCustomFieldState : Set String } @@ -73,6 +74,7 @@ initSelectViewModel = , deleteAllConfirm = Comp.YesNoDimmer.initActive , editModel = Comp.ItemDetail.EditMenu.init , saveNameState = SaveSuccess + , saveCustomFieldState = Set.empty } diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index a57fd172..454c7790 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -459,6 +459,16 @@ update mId key flags settings msg model = _ -> svm.saveNameState + , saveCustomFieldState = + case res.change of + CustomValueChange field _ -> + Set.insert field.id svm.saveCustomFieldState + + RemoveCustomValue field -> + Set.insert field.id svm.saveCustomFieldState + + _ -> + svm.saveCustomFieldState } cmd_ = @@ -542,6 +552,16 @@ update mId key flags settings msg model = updateSelectViewNameState : Bool -> Model -> FormChange -> Model updateSelectViewNameState success model change = + let + removeCustomField field svm = + { model + | viewMode = + SelectView + { svm + | saveCustomFieldState = Set.remove field.id svm.saveCustomFieldState + } + } + in case model.viewMode of SelectView svm -> case change of @@ -559,6 +579,12 @@ updateSelectViewNameState success model change = in { model | viewMode = SelectView svm_ } + RemoveCustomValue field -> + removeCustomField field svm + + CustomValueChange field _ -> + removeCustomField field svm + _ -> model diff --git a/modules/webapp/src/main/elm/Page/Home/View.elm b/modules/webapp/src/main/elm/Page/Home/View.elm index dd2d3ef1..fada780f 100644 --- a/modules/webapp/src/main/elm/Page/Home/View.elm +++ b/modules/webapp/src/main/elm/Page/Home/View.elm @@ -178,7 +178,16 @@ viewLeftMenu flags settings model = Comp.ItemDetail.EditMenu.defaultViewConfig cfg = - { cfg_ | nameState = svm.saveNameState } + { cfg_ + | nameState = svm.saveNameState + , customFieldState = + \fId -> + if Set.member fId svm.saveCustomFieldState then + Comp.ItemDetail.EditMenu.Saving + + else + Comp.ItemDetail.EditMenu.SaveSuccess + } in [ div [ class "ui dividing header" ] [ text "Multi-Edit" From cdcc8210fe4bb39fbc3556d22493671c64cafa72 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 23 Nov 2020 09:27:05 +0100 Subject: [PATCH 21/29] Throttle customfield update requests --- .../src/main/elm/Comp/CustomFieldInput.elm | 18 ++-- .../main/elm/Comp/CustomFieldMultiInput.elm | 19 ++-- .../src/main/elm/Comp/ItemDetail/EditMenu.elm | 5 +- .../src/main/elm/Comp/ItemDetail/Model.elm | 6 +- .../src/main/elm/Comp/ItemDetail/Update.elm | 97 +++++++++++++++---- .../src/main/elm/Comp/ItemDetail/View.elm | 6 +- 6 files changed, 104 insertions(+), 47 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm index d9c4fa48..e49177db 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm @@ -175,7 +175,6 @@ type alias UpdateResult = { model : Model , cmd : Cmd Msg , result : FieldResult - , subs : Sub Msg } @@ -219,7 +218,7 @@ update msg model = model_ = { model | fieldModel = TextField (Just str) } in - UpdateResult model_ Cmd.none (Value str) Sub.none + UpdateResult model_ Cmd.none (Value str) ( NumberMsg str, NumberField _ ) -> let @@ -229,7 +228,7 @@ update msg model = model_ = { model | fieldModel = NumberField fm } in - UpdateResult model_ Cmd.none res Sub.none + UpdateResult model_ Cmd.none res ( MoneyMsg str, MoneyField _ ) -> let @@ -242,7 +241,7 @@ update msg model = model_ = { model | fieldModel = MoneyField fm } in - UpdateResult model_ Cmd.none res Sub.none + UpdateResult model_ Cmd.none res ( ToggleBool, BoolField b ) -> let @@ -259,7 +258,7 @@ update msg model = else "false" in - UpdateResult model_ Cmd.none (Value value) Sub.none + UpdateResult model_ Cmd.none (Value value) ( DateMsg lm, DateField _ picker ) -> let @@ -280,14 +279,14 @@ update msg model = model_ = { model | fieldModel = DateField newDate picker_ } in - UpdateResult model_ Cmd.none value Sub.none + UpdateResult model_ Cmd.none value ( Remove, _ ) -> - UpdateResult model Cmd.none RemoveField Sub.none + UpdateResult model Cmd.none RemoveField -- no other possibilities, not well encoded here _ -> - UpdateResult model Cmd.none NoResult Sub.none + UpdateResult model Cmd.none NoResult mkLabel : Model -> String @@ -396,7 +395,8 @@ makeInput icon model = DateField v dp -> div [ class "ui action left icon input" ] - [ Html.map DateMsg (Comp.DatePicker.view v Comp.DatePicker.defaultSettings dp) + [ Html.map DateMsg + (Comp.DatePicker.view v Comp.DatePicker.defaultSettings dp) , removeButton "" , i [ class (iconOr <| Icons.customFieldType Data.CustomFieldType.Date) ] [] ] diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm index 21ee078f..9d4e2cdb 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm @@ -138,7 +138,6 @@ mkFieldSelect fields = type alias UpdateResult = { model : Model , cmd : Cmd Msg - , subs : Sub Msg , result : CustomFieldChange } @@ -152,7 +151,7 @@ update : Msg -> Model -> UpdateResult update msg model = case msg of CreateNewField -> - UpdateResult model Cmd.none Sub.none FieldCreateNew + UpdateResult model Cmd.none FieldCreateNew CustomFieldResp (Ok list) -> let @@ -162,10 +161,10 @@ update msg model = , fieldSelect = mkFieldSelect (currentOptions list.items model.visibleFields) } in - UpdateResult model_ Cmd.none Sub.none NoFieldChange + UpdateResult model_ Cmd.none NoFieldChange CustomFieldResp (Err _) -> - UpdateResult model Cmd.none Sub.none NoFieldChange + UpdateResult model Cmd.none NoFieldChange FieldSelectMsg lm -> let @@ -188,7 +187,7 @@ update msg model = update (ApplyField field) model Nothing -> - UpdateResult model_ Cmd.none Sub.none NoFieldChange + UpdateResult model_ Cmd.none NoFieldChange ApplyField f -> let @@ -218,7 +217,7 @@ update msg model = cmd_ = Cmd.map (CustomFieldInputMsg f) fc in - UpdateResult model_ cmd_ Sub.none NoFieldChange + UpdateResult model_ cmd_ NoFieldChange RemoveField f -> let @@ -231,7 +230,7 @@ update msg model = , fieldSelect = mkFieldSelect (currentOptions model.allFields visible) } in - UpdateResult model_ Cmd.none Sub.none (FieldValueRemove f) + UpdateResult model_ Cmd.none (FieldValueRemove f) CustomFieldInputMsg f lm -> let @@ -268,10 +267,10 @@ update msg model = update (RemoveField field) model_ else - UpdateResult model_ cmd_ Sub.none result + UpdateResult model_ cmd_ result Nothing -> - UpdateResult model Cmd.none Sub.none NoFieldChange + UpdateResult model Cmd.none NoFieldChange SetValues values -> let @@ -299,7 +298,7 @@ update msg model = , visibleFields = modelDict } in - UpdateResult model_ (Cmd.batch cmdList) Sub.none NoFieldChange + UpdateResult model_ (Cmd.batch cmdList) NoFieldChange diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm index 40f0c13a..6ac7b7f5 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm @@ -564,9 +564,6 @@ update flags msg model = cmd_ = Cmd.map CustomFieldMsg res.cmd - sub_ = - Sub.map CustomFieldMsg res.subs - change = case res.result of NoFieldChange -> @@ -581,7 +578,7 @@ update flags msg model = FieldCreateNew -> NoFormChange in - UpdateResult model_ cmd_ sub_ change + UpdateResult model_ cmd_ Sub.none change nameThrottleSub : Model -> Sub Msg diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm index 8f5bbf67..031df3ee 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm @@ -13,6 +13,7 @@ module Comp.ItemDetail.Model exposing ) import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.CustomField exposing (CustomField) import Api.Model.EquipmentList exposing (EquipmentList) import Api.Model.FolderItem exposing (FolderItem) import Api.Model.FolderList exposing (FolderList) @@ -96,6 +97,7 @@ type alias Model = , keyInputModel : Comp.KeyInput.Model , customFieldsModel : Comp.CustomFieldMultiInput.Model , customFieldSavingIcon : Dict String String + , customFieldThrottle : Throttle Msg } @@ -199,6 +201,7 @@ emptyModel = , keyInputModel = Comp.KeyInput.init , customFieldsModel = Comp.CustomFieldMultiInput.initWith [] , customFieldSavingIcon = Dict.empty + , customFieldThrottle = Throttle.create 1 } @@ -285,7 +288,8 @@ type Msg | UiSettingsUpdated | SetLinkTarget LinkTarget | CustomFieldMsg Comp.CustomFieldMultiInput.Msg - | CustomFieldSaveResp String (Result Http.Error BasicResult) + | CustomFieldSaveResp CustomField String (Result Http.Error BasicResult) + | CustomFieldRemoveResp String (Result Http.Error BasicResult) type SaveNameState diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm index 2dd84c8d..7ea31fdc 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -2,10 +2,12 @@ module Comp.ItemDetail.Update exposing (update) import Api import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.CustomField exposing (CustomField) import Api.Model.CustomFieldValue exposing (CustomFieldValue) import Api.Model.DirectionValue exposing (DirectionValue) import Api.Model.IdName exposing (IdName) import Api.Model.ItemDetail exposing (ItemDetail) +import Api.Model.ItemFieldValue exposing (ItemFieldValue) import Api.Model.MoveAttachment exposing (MoveAttachment) import Api.Model.OptionalDate exposing (OptionalDate) import Api.Model.OptionalId exposing (OptionalId) @@ -1232,10 +1234,16 @@ update key flags inav settings msg model = UpdateThrottle -> let - ( newThrottle, cmd ) = + ( newSaveName, cmd1 ) = Throttle.update model.nameSaveThrottle + + ( newCustomField, cmd2 ) = + Throttle.update model.customFieldThrottle in - withSub ( { model | nameSaveThrottle = newThrottle }, cmd ) + withSub + ( { model | nameSaveThrottle = newSaveName, customFieldThrottle = newCustomField } + , Cmd.batch [ cmd1, cmd2 ] + ) KeyInputMsg lm -> let @@ -1303,7 +1311,7 @@ update key flags inav settings msg model = loadingIcon = "refresh loading icon" - ( action, icons ) = + ( action_, icons ) = case result.result of NoFieldChange -> ( Cmd.none, model.customFieldSavingIcon ) @@ -1312,7 +1320,7 @@ update key flags inav settings msg model = ( Api.deleteCustomValue flags model.item.id field.id - (CustomFieldSaveResp field.id) + (CustomFieldRemoveResp field.id) , Dict.insert field.id loadingIcon model.customFieldSavingIcon ) @@ -1320,16 +1328,13 @@ update key flags inav settings msg model = ( Api.putCustomValue flags model.item.id (CustomFieldValue field.id value) - (CustomFieldSaveResp field.id) + (CustomFieldSaveResp field value) , Dict.insert field.id loadingIcon model.customFieldSavingIcon ) FieldCreateNew -> ( Cmd.none, model.customFieldSavingIcon ) - sub_ = - Sub.map CustomFieldMsg result.subs - modalEdit = if result.result == FieldCreateNew then Just (Comp.DetailEdit.initCustomField model.item.id) @@ -1337,20 +1342,41 @@ update key flags inav settings msg model = else Nothing + ( throttle, action ) = + if action_ == Cmd.none then + ( model.customFieldThrottle, action_ ) + + else + Throttle.try action_ model.customFieldThrottle + model_ = { model | customFieldsModel = result.model + , customFieldThrottle = throttle , modalEdit = modalEdit , customFieldSavingIcon = icons } in - { model = model_ - , cmd = Cmd.batch [ cmd_, action ] - , sub = sub_ - , linkTarget = Comp.LinkTarget.LinkNone - } + withSub ( model_, Cmd.batch [ cmd_, action ] ) - CustomFieldSaveResp fieldId (Ok res) -> + CustomFieldSaveResp cf fv (Ok res) -> + let + model_ = + { model | customFieldSavingIcon = Dict.remove cf.id model.customFieldSavingIcon } + in + if res.success then + resultModelCmd + ( { model_ | item = setCustomField model.item cf fv } + , Cmd.none + ) + + else + resultModel model_ + + CustomFieldSaveResp cf _ (Err _) -> + resultModel { model | customFieldSavingIcon = Dict.remove cf.id model.customFieldSavingIcon } + + CustomFieldRemoveResp fieldId (Ok res) -> let model_ = { model | customFieldSavingIcon = Dict.remove fieldId model.customFieldSavingIcon } @@ -1364,7 +1390,7 @@ update key flags inav settings msg model = else resultModel model_ - CustomFieldSaveResp fieldId (Err _) -> + CustomFieldRemoveResp fieldId (Err _) -> resultModel { model | customFieldSavingIcon = Dict.remove fieldId model.customFieldSavingIcon } @@ -1506,9 +1532,14 @@ withSub ( m, c ) = { model = m , cmd = c , sub = - Throttle.ifNeeded - (Time.every 400 (\_ -> UpdateThrottle)) - m.nameSaveThrottle + Sub.batch + [ Throttle.ifNeeded + (Time.every 200 (\_ -> UpdateThrottle)) + m.nameSaveThrottle + , Throttle.ifNeeded + (Time.every 200 (\_ -> UpdateThrottle)) + m.customFieldThrottle + ] , linkTarget = Comp.LinkTarget.LinkNone } @@ -1564,3 +1595,33 @@ resetHiddenFields settings flags item tagger = setItemName : ItemDetail -> String -> ItemDetail setItemName item name = { item | name = name } + + +{-| Sets the field value of the given id into the item detail. +-} +setCustomField : ItemDetail -> CustomField -> String -> ItemDetail +setCustomField item cf fv = + let + change ifv = + if ifv.id == cf.id then + ( { ifv | value = fv }, True ) + + else + ( ifv, False ) + + ( fields, isChanged ) = + List.map change item.customfields + |> List.foldl + (\( e, isChange ) -> + \( list, flag ) -> ( e :: list, isChange || flag ) + ) + ( [], False ) + in + if isChanged then + { item | customfields = fields } + + else + { item + | customfields = + ItemFieldValue cf.id cf.name cf.label cf.ftype fv :: item.customfields + } diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm index b5fbd555..88707470 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm @@ -183,11 +183,7 @@ renderDetailMenu settings inav model = actionInputDatePicker : DatePicker.Settings actionInputDatePicker = - let - ds = - Comp.DatePicker.defaultSettings - in - { ds | containerClassList = [ ( "ui action input", True ) ] } + Comp.DatePicker.defaultSettings renderIdInfo : Model -> List (Html msg) From 44459aecd0fda04450191399806afbf281160773 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 23 Nov 2020 09:43:12 +0100 Subject: [PATCH 22/29] Fix initialising date fields --- modules/webapp/src/main/elm/Comp/CustomFieldInput.elm | 6 +++--- modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm index e49177db..cb623a5d 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm @@ -260,7 +260,7 @@ update msg model = in UpdateResult model_ Cmd.none (Value value) - ( DateMsg lm, DateField _ picker ) -> + ( DateMsg lm, DateField old picker ) -> let ( picker_, event ) = Comp.DatePicker.updateDefault lm picker @@ -271,10 +271,10 @@ update msg model = ( Just date, Value (Date.toIsoString date) ) DatePicker.None -> - ( Nothing, NoResult ) + ( old, NoResult ) DatePicker.FailedInput _ -> - ( Nothing, NoResult ) + ( old, NoResult ) model_ = { model | fieldModel = DateField newDate picker_ } diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm index 7ea31fdc..e04e7714 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -247,6 +247,7 @@ update key flags inav settings msg model = , res6.cmd , res7.cmd , res8.cmd + , res9.cmd , getOptions flags , proposalCmd , Api.getSentMails flags item.id SentMailsResp @@ -262,6 +263,7 @@ update key flags inav settings msg model = , res6.sub , res7.sub , res8.sub + , res9.sub ] , linkTarget = Comp.LinkTarget.LinkNone } From 7b7f1e4d6d9f69ea4b581c34b0f21ab173bad86c Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 23 Nov 2020 10:23:25 +0100 Subject: [PATCH 23/29] Return custom field values with search results --- .../restapi/src/main/resources/docspell-openapi.yml | 4 ++++ .../docspell/restserver/conv/Conversions.scala | 13 +++++++++---- .../main/scala/docspell/store/queries/QItem.scala | 7 +++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index ed0df51b..7319e878 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -5090,6 +5090,10 @@ components: type: array items: $ref: "#/components/schemas/Tag" + customfields: + type: array + items: + $ref: "#/components/schemas/ItemFieldValue" notes: description: | Some prefix of the item notes. diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index e5d49fc2..35a88eaa 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -213,10 +213,11 @@ trait Conversions { i.concEquip.map(mkIdName), i.folder.map(mkIdName), i.fileCount, - Nil, - Nil, + Nil, //attachments + Nil, //tags + Nil, //customfields i.notes, - Nil + Nil // highlight ) def mkItemLight(i: OFulltext.FtsItem): ItemLight = { @@ -227,7 +228,11 @@ trait Conversions { def mkItemLightWithTags(i: OItemSearch.ListItemWithTags): ItemLight = mkItemLight(i.item) - .copy(tags = i.tags.map(mkTag), attachments = i.attachments.map(mkAttachmentLight)) + .copy( + tags = i.tags.map(mkTag), + attachments = i.attachments.map(mkAttachmentLight), + customfields = i.customfields.map(mkItemFieldValue) + ) private def mkAttachmentLight(qa: QItem.AttachmentLight): AttachmentLight = AttachmentLight(qa.id, qa.position, qa.name, qa.pageCount) diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index f587ae9f..a742547a 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -524,7 +524,8 @@ object QItem { case class ListItemWithTags( item: ListItem, tags: List[RTag], - attachments: List[AttachmentLight] + attachments: List[AttachmentLight], + customfields: List[ItemFieldValue] ) /** Same as `findItems` but resolves the tags for each item. Note that @@ -560,10 +561,12 @@ object QItem { tags <- Stream.eval(tagItems.traverse(ti => findTag(resolvedTags, ti))) attachs <- Stream.eval(findAttachmentLight(item.id)) ftags = tags.flatten.filter(t => t.collective == collective) + cfields <- Stream.eval(findCustomFieldValuesForItem(item.id)) } yield ListItemWithTags( item, ftags.toList.sortBy(_.name), - attachs.sortBy(_.position) + attachs.sortBy(_.position), + cfields.toList ) } From 6d22bac720d068138842df022cd12237a1d58e49 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 23 Nov 2020 10:23:37 +0100 Subject: [PATCH 24/29] Display custom field values on item card --- modules/webapp/src/main/elm/Comp/ItemCard.elm | 57 +++++++++++++------ .../src/main/elm/Comp/ItemDetail/View.elm | 25 +------- modules/webapp/src/main/elm/Data/Icons.elm | 12 +++- .../webapp/src/main/elm/Util/CustomField.elm | 30 ++++++++++ 4 files changed, 81 insertions(+), 43 deletions(-) create mode 100644 modules/webapp/src/main/elm/Util/CustomField.elm diff --git a/modules/webapp/src/main/elm/Comp/ItemCard.elm b/modules/webapp/src/main/elm/Comp/ItemCard.elm index 31836733..63992786 100644 --- a/modules/webapp/src/main/elm/Comp/ItemCard.elm +++ b/modules/webapp/src/main/elm/Comp/ItemCard.elm @@ -25,6 +25,7 @@ import Html.Events exposing (onClick) import Markdown import Page exposing (Page(..)) import Set exposing (Set) +import Util.CustomField import Util.ItemDragDrop as DD import Util.List import Util.Maybe @@ -360,28 +361,48 @@ mainContent cardAction cardColor isConfirmed settings _ item = [ Util.Time.formatDate item.date |> text ] , div [ class "meta description" ] - [ div - [ classList - [ ( "ui right floated tiny labels", True ) - , ( "invisible hidden", item.tags == [] || fieldHidden Data.Fields.Tag ) - ] - ] - (List.map - (\tag -> - div - [ classList - [ ( "ui basic label", True ) - , ( Data.UiSettings.tagColorString tag settings, True ) - ] - ] - [ text tag.name ] - ) - item.tags - ) + [ mainTagsAndFields settings item ] ] +mainTagsAndFields : UiSettings -> ItemLight -> Html Msg +mainTagsAndFields settings item = + let + fieldHidden f = + Data.UiSettings.fieldHidden settings f + + hideTags = + item.tags == [] || fieldHidden Data.Fields.Tag + + hideFields = + item.customfields == [] || fieldHidden Data.Fields.CustomFields + + showTag tag = + div + [ classList + [ ( "ui basic label", True ) + , ( Data.UiSettings.tagColorString tag settings, True ) + ] + ] + [ i [ class "tag icon" ] [] + , div [ class "detail" ] + [ text tag.name + ] + ] + + showField fv = + Util.CustomField.renderValue "ui basic label" fv + in + div + [ classList + [ ( "ui right floated tiny labels", True ) + , ( "invisible hidden", hideTags && hideFields ) + ] + ] + (List.map showField item.customfields ++ List.map showTag item.tags) + + previewImage : UiSettings -> Attribute Msg -> Model -> ItemLight -> Html Msg previewImage settings cardAction model item = let diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm index 88707470..ac03a753 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm @@ -32,6 +32,7 @@ import Html.Events exposing (onCheck, onClick, onInput) import Markdown import Page exposing (Page(..)) import Set +import Util.CustomField import Util.File exposing (makeFileId) import Util.Folder import Util.List @@ -628,30 +629,8 @@ renderTags settings model = renderCustomValues : UiSettings -> Model -> List (Html Msg) renderCustomValues settings model = let - cfIcon cv = - Data.CustomFieldType.fromString cv.ftype - |> Maybe.map (Icons.customFieldTypeIcon "") - |> Maybe.withDefault (i [ class "question circle outline icon" ] []) - - renderBool cv = - if cv.value == "true" then - i [ class "check icon" ] [] - - else - i [ class "minus icon" ] [] - fieldView cv = - div [ class "ui secondary basic label" ] - [ cfIcon cv - , Maybe.withDefault cv.name cv.label |> text - , div [ class "detail" ] - [ if Data.CustomFieldType.fromString cv.ftype == Just Data.CustomFieldType.Boolean then - renderBool cv - - else - text cv.value - ] - ] + Util.CustomField.renderValue "ui secondary basic label" cv labelThenName cv = Maybe.withDefault cv.name cv.label diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm index cc5b364f..243a49af 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -9,6 +9,7 @@ module Data.Icons exposing , customFieldIcon , customFieldType , customFieldTypeIcon + , customFieldTypeIconString , date , dateIcon , direction @@ -43,7 +44,7 @@ customFieldType : CustomFieldType -> String customFieldType ftype = case ftype of Data.CustomFieldType.Text -> - "pen icon" + "stream icon" Data.CustomFieldType.Numeric -> "hashtag icon" @@ -52,7 +53,7 @@ customFieldType ftype = "calendar icon" Data.CustomFieldType.Boolean -> - "question icon" + "marker icon" Data.CustomFieldType.Money -> "money bill icon" @@ -64,6 +65,13 @@ customFieldTypeIcon classes ftype = [] +customFieldTypeIconString : String -> String -> Html msg +customFieldTypeIconString classes ftype = + Data.CustomFieldType.fromString ftype + |> Maybe.map (customFieldTypeIcon classes) + |> Maybe.withDefault (i [ class "question circle outline icon" ] []) + + customField : String customField = "highlighter icon" diff --git a/modules/webapp/src/main/elm/Util/CustomField.elm b/modules/webapp/src/main/elm/Util/CustomField.elm new file mode 100644 index 00000000..3006ae7e --- /dev/null +++ b/modules/webapp/src/main/elm/Util/CustomField.elm @@ -0,0 +1,30 @@ +module Util.CustomField exposing (renderValue) + +import Api.Model.ItemFieldValue exposing (ItemFieldValue) +import Data.CustomFieldType +import Data.Icons as Icons +import Html exposing (..) +import Html.Attributes exposing (..) + + +renderValue : String -> ItemFieldValue -> Html msg +renderValue classes cv = + let + renderBool = + if cv.value == "true" then + i [ class "check icon" ] [] + + else + i [ class "minus icon" ] [] + in + div [ class classes ] + [ Icons.customFieldTypeIconString "" cv.ftype + , Maybe.withDefault cv.name cv.label |> text + , div [ class "detail" ] + [ if Data.CustomFieldType.fromString cv.ftype == Just Data.CustomFieldType.Boolean then + renderBool + + else + text cv.value + ] + ] From f8c6d183ed7f337f958763e74515295f2c32cecf Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 23 Nov 2020 10:30:24 +0100 Subject: [PATCH 25/29] Don't scroll in preview image --- modules/webapp/src/main/webjar/docspell.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/webapp/src/main/webjar/docspell.css b/modules/webapp/src/main/webjar/docspell.css index 1d4381e7..f65208ad 100644 --- a/modules/webapp/src/main/webjar/docspell.css +++ b/modules/webapp/src/main/webjar/docspell.css @@ -109,7 +109,7 @@ box-shadow: 0 0 0 1px #d4d4d5,0 2px 0 0 #2185d0,0 2px 10px 0 rgba(34,36,38,.15); } .default-layout .image.ds-card-image { - overflow: auto; + overflow: hidden; } .default-layout .image.ds-card-image.small { max-height: 120px; From 7712e02d2d4a32ee97c28956af3fbc36873c0fe1 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 23 Nov 2020 10:38:59 +0100 Subject: [PATCH 26/29] Don't allow empty custom field values --- .../main/scala/docspell/common/CustomFieldType.scala | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala index 1505233f..6c217550 100644 --- a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala +++ b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala @@ -28,7 +28,10 @@ object CustomFieldType { value def parseValue(value: String): Either[String, String] = - Right(value) + Option(value) + .map(_.trim) + .filter(_.nonEmpty) + .toRight("Empty values are not allowed.") } case object Numeric extends CustomFieldType { @@ -62,7 +65,11 @@ object CustomFieldType { value.toString def parseValue(value: String): Either[String, Boolean] = - Right(value.equalsIgnoreCase("true")) + Option(value) + .map(_.trim) + .filter(_.nonEmpty) + .toRight("Empty values not allowed") + .map(_.equalsIgnoreCase("true")) } From 473985c80e4bdf3e0716de03309de03fb4b64893 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 23 Nov 2020 10:47:45 +0100 Subject: [PATCH 27/29] Filter empty custom field values in webui --- .../src/main/elm/Comp/CustomFieldInput.elm | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm index cb623a5d..202b59c8 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm @@ -20,6 +20,7 @@ import DatePicker exposing (DatePicker) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onCheck, onClick, onInput) +import Util.Maybe type alias Model = @@ -60,17 +61,6 @@ fieldType field = errorMsg : Model -> Maybe String errorMsg model = let - floatModel = - case model.fieldModel of - NumberField fm -> - Just fm - - MoneyField fm -> - Just fm - - _ -> - Nothing - getMsg res = case res of Ok _ -> @@ -79,7 +69,22 @@ errorMsg model = Err m -> Just m in - Maybe.andThen getMsg (Maybe.map .result floatModel) + case model.fieldModel of + NumberField fm -> + getMsg fm.result + + MoneyField fm -> + getMsg fm.result + + TextField mt -> + if mt == Nothing then + Just "Please fill in some value" + + else + Nothing + + _ -> + Nothing init : CustomField -> ( Model, Cmd Msg ) @@ -215,10 +220,13 @@ update msg model = case ( msg, model.fieldModel ) of ( SetText str, TextField _ ) -> let + newValue = + Util.Maybe.fromString str + model_ = - { model | fieldModel = TextField (Just str) } + { model | fieldModel = TextField newValue } in - UpdateResult model_ Cmd.none (Value str) + UpdateResult model_ Cmd.none (Maybe.map Value newValue |> Maybe.withDefault NoResult) ( NumberMsg str, NumberField _ ) -> let From 9bea0298ad7a7eceac5da9cd8a8af0198b92e4d0 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 23 Nov 2020 10:59:13 +0100 Subject: [PATCH 28/29] Allow to query custom field values with wildcards --- .../restapi/src/main/resources/docspell-openapi.yml | 11 +++++++++-- .../src/main/scala/docspell/store/queries/QItem.scala | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 7319e878..bff39091 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1299,6 +1299,10 @@ paths: The `fulltext` field can be used to restrict the results by using full-text search in the documents contents. + + The customfields used in the search query are allowed to be + specified by either field id or field name. The values may + contain the wildcard `*` at beginning or end. security: - authTokenHeader: [] requestBody: @@ -1920,7 +1924,8 @@ paths: summary: Set the value of a custom field. description: | Sets the value for a custom field to this item. If a value - already exists, it is overwritten. + already exists, it is overwritten. A value must comply to the + type of the associated field. It must not be the empty string. security: - authTokenHeader: [] parameters: @@ -2389,7 +2394,9 @@ paths: summary: Set the value of a custom field for multiple items description: | Sets the value for a custom field to multiple given items. If - a value already exists, it is overwritten. + a value already exists, it is overwritten. The value must + comply to the associated field type. It must not be the empty + string. security: - authTokenHeader: [] requestBody: diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index a742547a..4252d590 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -289,7 +289,7 @@ object QItem { and( cfColl.is(coll), or(cfName.is(v.field), cfId.is(v.field)), - cvValue.is(v.value) + cvValue.lowerLike(QueryWildcard(v.value.toLowerCase)) ) ) if (cv.isEmpty) Seq.empty From 1ef035f061b4ea035261ce16eca35fc773dcd32f Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 23 Nov 2020 11:20:59 +0100 Subject: [PATCH 29/29] Fix hiding tags/fields when corresponding setting exists --- modules/webapp/src/main/elm/Comp/ItemCard.elm | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/modules/webapp/src/main/elm/Comp/ItemCard.elm b/modules/webapp/src/main/elm/Comp/ItemCard.elm index 63992786..bed1dafe 100644 --- a/modules/webapp/src/main/elm/Comp/ItemCard.elm +++ b/modules/webapp/src/main/elm/Comp/ItemCard.elm @@ -393,6 +393,20 @@ mainTagsAndFields settings item = showField fv = Util.CustomField.renderValue "ui basic label" fv + + renderFields = + if hideFields then + [] + + else + List.map showField item.customfields + + renderTags = + if hideTags then + [] + + else + List.map showTag item.tags in div [ classList @@ -400,7 +414,7 @@ mainTagsAndFields settings item = , ( "invisible hidden", hideTags && hideFields ) ] ] - (List.map showField item.customfields ++ List.map showTag item.tags) + (renderFields ++ renderTags) previewImage : UiSettings -> Attribute Msg -> Model -> ItemLight -> Html Msg