Merge pull request #2470 from Budibase/multi-option-datatype

Multi-Option Data Type
This commit is contained in:
PClmnt 2021-08-27 14:43:09 +01:00 committed by GitHub
commit 58b966c0cc
24 changed files with 356 additions and 51 deletions

View File

@ -0,0 +1,17 @@
<script>
import "@spectrum-css/label/dist/index-vars.css"
import Badge from "../Badge/Badge.svelte"
export let value
const displayLimit = 5
$: badges = value?.slice(0, displayLimit) ?? []
$: leftover = (value?.length ?? 0) - badges.length
</script>
{#each badges as badge}
<Badge size="S" grey>{badge}</Badge>
{/each}
{#if leftover}
<div>+{leftover} more</div>
{/if}

View File

@ -4,7 +4,7 @@
import DateTimeRenderer from "./DateTimeRenderer.svelte"
import RelationshipRenderer from "./RelationshipRenderer.svelte"
import AttachmentRenderer from "./AttachmentRenderer.svelte"
import ArrayRenderer from "./ArrayRenderer.svelte"
export let row
export let schema
export let value
@ -19,6 +19,7 @@
options: StringRenderer,
number: StringRenderer,
longform: StringRenderer,
array: ArrayRenderer,
}
$: type = schema?.type ?? "string"
$: customRenderer = customRenderers?.find(x => x.column === schema?.name)

View File

@ -131,6 +131,7 @@ const fieldTypeToComponentMap = {
string: "stringfield",
number: "numberfield",
options: "optionsfield",
array: "multifieldselect",
boolean: "booleanfield",
longform: "longformfield",
datetime: "datetimefield",
@ -167,6 +168,13 @@ export function makeDatasourceFormComponents(datasource) {
optionsSource: "schema",
})
}
if (fieldType === "array") {
component.customProps({
placeholder: "Choose an option",
optionsSource: "schema",
})
}
if (fieldType === "link") {
let placeholder =
fieldSchema.relationshipType === "one-to-many"

View File

@ -1,5 +1,12 @@
<script>
import { Input, Select, DatePicker, Toggle, TextArea } from "@budibase/bbui"
import {
Input,
Select,
DatePicker,
Toggle,
TextArea,
Multiselect,
} from "@budibase/bbui"
import Dropzone from "components/common/Dropzone.svelte"
import { capitalise } from "helpers"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
@ -27,6 +34,8 @@
<Dropzone {label} bind:value />
{:else if type === "boolean"}
<Toggle text={label} bind:value data-cy="{meta.name}-input" />
{:else if type === "array"}
<Multiselect bind:value {label} options={meta.constraints.inclusion} />
{:else if type === "link"}
<LinkedRowSelector bind:linkedRows={value} schema={meta} />
{:else if type === "longform"}

View File

@ -262,6 +262,11 @@
label="Options (one per line)"
bind:values={field.constraints.inclusion}
/>
{:else if field.type === "array"}
<ValuesList
label="Options (one per line)"
bind:values={field.constraints.inclusion}
/>
{:else if field.type === "datetime"}
<DatePicker
label="Earliest"

View File

@ -113,6 +113,10 @@
label: "Options",
value: FIELDS.OPTIONS.type,
},
{
label: "Multi-select",
value: FIELDS.ARRAY.type,
},
]
</script>

View File

