Merge pull request #463 from eikek/custom-fields

Custom fields
This commit is contained in:
mergify[bot] 2020-11-23 10:56:42 +00:00 committed by GitHub
commit 470471dff6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 3834 additions and 134 deletions

View File

@ -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")))
}))
)

View File

@ -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](

View File

@ -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)))
}
})
}

View File

@ -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

View File

@ -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)
}

View File

@ -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.

View File

@ -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] =

View File

@ -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")

View File

@ -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
)
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -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")
)

View File

@ -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 {

View File

@ -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") "

View File

@ -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
}
}

View File

@ -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,

View File

@ -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))
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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*"))
}
}

View File

@ -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

View 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"
]
]

View 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) ] []
]

View 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" ] []
]
]

View 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" ] []
]

View 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
]
]

View File

@ -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

View File

@ -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

View File

@ -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)
]
]
]

View File

@ -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 =

View File

@ -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

View File

@ -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
}

View File

@ -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)
]
]
]

View File

@ -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

View 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

View 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

View File

@ -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 =

View File

@ -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"

View 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

View File

@ -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
}

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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
)

View File

@ -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

View 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
]
]

View File

@ -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;
}

View 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