Merge pull request #2115 from Budibase/conditional-ui

Conditional UI
This commit is contained in:
Andrew Kingston 2021-07-27 10:02:22 +01:00 committed by GitHub
commit 4af29d15de
16 changed files with 638 additions and 177 deletions

View File

@ -115,7 +115,6 @@
} }
.spectrum-Modal { .spectrum-Modal {
background: var(--background);
overflow: visible; overflow: visible;
max-height: none; max-height: none;
margin: 40px 0; margin: 40px 0;

View File

@ -88,7 +88,6 @@
display: grid; display: grid;
position: relative; position: relative;
gap: var(--spacing-xl); gap: var(--spacing-xl);
color: var(--ink);
} }
.spectrum-Dialog-content { .spectrum-Dialog-content {
@ -106,13 +105,8 @@
position: absolute; position: absolute;
top: 15px; top: 15px;
right: 15px; right: 15px;
color: var(--ink);
font-size: var(--font-size-m); font-size: var(--font-size-m);
} }
.close-icon:hover {
color: var(--grey-6);
cursor: pointer;
}
.close-icon :global(svg) { .close-icon :global(svg) {
margin-right: 0; margin-right: 0;
} }

View File

@ -530,6 +530,11 @@ export const getFrontendStore = () => {
selected._styles = { normal: {}, hover: {}, active: {} } selected._styles = { normal: {}, hover: {}, active: {} }
await store.actions.preview.saveSelected() await store.actions.preview.saveSelected()
}, },
updateConditions: async conditions => {
const selected = get(selectedComponent)
selected._conditions = conditions
await store.actions.preview.saveSelected()
},
updateProp: async (name, value) => { updateProp: async (name, value) => {
let component = get(selectedComponent) let component = get(selectedComponent)
if (!name || !component) { if (!name || !component) {

View File

@ -1,32 +1,12 @@
<script> <script>
import { isEmpty } from "lodash/fp" import { isEmpty } from "lodash/fp"
import { Checkbox, Input, Select, DetailSummary } from "@budibase/bbui" import { Input, DetailSummary } from "@budibase/bbui"
import { store } from "builderStore" import { store } from "builderStore"
import PropertyControl from "./PropertyControls/PropertyControl.svelte" import PropertyControl from "./PropertyControls/PropertyControl.svelte"
import LayoutSelect from "./PropertyControls/LayoutSelect.svelte" import LayoutSelect from "./PropertyControls/LayoutSelect.svelte"
import RoleSelect from "./PropertyControls/RoleSelect.svelte" import RoleSelect from "./PropertyControls/RoleSelect.svelte"
import TableSelect from "./PropertyControls/TableSelect.svelte"
import DataSourceSelect from "./PropertyControls/DataSourceSelect.svelte"
import DataProviderSelect from "./PropertyControls/DataProviderSelect.svelte"
import FieldSelect from "./PropertyControls/FieldSelect.svelte"
import MultiFieldSelect from "./PropertyControls/MultiFieldSelect.svelte"
import SchemaSelect from "./PropertyControls/SchemaSelect.svelte"
import SectionSelect from "./PropertyControls/SectionSelect.svelte"
import NavigationEditor from "./PropertyControls/NavigationEditor/NavigationEditor.svelte"
import EventsEditor from "./PropertyControls/EventsEditor"
import FilterEditor from "./PropertyControls/FilterEditor/FilterEditor.svelte"
import { IconSelect } from "./PropertyControls/IconSelect"
import StringFieldSelect from "./PropertyControls/StringFieldSelect.svelte"
import NumberFieldSelect from "./PropertyControls/NumberFieldSelect.svelte"
import OptionsFieldSelect from "./PropertyControls/OptionsFieldSelect.svelte"
import BooleanFieldSelect from "./PropertyControls/BooleanFieldSelect.svelte"
import LongFormFieldSelect from "./PropertyControls/LongFormFieldSelect.svelte"
import DateTimeFieldSelect from "./PropertyControls/DateTimeFieldSelect.svelte"
import AttachmentFieldSelect from "./PropertyControls/AttachmentFieldSelect.svelte"
import RelationshipFieldSelect from "./PropertyControls/RelationshipFieldSelect.svelte"
import ResetFieldsButton from "./PropertyControls/ResetFieldsButton.svelte" import ResetFieldsButton from "./PropertyControls/ResetFieldsButton.svelte"
import ColorPicker from "./PropertyControls/ColorPicker.svelte" import { getComponentForSettingType } from "./PropertyControls/componentSettings"
import URLSelect from "./PropertyControls/URLSelect.svelte"
export let componentDefinition export let componentDefinition
export let componentInstance export let componentInstance
@ -45,40 +25,9 @@
$: assetDefinition = isLayout ? layoutDefinition : screenDefinition $: assetDefinition = isLayout ? layoutDefinition : screenDefinition
const updateProp = store.actions.components.updateProp const updateProp = store.actions.components.updateProp
const controlMap = {
text: Input,
select: Select,
dataSource: DataSourceSelect,
dataProvider: DataProviderSelect,
boolean: Checkbox,
number: Input,
event: EventsEditor,
table: TableSelect,
color: ColorPicker,
icon: IconSelect,
field: FieldSelect,
multifield: MultiFieldSelect,
schema: SchemaSelect,
section: SectionSelect,
navigation: NavigationEditor,
filter: FilterEditor,
url: URLSelect,
"field/string": StringFieldSelect,
"field/number": NumberFieldSelect,
"field/options": OptionsFieldSelect,
"field/boolean": BooleanFieldSelect,
"field/longform": LongFormFieldSelect,
"field/datetime": DateTimeFieldSelect,
"field/attachment": AttachmentFieldSelect,
"field/link": RelationshipFieldSelect,
}
const getControl = type => {
return controlMap[type]
}
const canRenderControl = setting => { const canRenderControl = setting => {
const control = getControl(setting?.type) const control = getComponentForSettingType(setting?.type)
if (!control) { if (!control) {
return false return false
} }
@ -101,11 +50,11 @@
/> />
{/if} {/if}
{#if settings && settings.length > 0} {#if settings && settings.length > 0}
{#each settings as setting (`${componentInstance._id}-${setting.key}`)} {#each settings as setting}
{#if canRenderControl(setting)} {#if canRenderControl(setting)}
<PropertyControl <PropertyControl
type={setting.type} type={setting.type}
control={getControl(setting.type)} control={getComponentForSettingType(setting.type)}
label={setting.label} label={setting.label}
key={setting.key} key={setting.key}
value={componentInstance[setting.key] ?? value={componentInstance[setting.key] ??

View File

@ -0,0 +1,36 @@
<script>
import { DetailSummary, ActionButton, Drawer, Button } from "@budibase/bbui"
import { store } from "builderStore"
import ConditionalUIDrawer from "./PropertyControls/ConditionalUIDrawer.svelte"
export let componentInstance
let tempValue
let drawer
const openDrawer = () => {
tempValue = JSON.parse(JSON.stringify(componentInstance?._conditions ?? []))
drawer.show()
}
const save = () => {
store.actions.components.updateConditions(tempValue)
drawer.hide()
}
</script>
<DetailSummary
name={`Conditions${componentInstance?._conditions ? " *" : ""}`}
collapsible={false}
>
<div>
<ActionButton on:click={openDrawer}>Configure conditions</ActionButton>
</div>
</DetailSummary>
<Drawer bind:this={drawer} title="Conditions">
<svelte:fragment slot="description">
Show, hide and update components in response to conditions being met.
</svelte:fragment>
<Button cta slot="buttons" on:click={() => save()}>Save</Button>
<ConditionalUIDrawer slot="body" bind:conditions={tempValue} />
</Drawer>

View File

@ -5,6 +5,7 @@
import ComponentSettingsSection from "./ComponentSettingsSection.svelte" import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
import DesignSection from "./DesignSection.svelte" import DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte" import CustomStylesSection from "./CustomStylesSection.svelte"
import ConditionalUISection from "./ConditionalUISection.svelte"
$: componentInstance = $selectedComponent $: componentInstance = $selectedComponent
$: componentDefinition = store.actions.components.getDefinition( $: componentDefinition = store.actions.components.getDefinition(
@ -15,10 +16,13 @@
<Tabs selected="Settings" noPadding> <Tabs selected="Settings" noPadding>
<Tab title="Settings"> <Tab title="Settings">
<div class="container"> <div class="container">
{#key componentInstance?._id}
<ScreenSettingsSection {componentInstance} {componentDefinition} /> <ScreenSettingsSection {componentInstance} {componentDefinition} />
<ComponentSettingsSection {componentInstance} {componentDefinition} /> <ComponentSettingsSection {componentInstance} {componentDefinition} />
<DesignSection {componentInstance} {componentDefinition} /> <DesignSection {componentInstance} {componentDefinition} />
<CustomStylesSection {componentInstance} {componentDefinition} /> <CustomStylesSection {componentInstance} {componentDefinition} />
<ConditionalUISection {componentInstance} {componentDefinition} />
{/key}
</div> </div>
</Tab> </Tab>
</Tabs> </Tabs>

View File

@ -0,0 +1,290 @@
<script>
import {
Button,
Body,
Icon,
DrawerContent,
Layout,
Select,
DatePicker,
} from "@budibase/bbui"
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import { generate } from "shortid"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { OperatorOptions, getValidOperatorsForType } from "helpers/lucene"
import { getBindableProperties } from "builderStore/dataBinding"
import { currentAsset, selectedComponent, store } from "builderStore"
import { getComponentForSettingType } from "./componentSettings"
import PropertyControl from "./PropertyControl.svelte"
export let conditions = []
const flipDurationMs = 150
const actionOptions = [
{
label: "Hide component",
value: "hide",
},
{
label: "Show component",
value: "show",
},
{
label: "Update setting",
value: "update",
},
]
const valueTypeOptions = [
{
value: "string",
label: "Binding",
},
{
value: "number",
label: "Number",
},
{
value: "datetime",
label: "Date",
},
{
value: "boolean",
label: "Boolean",
},
]
let dragDisabled = true
$: definition = store.actions.components.getDefinition(
$selectedComponent?._component
)
$: settings = (definition?.settings ?? []).map(setting => {
return {
label: setting.label,
value: setting.key,
}
})
$: bindableProperties = getBindableProperties(
$currentAsset,
$store.selectedComponentId
)
$: conditions.forEach(link => {
if (!link.id) {
link.id = generate()
}
})
const getSettingDefinition = key => {
return definition?.settings?.find(setting => {
return setting.key === key
})
}
const getComponentForSetting = key => {
const settingDefinition = getSettingDefinition(key)
return getComponentForSettingType(settingDefinition?.type || "text")
}
const addCondition = () => {
conditions = [
...conditions,
{
valueType: "string",
id: generate(),
action: "hide",
operator: OperatorOptions.Equals.value,
},
]
}
const removeCondition = id => {
conditions = conditions.filter(link => link.id !== id)
}
const handleFinalize = e => {
updateConditions(e)
dragDisabled = true
}
const updateConditions = e => {
conditions = e.detail.items
}
const getOperatorOptions = condition => {
return getValidOperatorsForType(condition.valueType)
}
const onOperatorChange = (condition, newOperator) => {
const noValueOptions = [
OperatorOptions.Empty.value,
OperatorOptions.NotEmpty.value,
]
condition.noValue = noValueOptions.includes(newOperator)
if (condition.noValue) {
condition.referenceValue = null
condition.valueType = "string"
}
}
const onValueTypeChange = (condition, newType) => {
condition.referenceValue = null
// Ensure a valid operator is set
const validOperators = getValidOperatorsForType(newType).map(x => x.value)
if (!validOperators.includes(condition.operator)) {
condition.operator = validOperators[0] ?? OperatorOptions.Equals.value
onOperatorChange(condition, condition.operator)
}
}
</script>
<DrawerContent>
<div class="container">
<Layout noPadding>
{#if conditions?.length}
<div
class="conditions"
use:dndzone={{
items: conditions,
flipDurationMs,
dropTargetStyle: { outline: "none" },
dragDisabled,
}}
on:finalize={handleFinalize}
on:consider={updateConditions}
>
{#each conditions as condition (condition.id)}
<div
class="condition"
class:update={condition.action === "update"}
animate:flip={{ duration: flipDurationMs }}
>
<div
class="handle"
aria-label="drag-handle"
style={dragDisabled ? "cursor: grab" : "cursor: grabbing"}
on:mousedown={() => (dragDisabled = false)}
>
<Icon name="DragHandle" size="XL" />
</div>
<Select
placeholder={null}
options={actionOptions}
bind:value={condition.action}
/>
{#if condition.action === "update"}
<Select options={settings} bind:value={condition.setting} />
<div>TO</div>
{#if getSettingDefinition(condition.setting)}
<PropertyControl
type={getSettingDefinition(condition.setting).type}
control={getComponentForSetting(condition.setting)}
key={getSettingDefinition(condition.setting).key}
value={condition.settingValue}
componentInstance={$selectedComponent}
onChange={val => (condition.settingValue = val)}
props={{
options: getSettingDefinition(condition.setting).options,
placeholder: getSettingDefinition(condition.setting)
.placeholder,
}}
/>
{:else}
<Select disabled placeholder=" " />
{/if}
{/if}
<div>IF</div>
<DrawerBindableInput
bindings={bindableProperties}
placeholder="Value"
value={condition.newValue}
on:change={e => (condition.newValue = e.detail)}
/>
<Select
placeholder={null}
options={getOperatorOptions(condition)}
bind:value={condition.operator}
on:change={e => onOperatorChange(condition, e.detail)}
/>
<Select
disabled={condition.noValue}
options={valueTypeOptions}
bind:value={condition.valueType}
placeholder={null}
on:change={e => onValueTypeChange(condition, e.detail)}
/>
{#if ["string", "number"].includes(condition.valueType)}
<DrawerBindableInput
disabled={condition.noValue}
bindings={bindableProperties}
placeholder="Value"
value={condition.referenceValue}
on:change={e => (condition.referenceValue = e.detail)}
/>
{:else if condition.valueType === "datetime"}
<DatePicker
placeholder="Value"
disabled={condition.noValue}
bind:value={condition.referenceValue}
/>
{:else if condition.valueType === "boolean"}
<Select
placeholder="Value"
disabled={condition.noValue}
options={["True", "False"]}
bind:value={condition.referenceValue}
/>
{/if}
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeCondition(condition.id)}
/>
</div>
{/each}
</div>
{:else}
<Body size="S">Add your first condition to get started.</Body>
{/if}
<div>
<Button secondary icon="Add" on:click={addCondition}>
Add condition
</Button>
</div>
</Layout>
</div>
</DrawerContent>
<style>
.container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.conditions {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-m);
}
.condition {
gap: var(--spacing-l);
display: grid;
align-items: center;
grid-template-columns: auto 1fr auto 1fr 1fr 1fr 1fr auto;
border-radius: var(--border-radius-s);
transition: background-color ease-in-out 130ms;
}
.condition.update {
grid-template-columns: auto 1fr 1fr auto 1fr auto 1fr 1fr 1fr 1fr auto;
}
.condition:hover {
background-color: var(--spectrum-global-color-gray-100);
}
.handle {
display: grid;
place-items: center;
}
</style>

View File

@ -11,44 +11,11 @@
import { getBindableProperties } from "builderStore/dataBinding" import { getBindableProperties } from "builderStore/dataBinding"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { generate } from "shortid" import { generate } from "shortid"
import { OperatorOptions, getValidOperatorsForType } from "helpers/lucene"
export let schemaFields export let schemaFields
export let value export let value
const OperatorOptions = {
Equals: {
value: "equal",
label: "Equals",
},
NotEquals: {
value: "notEqual",
label: "Not equals",
},
Empty: {
value: "empty",
label: "Is empty",
},
NotEmpty: {
value: "notEmpty",
label: "Is not empty",
},
StartsWith: {
value: "string",
label: "Starts with",
},
Like: {
value: "fuzzy",
label: "Like",
},
MoreThan: {
value: "rangeLow",
label: "More than",
},
LessThan: {
value: "rangeHigh",
label: "Less than",
},
}
const BannedTypes = ["link", "attachment"] const BannedTypes = ["link", "attachment"]
$: bindableProperties = getBindableProperties( $: bindableProperties = getBindableProperties(
$currentAsset, $currentAsset,
@ -75,61 +42,16 @@
value = value.filter(field => field.id !== id) value = value.filter(field => field.id !== id)
} }
const getValidOperatorsForType = type => {
const Op = OperatorOptions
if (type === "string") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "number") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "options") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "boolean") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "longform") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "datetime") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
}
return []
}
const onFieldChange = (expression, field) => { const onFieldChange = (expression, field) => {
// Update the field type // Update the field type
expression.type = schemaFields.find(x => x.name === field)?.type expression.type = schemaFields.find(x => x.name === field)?.type
// Ensure a valid operator is set // Ensure a valid operator is set
const validOperators = getValidOperatorsForType(expression.type) const validOperators = getValidOperatorsForType(expression.type).map(
x => x.value
)
if (!validOperators.includes(expression.operator)) { if (!validOperators.includes(expression.operator)) {
expression.operator = expression.operator = validOperators[0] ?? OperatorOptions.Equals.value
validOperators[0]?.value ?? OperatorOptions.Equals.value
onOperatorChange(expression, expression.operator) onOperatorChange(expression, expression.operator)
} }
} }

View File

@ -15,13 +15,13 @@
export let links = [] export let links = []
const flipDurationMs = 150 const flipDurationMs = 150
let dragDisabled = true
$: links.forEach(link => { $: links.forEach(link => {
if (!link.id) { if (!link.id) {
link.id = generate() link.id = generate()
} }
}) })
$: urlOptions = $store.screens $: urlOptions = $store.screens
.map(screen => screen.routing?.route) .map(screen => screen.routing?.route)
.filter(x => x != null) .filter(x => x != null)
@ -37,6 +37,11 @@
const updateLinks = e => { const updateLinks = e => {
links = e.detail.items links = e.detail.items
} }
const handleFinalize = e => {
updateLinks(e)
dragDisabled = true
}
</script> </script>
<DrawerContent> <DrawerContent>
@ -49,13 +54,21 @@
items: links, items: links,
flipDurationMs, flipDurationMs,
dropTargetStyle: { outline: "none" }, dropTargetStyle: { outline: "none" },
dragDisabled,
}} }}
on:finalize={updateLinks} on:finalize={handleFinalize}
on:consider={updateLinks} on:consider={updateLinks}
> >
{#each links as link (link.id)} {#each links as link (link.id)}
<div class="link" animate:flip={{ duration: flipDurationMs }}> <div class="link" animate:flip={{ duration: flipDurationMs }}>
<div
class="handle"
aria-label="drag-handle"
style={dragDisabled ? "cursor: grab" : "cursor: grabbing"}
on:mousedown={() => (dragDisabled = false)}
>
<Icon name="DragHandle" size="XL" /> <Icon name="DragHandle" size="XL" />
</div>
<Input bind:value={link.text} placeholder="Text" /> <Input bind:value={link.text} placeholder="Text" />
<Combobox <Combobox
bind:value={link.url} bind:value={link.url}
@ -90,7 +103,7 @@
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
gap: var(--spacing-s); gap: var(--spacing-m);
} }
.link { .link {
gap: var(--spacing-l); gap: var(--spacing-l);
@ -108,4 +121,8 @@
flex: 1 1 auto; flex: 1 1 auto;
width: 0; width: 0;
} }
.handle {
display: grid;
place-items: center;
}
</style> </style>

View File

@ -70,7 +70,7 @@
</script> </script>
<div class="property-control" bind:this={anchor} data-cy={`setting-${key}`}> <div class="property-control" bind:this={anchor} data-cy={`setting-${key}`}>
{#if type !== "boolean"} {#if type !== "boolean" && label}
<div class="label"> <div class="label">
<Label>{label}</Label> <Label>{label}</Label>
</div> </div>

View File

@ -0,0 +1,54 @@
import { Checkbox, Input, Select } from "@budibase/bbui"
import DataSourceSelect from "./DataSourceSelect.svelte"
import DataProviderSelect from "./DataProviderSelect.svelte"
import EventsEditor from "./EventsEditor"
import TableSelect from "./TableSelect.svelte"
import ColorPicker from "./ColorPicker.svelte"
import { IconSelect } from "./IconSelect"
import FieldSelect from "./FieldSelect.svelte"
import MultiFieldSelect from "./MultiFieldSelect.svelte"
import SchemaSelect from "./SchemaSelect.svelte"
import SectionSelect from "./SectionSelect.svelte"
import NavigationEditor from "./NavigationEditor/NavigationEditor.svelte"
import FilterEditor from "./FilterEditor/FilterEditor.svelte"
import URLSelect from "./URLSelect.svelte"
import StringFieldSelect from "./StringFieldSelect.svelte"
import NumberFieldSelect from "./NumberFieldSelect.svelte"
import OptionsFieldSelect from "./OptionsFieldSelect.svelte"
import BooleanFieldSelect from "./BooleanFieldSelect.svelte"
import LongFormFieldSelect from "./LongFormFieldSelect.svelte"
import DateTimeFieldSelect from "./DateTimeFieldSelect.svelte"
import AttachmentFieldSelect from "./AttachmentFieldSelect.svelte"
import RelationshipFieldSelect from "./RelationshipFieldSelect.svelte"
const componentMap = {
text: Input,
select: Select,
dataSource: DataSourceSelect,
dataProvider: DataProviderSelect,
boolean: Checkbox,
number: Input,
event: EventsEditor,
table: TableSelect,
color: ColorPicker,
icon: IconSelect,
field: FieldSelect,
multifield: MultiFieldSelect,
schema: SchemaSelect,
section: SectionSelect,
navigation: NavigationEditor,
filter: FilterEditor,
url: URLSelect,
"field/string": StringFieldSelect,
"field/number": NumberFieldSelect,
"field/options": OptionsFieldSelect,
"field/boolean": BooleanFieldSelect,
"field/longform": LongFormFieldSelect,
"field/datetime": DateTimeFieldSelect,
"field/attachment": AttachmentFieldSelect,
"field/link": RelationshipFieldSelect,
}
export const getComponentForSettingType = type => {
return componentMap[type]
}

View File

@ -0,0 +1,80 @@
export const OperatorOptions = {
Equals: {
value: "equal",
label: "Equals",
},
NotEquals: {
value: "notEqual",
label: "Not equals",
},
Empty: {
value: "empty",
label: "Is empty",
},
NotEmpty: {
value: "notEmpty",
label: "Is not empty",
},
StartsWith: {
value: "string",
label: "Starts with",
},
Like: {
value: "fuzzy",
label: "Like",
},
MoreThan: {
value: "rangeLow",
label: "More than",
},
LessThan: {
value: "rangeHigh",
label: "Less than",
},
}
export const getValidOperatorsForType = type => {
const Op = OperatorOptions
if (type === "string") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "number") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "options") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "boolean") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "longform") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "datetime") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
}
return []
}

View File

@ -8,11 +8,18 @@
import { hashString } from "../utils/hash" import { hashString } from "../utils/hash"
import Manifest from "@budibase/standard-components/manifest.json" import Manifest from "@budibase/standard-components/manifest.json"
import { Placeholder } from "@budibase/standard-components" import { Placeholder } from "@budibase/standard-components"
import {
getActiveConditions,
reduceConditionActions,
} from "../utils/conditions"
export let instance = {} export let instance = {}
// Props that will be passed to the component instance // The enriched component settings
let componentProps let enrichedSettings
// Any prop overrides that need to be applied due to conditional UI
let conditionalSettings
// Props are hashed when inside the builder preview and used as a key, so that // Props are hashed when inside the builder preview and used as a key, so that
// components fully remount whenever any props change // components fully remount whenever any props change
@ -28,6 +35,9 @@
let lastContextKey let lastContextKey
let lastInstanceKey let lastInstanceKey
// Visibility flag used by conditional UI
let visible = true
// Get contexts // Get contexts
const context = getContext("context") const context = getContext("context")
const insideScreenslot = !!getContext("screenslot") const insideScreenslot = !!getContext("screenslot")
@ -54,6 +64,8 @@
$builderStore.inBuilder && $builderStore.inBuilder &&
$builderStore.selectedComponentId === instance._id $builderStore.selectedComponentId === instance._id
$: interactive = $builderStore.previewType === "layout" || insideScreenslot $: interactive = $builderStore.previewType === "layout" || insideScreenslot
$: evaluateConditions(enrichedSettings?._conditions)
$: componentSettings = { ...enrichedSettings, ...conditionalSettings }
// Update component context // Update component context
$: componentStore.set({ $: componentStore.set({
@ -62,14 +74,14 @@
styles: { ...instance._styles, id, empty, interactive }, styles: { ...instance._styles, id, empty, interactive },
empty, empty,
selected, selected,
props: componentProps, props: componentSettings,
name, name,
}) })
const getRawProps = instance => { const getRawProps = instance => {
let validProps = {} let validProps = {}
Object.entries(instance) Object.entries(instance)
.filter(([name]) => !name.startsWith("_")) .filter(([name]) => name === "_conditions" || !name.startsWith("_"))
.forEach(([key, value]) => { .forEach(([key, value]) => {
validProps[key] = value validProps[key] = value
}) })
@ -123,34 +135,55 @@
return return
} }
let propsChanged = false let propsChanged = false
if (!componentProps) { if (!enrichedSettings) {
componentProps = {} enrichedSettings = {}
propsChanged = true propsChanged = true
} }
Object.keys(enrichedProps).forEach(key => { Object.keys(enrichedProps).forEach(key => {
if (!propsAreSame(enrichedProps[key], componentProps[key])) { if (!propsAreSame(enrichedProps[key], enrichedSettings[key])) {
propsChanged = true propsChanged = true
componentProps[key] = enrichedProps[key] enrichedSettings[key] = enrichedProps[key]
} }
}) })
// Update the hash if we're in the builder so we can fully remount this // Update the hash if we're in the builder so we can fully remount this
// component // component
if (get(builderStore).inBuilder && propsChanged) { if (get(builderStore).inBuilder && propsChanged) {
propsHash = hashString(JSON.stringify(componentProps)) propsHash = hashString(JSON.stringify(enrichedSettings))
} }
} }
const evaluateConditions = conditions => {
if (!conditions?.length) {
return
}
// Default visible to false if there is a show condition
let nextVisible = !conditions.find(condition => condition.action === "show")
// Execute conditions and determine settings and visibility changes
const activeConditions = getActiveConditions(conditions)
const result = reduceConditionActions(activeConditions)
if (result.visible != null) {
nextVisible = result.visible
}
// Update state from condition results
conditionalSettings = result.settingUpdates
visible = nextVisible
}
</script> </script>
{#key propsHash}
{#if constructor && componentSettings && visible}
<div <div
class={`component ${id}`} class={`component ${id}`}
data-type={interactive ? "component" : ""} data-type={interactive ? "component" : ""}
data-id={id} data-id={id}
data-name={name} data-name={name}
class:hidden={!visible}
> >
{#key propsHash} <svelte:component this={constructor} {...componentSettings}>
{#if constructor && componentProps}
<svelte:component this={constructor} {...componentProps}>
{#if children.length} {#if children.length}
{#each children as child (child._id)} {#each children as child (child._id)}
<svelte:self instance={child} /> <svelte:self instance={child} />
@ -159,9 +192,9 @@
<Placeholder /> <Placeholder />
{/if} {/if}
</svelte:component> </svelte:component>
</div>
{/if} {/if}
{/key} {/key}
</div>
<style> <style>
.component { .component {

View File

@ -43,5 +43,17 @@ export const enrichProps = (props, context) => {
) )
} }
// Enrich any click actions in conditions
if (enrichedProps._conditions) {
enrichedProps._conditions.forEach(condition => {
if (condition.setting === "onClick") {
condition.settingValue = enrichButtonActions(
condition.settingValue,
totalContext
)
}
})
}
return enrichedProps return enrichedProps
} }

View File

@ -0,0 +1,60 @@
import {
buildLuceneQuery,
luceneQuery,
} from "../../../standard-components/src/lucene"
export const getActiveConditions = conditions => {
if (!conditions?.length) {
return []
}
return conditions.filter(condition => {
// Parse values into correct types
if (condition.valueType === "number") {
condition.referenceValue = parseFloat(condition.referenceValue)
condition.newValue = parseFloat(condition.newValue)
} else if (condition.valueType === "datetime") {
if (condition.referenceValue) {
condition.referenceValue = new Date(
condition.referenceValue
).toISOString()
}
if (condition.newValue) {
condition.newValue = new Date(condition.newValue).toISOString()
}
} else if (condition.valueType === "boolean") {
condition.referenceValue =
`${condition.referenceValue}`.toLowerCase() === "true"
condition.newValue = `${condition.newValue}`.toLowerCase() === "true"
}
// Build lucene compatible condition expression
const luceneCondition = {
...condition,
type: condition.valueType,
field: "newValue",
value: condition.referenceValue,
}
const query = buildLuceneQuery([luceneCondition])
const result = luceneQuery([luceneCondition], query)
return result.length > 0
})
}
export const reduceConditionActions = conditions => {
let settingUpdates = {}
let visible = null
conditions?.forEach(condition => {
if (condition.action === "show") {
visible = true
} else if (condition.action === "hide") {
visible = false
} else if (condition.setting) {
settingUpdates[condition.setting] = condition.settingValue
}
})
return { settingUpdates, visible }
}

View File

@ -23,7 +23,7 @@ export const buildLuceneQuery = filter => {
value = parseFloat(value) value = parseFloat(value)
} }
if (type === "boolean") { if (type === "boolean") {
value = value?.toLowerCase() === "true" value = `${value}`?.toLowerCase() === "true"
} }
if (operator.startsWith("range")) { if (operator.startsWith("range")) {
if (!query.range[field]) { if (!query.range[field]) {
@ -91,6 +91,11 @@ export const luceneQuery = (docs, query) => {
return !doc[key] || !doc[key].startsWith(value) return !doc[key] || !doc[key].startsWith(value)
}) })
// Process a fuzzy match (treat the same as starts with when running locally)
const fuzzyMatch = match("fuzzy", (key, value, doc) => {
return !doc[key] || !doc[key].startsWith(value)
})
// Process a range match // Process a range match
const rangeMatch = match("range", (key, value, doc) => { const rangeMatch = match("range", (key, value, doc) => {
return !doc[key] || doc[key] < value.low || doc[key] > value.high return !doc[key] || doc[key] < value.low || doc[key] > value.high
@ -120,6 +125,7 @@ export const luceneQuery = (docs, query) => {
const docMatch = doc => { const docMatch = doc => {
return ( return (
stringMatch(doc) && stringMatch(doc) &&
fuzzyMatch(doc) &&
rangeMatch(doc) && rangeMatch(doc) &&
equalMatch(doc) && equalMatch(doc) &&
notEqualMatch(doc) && notEqualMatch(doc) &&