@ -21,7 +21,8 @@
"datetimefield",
"attachmentfield",
"relationshipfield",
"daterangepicker"
"daterangepicker",
"multifieldselect"
]
},
{

View File

@ -59,6 +59,14 @@
expression.operator = validOperators[0] ?? OperatorOptions.Equals.value
onOperatorChange(expression, expression.operator)
}
// if changed to an array, change default value to empty array
const idx = filters.findIndex(x => x.field === field)
if (expression.type === "array") {
filters[idx].value = []
} else {
filters[idx].value = null
}
}
const onOperatorChange = (expression, operator) => {
@ -74,7 +82,9 @@
const getFieldOptions = field => {
const schema = schemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
const opt = schema?.constraints?.inclusion || []
return opt
}
</script>
@ -122,7 +132,7 @@
/>
{:else if ["string", "longform", "number"].includes(filter.type)}
<Input disabled={filter.noValue} bind:value={filter.value} />
{:else if filter.type === "options"}
{:else if filter.type === "options" || "array"}
<Combobox
disabled={filter.noValue}
options={getFieldOptions(filter.field)}

View File

@ -58,11 +58,11 @@
value: "notRegex",
},
Contains: {
label: "Must contain row ID",
label: "Must contain",
value: "contains",
},
NotContains: {
label: "Must not contain row ID",
label: "Must not contain",
value: "notContains",
},
}
@ -102,6 +102,13 @@
Constraints.MinLength,
Constraints.MaxLength,
],
["array"]: [
Constraints.Required,
Constraints.MinLength,
Constraints.MaxLength,
Constraints.Contains,
Constraints.NotContains,
],
}
$: dataSourceSchema = getDataSourceSchema($currentAsset, $selectedComponent)
@ -109,7 +116,6 @@
$: schemaRules = parseRulesFromSchema(field, dataSourceSchema || {})
$: fieldType = type?.split("/")[1] || "string"
$: constraintOptions = getConstraintsForType(fieldType)
const getConstraintsForType = type => {
return ConstraintMap[type]
}
@ -190,6 +196,7 @@
valueType: "Binding",
type: fieldType,
id: generate(),
value: fieldType == "array" ? [] : null,
},
]
}
@ -275,7 +282,7 @@
disabled={rule.constraint === "required"}
on:change={e => (rule.value = e.detail)}
/>
{:else if ["maxLength", "minLength", "regex", "notRegex", "contains", "notContains"].includes(rule.constraint)}
{:else if rule.type !== "array" && ["maxLength", "minLength", "regex", "notRegex", "contains", "notContains"].includes(rule.constraint)}
<!-- Certain constraints always need string values-->
<Input
bind:value={rule.value}
@ -289,6 +296,15 @@
bind:value={rule.value}
placeholder="Constraint value"
/>
{:else if rule.type === "array"}
<Select
disabled={rule.constraint === "required"}
options={dataSourceSchema.schema[field].constraints
.inclusion}
getOptionLabel={x => x}
getOptionValue={x => x}
value={rule.value}
/>
{:else if rule.type === "boolean"}
<Select
disabled={rule.constraint === "required"}

View File

@ -43,9 +43,11 @@ const componentMap = {
"field/datetime": FormFieldSelect,
"field/attachment": FormFieldSelect,
"field/link": FormFieldSelect,
"field/array": FormFieldSelect,
// Some validation types are the same as others, so not all types are
// explicitly listed here. e.g. options uses string validation
"validation/string": ValidationEditor,
"validation/array": ValidationEditor,
"validation/number": ValidationEditor,
"validation/boolean": ValidationEditor,
"validation/datetime": ValidationEditor,

View File

@ -26,6 +26,15 @@ export const FIELDS = {
inclusion: [],
},
},
ARRAY: {
name: "Multi-select",
type: "array",
constraints: {
type: "array",
presence: false,
inclusion: [],
},
},
NUMBER: {
name: "Number",
type: "number",

View File

@ -31,6 +31,14 @@ export const OperatorOptions = {
value: "rangeHigh",
label: "Less than",
},
Contains: {
value: "equal",
label: "Contains",
},
NotContains: {
value: "notEqual",
label: "Does Not Contain",
},
}
export const getValidOperatorsForType = type => {
@ -55,6 +63,8 @@ export const getValidOperatorsForType = type => {
]
} else if (type === "options") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "array") {
return [Op.Contains, Op.NotContains, Op.Empty, Op.NotEmpty]
} else if (type === "boolean") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "longform") {

View File

@ -211,7 +211,6 @@ class QueryBuilder {
if (this.query.notEmpty) {
build(this.query.notEmpty, key => `${key}:["" TO *]`)
}
return query
}
@ -253,6 +252,7 @@ const runQuery = async (url, body) => {
method: "POST",
})
const json = await response.json()
let output = {
rows: [],
}

View File

@ -58,12 +58,24 @@ exports.validate = async ({ appId, tableId, row, table }) => {
const constraints = cloneDeep(table.schema[fieldName].constraints)
// special case for options, need to always allow unselected (null)
if (
table.schema[fieldName].type === FieldTypes.OPTIONS &&
table.schema[fieldName].type ===
(FieldTypes.OPTIONS || FieldTypes.ARRAY) &&
constraints.inclusion
) {
constraints.inclusion.push(null)
}
const res = validateJs.single(row[fieldName], constraints)
let res
// Validate.js doesn't seem to handle array
if (table.schema[fieldName].type === FieldTypes.ARRAY) {
row[fieldName].map(val => {
if (!constraints.inclusion.includes(val)) {
errors[fieldName] = "Field not in list"
}
})
} else {
res = validateJs.single(row[fieldName], constraints)
}
if (res) errors[fieldName] = res
}
return { valid: Object.keys(errors).length === 0, errors }

