Merge pull request #3476 from Budibase/multi-filter-component

Dynamic filter component
This commit is contained in:
Andrew Kingston 2021-11-24 08:39:28 +00:00 committed by GitHub
commit 567d188e29
17 changed files with 475 additions and 101 deletions

View File

@ -1,41 +1,44 @@
context("Add Multi-Option Datatype", () => { context("Add Multi-Option Datatype", () => {
before(() => { before(() => {
cy.login() cy.login()
cy.createTestApp() cy.createTestApp()
}) })
it("should create a new table, with data", () => { it("should create a new table, with data", () => {
cy.createTable("Multi Data") cy.createTable("Multi Data")
cy.addColumn("Multi Data", "Test Data", "Multi-select", "1\n2\n3\n4\n5") cy.addColumn("Multi Data", "Test Data", "Multi-select", "1\n2\n3\n4\n5")
cy.addRowMultiValue(["1", "2", "3", "4", "5"]) cy.addRowMultiValue(["1", "2", "3", "4", "5"])
}) })
it ("should add form with multi select picker, containing 5 options", () => { it("should add form with multi select picker, containing 5 options", () => {
cy.navigateToFrontend() cy.navigateToFrontend()
cy.wait(500) cy.wait(500)
// Add data provider // Add data provider
cy.get(`[data-cy="category-Data Provider"]`).click() cy.get(`[data-cy="category-Data"]`).click()
cy.get('[data-cy="dataSource-prop-control"]').click() cy.get(`[data-cy="component-Data Provider"]`).click()
cy.get(".dropdown").contains("Multi Data").click() cy.get('[data-cy="dataSource-prop-control"]').click()
cy.wait(500) cy.get(".dropdown").contains("Multi Data").click()
// Add Form with schema to match table cy.wait(500)
cy.addComponent("Form", "Form") // Add Form with schema to match table
cy.get('[data-cy="dataSource-prop-control"').click() cy.addComponent("Form", "Form")
cy.get(".dropdown").contains("Multi Data").click() cy.get('[data-cy="dataSource-prop-control"').click()
cy.wait(500) cy.get(".dropdown").contains("Multi Data").click()
// Add multi-select picker to form cy.wait(500)
cy.addComponent("Form", "Multi-select Picker").then((componentId) => { // Add multi-select picker to form
cy.get('[data-cy="field-prop-control"]').type("Test Data").type('{enter}') cy.addComponent("Form", "Multi-select Picker").then(componentId => {
cy.wait(1000) cy.get('[data-cy="field-prop-control"]').type("Test Data").type("{enter}")
cy.getComponent(componentId).contains("Choose some options").click() cy.wait(1000)
// Check picker has 5 items cy.getComponent(componentId).contains("Choose some options").click()
cy.getComponent(componentId).find('li').should('have.length', 5) // Check picker has 5 items
// Select all items cy.getComponent(componentId).find("li").should("have.length", 5)
for (let i = 1; i < 6; i++) { // Select all items
cy.getComponent(componentId).find('li').contains(i).click() for (let i = 1; i < 6; i++) {
} cy.getComponent(componentId).find("li").contains(i).click()
// Check items have been selected }
cy.getComponent(componentId).find('.spectrum-Picker-label').contains("(5)") // Check items have been selected
}) cy.getComponent(componentId)
.find(".spectrum-Picker-label")
.contains("(5)")
}) })
})
}) })

View File

@ -8,12 +8,24 @@
"repeaterblock" "repeaterblock"
] ]
}, },
"section", {
"container", "name": "Layout",
"dataprovider", "icon": "ClassicGridView",
"table", "children": [
"repeater", "container",
"button", "section"
]
},
{
"name": "Data",
"icon": "Data",
"children": [
"dataprovider",
"repeater",
"table",
"dynamicfilter"
]
},
{ {
"name": "Form", "name": "Form",
"icon": "Form", "icon": "Form",
@ -60,6 +72,7 @@
"children": [ "children": [
"heading", "heading",
"text", "text",
"button",
"divider", "divider",
"image", "image",
"backgroundimage", "backgroundimage",

View File

@ -247,7 +247,6 @@
"description": "A basic html button that is ready for styling", "description": "A basic html button that is ready for styling",
"icon": "Button", "icon": "Button",
"editable": true, "editable": true,
"illegalChildren": ["section"],
"showSettingsBar": true, "showSettingsBar": true,
"settings": [ "settings": [
{ {
@ -2647,6 +2646,49 @@
} }
] ]
}, },
"dynamicfilter": {
"name": "Dynamic Filter",
"icon": "FilterEdit",
"showSettingsBar": true,
"settings": [
{
"type": "dataProvider",
"label": "Provider",
"key": "dataProvider"
},
{
"type": "multifield",
"label": "Allowed filter fields",
"key": "allowedFields",
"placeholder": "All fields"
},
{
"type": "select",
"label": "Button size",
"showInBar": true,
"key": "size",
"options": [
{
"label": "Small",
"value": "S"
},
{
"label": "Medium",
"value": "M"
},
{
"label": "Large",
"value": "L"
},
{
"label": "Extra large",
"value": "XL"
}
],
"defaultValue": "M"
}
]
},
"tableblock": { "tableblock": {
"block": true, "block": true,
"name": "Table block", "name": "Table block",

View File

@ -121,6 +121,9 @@
--> -->
<div id="flatpickr-root" /> <div id="flatpickr-root" />
<!-- Modal container to ensure they sit on top -->
<div class="modal-container" />
<!-- Layers on top of app --> <!-- Layers on top of app -->
<NotificationDisplay /> <NotificationDisplay />
<ConfirmationDisplay /> <ConfirmationDisplay />

View File

@ -72,13 +72,18 @@
$: inSelectedPath = $builderStore.selectedComponentPath?.includes(id) $: inSelectedPath = $builderStore.selectedComponentPath?.includes(id)
$: inDragPath = inSelectedPath && $builderStore.editMode $: inDragPath = inSelectedPath && $builderStore.editMode
// Derive definition properties which can all be optional, so need to be
// coerced to booleans
$: editable = !!definition?.editable
$: hasChildren = !!definition?.hasChildren
$: showEmptyState = definition?.showEmptyState !== false
// Interactive components can be selected, dragged and highlighted inside // Interactive components can be selected, dragged and highlighted inside
// the builder preview // the builder preview
$: interactive = $: interactive =
$builderStore.inBuilder && $builderStore.inBuilder &&
($builderStore.previewType === "layout" || insideScreenslot) && ($builderStore.previewType === "layout" || insideScreenslot) &&
!isBlock !isBlock
$: editable = definition?.editable
$: editing = editable && selected && $builderStore.editMode $: editing = editable && selected && $builderStore.editMode
$: draggable = !inDragPath && interactive && !isLayout && !isScreen $: draggable = !inDragPath && interactive && !isLayout && !isScreen
$: droppable = interactive && !isLayout && !isScreen $: droppable = interactive && !isLayout && !isScreen
@ -86,8 +91,8 @@
// Empty components are those which accept children but do not have any. // Empty components are those which accept children but do not have any.
// Empty states can be shown for these components, but can be disabled // Empty states can be shown for these components, but can be disabled
// in the component manifest. // in the component manifest.
$: empty = interactive && !children.length && definition?.hasChildren $: empty = interactive && !children.length && hasChildren
$: emptyState = empty && definition?.showEmptyState !== false $: emptyState = empty && showEmptyState
// Raw settings are all settings excluding internal props and children // Raw settings are all settings excluding internal props and children
$: rawSettings = getRawSettings(instance) $: rawSettings = getRawSettings(instance)
@ -103,6 +108,9 @@
// Build up the final settings object to be passed to the component // Build up the final settings object to be passed to the component
$: cacheSettings(enrichedSettings, nestedSettings, conditionalSettings) $: cacheSettings(enrichedSettings, nestedSettings, conditionalSettings)
// Render key is used to determine when components need to fully remount
$: renderKey = getRenderKey(id, editing)
// Update component context // Update component context
$: componentStore.set({ $: componentStore.set({
id, id,
@ -268,35 +276,43 @@
}) })
} }
} }
// Generates a key used to determine when components need to fully remount.
// Currently only toggling editing requires remounting.
const getRenderKey = (id, editing) => {
return hashString(`${id}-${editing}`)
}
</script> </script>
{#if constructor && cachedSettings && (visible || inSelectedPath)} {#key renderKey}
<!-- The ID is used as a class because getElementsByClassName is O(1) --> {#if constructor && cachedSettings && (visible || inSelectedPath)}
<!-- and the performance matters for the selection indicators --> <!-- The ID is used as a class because getElementsByClassName is O(1) -->
<div <!-- and the performance matters for the selection indicators -->
class={`component ${id}`} <div
class:draggable class={`component ${id}`}
class:droppable class:draggable
class:empty class:droppable
class:interactive class:empty
class:editing class:interactive
class:block={isBlock} class:editing
data-id={id} class:block={isBlock}
data-name={name} data-id={id}
> data-name={name}
<svelte:component this={constructor} {...cachedSettings}> >
{#if children.length} <svelte:component this={constructor} {...cachedSettings}>
{#each children as child (child._id)} {#if children.length}
<svelte:self instance={child} /> {#each children as child (child._id)}
{/each} <svelte:self instance={child} />
{:else if emptyState} {/each}
<Placeholder /> {:else if emptyState}
{:else if isBlock} <Placeholder />
<slot /> {:else if isBlock}
{/if} <slot />
</svelte:component> {/if}
</div> </svelte:component>
{/if} </div>
{/if}
{/key}
<style> <style>
.component { .component {

View File

@ -12,6 +12,10 @@
export let type = "primary" export let type = "primary"
export let quiet = false export let quiet = false
// For internal use only for now - not defined in the manifest
export let icon = null
export let active = false
let node let node
$: $component.editing && node?.focus() $: $component.editing && node?.focus()
@ -25,7 +29,7 @@
} }
const updateText = e => { const updateText = e => {
builderStore.actions.updateProp("text", e.target.textContent) builderStore.actions.updateProp("text", e.target.textContent.trim())
} }
</script> </script>
@ -35,10 +39,22 @@
{disabled} {disabled}
use:styleable={$component.styles} use:styleable={$component.styles}
on:click={onClick} on:click={onClick}
contenteditable={$component.editing} contenteditable={$component.editing && !icon}
on:blur={$component.editing ? updateText : null} on:blur={$component.editing ? updateText : null}
bind:this={node} bind:this={node}
class:active
> >
{#if icon}
<svg
class:hasText={componentText?.length > 0}
class="spectrum-Icon spectrum-Icon--size{size.toUpperCase()}"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
{componentText} {componentText}
</button> </button>
@ -56,4 +72,10 @@
.spectrum-Button::after { .spectrum-Button::after {
display: none; display: none;
} }
.spectrum-Icon.hasText {
margin-right: var(--spectrum-button-primary-icon-gap);
}
.active {
color: var(--spectrum-global-color-blue-600);
}
</style> </style>

View File

@ -275,11 +275,10 @@
allRows = res.rows allRows = res.rows
} }
const addQueryExtension = (key, operator, field, value) => { const addQueryExtension = (key, extension) => {
if (!key || !operator || !field) { if (!key || !extension) {
return return
} }
const extension = { operator, field, value }
queryExtensions = { ...queryExtensions, [key]: extension } queryExtensions = { ...queryExtensions, [key]: extension }
} }
@ -295,11 +294,13 @@
const extendQuery = (defaultQuery, extensions) => { const extendQuery = (defaultQuery, extensions) => {
const extensionValues = Object.values(extensions || {}) const extensionValues = Object.values(extensions || {})
let extendedQuery = { ...defaultQuery } let extendedQuery = { ...defaultQuery }
extensionValues.forEach(({ operator, field, value }) => { extensionValues.forEach(extension => {
extendedQuery[operator] = { Object.entries(extension || {}).forEach(([operator, fields]) => {
...extendedQuery[operator], extendedQuery[operator] = {
[field]: value, ...extendedQuery[operator],
} ...fields,
}
})
}) })
if (JSON.stringify(query) !== JSON.stringify(extendedQuery)) { if (JSON.stringify(query) !== JSON.stringify(extendedQuery)) {

View File

@ -13,15 +13,6 @@
const component = getContext("component") const component = getContext("component")
const { styleable, ActionTypes, getAction } = getContext("sdk") const { styleable, ActionTypes, getAction } = getContext("sdk")
$: addExtension = getAction(
dataProvider?.id,
ActionTypes.AddDataProviderQueryExtension
)
$: removeExtension = getAction(
dataProvider?.id,
ActionTypes.RemoveDataProviderQueryExtension
)
const options = [ const options = [
"Last 1 day", "Last 1 day",
"Last 7 days", "Last 7 days",
@ -32,10 +23,23 @@
] ]
let value = options.includes(defaultValue) ? defaultValue : "Last 30 days" let value = options.includes(defaultValue) ? defaultValue : "Last 30 days"
$: queryExtension = getQueryExtension(value) $: dataProviderId = dataProvider?.id
$: addExtension?.($component.id, "range", field, queryExtension) $: addExtension = getAction(
dataProviderId,
ActionTypes.AddDataProviderQueryExtension
)
$: removeExtension = getAction(
dataProviderId,
ActionTypes.RemoveDataProviderQueryExtension
)
$: queryExtension = getQueryExtension(field, value)
$: addExtension?.($component.id, queryExtension)
const getQueryExtension = (field, value) => {
if (!field || !value) {
return null
}
const getQueryExtension = value => {
let low = dayjs.utc().subtract(1, "year") let low = dayjs.utc().subtract(1, "year")
let high = dayjs.utc().add(1, "day") let high = dayjs.utc().add(1, "day")
@ -51,7 +55,14 @@
low = dayjs.utc().subtract(6, "months") low = dayjs.utc().subtract(6, "months")
} }
return { low: low.format(), high: high.format() } return {
range: {
[field]: {
low: low.format(),
high: high.format(),
},
},
}
} }
onDestroy(() => { onDestroy(() => {

View File

@ -47,7 +47,7 @@
// Convert contenteditable HTML to text and save // Convert contenteditable HTML to text and save
const updateText = e => { const updateText = e => {
const sanitized = e.target.innerHTML.replace(/<br>/gi, "\n") const sanitized = e.target.innerHTML.replace(/<br>/gi, "\n").trim()
builderStore.actions.updateProp("text", sanitized) builderStore.actions.updateProp("text", sanitized)
} }
</script> </script>

View File

@ -47,7 +47,7 @@
} }
const updateText = e => { const updateText = e => {
builderStore.actions.updateProp("text", e.target.textContent) builderStore.actions.updateProp("text", e.target.textContent.trim())
} }
</script> </script>

View File

@ -46,7 +46,7 @@
// Convert contenteditable HTML to text and save // Convert contenteditable HTML to text and save
const updateText = e => { const updateText = e => {
const sanitized = e.target.innerHTML.replace(/<br>/gi, "\n") const sanitized = e.target.innerHTML.replace(/<br>/gi, "\n").trim()
builderStore.actions.updateProp("text", sanitized) builderStore.actions.updateProp("text", sanitized)
} }
</script> </script>

View File

@ -0,0 +1,85 @@
<script>
import { get } from "svelte/store"
import { getContext, onDestroy } from "svelte"
import { ModalContent, Modal } from "@budibase/bbui"
import FilterModal from "./FilterModal.svelte"
import { buildLuceneQuery } from "builder/src/helpers/lucene"
import Button from "../Button.svelte"
export let dataProvider
export let allowedFields
export let size = "M"
const component = getContext("component")
const { builderStore, ActionTypes, getAction } = getContext("sdk")
let modal
let tmpFilters = []
let filters = []
$: dataProviderId = dataProvider?.id
$: addExtension = getAction(
dataProviderId,
ActionTypes.AddDataProviderQueryExtension
)
$: removeExtension = getAction(
dataProviderId,
ActionTypes.RemoveDataProviderQueryExtension
)
$: schema = dataProvider?.schema
$: schemaFields = getSchemaFields(schema, allowedFields)
// Add query extension to data provider
$: {
if (filters?.length) {
const queryExtension = buildLuceneQuery(filters)
addExtension?.($component.id, queryExtension)
} else {
removeExtension?.($component.id)
}
}
const getSchemaFields = (schema, allowedFields) => {
let clonedSchema = {}
if (!allowedFields?.length) {
clonedSchema = schema
} else {
allowedFields?.forEach(field => {
clonedSchema[field] = schema[field]
})
}
return Object.values(clonedSchema || {})
}
const openEditor = () => {
if (get(builderStore).inBuilder) {
return
}
tmpFilters = [...filters]
modal.show()
}
const updateQuery = () => {
filters = [...tmpFilters]
}
onDestroy(() => {
removeExtension?.($component.id)
})
</script>
<Button
onClick={openEditor}
icon="Properties"
text="Filter"
{size}
type="secondary"
quiet
active={filters?.length > 0}
/>
<Modal bind:this={modal}>
<ModalContent title="Edit filters" size="XL" onConfirm={updateQuery}>
<FilterModal bind:filters={tmpFilters} {schemaFields} />
</ModalContent>
</Modal>

View File

@ -0,0 +1,176 @@
<script>
import {
Body,
Button,
Combobox,
DatePicker,
DrawerContent,
Icon,
Input,
Layout,
Select,
} from "@budibase/bbui"
import { generate } from "shortid"
import {
getValidOperatorsForType,
OperatorOptions,
} from "builder/src/constants/lucene"
export let schemaFields
export let filters = []
const BannedTypes = ["link", "attachment", "formula"]
$: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type))
.map(field => field.name)
const addFilter = () => {
filters = [
...filters,
{
id: generate(),
field: null,
operator: OperatorOptions.Equals.value,
value: null,
valueType: "Value",
},
]
}
const removeFilter = id => {
filters = filters.filter(field => field.id !== id)
}
const duplicateFilter = id => {
const existingFilter = filters.find(filter => filter.id === id)
const duplicate = { ...existingFilter, id: generate() }
filters = [...filters, duplicate]
}
const onFieldChange = (expression, field) => {
// Update the field type
expression.type = schemaFields.find(x => x.name === field)?.type
// Ensure a valid operator is set
const validOperators = getValidOperatorsForType(expression.type).map(
x => x.value
)
if (!validOperators.includes(expression.operator)) {
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) => {
const noValueOptions = [
OperatorOptions.Empty.value,
OperatorOptions.NotEmpty.value,
]
expression.noValue = noValueOptions.includes(operator)
if (expression.noValue) {
expression.value = null
}
}
const getFieldOptions = field => {
const schema = schemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
}
</script>
<DrawerContent>
<div class="container">
<Layout noPadding>
<Body size="S">
{#if !filters?.length}
Add your first filter expression.
{:else}
Results are filtered to only those which match all of the following
constraints.
{/if}
</Body>
{#if filters?.length}
<div class="fields">
{#each filters as filter, idx}
<Select
bind:value={filter.field}
options={fieldOptions}
on:change={e => onFieldChange(filter, e.detail)}
placeholder="Column"
/>
<Select
disabled={!filter.field}
options={getValidOperatorsForType(filter.type)}
bind:value={filter.operator}
on:change={e => onOperatorChange(filter, e.detail)}
placeholder={null}
/>
{#if ["string", "longform", "number"].includes(filter.type)}
<Input disabled={filter.noValue} bind:value={filter.value} />
{:else if ["options", "array"].includes(filter.type)}
<Combobox
disabled={filter.noValue}
options={getFieldOptions(filter.field)}
bind:value={filter.value}
/>
{:else if filter.type === "boolean"}
<Combobox
disabled={filter.noValue}
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
bind:value={filter.value}
/>
{:else if filter.type === "datetime"}
<DatePicker disabled={filter.noValue} bind:value={filter.value} />
{:else}
<Input disabled />
{/if}
<Icon
name="Duplicate"
hoverable
size="S"
on:click={() => duplicateFilter(filter.id)}
/>
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeFilter(filter.id)}
/>
{/each}
</div>
{/if}
<div>
<Button icon="AddCircle" size="M" secondary on:click={addFilter}>
Add filter
</Button>
</div>
</Layout>
</div>
</DrawerContent>
<style>
.container {
width: 100%;
max-width: 1000px;
margin: 0 auto;
}
.fields {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
align-items: center;
grid-template-columns: 1fr 120px 1fr auto auto;
}
</style>

View File

@ -0,0 +1 @@
export { default as dynamicfilter } from "./DynamicFilter.svelte"

View File

@ -49,7 +49,7 @@
$: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}` $: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}`
const updateLabel = e => { const updateLabel = e => {
builderStore.actions.updateProp("label", e.target.textContent) builderStore.actions.updateProp("label", e.target.textContent.trim())
} }
</script> </script>

View File

@ -33,6 +33,7 @@ export * from "./charts"
export * from "./forms" export * from "./forms"
export * from "./table" export * from "./table"
export * from "./blocks" export * from "./blocks"
export * from "./dynamic-filter"
// Deprecated component left for compatibility in old apps // Deprecated component left for compatibility in old apps
export { default as navigation } from "./deprecated/Navigation.svelte" export { default as navigation } from "./deprecated/Navigation.svelte"

View File

@ -10,7 +10,6 @@ const dispatchEvent = (type, data = {}) => {
const createBuilderStore = () => { const createBuilderStore = () => {
const initialState = { const initialState = {
inBuilder: false, inBuilder: false,
appId: null,
layout: null, layout: null,
screen: null, screen: null,
selectedComponentId: null, selectedComponentId: null,
@ -94,6 +93,7 @@ const createBuilderStore = () => {
} }
return { return {
...writableStore, ...writableStore,
set: state => writableStore.set({ ...initialState, ...state }),
subscribe: derivedStore.subscribe, subscribe: derivedStore.subscribe,
actions, actions,
} }