mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-02-15 20:33:26 +00:00
commit
470471dff6
@ -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")))
|
||||
}))
|
||||
)
|
||||
|
||||
|
@ -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](
|
||||
|
@ -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)))
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
}
|
@ -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.
|
||||
|
@ -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] =
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
)
|
||||
}
|
@ -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
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
)
|
@ -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 {
|
||||
|
@ -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") "
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
|
||||
|
@ -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*"))
|
||||
}
|
||||
}
|
@ -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
|
||||
|
||||
|
||||
|
305
modules/webapp/src/main/elm/Comp/CustomFieldForm.elm
Normal file
305
modules/webapp/src/main/elm/Comp/CustomFieldForm.elm
Normal file
@ -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"
|
||||
]
|
||||
]
|
410
modules/webapp/src/main/elm/Comp/CustomFieldInput.elm
Normal file
410
modules/webapp/src/main/elm/Comp/CustomFieldInput.elm
Normal file
@ -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) ] []
|
||||
]
|
194
modules/webapp/src/main/elm/Comp/CustomFieldManage.elm
Normal file
194
modules/webapp/src/main/elm/Comp/CustomFieldManage.elm
Normal file
@ -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" ] []
|
||||
]
|
||||
]
|
374
modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm
Normal file
374
modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm
Normal file
@ -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" ] []
|
||||
]
|
90
modules/webapp/src/main/elm/Comp/CustomFieldTable.elm
Normal file
90
modules/webapp/src/main/elm/Comp/CustomFieldTable.elm
Normal file
@ -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
|
||||
]
|
||||
]
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
@ -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 =
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
@ -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
|
||||
|
77
modules/webapp/src/main/elm/Data/CustomFieldChange.elm
Normal file
77
modules/webapp/src/main/elm/Data/CustomFieldChange.elm
Normal file
@ -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
|
80
modules/webapp/src/main/elm/Data/CustomFieldType.elm
Normal file
80
modules/webapp/src/main/elm/Data/CustomFieldType.elm
Normal file
@ -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
|
@ -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 =
|
||||
|
@ -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"
|
||||
|
52
modules/webapp/src/main/elm/Data/Money.elm
Normal file
52
modules/webapp/src/main/elm/Data/Money.elm
Normal file
@ -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
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
|
30
modules/webapp/src/main/elm/Util/CustomField.elm
Normal file
30
modules/webapp/src/main/elm/Util/CustomField.elm
Normal file
@ -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
|
||||
]
|
||||
]
|
@ -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;
|
||||
}
|
||||
|
159
website/site/content/docs/dev/adr/0016_custom_fields.md
Normal file
159
website/site/content/docs/dev/adr/0016_custom_fields.md
Normal file
@ -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
|
Loading…
Reference in New Issue
Block a user