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..bdec0212 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala @@ -0,0 +1,173 @@ +package docspell.backend.ops + +import cats.data.EitherT +import cats.data.NonEmptyList +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 +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._ +import org.log4s.getLogger + +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] + + def setValueMultiple(items: NonEmptyList[Ident], value: SetValue): F[SetValueResult] + + /** Deletes a value for a given field an item. */ + def deleteValue(in: RemoveValue): F[UpdateResult] +} + +object OCustomFields { + + type CustomFieldData = QCustomField.CustomFieldData + val CustomFieldData = QCustomField.CustomFieldData + + case class NewCustomField( + name: Ident, + label: Option[String], + ftype: CustomFieldType, + 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: NonEmptyList[Ident], + collective: Ident + ) + + def apply[F[_]: Effect]( + store: Store[F] + ): 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.filter(_.nonEmpty))) + + 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)) + _ <- 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 + + UpdateResult.fromUpdate(store.transact(update.getOrElse(0))) + } + + 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 + ) + 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]( + items + .traverse(item => store.transact(RCustomField.setValue(field, item, fval))) + .map(_.toList.sum) + ) + } yield nu).fold(identity, _ => SetValueResult.success) + + def deleteValue(in: RemoveValue): F[UpdateResult] = { + 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 + + UpdateResult.fromUpdate(store.transact(update.getOrElse(0))) + } + + }) + +} 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..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 @@ -65,6 +68,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/common/src/main/scala/docspell/common/CustomFieldType.scala b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala new file mode 100644 index 00000000..6c217550 --- /dev/null +++ b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala @@ -0,0 +1,115 @@ +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 { + + type ValueType = String + + def valueString(value: String): String = + value + + def parseValue(value: String): Either[String, String] = + Option(value) + .map(_.trim) + .filter(_.nonEmpty) + .toRight("Empty values are not allowed.") + } + + 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] = + Option(value) + .map(_.trim) + .filter(_.nonEmpty) + .toRight("Empty values not allowed") + .map(_.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 + 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..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: @@ -1914,6 +1918,51 @@ 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. A value must comply to the + type of the associated field. It must not be the empty string. + 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 ] @@ -2339,6 +2388,54 @@ 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. The value must + comply to the associated field type. It must not be the empty + string. + 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: @@ -3202,8 +3299,117 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/customfield: + get: + tags: [ Custom Fields ] + summary: Get all defined custom fields. + description: | + Get all custom fields defined for the current collective. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/q" + responses: + 200: + description: Ok + content: + 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/customfield/{id}: + parameters: + - $ref: "#/components/parameters/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: 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 @@ -3282,6 +3488,115 @@ components: format: date-time + CustomFieldList: + description: | + A list of known custom fields. + required: + - items + properties: + items: + type: array + 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. + required: + - field + - value + properties: + field: + type: string + format: ident + value: + type: string + + 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 + enum: + - text + - numeric + - date + - bool + - money + + CustomField: + description: | + A custom field definition. + required: + - id + - name + - ftype + - usages + - created + 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 + usages: + type: integer + format: int32 + created: + type: integer + format: date-time + JobPriority: description: | Transfer the priority of a job. @@ -3974,6 +4289,7 @@ components: - sources - archives - tags + - customfields properties: id: type: string @@ -4033,6 +4349,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. @@ -4372,6 +4692,7 @@ components: type: array items: $ref: "#/components/schemas/SourceAndTags" + Source: description: | Data about a Source. A source defines the endpoint where @@ -4638,6 +4959,7 @@ components: - inbox - offset - limit + - customValues properties: tagsInclude: type: array @@ -4717,6 +5039,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. @@ -4771,6 +5097,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/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/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 8c889f6b..35a88eaa 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._ @@ -95,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 = { @@ -138,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)) @@ -204,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 = { @@ -218,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) @@ -589,6 +603,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/CustomFieldRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala new file mode 100644 index 00000000..0aab3c17 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala @@ -0,0 +1,105 @@ +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.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +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 :? QueryParam.QueryOpt(param) => + for { + 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(user.account.collective, id) + resp <- Ok(convertResult(res)) + } yield resp + } + } + + private def convertResult(r: AddResult): BasicResult = + Conversions.basicResult(r, "New field 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/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 ba0c8c08..c6606667 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, NonEmptyList.of(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 new file mode 100644 index 00000000..708989bf --- /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, + "label" varchar(254), + "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, + "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/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/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..4252d590 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 = findCustomFieldValuesForItem(id) for { data <- q @@ -138,11 +147,34 @@ 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 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") + 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, @@ -161,6 +193,8 @@ object QItem { notes: Option[String] ) + case class CustomValue(field: Ident, value: String) + case class Query( account: AccountId, name: Option[String], @@ -181,6 +215,7 @@ object QItem { dueDateTo: Option[Timestamp], allNames: Option[String], itemIds: Option[Set[Ident]], + customValues: Seq[CustomValue], orderAsc: Option[RItem.Columns.type => Column] ) @@ -206,6 +241,7 @@ object QItem { None, None, None, + Seq.empty, None ) } @@ -231,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.lowerLike(QueryWildcard(v.value.toLowerCase)) + ) + ) + if (cv.isEmpty) Seq.empty + else Seq("customvalues" -> cv.map(singleSelect).reduce(_ ++ fr"INTERSECT" ++ _)) + } + private def findItemsBase( q: Query, distinct: Boolean, @@ -249,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( @@ -295,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( @@ -304,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")) ++ @@ -314,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( @@ -342,8 +414,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), @@ -452,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 @@ -488,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 ) } @@ -516,8 +591,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 +694,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 new file mode 100644 index 00000000..f74c7cc3 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala @@ -0,0 +1,89 @@ +package docspell.store.records + +import cats.implicits._ + +import docspell.common._ +import docspell.store.impl.Column +import docspell.store.impl.Implicits._ + +import doobie._ +import doobie.implicits._ + +case class RCustomField( + id: Ident, + name: Ident, + label: Option[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 label = Column("label") + val cid = Column("cid") + val ftype = Column("ftype") + val created = Column("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.label},${value.cid},${value.ftype},${value.created}" + ) + sql.update.run + } + + 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 + + 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(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 + + 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 new file mode 100644 index 00000000..8830dc58 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala @@ -0,0 +1,67 @@ +package docspell.store.records + +import cats.data.NonEmptyList + +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, + value: String +) + +object RCustomFieldValue { + + val table = fr"custom_field_value" + + object Columns { + + val id = Column("id") + val itemId = Column("item_id") + val field = Column("field") + val value = Column("field_value") + + 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.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 + + 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 + + def deleteValue(fieldId: Ident, items: NonEmptyList[Ident]): ConnectionIO[Int] = + deleteFrom( + table, + and(Columns.field.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 a023e136..980cd324 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,18 @@ 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 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 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*")) + } +} diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 5af07dc1..c36c709f 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -19,6 +19,9 @@ module Api exposing , createScanMailbox , deleteAllItems , deleteAttachment + , deleteCustomField + , deleteCustomValue + , deleteCustomValueMultiple , deleteEquip , deleteFolder , deleteImapSettings @@ -36,6 +39,7 @@ module Api exposing , getCollective , getCollectiveSettings , getContacts + , getCustomFields , getEquipment , getEquipments , getFolderDetail @@ -68,12 +72,16 @@ module Api exposing , logout , moveAttachmentBefore , newInvite + , postCustomField , postEquipment , postNewUser , postOrg , postPerson , postSource , postTag + , putCustomField + , putCustomValue + , putCustomValueMultiple , putUser , refreshSession , register @@ -129,6 +137,8 @@ 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.CustomFieldValue exposing (CustomFieldValue) import Api.Model.DirectionValue exposing (DirectionValue) import Api.Model.EmailSettings exposing (EmailSettings) import Api.Model.EmailSettingsList exposing (EmailSettingsList) @@ -145,19 +155,20 @@ 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) 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) 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 +188,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 +211,117 @@ 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 + -> 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 + { 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/CustomFieldForm.elm b/modules/webapp/src/main/elm/Comp/CustomFieldForm.elm new file mode 100644 index 00000000..2945f054 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/CustomFieldForm.elm @@ -0,0 +1,305 @@ +module Comp.CustomFieldForm exposing + ( Model + , Msg + , ViewSettings + , fullViewSettings + , init + , initEmpty + , makeField + , 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 + + +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 viewSettings.classes ] + ([ 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." + ] + ] + ] + ++ (if viewSettings.showControls then + viewButtons model + + else + [] + ) + ) + + +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/CustomFieldInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm new file mode 100644 index 00000000..202b59c8 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/CustomFieldInput.elm @@ -0,0 +1,410 @@ +module Comp.CustomFieldInput exposing + ( FieldResult(..) + , Model + , 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 Data.Icons as Icons +import Data.Money +import Date exposing (Date) +import DatePicker exposing (DatePicker) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onCheck, onClick, onInput) +import Util.Maybe + + +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 + getMsg res = + case res of + Ok _ -> + Nothing + + Err m -> + Just m + in + 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 ) +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 + ) + + +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 string2Float identity + in + NumberField fm + + Data.CustomFieldType.Money -> + let + ( fm, _ ) = + updateFloatModel + value.value + Data.Money.fromString + Data.Money.normalizeInput + 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 + | Value String + + +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , result : FieldResult + } + + +updateFloatModel : + String + -> (String -> Result String Float) + -> (String -> String) + -> ( FloatModel, FieldResult ) +updateFloatModel msg parse normalize = + case parse msg of + Ok n -> + ( { input = normalize msg + , result = Ok n + } + , Value (normalize msg) + ) + + Err err -> + ( { input = msg + , result = Err err + } + , NoResult + ) + + +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 +update msg model = + case ( msg, model.fieldModel ) of + ( SetText str, TextField _ ) -> + let + newValue = + Util.Maybe.fromString str + + model_ = + { model | fieldModel = TextField newValue } + in + UpdateResult model_ Cmd.none (Maybe.map Value newValue |> Maybe.withDefault NoResult) + + ( NumberMsg str, NumberField _ ) -> + let + ( fm, res ) = + updateFloatModel str string2Float identity + + model_ = + { model | fieldModel = NumberField fm } + in + UpdateResult model_ Cmd.none res + + ( MoneyMsg str, MoneyField _ ) -> + let + ( fm, res ) = + updateFloatModel + str + Data.Money.fromString + Data.Money.normalizeInput + + model_ = + { model | fieldModel = MoneyField fm } + in + UpdateResult model_ Cmd.none res + + ( 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) + + ( DateMsg lm, DateField old 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 -> + ( old, NoResult ) + + DatePicker.FailedInput _ -> + ( old, NoResult ) + + model_ = + { model | fieldModel = DateField newDate picker_ } + in + UpdateResult model_ Cmd.none value + + ( Remove, _ ) -> + UpdateResult model Cmd.none RemoveField + + -- no other possibilities, not well encoded here + _ -> + UpdateResult model Cmd.none NoResult + + +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 <| Icons.customFieldType Data.CustomFieldType.Text) ] [] + ] + + NumberField nm -> + div [ class "ui action left icon input" ] + [ input + [ type_ "text" + , value nm.input + , onInput NumberMsg + ] + [] + , removeButton "" + , i [ class (iconOr <| Icons.customFieldType Data.CustomFieldType.Numeric) ] [] + ] + + MoneyField nm -> + div [ class "ui action left icon input" ] + [ input + [ type_ "text" + , value nm.input + , onInput MoneyMsg + ] + [] + , removeButton "" + , i [ class (iconOr <| Icons.customFieldType Data.CustomFieldType.Money) ] [] + ] + + 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 <| Icons.customFieldType Data.CustomFieldType.Date) ] [] + ] 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..f4915a85 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/CustomFieldManage.elm @@ -0,0 +1,194 @@ +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.CustomFieldForm +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.CustomFieldForm.Model + , fields : List CustomField + , query : String + , loading : Bool + } + + +type Msg + = TableMsg Comp.CustomFieldTable.Msg + | DetailMsg Comp.CustomFieldForm.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.CustomFieldForm.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.CustomFieldForm.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.CustomFieldForm.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.CustomFieldForm.Model -> Html Msg +viewDetail flags detailModel = + let + viewSettings = + Comp.CustomFieldForm.fullViewSettings + in + div [] + [ Html.map DetailMsg (Comp.CustomFieldForm.view viewSettings 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/CustomFieldMultiInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm new file mode 100644 index 00000000..9d4e2cdb --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm @@ -0,0 +1,374 @@ +module Comp.CustomFieldMultiInput exposing + ( Model + , Msg + , UpdateResult + , ViewSettings + , init + , initCmd + , initWith + , isEmpty + , nonEmpty + , reset + , setValues + , update + , view + ) + +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.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.Maybe + + +type alias Model = + { 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 = + let + labelThenName cv = + Maybe.withDefault cv.name cv.label + in + Dict.toList model.visibleFields + |> List.map (Tuple.second >> .field) + |> List.sortBy labelThenName + + +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 + | RemoveField CustomField + | CreateNewField + | CustomFieldResp (Result Http.Error CustomFieldList) + | FieldSelectMsg (Comp.FixedDropdown.Msg CustomField) + | SetValues (List ItemFieldValue) + + +nonEmpty : Model -> Bool +nonEmpty model = + not (isEmpty model) + + +isEmpty : Model -> Bool +isEmpty model = + List.isEmpty model.allFields + + +initWith : List CustomField -> Model +initWith fields = + { fieldSelect = mkFieldSelect (currentOptions fields Dict.empty) + , visibleFields = Dict.empty + , allFields = fields + } + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + ( initWith [] + , initCmd flags + ) + + +initCmd : Flags -> Cmd Msg +initCmd flags = + Api.getCustomFields flags "" CustomFieldResp + + +setValues : List ItemFieldValue -> Msg +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 + , dropdown = Comp.FixedDropdown.init (List.map mkItem fields) + } + + + +--- Update + + +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , result : CustomFieldChange + } + + +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 FieldCreateNew + + CustomFieldResp (Ok list) -> + let + model_ = + { model + | allFields = list.items + , fieldSelect = mkFieldSelect (currentOptions list.items model.visibleFields) + } + in + UpdateResult model_ Cmd.none NoFieldChange + + CustomFieldResp (Err _) -> + UpdateResult model Cmd.none NoFieldChange + + 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 + case sel of + Just field -> + update (ApplyField field) model + + Nothing -> + UpdateResult model_ Cmd.none NoFieldChange + + ApplyField f -> + let + ( fm, fc ) = + Comp.CustomFieldInput.init f + + visible = + Dict.insert f.name (VisibleField f fm) model.visibleFields + + fSelect = + mkFieldSelect (currentOptions model.allFields visible) + + -- 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 = { fSelect | dropdown = dropdownOpen } + , visibleFields = visible + } + + cmd_ = + Cmd.map (CustomFieldInputMsg f) fc + in + UpdateResult model_ cmd_ NoFieldChange + + RemoveField f -> + let + visible = + Dict.remove f.name model.visibleFields + + model_ = + { model + | visibleFields = visible + , fieldSelect = mkFieldSelect (currentOptions model.allFields visible) + } + in + UpdateResult model_ Cmd.none (FieldValueRemove f) + + CustomFieldInputMsg f lm -> + let + visibleField = + Dict.get f.name model.visibleFields + in + case visibleField of + Just { field, inputModel } -> + let + res = + Comp.CustomFieldInput.update lm inputModel + + model_ = + { model + | visibleFields = + Dict.insert field.name (VisibleField field res.model) model.visibleFields + } + + 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 -> + NoFieldChange + in + if res.result == Comp.CustomFieldInput.RemoveField then + update (RemoveField field) model_ + + else + UpdateResult model_ cmd_ result + + Nothing -> + UpdateResult model Cmd.none NoFieldChange + + 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 + + f = + field fv + in + ( Dict.insert fv.name (VisibleField f fim) dict + , Cmd.map (CustomFieldInputMsg f) fic :: cmds + ) + + ( modelDict, cmdList ) = + List.foldl merge ( Dict.empty, [] ) values + + model_ = + { model + | fieldSelect = mkFieldSelect (currentOptions model.allFields modelDict) + , visibleFields = modelDict + } + in + UpdateResult model_ (Cmd.batch cmdList) NoFieldChange + + + +--- View + + +type alias ViewSettings = + { showAddButton : Bool + , classes : String + , fieldIcon : CustomField -> Maybe String + } + + +view : ViewSettings -> Model -> Html Msg +view viewSettings model = + div [ class viewSettings.classes ] + (viewMenuBar viewSettings model + :: List.map (viewCustomField viewSettings model) (visibleFields model) + ) + + +viewMenuBar : ViewSettings -> Model -> Html Msg +viewMenuBar viewSettings model = + let + { dropdown, selected } = + model.fieldSelect + in + 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 : ViewSettings -> Model -> CustomField -> Html Msg +viewCustomField viewSettings model field = + let + visibleField = + Dict.get field.name model.visibleFields + in + case visibleField of + Just vf -> + Html.map (CustomFieldInputMsg field) + (Comp.CustomFieldInput.view "field" + (viewSettings.fieldIcon vf.field) + vf.inputModel + ) + + 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/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/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/ItemCard.elm b/modules/webapp/src/main/elm/Comp/ItemCard.elm index 31836733..bed1dafe 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,62 @@ 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 + + renderFields = + if hideFields then + [] + + else + List.map showField item.customfields + + renderTags = + if hideTags then + [] + + else + List.map showTag item.tags + in + div + [ classList + [ ( "ui right floated tiny labels", True ) + , ( "invisible hidden", hideTags && hideFields ) + ] + ] + (renderFields ++ renderTags) + + previewImage : UiSettings -> Attribute Msg -> Model -> ItemLight -> Html Msg previewImage settings cardAction model item = let diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm index 161b5871..6ac7b7f5 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm @@ -18,10 +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 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) @@ -77,6 +79,7 @@ type alias Model = , concEquipModel : Comp.Dropdown.Model IdName , modalEdit : Maybe Comp.DetailEdit.Model , tagEditMode : TagEditMode + , customFieldModel : Comp.CustomFieldMultiInput.Model } @@ -102,6 +105,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 +159,7 @@ init = , dueDatePicker = Comp.DatePicker.emptyModel , modalEdit = Nothing , tagEditMode = AddTags + , customFieldModel = Comp.CustomFieldMultiInput.initWith [] } @@ -170,6 +175,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 +553,33 @@ 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 + + change = + case res.result of + NoFieldChange -> + NoFormChange + + FieldValueRemove cf -> + RemoveCustomValue cf + + FieldValueChange cf value -> + CustomValueChange cf value + + FieldCreateNew -> + NoFormChange + in + UpdateResult model_ cmd_ Sub.none change + nameThrottleSub : Model -> Sub Msg nameThrottleSub model = @@ -562,6 +595,7 @@ nameThrottleSub model = type alias ViewConfig = { menuClass : String , nameState : SaveNameState + , customFieldState : String -> SaveNameState } @@ -569,6 +603,7 @@ defaultViewConfig : ViewConfig defaultViewConfig = { menuClass = "ui vertical segment" , nameState = SaveSuccess + , customFieldState = \_ -> SaveSuccess } @@ -614,6 +649,23 @@ 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" + customFieldIcon in div [ class cfg.menuClass ] [ div [ class "ui form warning" ] @@ -687,13 +739,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 +758,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 +768,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 +777,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 +786,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 +831,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/Model.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm index 57625afc..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) @@ -24,6 +25,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 +95,9 @@ type alias Model = , modalEdit : Maybe Comp.DetailEdit.Model , attachRename : Maybe AttachmentRename , keyInputModel : Comp.KeyInput.Model + , customFieldsModel : Comp.CustomFieldMultiInput.Model + , customFieldSavingIcon : Dict String String + , customFieldThrottle : Throttle Msg } @@ -194,6 +199,9 @@ emptyModel = , modalEdit = Nothing , attachRename = Nothing , keyInputModel = Comp.KeyInput.init + , customFieldsModel = Comp.CustomFieldMultiInput.initWith [] + , customFieldSavingIcon = Dict.empty + , customFieldThrottle = Throttle.create 1 } @@ -279,6 +287,9 @@ type Msg | ToggleAttachMenu | UiSettingsUpdated | SetLinkTarget LinkTarget + | CustomFieldMsg Comp.CustomFieldMultiInput.Msg + | 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 19ad419d..e04e7714 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -2,9 +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) @@ -13,6 +16,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) @@ -39,6 +43,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) @@ -72,14 +77,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 ] ) @@ -187,6 +202,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 @@ -195,7 +218,7 @@ update key flags inav settings msg model = Cmd.none lastModel = - res8.model + res9.model in { model = { lastModel @@ -224,9 +247,11 @@ update key flags inav settings msg model = , res6.cmd , res7.cmd , res8.cmd + , res9.cmd , getOptions flags , proposalCmd , Api.getSentMails flags item.id SentMailsResp + , Cmd.map CustomFieldMsg (Comp.CustomFieldMultiInput.initCmd flags) ] , sub = Sub.batch @@ -238,6 +263,7 @@ update key flags inav settings msg model = , res6.sub , res7.sub , res8.sub + , res9.sub ] , linkTarget = Comp.LinkTarget.LinkNone } @@ -1210,10 +1236,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 @@ -1270,6 +1302,99 @@ update key flags inav settings msg model = , linkTarget = lt } + CustomFieldMsg lm -> + let + result = + Comp.CustomFieldMultiInput.update lm model.customFieldsModel + + cmd_ = + Cmd.map CustomFieldMsg result.cmd + + loadingIcon = + "refresh loading icon" + + ( action_, icons ) = + case result.result of + NoFieldChange -> + ( Cmd.none, model.customFieldSavingIcon ) + + FieldValueRemove field -> + ( Api.deleteCustomValue flags + model.item.id + field.id + (CustomFieldRemoveResp field.id) + , Dict.insert field.id loadingIcon model.customFieldSavingIcon + ) + + FieldValueChange field value -> + ( Api.putCustomValue flags + model.item.id + (CustomFieldValue field.id value) + (CustomFieldSaveResp field value) + , Dict.insert field.id loadingIcon model.customFieldSavingIcon + ) + + FieldCreateNew -> + ( Cmd.none, model.customFieldSavingIcon ) + + modalEdit = + if result.result == FieldCreateNew then + Just (Comp.DetailEdit.initCustomField model.item.id) + + 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 + withSub ( model_, Cmd.batch [ cmd_, action ] ) + + 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 } + in + if res.success then + resultModelCmd + ( model_ + , Api.itemDetail flags model.item.id GetItemResp + ) + + else + resultModel model_ + + CustomFieldRemoveResp fieldId (Err _) -> + resultModel { model | customFieldSavingIcon = Dict.remove fieldId model.customFieldSavingIcon } + --- Helper @@ -1409,9 +1534,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 } @@ -1449,6 +1579,9 @@ resetField flags item tagger field = Data.Fields.PreviewImage -> Cmd.none + Data.Fields.CustomFields -> + Cmd.none + resetHiddenFields : UiSettings @@ -1464,3 +1597,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 27eef314..ac03a753 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 @@ -16,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 @@ -30,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 @@ -181,11 +184,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) @@ -598,33 +597,49 @@ 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 + fieldView cv = + Util.CustomField.renderValue "ui secondary basic label" cv + + 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) @@ -727,19 +742,20 @@ renderEditForm settings model = else span [ class "invisible hidden" ] [] + + showCustomFields = + fieldVisible Data.Fields.CustomFields + && Comp.CustomFieldMultiInput.nonEmpty model.customFieldsModel + + customFieldSettings = + Comp.CustomFieldMultiInput.ViewSettings + True + "field" + (\f -> Dict.get f.id model.customFieldSavingIcon) 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 +769,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 +798,32 @@ 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) + , if showCustomFields then + h4 [ class "ui dividing header" ] + [ Icons.customFieldIcon "" + , text "Custom Fields" + ] + + 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 "" + , text "Item 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 +833,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 +843,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 +852,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 +916,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/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index 07af0830..406cbf9a 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" (\_ -> Nothing)) + model.customFieldModel + ) + ] , div [ class segmentClass ] [ formHeader (Icons.searchIcon "") "Text Search" , div 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 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..243a49af 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -5,6 +5,11 @@ module Data.Icons exposing , concernedIcon , correspondent , correspondentIcon + , customField + , customFieldIcon + , customFieldType + , customFieldTypeIcon + , customFieldTypeIconString , date , dateIcon , direction @@ -17,6 +22,7 @@ module Data.Icons exposing , equipmentIcon , folder , folderIcon + , itemDatesIcon , organization , organizationIcon , person @@ -29,10 +35,53 @@ 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 -> + "stream icon" + + Data.CustomFieldType.Numeric -> + "hashtag icon" + + Data.CustomFieldType.Date -> + "calendar icon" + + Data.CustomFieldType.Boolean -> + "marker icon" + + Data.CustomFieldType.Money -> + "money bill icon" + + +customFieldTypeIcon : String -> CustomFieldType -> Html msg +customFieldTypeIcon classes ftype = + i [ class (customFieldType ftype ++ " " ++ classes) ] + [] + + +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" + + +customFieldIcon : String -> Html msg +customFieldIcon classes = + i [ class (customField ++ " " ++ classes) ] [] + + search : String search = "search icon" @@ -73,6 +122,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" 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..7856f517 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/Money.elm @@ -0,0 +1,52 @@ +module Data.Money exposing + ( Money + , format + , fromString + , normalizeInput + , roundMoney + ) + + +type alias Money = + Float + + +fromString : String -> Result String Money +fromString str = + let + input = + normalizeInput str + + points = + String.indexes "." input + + len = + String.length str + in + case points of + index :: [] -> + if index == (len - 3) then + String.toFloat input + |> 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 + + +normalizeInput : String -> String +normalizeInput str = + String.replace "," "." str 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" 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 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 + ] + ] diff --git a/modules/webapp/src/main/webjar/docspell.css b/modules/webapp/src/main/webjar/docspell.css index 88f3575b..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; @@ -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; } 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