diff --git a/packages/bbui/src/Table/ArrayRenderer.svelte b/packages/bbui/src/Table/ArrayRenderer.svelte new file mode 100644 index 0000000000..679973a03a --- /dev/null +++ b/packages/bbui/src/Table/ArrayRenderer.svelte @@ -0,0 +1,17 @@ + + +{#each badges as badge} + {badge} +{/each} +{#if leftover} +
+{leftover} more
+{/if} diff --git a/packages/bbui/src/Table/CellRenderer.svelte b/packages/bbui/src/Table/CellRenderer.svelte index 2d073b7782..d6a2f3196d 100644 --- a/packages/bbui/src/Table/CellRenderer.svelte +++ b/packages/bbui/src/Table/CellRenderer.svelte @@ -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) diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js b/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js index 1a64a8958f..5b3bc041ff 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js @@ -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" diff --git a/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte b/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte index 0724016679..e82c55679a 100644 --- a/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte +++ b/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte @@ -1,5 +1,12 @@ diff --git a/packages/builder/src/components/design/AppPreview/componentStructure.json b/packages/builder/src/components/design/AppPreview/componentStructure.json index cea20a7dcf..3bc2554fde 100644 --- a/packages/builder/src/components/design/AppPreview/componentStructure.json +++ b/packages/builder/src/components/design/AppPreview/componentStructure.json @@ -21,7 +21,8 @@ "datetimefield", "attachmentfield", "relationshipfield", - "daterangepicker" + "daterangepicker", + "multifieldselect" ] }, { diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/FilterEditor/FilterDrawer.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/FilterEditor/FilterDrawer.svelte index d41d28e4e3..ad647d1550 100644 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/FilterEditor/FilterDrawer.svelte +++ b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/FilterEditor/FilterDrawer.svelte @@ -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 } @@ -122,7 +132,7 @@ /> {:else if ["string", "longform", "number"].includes(filter.type)} - {:else if filter.type === "options"} + {:else if filter.type === "options" || "array"} { 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)} + {:else if rule.type === "array"} + { @@ -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") { diff --git a/packages/server/src/api/controllers/row/internalSearch.js b/packages/server/src/api/controllers/row/internalSearch.js index cc35da40ec..793454e601 100644 --- a/packages/server/src/api/controllers/row/internalSearch.js +++ b/packages/server/src/api/controllers/row/internalSearch.js @@ -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: [], } diff --git a/packages/server/src/api/controllers/row/utils.js b/packages/server/src/api/controllers/row/utils.js index 16c48181d1..cb9a5e166c 100644 --- a/packages/server/src/api/controllers/row/utils.js +++ b/packages/server/src/api/controllers/row/utils.js @@ -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 } diff --git a/packages/server/src/constants/index.js b/packages/server/src/constants/index.js index 703b33deb1..bc7b5b368f 100644 --- a/packages/server/src/constants/index.js +++ b/packages/server/src/constants/index.js @@ -12,6 +12,7 @@ exports.FieldTypes = { OPTIONS: "options", NUMBER: "number", BOOLEAN: "boolean", + ARRAY: "array", DATETIME: "datetime", ATTACHMENT: "attachment", LINK: "link", diff --git a/packages/server/src/db/views/staticViews.js b/packages/server/src/db/views/staticViews.js index 23f320d7eb..6e52e9699c 100644 --- a/packages/server/src/db/views/staticViews.js +++ b/packages/server/src/db/views/staticViews.js @@ -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") { diff --git a/packages/server/src/utilities/rowProcessor.js b/packages/server/src/utilities/rowProcessor.js index c067d4de87..3c43a20409 100644 --- a/packages/server/src/utilities/rowProcessor.js +++ b/packages/server/src/utilities/rowProcessor.js @@ -29,6 +29,11 @@ const TYPE_TRANSFORM_MAP = { [null]: null, [undefined]: undefined, }, + [FieldTypes.ARRAY]: { + "": [], + [null]: [], + [undefined]: undefined, + }, [FieldTypes.STRING]: { "": "", [null]: "", diff --git a/packages/standard-components/manifest.json b/packages/standard-components/manifest.json index da3736a076..865374da11 100644 --- a/packages/standard-components/manifest.json +++ b/packages/standard-components/manifest.json @@ -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", diff --git a/packages/standard-components/src/forms/MultiFieldSelect.svelte b/packages/standard-components/src/forms/MultiFieldSelect.svelte new file mode 100644 index 0000000000..cecc569b6f --- /dev/null +++ b/packages/standard-components/src/forms/MultiFieldSelect.svelte @@ -0,0 +1,58 @@ + + + + {#if fieldState} + 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} + diff --git a/packages/standard-components/src/forms/OptionsField.svelte b/packages/standard-components/src/forms/OptionsField.svelte index c5efa6f58d..4ad8f4611e 100644 --- a/packages/standard-components/src/forms/OptionsField.svelte +++ b/packages/standard-components/src/forms/OptionsField.svelte @@ -1,7 +1,7 @@ { + 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 [] +} diff --git a/packages/standard-components/src/forms/validation.js b/packages/standard-components/src/forms/validation.js index 30b6fd7ca7..deb228c4c0 100644 --- a/packages/standard-components/src/forms/validation.js +++ b/packages/standard-components/src/forms/validation.js @@ -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 } diff --git a/packages/standard-components/src/lucene.js b/packages/standard-components/src/lucene.js index 50aae1f32c..36f6026a06 100644 --- a/packages/standard-components/src/lucene.js +++ b/packages/standard-components/src/lucene.js @@ -11,6 +11,8 @@ export const buildLuceneQuery = filter => { notEqual: {}, empty: {}, notEmpty: {}, + contains: {}, + notContains: {}, } if (Array.isArray(filter)) { filter.forEach(expression => {