View File

@ -12,6 +12,7 @@ exports.FieldTypes = {
OPTIONS: "options",
NUMBER: "number",
BOOLEAN: "boolean",
ARRAY: "array",
DATETIME: "datetime",
ATTACHMENT: "attachment",
LINK: "link",

View File

@ -99,7 +99,14 @@ exports.createAllSearchIndex = async appId => {
for (let key of Object.keys(input)) {
let idxKey = prev != null ? `${prev}.${key}` : key
idxKey = idxKey.replace(/ /, "_")
if (key === "_id" || key === "_rev" || input[key] == null) {
if (Array.isArray(input[key])) {
for (let val in input[key]) {
// eslint-disable-next-line no-undef
index(idxKey, input[key][val], {
store: true,
})
}
} else if (key === "_id" || key === "_rev" || input[key] == null) {
continue
}
if (typeof input[key] === "string") {

View File

@ -29,6 +29,11 @@ const TYPE_TRANSFORM_MAP = {
[null]: null,
[undefined]: undefined,
},
[FieldTypes.ARRAY]: {
"": [],
[null]: [],
[undefined]: undefined,
},
[FieldTypes.STRING]: {
"": "",
[null]: "",

View File

@ -2041,6 +2041,108 @@
}
]
},
"multifieldselect": {
"name": "Multi-select Picker",
"icon": "ViewList",
"styles": ["size"],
"illegalChildren": ["section"],
"settings": [
{
"type": "field/array",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Placeholder",
"key": "placeholder",
"placeholder": "Choose an option"
},
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
},
{
"type": "boolean",
"label": "Autocomplete",
"key": "autocomplete",
"defaultValue": false
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "select",
"label": "Options source",
"key": "optionsSource",
"defaultValue": "schema",
"placeholder": "Pick an options source",
"options": [
{
"label": "Schema",
"value": "schema"
},
{
"label": "Data provider",
"value": "provider"
},
{
"label": "Custom",
"value": "custom"
}
]
},
{
"type": "dataProvider",
"label": "Options Provider",
"key": "dataProvider",
"dependsOn": {
"setting": "optionsSource",
"value": "provider"
}
},
{
"type": "field",
"label": "Label Column",
"key": "labelColumn",
"dependsOn": {
"setting": "optionsSource",
"value": "provider"
}
},
{
"type": "field",
"label": "Value Column",
"key": "valueColumn",
"dependsOn": {
"setting": "optionsSource",
"value": "provider"
}
},
{
"type": "options",
"key": "customOptions",
"dependsOn": {
"setting": "optionsSource",
"value": "custom"
}
},
{
"type": "validation/array",
"label": "Validation",
"key": "validation"
}
]
},
"booleanfield": {
"name": "Checkbox",
"icon": "Checkmark",

View File

@ -0,0 +1,58 @@
<script>
import { CoreMultiselect } from "@budibase/bbui"
import Field from "./Field.svelte"
import { getOptions } from "./optionsParser"
export let field
export let label
export let placeholder
export let disabled = false
export let validation
export let defaultValue
export let optionsSource = "schema"
export let dataProvider
export let labelColumn
export let valueColumn
export let customOptions
export let autocomplete = false
let fieldState
let fieldApi
let fieldSchema
$: flatOptions = optionsSource == null || optionsSource === "schema"
$: options = getOptions(
optionsSource,
fieldSchema,
dataProvider,
labelColumn,
valueColumn,
customOptions
)
</script>
<Field
{field}
{label}
{disabled}
{validation}
{defaultValue}
type="array"
bind:fieldState
bind:fieldApi
bind:fieldSchema
>
{#if fieldState}
<CoreMultiselect
value={fieldState.value || []}
error={fieldState.error}
getOptionLabel={flatOptions ? x => x : x => x.label}
getOptionValue={flatOptions ? x => x : x => x.value}
id={fieldState.fieldId}
disabled={fieldState.disabled}
on:change={e => fieldApi.setValue(e.detail)}
{placeholder}
{options}
{autocomplete}
/>
{/if}
</Field>

View File

@ -1,7 +1,7 @@
<script>
import { CoreSelect, CoreRadioGroup } from "@budibase/bbui"
import Field from "./Field.svelte"
import { getOptions } from "./optionsParser"
export let field
export let label
export let placeholder
@ -26,41 +26,9 @@
fieldSchema,
dataProvider,
labelColumn,
valueColumn
valueColumn,
customOptions
)
const getOptions = (
optionsSource,
fieldSchema,
dataProvider,
labelColumn,
valueColumn
) => {
// Take options from schema
if (optionsSource == null || optionsSource === "schema") {
return fieldSchema?.constraints?.inclusion ?? []
}
// Extract options from data provider
if (optionsSource === "provider" && valueColumn) {
let optionsSet = {}
dataProvider?.rows?.forEach(row => {
const value = row?.[valueColumn]
if (value) {
const label = row[labelColumn] || value
optionsSet[value] = { value, label }
}
})
return Object.values(optionsSet)
}
// Extract custom options
if (optionsSource === "custom" && customOptions) {
return customOptions
}
return []
}
</script>
<Field

View File

@ -3,6 +3,7 @@ export { default as fieldgroup } from "./FieldGroup.svelte"
export { default as stringfield } from "./StringField.svelte"
export { default as numberfield } from "./NumberField.svelte"
export { default as optionsfield } from "./OptionsField.svelte"
export { default as multifieldselect } from "./MultiFieldSelect.svelte"
export { default as booleanfield } from "./BooleanField.svelte"
export { default as longformfield } from "./LongFormField.svelte"
export { default as datetimefield } from "./DateTimeField.svelte"

View File

@ -0,0 +1,47 @@
export const getOptions = (
optionsSource,
fieldSchema,
dataProvider,
labelColumn,
valueColumn,
customOptions
) => {
const isArray = fieldSchema?.type === "array"
// Take options from schema
if (optionsSource == null || optionsSource === "schema") {
return fieldSchema?.constraints?.inclusion ?? []
}
if (optionsSource === "provider" && isArray) {
let optionsSet = {}
dataProvider?.rows?.forEach(row => {
const value = row?.[valueColumn]
if (value) {
const label = row[labelColumn] || value
optionsSet[value] = { value, label }
}
})
return Object.values(optionsSet)
}
// Extract options from data provider
if (optionsSource === "provider" && valueColumn) {
let optionsSet = {}
dataProvider?.rows?.forEach(row => {
const value = row?.[valueColumn]
if (value) {
const label = row[labelColumn] || value
optionsSet[value] = { value, label }
}
})
return Object.values(optionsSet)
}
// Extract custom options
if (optionsSource === "custom" && customOptions) {
return customOptions
}
return []
}

View File

@ -25,7 +25,7 @@ export const createValidatorFromConstraints = (
schemaConstraints.presence?.allowEmpty === false
) {
rules.push({
type: "string",
type: schemaConstraints.type == "array" ? "array" : "string",
constraint: "required",
error: "Required",
})
@ -63,7 +63,10 @@ export const createValidatorFromConstraints = (
}
// Inclusion constraint
if (exists(schemaConstraints.inclusion)) {
if (
exists(schemaConstraints.inclusion) &&
schemaConstraints.type !== "array"
) {
const options = schemaConstraints.inclusion || []
rules.push({
type: "string",
@ -142,7 +145,7 @@ const evaluateRule = (rule, value) => {
* in the same format.
* @param value the value to parse
* @param type the type to parse
* @returns {boolean|string|*|number|null} the parsed value, or null if invalid
* @returns {boolean|string|*|number|null|array} the parsed value, or null if invalid
*/
const parseType = (value, type) => {
// Treat nulls or empty strings as null
@ -202,6 +205,13 @@ const parseType = (value, type) => {
return value
}
if (type === "array") {
if (!Array.isArray(value) || !value.length) {
return null
}
return value
}
// If some unknown type, treat as null to avoid breaking validators
return null
}

View File

@ -11,6 +11,8 @@ export const buildLuceneQuery = filter => {
notEqual: {},
empty: {},
notEmpty: {},
contains: {},
notContains: {},
}
if (Array.isArray(filter)) {
filter.forEach(expression => {