Merge branch 'master' into feat/ai-ui-fix

This commit is contained in:
Peter Clement 2025-05-06 11:51:28 +01:00 committed by GitHub
commit 4d53cc1e2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1882 additions and 49 deletions

View File

@ -13,7 +13,7 @@
export let readonly = false
export let error = null
export let enableTime = true
export let value = null
export let value = undefined
export let placeholder = null
export let timeOnly = false
export let ignoreTimezones = false

View File

@ -7,6 +7,8 @@
export let type = "number"
$: style = width ? `width:${width}px;` : ""
const selectAll = event => event.target.select()
</script>
<input
@ -16,7 +18,7 @@
{value}
{min}
{max}
onclick="this.select()"
on:click={selectAll}
on:change
on:input
/>

View File

@ -1,24 +1,87 @@
<script>
<script lang="ts">
import dayjs, { type Dayjs } from "dayjs"
import { createEventDispatcher } from "svelte"
import CoreDatePicker from "./DatePicker/DatePicker.svelte"
import Icon from "../../Icon/Icon.svelte"
import { parseDate } from "../../helpers"
import { writable } from "svelte/store"
let fromDate
let toDate
export let enableTime: boolean | undefined = false
export let timeOnly: boolean | undefined = false
export let ignoreTimezones: boolean | undefined = false
export let value: string[] | undefined = []
const dispatch = createEventDispatcher()
const valueStore = writable<string[]>()
let fromDate: Dayjs | null
let toDate: Dayjs | null
$: valueStore.set(value || [])
$: parseValue($valueStore)
$: parsedFrom = fromDate ? parseDate(fromDate, { enableTime }) : undefined
$: parsedTo = toDate ? parseDate(toDate, { enableTime }) : undefined
const parseValue = (value: string[]) => {
if (!Array.isArray(value) || !value[0] || !value[1]) {
fromDate = null
toDate = null
} else {
fromDate = dayjs(value[0])
toDate = dayjs(value[1])
}
}
const onChangeFrom = (utc: string) => {
// Preserve the time if its editable
const fromDate = utc
? enableTime
? dayjs(utc)
: dayjs(utc).startOf("day")
: null
if (fromDate && (!toDate || fromDate.isAfter(toDate))) {
toDate = !enableTime ? fromDate.endOf("day") : fromDate
} else if (!fromDate) {
toDate = null
}
dispatch("change", [fromDate, toDate])
}
const onChangeTo = (utc: string) => {
// Preserve the time if its editable
const toDate = utc
? enableTime
? dayjs(utc)
: dayjs(utc).startOf("day")
: null
if (toDate && (!fromDate || toDate.isBefore(fromDate))) {
fromDate = !enableTime ? toDate.startOf("day") : toDate
} else if (!toDate) {
fromDate = null
}
dispatch("change", [fromDate, toDate])
}
</script>
<div class="date-range">
<CoreDatePicker
value={fromDate}
on:change={e => (fromDate = e.detail)}
enableTime={false}
value={parsedFrom}
on:change={e => onChangeFrom(e.detail)}
{enableTime}
{timeOnly}
{ignoreTimezones}
/>
<div class="arrow">
<Icon name="ChevronRight" />
</div>
<CoreDatePicker
value={toDate}
on:change={e => (toDate = e.detail)}
enableTime={false}
value={parsedTo}
on:change={e => onChangeTo(e.detail)}
{enableTime}
{timeOnly}
{ignoreTimezones}
/>
</div>

View File

@ -3,7 +3,7 @@
import DateRangePicker from "./Core/DateRangePicker.svelte"
import { createEventDispatcher } from "svelte"
export let value = null
export let value = undefined
export let label = null
export let labelPosition = "above"
export let disabled = false
@ -12,6 +12,8 @@
export let helpText = null
export let appendTo = undefined
export let ignoreTimezones = false
export let enableTime = false
export let timeOnly = false
const dispatch = createEventDispatcher()
@ -29,6 +31,8 @@
{value}
{appendTo}
{ignoreTimezones}
{enableTime}
{timeOnly}
on:change={onChange}
/>
</Field>

View File

@ -26,6 +26,7 @@ import TopLevelColumnEditor from "./controls/ColumnEditor/TopLevelColumnEditor.s
import GridColumnEditor from "./controls/GridColumnConfiguration/GridColumnConfiguration.svelte"
import BarButtonList from "./controls/BarButtonList.svelte"
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
import FilterConfiguration from "./controls/FilterConfiguration/FilterConfiguration.svelte"
import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte"
import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte"
import FormStepConfiguration from "./controls/FormStepConfiguration.svelte"
@ -33,6 +34,7 @@ import FormStepControls from "./controls/FormStepControls.svelte"
import PaywalledSetting from "./controls/PaywalledSetting.svelte"
import TableConditionEditor from "./controls/TableConditionEditor.svelte"
import MultilineDrawerBindableInput from "@/components/common/MultilineDrawerBindableInput.svelte"
import FilterableSelect from "./controls/FilterableSelect.svelte"
const componentMap = {
text: DrawerBindableInput,
@ -42,6 +44,7 @@ const componentMap = {
radio: RadioGroup,
dataSource: DataSourceSelect,
"dataSource/s3": S3DataSourceSelect,
"dataSource/filterable": FilterableSelect,
dataProvider: DataProviderSelect,
boolean: Checkbox,
number: Stepper,
@ -59,6 +62,7 @@ const componentMap = {
"filter/relationship": RelationshipFilterEditor,
url: URLSelect,
fieldConfiguration: FieldConfiguration,
filterConfiguration: FilterConfiguration,
buttonConfiguration: ButtonConfiguration,
stepConfiguration: FormStepConfiguration,
formStepControls: FormStepControls,

View File

@ -11,7 +11,7 @@
export let listTypeProps = {}
export let listItemKey
export let draggable = true
export let focus
export let focus = undefined
let zoneType = generate()

View File

@ -0,0 +1,127 @@
<script>
import { Icon, Popover, Layout } from "@budibase/bbui"
import { componentStore, selectedScreen } from "@/stores/builder"
import { cloneDeep } from "lodash/fp"
import { createEventDispatcher, getContext } from "svelte"
import { getComponentBindableProperties } from "@/dataBinding"
import ComponentSettingsSection from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
export let anchor
export let componentInstance
export let bindings
export let parseSettings
const draggable = getContext("draggable")
const dispatch = createEventDispatcher()
let popover
let drawers = []
let isOpen = false
// Auto hide the component when another item is selected
$: if (
open &&
$draggable.selected &&
$draggable.selected !== componentInstance._instanceName
) {
close()
}
// Open automatically if the component is marked as selected
$: if (!open && $draggable.selected === componentInstance._id && popover) {
open()
}
$: componentDef = componentStore.getDefinition(componentInstance._component)
$: parsedComponentDef = processComponentDefinitionSettings(componentDef)
$: componentBindings = getComponentBindableProperties(
$selectedScreen,
$componentStore.selectedComponentId
)
const open = () => {
isOpen = true
drawers = []
$draggable.actions.select(componentInstance._id)
}
const close = () => {
// Slight delay allows us to be able to properly toggle open/close state by
// clicking again on the settings icon
setTimeout(() => {
isOpen = false
if ($draggable.selected === componentInstance._id) {
$draggable.actions.select()
}
}, 10)
}
const toggleOpen = () => {
if (isOpen) {
close()
} else {
open()
}
}
const processComponentDefinitionSettings = componentDef => {
if (!componentDef) {
return {}
}
const clone = cloneDeep(componentDef)
if (typeof parseSettings === "function") {
clone.settings = parseSettings(clone.settings)
}
return clone
}
const updateSetting = async (setting, value) => {
const nestedComponentInstance = cloneDeep(componentInstance)
const patchFn = componentStore.updateComponentSetting(setting.key, value)
patchFn(nestedComponentInstance)
dispatch("change", nestedComponentInstance)
}
</script>
<Icon name="Settings" hoverable size="S" on:click={toggleOpen} />
<Popover
open={isOpen}
on:close={close}
{anchor}
align="left-outside"
showPopover={drawers.length === 0}
clickOutsideOverride={drawers.length > 0}
maxHeight={600}
minWidth={360}
maxWidth={360}
offset={18}
>
<span class="popover-wrap">
<Layout noPadding noGap>
<slot name="header" />
<ComponentSettingsSection
includeHidden
{componentInstance}
componentDefinition={parsedComponentDef}
isScreen={false}
onUpdateSetting={updateSetting}
showSectionTitle={false}
showInstanceName={false}
{bindings}
{componentBindings}
on:drawerShow={e => {
drawers = [...drawers, e.detail]
}}
on:drawerHide={() => {
drawers = drawers.slice(0, -1)
}}
/>
</Layout>
</span>
</Popover>
<style>
.popover-wrap {
background-color: var(--spectrum-alias-background-color-primary);
}
</style>

View File

@ -0,0 +1,186 @@
<script lang="ts">
import { enrichSchemaWithRelColumns, search } from "@budibase/frontend-core"
import { Toggle } from "@budibase/bbui"
import {
getSchemaForDatasource,
extractLiteralHandlebarsID,
getDatasourceForProvider,
} from "@/dataBinding"
import { selectedScreen } from "@/stores/builder"
import DraggableList from "../DraggableList/DraggableList.svelte"
import { createEventDispatcher } from "svelte"
import FilterSetting from "./FilterSetting.svelte"
import { removeInvalidAddMissing } from "../GridColumnConfiguration/getColumns.js"
import {
FieldType,
type UIFieldSchema,
type Component,
type FilterConfig,
type Screen,
type TableSchema,
type Table,
} from "@budibase/types"
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
import { findComponent } from "@/helpers/components"
import { tables } from "@/stores/builder"
export let value
export let componentInstance
export let bindings
const dispatch = createEventDispatcher()
let selectedAll = false
// Load the component for processing
$: targetId = extractLiteralHandlebarsID(componentInstance.targetComponent)
$: targetComponent =
$selectedScreen && targetId
? findComponent($selectedScreen?.props, targetId)
: null
$: contextDS = getDatasourceForProvider($selectedScreen, targetComponent)
$: schema = $selectedScreen
? getSchema($selectedScreen, contextDS)
: undefined
$: searchable = getSearchableFields(schema, $tables.list)
$: defaultValues = searchable
.filter((column: UIFieldSchema) => !column.nestedJSON)
.map(
(column: UIFieldSchema): FilterConfig => ({
field: column.name,
active: !!value == false ? false : !!column.visible,
columnType: column.type,
})
)
$: parsedColumns = schema
? removeInvalidAddMissing(value || [], [...defaultValues]).map(column => ({
...column,
}))
: []
const itemUpdate = (e: CustomEvent) => {
// The item is a component instance. '_instanceName' === 'field'
const item: Component = e.detail
const { label } = item
const updated = parsedColumns.map(entry => {
if (item._instanceName === entry.field) {
return { ...entry, label }
}
return entry
})
dispatch("change", updated)
}
const listUpdate = (list: FilterConfig[]) => {
dispatch("change", list)
}
const getSearchableFields = (
schema: TableSchema | undefined,
tableList: Table[]
) => {
// Omit calculated fields
const filtered = Object.values(schema || {}).filter(
field => !("calculationType" in field)
)
return search.getFields(tableList, filtered, {
allowLinks: true,
})
}
const getSchema = (screen: Screen, datasource: any) => {
const schema = getSchemaForDatasource(screen, datasource, null)
.schema as Record<string, UIFieldSchema>
if (!schema) {
return
}
// Don't show ID and rev in tables
delete schema._id
delete schema._rev
const excludedTypes = [
FieldType.ATTACHMENT_SINGLE,
FieldType.ATTACHMENTS,
FieldType.AI,
FieldType.SIGNATURE_SINGLE,
]
const filteredSchema = Object.entries(schema || {}).filter(
([_, field]: [string, UIFieldSchema]) => {
return !excludedTypes.includes(field.type)
}
)
const result = enrichSchemaWithRelColumns(
Object.fromEntries(filteredSchema)
)
return result
}
</script>
<div class="filter-configuration">
<div class="toggle-all">
<span>Fields</span>
<Toggle
on:change={() => {
selectedAll = !selectedAll
let update = parsedColumns.map(field => {
return {
...field,
active: selectedAll,
}
})
listUpdate(update)
}}
value={selectedAll}
text=""
/>
</div>
{#if parsedColumns?.length}
<DraggableList
on:change={e => {
listUpdate(e.detail)
}}
on:itemChange={itemUpdate}
items={parsedColumns || []}
listItemKey={"field"}
listType={FilterSetting}
listTypeProps={{
bindings,
}}
/>
{:else}
<InfoDisplay body={"No available columns"} />
{/if}
</div>
<style>
.filter-configuration {
padding-top: 8px;
}
.toggle-all {
display: flex;
justify-content: space-between;
}
.toggle-all :global(.spectrum-Switch) {
margin-right: 0px;
padding-right: calc(var(--spacing-s) - 1px);
min-height: unset;
}
.toggle-all :global(.spectrum-Switch .spectrum-Switch-switch) {
margin-top: 0px;
}
.toggle-all span {
color: var(--spectrum-global-color-gray-700);
font-size: 12px;
}
</style>

View File

@ -0,0 +1,135 @@
<script>
import EditFilterPopover from "./EditFilterPopover.svelte"
import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp"
import { FIELDS } from "@/constants/backend"
import { Constants } from "@budibase/frontend-core"
import { FieldType } from "@budibase/types"
import { componentStore } from "@/stores/builder"
export let item
export let anchor
export let bindings
const dispatch = createEventDispatcher()
$: fieldIconLookupMap = buildFieldIconLookupMap(FIELDS)
const buildFieldIconLookupMap = fields => {
let map = {}
Object.values(fields).forEach(fieldInfo => {
map[fieldInfo.type] = fieldInfo.icon
})
return map
}
const onToggle = item => {
return e => {
item.active = e.detail
dispatch("change", { ...cloneDeep(item), active: e.detail })
}
}
const parseSettings = settings => {
let columnSettings = settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
// Filter out conditions for invalid types.
// Allow formulas as we have all the data already loaded in the table.
if (
Constants.BannedSearchTypes.includes(item.columnType) &&
item.columnType !== FieldType.FORMULA
) {
return columnSettings.filter(x => x.key !== "conditions")
}
return columnSettings
}
const itemToComponent = item => {
return componentStore.createInstance(
"@budibase/standard-components/filterconfig",
{
_id: item._id,
_instanceName: item.field,
label: item.label,
}
)
}
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditFilterPopover
componentInstance={itemToComponent(item)}
{bindings}
{anchor}
{parseSettings}
on:change
>
<div slot="header" class="type-icon">
<Icon name={fieldIconLookupMap[item.columnType]} />
<span>{item.field}</span>
</div>
</EditFilterPopover>
<div class="field-label">{item.label || item.field}</div>
</div>
<div class="list-item-right">
<Toggle
on:click={e => {
e.stopPropagation()
}}
on:change={onToggle(item)}
text=""
value={item.active}
thin
/>
</div>
</div>
<style>
.field-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.list-item-body,
.list-item-left {
display: flex;
align-items: center;
gap: var(--spacing-m);
min-width: 0;
}
.list-item-right :global(div.spectrum-Switch) {
margin: 0px;
}
.list-item-body {
justify-content: space-between;
}
.type-icon {
display: flex;
gap: var(--spacing-m);
margin: var(--spacing-xl);
margin-bottom: 0px;
height: var(--spectrum-alias-item-height-m);
padding: 0px var(--spectrum-alias-item-padding-m);
border-width: var(--spectrum-actionbutton-border-size);
border-radius: var(--spectrum-alias-border-radius-regular);
border: 1px solid
var(
--spectrum-actionbutton-m-border-color,
var(--spectrum-alias-border-color)
);
align-items: center;
}
.type-icon span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 0;
flex: 1 1 auto;
}
</style>

View File

@ -0,0 +1,36 @@
<script lang="ts">
import { Select } from "@budibase/bbui"
import { selectedScreen } from "@/stores/builder"
import { findAllComponents, getComponentContexts } from "@/helpers/components"
import { makePropSafe as safe } from "@budibase/string-templates"
import { type Component } from "@budibase/types"
export let value: string | undefined = undefined
$: providers = getProviders($selectedScreen?.props)
const getProviders = (rootComponent: Component | undefined) => {
if (!rootComponent) {
return []
}
return findAllComponents(rootComponent)
.filter(component => {
return getComponentContexts(component._component).some(ctx =>
ctx.actions?.find(
act =>
act.type === "AddDataProviderQueryExtension" ||
act.type === "AddDataProviderFilterExtension"
)
)
})
.map(provider => ({
label: provider._instanceName,
value: `{{ literal ${safe(provider._id)} }}`,
subtitle: `${
provider?.dataSource?.label || provider?.table?.label || "-"
}`,
}))
}
</script>
<Select {value} options={providers} on:change />

View File

@ -9,7 +9,7 @@
import { createEventDispatcher } from "svelte"
import FieldSetting from "./FieldSetting.svelte"
import PrimaryColumnFieldSetting from "./PrimaryColumnFieldSetting.svelte"
import getColumns from "./getColumns.js"
import { getColumns } from "./getColumns.js"
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
export let value

View File

@ -1,4 +1,4 @@
const modernize = columns => {
export const modernize = columns => {
if (!columns) {
return []
}
@ -14,9 +14,9 @@ const modernize = columns => {
return columns
}
const removeInvalidAddMissing = (
export const removeInvalidAddMissing = (
columns = [],
defaultColumns,
defaultColumns = [],
primaryDisplayColumnName
) => {
const defaultColumnNames = defaultColumns.map(column => column.field)
@ -47,7 +47,7 @@ const removeInvalidAddMissing = (
return combinedColumns
}
const getDefault = (schema = {}) => {
export const getDefault = (schema = {}) => {
const defaultValues = Object.values(schema)
.filter(column => !column.nestedJSON)
.map(column => ({
@ -93,7 +93,7 @@ const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
})
}
const getColumns = ({
export const getColumns = ({
columns,
schema,
primaryDisplayColumnName,
@ -132,5 +132,3 @@ const getColumns = ({
},
}
}
export default getColumns

View File

@ -1,5 +1,5 @@
import { it, expect, describe, beforeEach, vi } from "vitest"
import getColumns from "./getColumns"
import { getColumns } from "./getColumns"
describe("getColumns", () => {
beforeEach(ctx => {

View File

@ -109,13 +109,12 @@
/>
{/each}
</List>
<div>
<Button secondary icon="Add" on:click={addOAuth2Configuration}
>Add OAuth2</Button
>
</div>
{/if}
<div>
<Button secondary icon="Add" on:click={addOAuth2Configuration}
>Add OAuth2</Button
>
</div>
</DetailPopover>
<style>

View File

@ -1424,7 +1424,7 @@ const bindingReplacement = (
* Extracts a component ID from a handlebars expression setting of
* {{ literal [componentId] }}
*/
const extractLiteralHandlebarsID = value => {
export const extractLiteralHandlebarsID = value => {
if (!value || typeof value !== "string") {
return null
}

View File

@ -1,8 +1,8 @@
<script>
import { Icon } from "@budibase/bbui"
export let title
export let body
export let title = undefined
export let body = undefined
export let icon = "HelpOutline"
export let quiet = false
export let warning = false

View File

@ -27,6 +27,7 @@
"pdftable",
"spreadsheet",
"dynamicfilter",
"filter",
"daterangepicker"
]
},

View File

@ -9,6 +9,7 @@
Link,
TooltipWrapper,
} from "@budibase/bbui"
import { Feature } from "@budibase/types"
import { onMount } from "svelte"
import { admin, auth, licensing } from "@/stores/portal"
import { Constants } from "@budibase/frontend-core"
@ -33,6 +34,7 @@
const EXCLUDE_QUOTAS = {
["Day Passes"]: () => true,
[Feature.AI_CUSTOM_CONFIGS]: () => true,
Queries: () => true,
Users: license => {
return license.plan.model !== PlanModel.PER_USER

View File

@ -65,7 +65,6 @@ export const INITIAL_COMPONENTS_STATE: ComponentState = {
export class ComponentStore extends BudiStore<ComponentState> {
constructor() {
super(INITIAL_COMPONENTS_STATE)
this.reset = this.reset.bind(this)
this.refreshDefinitions = this.refreshDefinitions.bind(this)
this.getDefinition = this.getDefinition.bind(this)
@ -214,7 +213,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
const def = this.getDefinition(enrichedComponent?._component)
const filterableTypes = def?.settings?.filter(setting =>
setting?.type?.startsWith("filter")
["filter", "filter/relationship"].includes(setting?.type)
)
for (let setting of filterableTypes || []) {
const isLegacy = Array.isArray(enrichedComponent[setting.key])

View File

@ -5204,7 +5204,7 @@
"icon": "Data",
"illegalChildren": ["section"],
"hasChildren": true,
"actions": ["RefreshDatasource"],
"actions": ["RefreshDatasource", "AddDataProviderQueryExtension"],
"size": {
"width": 500,
"height": 200
@ -5526,7 +5526,59 @@
}
]
},
"filter": {
"name": "Filter",
"icon": "Filter",
"size": {
"width": 100,
"height": 35
},
"new": true,
"settings": [
{
"type": "dataSource/filterable",
"label": "Target component",
"required": true,
"key": "targetComponent",
"wide": true
},
{
"label": "",
"type": "filterConfiguration",
"key": "filterConfig",
"nested": true,
"dependsOn": "targetComponent",
"resetOn": "targetComponent"
},
{
"type": "boolean",
"label": "Persist filters",
"key": "persistFilters",
"defaultValue": false
},
{
"type": "boolean",
"label": "Clear filters",
"key": "showClear",
"defaultValue": false
}
]
},
"filterconfig": {
"name": "",
"icon": "",
"editable": true,
"settings": [
{
"type": "plainText",
"label": "Label",
"key": "label"
}
]
},
"dynamicfilter": {
"deprecated": true,
"name": "Dynamic Filter",
"icon": "Filter",
"size": {
@ -7671,7 +7723,8 @@
"setting": "table.type",
"value": "custom",
"invert": true
}
},
"resetOn": "table"
},
{
"type": "field/sortable",
@ -7823,7 +7876,7 @@
]
}
],
"actions": ["RefreshDatasource"]
"actions": ["RefreshDatasource", "AddDataProviderFilterExtension"]
},
"bbreferencefield": {
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",

View File

@ -100,6 +100,7 @@
},
limit,
primaryDisplay: ($fetch.definition as any)?.primaryDisplay,
loaded: $fetch.loaded,
}
const createFetch = (datasource: ProviderDatasource) => {

View File

@ -6,6 +6,7 @@
import { featuresStore } from "@/stores"
import { Grid } from "@budibase/frontend-core"
import { processStringSync } from "@budibase/string-templates"
import { UILogicalOperator, EmptyFilterOption } from "@budibase/types"
// table is actually any datasource, but called table for legacy compatibility
export let table
@ -43,6 +44,8 @@
let gridContext
let minHeight = 0
let filterExtensions = {}
$: id = $component.id
$: currentTheme = $context?.device?.theme
$: darkMode = !currentTheme?.includes("light")
@ -51,15 +54,73 @@
$: schemaOverrides = getSchemaOverrides(parsedColumns, $context)
$: selectedRows = deriveSelectedRows(gridContext)
$: styles = patchStyles($component.styles, minHeight)
$: data = { selectedRows: $selectedRows }
$: rowMap = gridContext?.rowLookupMap
$: data = {
selectedRows: $selectedRows,
embeddedData: {
dataSource: table,
componentId: $component.id,
loaded: !!$rowMap,
},
}
$: actions = [
{
type: ActionTypes.RefreshDatasource,
callback: () => gridContext?.rows.actions.refreshData(),
metadata: { dataSource: table },
},
{
type: ActionTypes.AddDataProviderFilterExtension,
callback: addFilterExtension,
},
{
type: ActionTypes.RemoveDataProviderFilterExtension,
callback: removeFilterExtension,
},
]
$: extendedFilter = extendFilter(initialFilter, filterExtensions)
/**
*
* @param componentId Originating Component id
* @param extension Filter extension
*/
const addFilterExtension = (componentId, extension) => {
if (!componentId || !extension) {
return
}
filterExtensions = { ...filterExtensions, [componentId]: extension }
}
/**
*
* @param componentId Originating Component id
* @param extension Filter extension
*/
const removeFilterExtension = componentId => {
if (!componentId) {
return
}
const { [componentId]: removed, ...rest } = filterExtensions
filterExtensions = { ...rest }
}
const extendFilter = (initialFilter, extensions) => {
if (!Object.keys(extensions || {}).length) {
return initialFilter
}
return {
groups: (initialFilter ? [initialFilter] : []).concat(
Object.values(extensions)
),
logicalOperator: UILogicalOperator.ALL,
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
}
}
// Provide additional data context for live binding eval
export const getAdditionalDataContext = () => {
const gridContext = grid?.getContext()
@ -197,7 +258,7 @@
{stripeRows}
{quiet}
{darkMode}
{initialFilter}
initialFilter={extendedFilter}
{initialSortColumn}
{initialSortOrder}
{fixedRowHeight}

View File

@ -0,0 +1,438 @@
<script lang="ts">
import { Button, Helpers } from "@budibase/bbui"
import {
type FilterConfig,
type FieldSchema,
type SearchFilter,
type SearchFilterGroup,
type TableSchema,
type UISearchFilter,
type Component,
type TableDatasource,
ArrayOperator,
EmptyFilterOption,
FieldType,
FilterType,
InternalTable,
RangeOperator,
UILogicalOperator,
} from "@budibase/types"
import { getContext } from "svelte"
import Container from "../container/Container.svelte"
import { getAction } from "@/utils/getAction"
import { ActionTypes } from "@/constants"
import { QueryUtils, fetchData, memo } from "@budibase/frontend-core"
import FilterButton from "./FilterButton.svelte"
import { onDestroy } from "svelte"
import { uiStateStore, componentStore } from "@/stores"
import { onMount, setContext } from "svelte"
import { writable } from "svelte/store"
export let persistFilters: boolean | undefined = false
export let showClear: boolean | undefined = false
export let filterConfig: FilterConfig[] | undefined = []
export let targetComponent: any
const memoFilters = memo({} as Record<string, SearchFilter>)
const component = getContext("component")
const { API, fetchDatasourceSchema, getRelationshipSchemaAdditions } =
getContext("sdk")
const rowCache = writable({})
setContext("rows", rowCache)
// Core filter template
const baseFilter: UISearchFilter = {
logicalOperator: UILogicalOperator.ALL,
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
groups: [
{
logicalOperator: UILogicalOperator.ALL, // could be configurable
filters: [],
},
],
}
let userFetch: any
let hydrated = false
// All current filters.
let filters: Record<string, SearchFilter> = {}
let schema: TableSchema | null
// Target component being filtered
let dataComponent: Component | null = null
// Update the memo when the filters are updated
$: memoFilters.set(filters)
// Fully built filter extension
$: filterExtension = buildExtension($memoFilters)
$: query = filterExtension ? QueryUtils.buildQuery(filterExtension) : null
// Embedded datasources are those inside blocks or other components
$: filterDatasource =
targetComponent?.embeddedData?.dataSource ?? targetComponent?.datasource
// The target component
$: componentId =
targetComponent?.embeddedData?.componentId ?? targetComponent?.id
// Process the target component
$: target = componentStore.actions.getComponentById(componentId)
// Ensure the target has intialised
$: loaded =
(targetComponent?.embeddedData?.loaded ?? targetComponent?.loaded) || false
// Init the target component
$: loaded && initTarget(target)
// Update schema when the target instance
$: dataComponent && fetchSchema(filterDatasource)
$: isGridblock =
dataComponent?._component === "@budibase/standard-components/gridblock"
// Must be active and part of the current schema
$: visibleFilters = filterConfig?.filter(
filter => filter.active && schema?.[filter.field]
)
// Choose the appropriate action based on component type
$: addAction = isGridblock
? ActionTypes.AddDataProviderFilterExtension
: ActionTypes.AddDataProviderQueryExtension
$: removeAction = isGridblock
? ActionTypes.RemoveDataProviderFilterExtension
: ActionTypes.RemoveDataProviderQueryExtension
// Register extension actions
$: addExtension = componentId ? getAction(componentId, addAction) : null
$: removeExtension = componentId ? getAction(componentId, removeAction) : null
// If the filters are updated, notify the target of the change
$: hydrated && dataComponent && loaded && fire(filterExtension)
const initTarget = (target: Component) => {
if (!dataComponent && target) {
dataComponent = target
// In the event the underlying component is remounted and requires hydration
if (!hydrated) {
hydrateFilters()
}
} else if (dataComponent && !target) {
hydrated = false
dataComponent = null
filters = {}
schema = null
}
}
const getValidOperatorsForType = (
config: FilterConfig | undefined,
schema: TableSchema | null
): {
value: string
label: string
}[] => {
if (!config?.field || !config.columnType || !schema) {
return []
}
const fieldType = config.columnType || schema[config.field].type
let coreOperators = QueryUtils.getValidOperatorsForType(
{
type: fieldType,
},
config.field,
filterDatasource
)
const isDateTime = fieldType === FieldType.DATETIME
// Label mixins. Override the display text
const opToDisplay: Record<string, any> = {
rangeHigh: isDateTime ? "Before" : undefined,
rangeLow: isDateTime ? "After" : undefined,
[FilterType.EQUAL]: "Is",
[FilterType.NOT_EQUAL]: "Is not",
}
// Add in the range operator for datetime fields
coreOperators = [
...coreOperators,
...(fieldType === FieldType.DATETIME
? [{ value: RangeOperator.RANGE, label: "Between" }]
: []),
]
// Map the operators to set an label overrides
return coreOperators
.filter(op => {
if (isDateTime && op.value === FilterType.ONE_OF) return false
return true
})
.map(op => ({
...op,
label: opToDisplay[op.value] || op.label,
}))
}
const buildExtension = (filters: Record<string, SearchFilter>) => {
const extension = Helpers.cloneDeep(baseFilter)
delete extension.onEmptyFilter
// Pass in current filters
if (extension.groups) {
const groups: SearchFilterGroup[] = extension.groups
groups[0].filters = Object.values(filters)
}
return extension
}
const fire = (extension: UISearchFilter) => {
if (!$component?.id || !componentId) return
if (Object.keys(filters).length == 0) {
removeExtension?.($component.id)
toLocalStorage()
return
}
if (persistFilters) {
toLocalStorage()
}
addExtension?.($component.id, isGridblock ? extension : query)
}
/**
* Serialise any selected config to localstorage to retain state on refresh
*/
const toLocalStorage = () => {
uiStateStore.update(state => ({
...state,
[$component.id]: { sourceId: filterDatasource.resourceId, filters },
}))
}
/**
* Purge the cached component settings when the source changes
*/
const clearLocalStorage = () => {
uiStateStore.update(state => {
delete state[$component.id]
return state
})
}
const excludedTypes = [
FieldType.ATTACHMENT_SINGLE,
FieldType.ATTACHMENTS,
FieldType.AI,
]
async function fetchSchema(datasource: any) {
if (datasource) {
const fetchedSchema: TableSchema | null = await fetchDatasourceSchema(
datasource,
{
enrichRelationships: true,
formSchema: false,
}
)
const filteredSchemaEntries = Object.entries(fetchedSchema || {}).filter(
([_, field]: [string, FieldSchema]) => {
return !excludedTypes.includes(field.type)
}
)
const filteredSchema = Object.fromEntries(filteredSchemaEntries)
// Necessary to ensure that link fields possess all config required
// to render their respective UX
const enrichedSchema = await getRelationshipSchemaAdditions(
filteredSchema as Record<string, any>
)
schema = { ...filteredSchema, ...enrichedSchema }
} else {
schema = null
}
}
const getOperators = (
config: FilterConfig | undefined,
schema: TableSchema | null
) => {
const operators = getValidOperatorsForType(config, schema)
return operators
}
const clearAll = () => {
filters = {}
uiStateStore.update(state => {
delete state[$component.id]
return state
})
}
// InitFetch for RelationShipField Display on load.
// Hardcoded for the user for now
const createFetch = (initValue: any) => {
// field and primary - based on datasource
let searchFilter: SearchFilterGroup = {
logicalOperator: UILogicalOperator.ALL,
filters: [
{
field: "_id",
operator: ArrayOperator.ONE_OF,
value: Array.isArray(initValue) ? initValue : [initValue],
},
],
}
// Default filter
let initFilter = initValue
? {
logicalOperator: UILogicalOperator.ALL,
groups: [searchFilter],
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
}
: undefined
const ds: { type: string; tableId: string } = {
type: "user",
tableId: InternalTable.USER_METADATA,
}
return fetchData({
API,
datasource: ds as TableDatasource,
options: {
filter: initFilter,
limit: 100,
},
})
}
// Only dealing with user fields.
const initRelationShips = (filters: Record<string, SearchFilter>) => {
const filtered = Object.entries(filters || {}).filter(
([_, filter]: [string, SearchFilter]) => {
return (
filter.type === FieldType.BB_REFERENCE_SINGLE ||
filter.type === FieldType.BB_REFERENCE
)
}
)
return Object.fromEntries(filtered)
}
// Flatten and gather all configured users ids
const getRelIds = (filters: Record<string, SearchFilter>) => {
const filtered = Object.entries(filters || {}).reduce(
(acc: string[], [_, filter]) => {
if (
(filter.type === FieldType.BB_REFERENCE_SINGLE ||
filter.type === FieldType.BB_REFERENCE) &&
filter.value
) {
acc = [
...acc,
...(Array.isArray(filter.value) ? filter.value : [filter.value]),
]
}
return acc
},
[]
)
const uniqueIds = new Set(filtered)
return Array.from(uniqueIds)
}
$: rels = initRelationShips($memoFilters)
$: relIds = getRelIds(rels)
$: if (!userFetch && relIds.length) {
userFetch = createFetch(relIds)
}
$: fetchedRows = userFetch ? $userFetch?.rows : []
$: if (fetchedRows.length) {
const fetched = fetchedRows.reduce((acc: any, ele: any) => {
acc[ele._id] = ele
return acc
}, {})
// User cache
rowCache.update(state => ({
...state,
...fetched,
}))
}
const hydrateFilters = () => {
// Hydrate with previously set config
if (persistFilters) {
const filterState = $uiStateStore[$component.id]?.filters
filters = Helpers.cloneDeep(filterState || {})
}
hydrated = true
}
onMount(() => {
if (!persistFilters) {
clearLocalStorage()
} else {
hydrateFilters()
}
})
onDestroy(() => {
// Ensure the extension is cleared on destroy
removeExtension($component.id)
})
</script>
<div>
<div class="filters">
<Container wrap direction={"row"} hAlign={"left"} vAlign={"top"} gap={"S"}>
{#each visibleFilters || [] as config}
{@const filter = $memoFilters[config.field]}
<FilterButton
{config}
{filter}
{schema}
operators={getOperators(config, schema)}
on:change={e => {
if (!e.detail) {
const { [config.field]: removed, ...rest } = filters
filters = { ...rest }
} else {
filters = { ...filters, [config.field]: e.detail }
}
}}
on:toggle={() => {
const { [config.field]: removed, ...rest } = filters
filters = { ...rest }
}}
/>
{/each}
{#if showClear && Object.keys(filters).length}
<Button size={"S"} secondary on:click={clearAll}>Clear all</Button>
{/if}
</Container>
</div>
</div>
<style>
.filters {
display: flex;
gap: var(--spacing-m);
}
</style>

View File

@ -0,0 +1,250 @@
<script lang="ts">
import FilterPopover from "./FilterPopover.svelte"
import {
type FieldSchema,
type FilterConfig,
type TableSchema,
type SearchFilter,
FieldType,
RangeOperator,
} from "@budibase/types"
import { type PopoverAPI, Helpers } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte"
import { type Writable } from "svelte/store"
import { isArrayOperator } from "@/utils/filtering"
export let disabled = false
export let size = "S"
export let filter: SearchFilter | undefined = undefined
export let config: FilterConfig | undefined = undefined
export let schema: TableSchema | null = null
export let operators:
| {
value: string
label: string
}[]
| undefined = undefined
const dispatch = createEventDispatcher()
const rowCache: Writable<Record<string, any>> = getContext("rows")
let popover: PopoverAPI
let button: HTMLDivElement
let filterMeta: string | undefined
let filterTitle: string | undefined
$: icon = !filter ? "AddToSelection" : "CloseCircle"
$: fieldSchema = config ? schema?.[config?.field] : undefined
$: filterOp = filter
? operators?.find(op => op.value === filter.operator)
: undefined
$: truncate = filterOp?.value !== RangeOperator.RANGE
$: filterDisplay = displayText(filter, fieldSchema)
const parseDateDisplay = (
filter: SearchFilter | undefined,
fieldSchema: FieldSchema | undefined
) => {
if (!filter || !fieldSchema || fieldSchema.type !== FieldType.DATETIME)
return ""
if (filter.operator === RangeOperator.RANGE) {
const enableTime = !fieldSchema.dateOnly
const { high, low } = filter.value
return `${Helpers.getDateDisplayValue(low, { enableTime })}
- ${Helpers.getDateDisplayValue(high, { enableTime })}`
}
const parsed = Helpers.parseDate(filter.value, {
enableTime: !fieldSchema.dateOnly,
})
const display = Helpers.getDateDisplayValue(parsed, {
enableTime: !fieldSchema.dateOnly,
})
return `${display}`
}
const parseMultiDisplay = (value: string[] | undefined) => {
const moreThanOne =
Array.isArray(value) && value?.length > 1
? `+${value?.length - 1} more`
: undefined
filterMeta = moreThanOne
filterTitle = `${value?.join(", ")}`
return `${value?.[0]}`
}
/**
* Determine appropriate display text for the filter button
* @param filter
* @param fieldSchema
*/
const displayText = (
filter: SearchFilter | undefined,
fieldSchema: FieldSchema | undefined
) => {
filterMeta = undefined
filterTitle = undefined
if (!filter || !fieldSchema) return
// Default to the base value. This could be a string or an array
// Some of the values could be refs for users.
let display = filter.value
if (fieldSchema.type === FieldType.BOOLEAN) {
display = Helpers.capitalise(filter.value)
} else if (fieldSchema.type === FieldType.DATETIME) {
display = parseDateDisplay(filter, fieldSchema)
} else if (fieldSchema.type === FieldType.ARRAY) {
if (!isArrayOperator(filter.operator)) {
display = filter.value
} else {
const filterVals = Array.isArray(filter.value)
? filter.value
: [filter.value]
display = parseMultiDisplay(filterVals)
}
} else if (
fieldSchema.type === FieldType.BB_REFERENCE ||
fieldSchema.type === FieldType.BB_REFERENCE_SINGLE
) {
// The display text for the user refs is dependent on
// a row cache.
let userDisplay: string = ""
// Process as single if the operator requires it
if (!isArrayOperator(filter.operator)) {
const userRow = $rowCache?.[filter.value]
userDisplay = userRow?.email ?? filter.value
} else {
const filterVals = Array.isArray(filter.value)
? filter.value
: [filter.value]
// Email is currently the default display field for users.
userDisplay = parseMultiDisplay(
filterVals.map((val: string) => $rowCache?.[val]?.email ?? val)
)
}
display = userDisplay
}
return `${filterOp?.label.toLowerCase()} ${filter.noValue ? "" : display}`
}
</script>
<FilterPopover
bind:this={popover}
{filter}
{operators}
{schema}
{config}
on:change
>
<div slot="anchor">
<div class="filter-button-wrap" class:inactive={!filter}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={button}
class:is-disabled={disabled}
class="new-styles spectrum-Button spectrum-Button--secondary spectrum-Button--size{size.toUpperCase()}"
title={filterTitle || filterDisplay}
>
<div
class="toggle-wrap"
on:click={e => {
if (filter) {
e.stopPropagation()
}
if (!disabled) {
dispatch("toggle")
}
}}
>
<svg
class="spectrum-Icon spectrum-Icon--sizeS"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
</div>
<span class="spectrum-Button-label" class:truncate>
<span class="field">{config?.label || config?.field}</span>
{#if filter}
<span class="display">
{filterDisplay}
</span>
{/if}
</span>
{#if filterMeta}
<span class="filterMeta">{filterMeta}</span>
{/if}
</div>
</div>
</div>
</FilterPopover>
<style>
.toggle-wrap {
z-index: 1;
}
.toggle-wrap svg {
width: 12px;
pointer-events: none;
}
.filter-button-wrap.inactive .spectrum-Button .spectrum-Icon {
color: var(--grey-6);
}
.spectrum-Button {
position: relative;
display: flex;
gap: var(--spacing-xs);
}
.spectrum-Button.is-disabled {
cursor: default;
}
.filter-button-wrap.inactive .spectrum-Button {
background: var(--spectrum-global-color-gray-50);
border: 1px dashed var(--spectrum-global-color-gray-200);
}
.spectrum-Button-label.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.spectrum-Button-label .display {
color: var(--spectrum-global-color-blue-600);
}
.spectrum-Button--secondary.new-styles {
background: var(--spectrum-global-color-gray-200);
border: none;
border-color: transparent;
color: var(--spectrum-global-color-gray-900);
}
.spectrum-Button--secondary.new-styles:not(.is-disabled):hover {
background: var(--spectrum-global-color-gray-300);
}
.spectrum-Button--secondary.new-styles.is-disabled {
color: var(--spectrum-global-color-gray-500);
}
.spectrum-Button .spectrum-Button-label {
padding: 0px;
}
</style>

View File

@ -0,0 +1,387 @@
<script lang="ts">
import {
type PopoverAPI,
Popover,
PopoverAlignment,
Input,
Button,
Select,
Helpers,
CoreCheckboxGroup,
CoreRadioGroup,
DatePicker,
DateRangePicker,
} from "@budibase/bbui"
import {
FilterValueType,
OperatorOptions,
} from "@budibase/frontend-core/src/constants"
import {
type FieldSchema,
type FilterConfig,
type TableSchema,
type SearchFilter,
ArrayOperator,
BasicOperator,
FieldType,
} from "@budibase/types"
import { createEventDispatcher } from "svelte"
import BbReferenceField from "../forms/BBReferenceField.svelte"
import { type Writable } from "svelte/store"
import { getContext } from "svelte"
import { isArrayOperator } from "@/utils/filtering"
import dayjs from "dayjs"
import utc from "dayjs/plugin/utc"
dayjs.extend(utc)
export const show = () => popover?.show()
export const hide = () => popover?.hide()
export let align: PopoverAlignment = PopoverAlignment.Left
export let showPopover: boolean = true
export let filter: SearchFilter | undefined = undefined
export let schema: TableSchema | null = null
export let config: FilterConfig | undefined = undefined
export let operators:
| {
value: string
label: string
}[]
| undefined = undefined
const dispatch = createEventDispatcher()
const rowCache: Writable<Record<string, any>> = getContext("rows")
let popover: PopoverAPI | undefined
let anchor: HTMLElement | undefined
let open: boolean = false
// Date/time
let enableTime: boolean
let timeOnly: boolean
let ignoreTimezones: boolean
// Change on update
$: editableFilter = getDefaultFilter(filter, schema, config)
$: fieldSchema = config ? schema?.[config?.field] : undefined
$: options = getOptions(fieldSchema)
$: if (fieldSchema?.type === FieldType.DATETIME) {
enableTime = !fieldSchema?.dateOnly
timeOnly = !!fieldSchema?.timeOnly
ignoreTimezones = !!fieldSchema?.ignoreTimezones
}
const parseDateRange = (
range: { high: string; low: string } | undefined
): string[] | undefined => {
if (!range) {
return
}
const values = [range.low, range.high]
if (values.filter(value => value).length === 2) {
return values
}
}
const sanitizeOperator = (
filter: SearchFilter | undefined
): SearchFilter | undefined => {
if (!filter) return
const clone = Helpers.cloneDeep(filter)
const isOperatorArray = isArrayOperator(filter.operator)
// Ensure the correct filter value types when switching between operators.
// Accommodates the user picker which always returns an array.
// Also ensures that strings using 'oneOf' operator are left as strings
if (
isOperatorArray &&
typeof filter.value === "string" &&
![FieldType.STRING, FieldType.NUMBER].includes(filter.type!)
) {
clone.value = [filter.value]
} else if (isOperatorArray && !filter?.value?.length) {
delete clone.value
} else if (!isOperatorArray && Array.isArray(filter.value)) {
clone.value = filter.value[0]
}
// Update the noValue flag if the operator does not take a value
const noValueOptions = [
OperatorOptions.Empty.value,
OperatorOptions.NotEmpty.value,
]
clone.noValue = noValueOptions.includes(clone.operator)
// Clear out the value when the operator is unset or the value
if (!clone?.operator) {
delete clone.value
}
return clone
}
const getOptions = (schema: FieldSchema | undefined) => {
if (!schema) return []
const constraints = fieldSchema?.constraints
const opts = constraints?.inclusion ?? []
return opts
}
const getDefaultFilter = (
filter: SearchFilter | undefined,
schema: TableSchema | null,
config: FilterConfig | undefined
) => {
if (filter) {
return Helpers.cloneDeep(filter)
} else if (!schema || !config) {
return
}
const schemaField = schema[config.field]
const defaultValue =
schemaField?.type === FieldType.BOOLEAN ? "true" : undefined
const defaultFilter: SearchFilter = {
valueType: FilterValueType.VALUE,
field: config.field,
type: schemaField?.type,
operator: BasicOperator.EMPTY,
value: defaultValue,
}
return {
...defaultFilter,
operator: operators?.[0]?.value,
} as SearchFilter
}
const changeUser = (update: { value: string[] }) => {
if (!update || !editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: update.value,
})
}
const cacheUserRows = (e: CustomEvent) => {
const retrieved = e.detail.reduce((acc: any, ele: any) => {
acc[ele._id] = ele
return acc
}, {})
rowCache.update(state => ({
...state,
...retrieved,
}))
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="anchor" bind:this={anchor} on:click={show}>
<slot name="anchor" />
</div>
<Popover
bind:this={popover}
bind:open
minWidth={300}
maxWidth={300}
{anchor}
{align}
{showPopover}
on:open
on:close
customZIndex={5}
>
<div class="filter-popover">
<div class="filter-popover-body">
{#if editableFilter && fieldSchema}
<div class="operator">
<Select
quiet
value={editableFilter.operator}
disabled={!editableFilter.field}
options={operators}
autoWidth
on:change={e => {
if (!editableFilter) return
const sanitized = sanitizeOperator({
...editableFilter,
operator: e.detail,
})
editableFilter = { ...(sanitized || editableFilter) }
}}
/>
</div>
{#if editableFilter?.type && [FieldType.STRING, FieldType.LONGFORM, FieldType.NUMBER, FieldType.BIGINT, FieldType.FORMULA, FieldType.AI].includes(editableFilter.type)}
<Input
disabled={editableFilter.noValue}
value={editableFilter.value}
on:change={e => {
if (!editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: e.detail,
})
}}
/>
{:else if (editableFilter?.type && editableFilter?.type === FieldType.ARRAY) || (editableFilter.type === FieldType.OPTIONS && editableFilter.operator === ArrayOperator.ONE_OF)}
{@const isMulti = isArrayOperator(editableFilter.operator)}
{@const type = isMulti ? CoreCheckboxGroup : CoreRadioGroup}
{#key type}
<svelte:component
this={type}
value={editableFilter.value || []}
disabled={editableFilter.noValue}
{options}
direction={"vertical"}
on:change={e => {
if (!editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: e.detail,
})
}}
/>
{/key}
{:else if editableFilter.type === FieldType.OPTIONS}
<CoreRadioGroup
value={editableFilter.value}
disabled={editableFilter.noValue}
{options}
direction={"vertical"}
on:change={e => {
if (!editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: e.detail,
})
}}
/>
{:else if editableFilter.type === FieldType.DATETIME && editableFilter.operator === "range"}
<DateRangePicker
{enableTime}
{timeOnly}
{ignoreTimezones}
value={parseDateRange(editableFilter.value)}
on:change={e => {
const [from, to] = e.detail
const parsedFrom = enableTime
? from.utc().format()
: Helpers.stringifyDate(from, {
enableTime,
timeOnly,
ignoreTimezones,
})
const parsedTo = enableTime
? to.utc().format()
: Helpers.stringifyDate(to, {
enableTime,
timeOnly,
ignoreTimezones,
})
if (!editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: {
low: parsedFrom,
high: parsedTo,
},
})
}}
/>
{:else if editableFilter.type === FieldType.DATETIME}
<DatePicker
{enableTime}
{timeOnly}
{ignoreTimezones}
disabled={editableFilter.noValue}
value={editableFilter.value}
on:change={e => {
if (!editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: e.detail,
})
}}
/>
{:else if editableFilter.type === FieldType.BOOLEAN}
<Select
value={editableFilter.value}
disabled={editableFilter.noValue}
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
on:change={e => {
if (!editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: e.detail,
})
}}
/>
{:else if editableFilter.type && [FieldType.BB_REFERENCE, FieldType.BB_REFERENCE_SINGLE].includes(editableFilter.type)}
<!-- Multi relates to the operator, not the type -->
{@const multi = isArrayOperator(editableFilter.operator)}
{#key multi}
<BbReferenceField
disabled={editableFilter.noValue}
defaultValue={editableFilter.value}
{multi}
onChange={changeUser}
defaultRows={Object.values($rowCache)}
on:rows={cacheUserRows}
/>
{/key}
{:else}
<Input disabled />
{/if}
<!-- Needs to be disabled if there is nothing-->
<Button
cta
on:click={() => {
const sanitized = sanitizeOperator(editableFilter)
const { noValue, value, operator } = sanitized || {}
// Check for empty filter. if empty on invalid set it to undefined.
const update =
(!noValue && !value) || !operator ? undefined : sanitized
dispatch("change", update)
hide()
}}
>
Apply
</Button>
{/if}
</div>
</div>
</Popover>
<style>
.operator {
display: flex;
}
.operator :global(.spectrum-Picker) {
height: auto;
}
.filter-popover {
background-color: var(--spectrum-alias-background-color-primary);
}
.filter-popover-body {
padding: var(--spacing-m);
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-m);
}
</style>

View File

@ -1,10 +1,12 @@
<script lang="ts">
import { sdk } from "@budibase/shared-core"
import { FieldType } from "@budibase/types"
import { FieldType, type Row } from "@budibase/types"
import RelationshipField from "./RelationshipField.svelte"
export let defaultValue: string
export let type = FieldType.BB_REFERENCE
export let multi: boolean | undefined = undefined
export let defaultRows: Row[] | undefined = []
function updateUserIDs(value: string | string[]) {
if (Array.isArray(value)) {
@ -33,4 +35,7 @@
datasourceType={"user"}
primaryDisplay={"email"}
defaultValue={updatedDefaultValue}
{defaultRows}
{multi}
on:rows
/>

View File

@ -14,13 +14,15 @@
type LegacyFilter,
type SearchFilterGroup,
type UISearchFilter,
type RelationshipFieldMetadata,
type Row,
} from "@budibase/types"
import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext } from "svelte"
import Field from "./Field.svelte"
import type { RelationshipFieldMetadata, Row } from "@budibase/types"
import type { FieldApi, FieldState, FieldValidation } from "@/types"
import { utils } from "@budibase/shared-core"
import { createEventDispatcher } from "svelte"
export let field: string | undefined = undefined
export let label: string | undefined = undefined
@ -30,7 +32,7 @@
export let validation: FieldValidation | undefined = undefined
export let autocomplete: boolean = true
export let defaultValue: ValueType | undefined = undefined
export let onChange: (_props: { value: ValueType }) => void
export let onChange: (_props: { value: ValueType; label?: string }) => void
export let filter: UISearchFilter | LegacyFilter[] | undefined = undefined
export let datasourceType: "table" | "user" = "table"
export let primaryDisplay: string | undefined = undefined
@ -41,8 +43,14 @@
| FieldType.BB_REFERENCE
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
export let multi: boolean | undefined = undefined
export let tableId: string | undefined = undefined
export let defaultRows: Row[] | undefined = []
const { API } = getContext("sdk")
const dispatch = createEventDispatcher()
// Field state
let fieldState: FieldState<string | string[]> | undefined
let fieldApi: FieldApi
@ -59,11 +67,11 @@
// Reset the available options when our base filter changes
$: filter, (optionsMap = {})
// Determine if we can select multiple rows or not
$: multiselect =
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
fieldSchema?.relationshipType !== "one-to-many"
multi ??
([FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
fieldSchema?.relationshipType !== "one-to-many")
// Get the proper string representation of the value
$: realValue = fieldState?.value as ValueType
@ -71,7 +79,7 @@
$: selectedIDs = getSelectedIDs(selectedValue)
// If writable, we use a fetch to load options
$: linkedTableId = fieldSchema?.tableId
$: linkedTableId = tableId ?? fieldSchema?.tableId
$: writable = !disabled && !readonly
$: migratedFilter = migrateFilter(filter)
$: fetch = createFetch(
@ -87,7 +95,13 @@
// Build our options map
$: rows = $fetch?.rows || []
$: processOptions(realValue, rows, primaryDisplayField)
$: rows && dispatch("rows", rows)
$: processOptions(
realValue,
[...rows, ...(defaultRows || [])],
primaryDisplayField
)
// If we ever have a value selected for which we don't have an option, we must
// fetch those rows to ensure we can render them as options

View File

@ -35,6 +35,7 @@ export { default as sidepanel } from "./SidePanel.svelte"
export { default as modal } from "./Modal.svelte"
export { default as gridblock } from "./GridBlock.svelte"
export { default as textv2 } from "./Text.svelte"
export { default as filter } from "./filter/Filter.svelte"
export { default as accordion } from "./Accordion.svelte"
export { default as singlerowprovider } from "./SingleRowProvider.svelte"
export * from "./charts"

View File

@ -6,6 +6,8 @@ export const ActionTypes = {
RefreshDatasource: "RefreshDatasource",
AddDataProviderQueryExtension: "AddDataProviderQueryExtension",
RemoveDataProviderQueryExtension: "RemoveDataProviderQueryExtension",
AddDataProviderFilterExtension: "AddDataProviderFilterExtension",
RemoveDataProviderFilterExtension: "RemoveDataProviderFilterExtension",
SetDataProviderSorting: "SetDataProviderSorting",
ClearForm: "ClearForm",
ChangeFormStep: "ChangeFormStep",

View File

@ -96,7 +96,10 @@ export interface SDK {
ActionTypes: typeof ActionTypes
fetchDatasourceSchema: any
fetchDatasourceDefinition: (datasource: DataFetchDatasource) => Promise<Table>
getRelationshipSchemaAdditions: (schema: Record<string, any>) => Promise<any>
enrichButtonActions: any
generateGoldenSample: any
createContextStore: any
builderStore: typeof builderStore
authStore: typeof authStore
notificationStore: typeof notificationStore

View File

@ -29,6 +29,7 @@ import { ActionTypes } from "./constants"
import {
fetchDatasourceSchema,
fetchDatasourceDefinition,
getRelationshipSchemaAdditions,
} from "./utils/schema"
import { getAPIKey } from "./utils/api.js"
import { enrichButtonActions } from "./utils/buttonActions.js"
@ -71,6 +72,7 @@ export default {
getAction,
fetchDatasourceSchema,
fetchDatasourceDefinition,
getRelationshipSchemaAdditions,
fetchData,
QueryUtils,
ContextScopes: Constants.ContextScopes,

View File

@ -8,6 +8,7 @@ export { dataSourceStore } from "./dataSource"
export { confirmationStore } from "./confirmation"
export { peekStore } from "./peek"
export { stateStore } from "./state"
export { uiStateStore } from "./uiState"
export { themeStore } from "./theme"
export { devToolsStore } from "./devTools"
export { componentStore } from "./components"

View File

@ -0,0 +1,26 @@
import { Writable } from "svelte/store"
import { createLocalStorageStore } from "@budibase/frontend-core"
/**
* Creates a generic app store for persisting component instance settings
* Keyed by component _id
*/
export class UIStateStore {
appId: string
localStorageKey: string
persistentStore: Writable<Record<string, any>>
subscribe: Writable<Record<string, any>>["subscribe"]
set: Writable<Record<string, any>>["set"]
update: Writable<Record<string, any>>["update"]
constructor() {
this.appId = window["##BUDIBASE_APP_ID##"] || "app"
this.localStorageKey = `${this.appId}.ui`
this.persistentStore = createLocalStorageStore(this.localStorageKey, {})
this.subscribe = this.persistentStore.subscribe
this.set = this.persistentStore.set
this.update = this.persistentStore.update
}
}
export const uiStateStore = new UIStateStore()

View File

@ -1,3 +1,4 @@
import { ArrayOperator, BasicOperator, RangeOperator } from "@budibase/types"
import { Readable } from "svelte/store"
export * from "./components"
@ -5,3 +6,10 @@ export * from "./fields"
export * from "./forms"
export type Context = Readable<Record<string, any>>
export type Operator =
| BasicOperator
| RangeOperator
| ArrayOperator
| "rangeLow"
| "rangeHigh"

View File

@ -0,0 +1,10 @@
import { type Operator } from "@/types"
import { ArrayOperator } from "@budibase/types"
/**
* Check if the supplied filter operator is for an array
* @param op
*/
export function isArrayOperator(op: Operator): op is ArrayOperator {
return op === ArrayOperator.CONTAINS_ANY || op === ArrayOperator.ONE_OF
}

View File

@ -121,6 +121,7 @@ export const getRelationshipSchemaAdditions = async (
relationshipAdditions[`${fieldKey}.${linkKey}`] = {
type: linkSchema[linkKey].type,
externalType: linkSchema[linkKey].externalType,
constraints: linkSchema[linkKey].constraints,
}
})
}

View File

@ -323,7 +323,13 @@ function buildCondition(filter?: SearchFilter): SearchFilters | undefined {
if (!value) {
return
}
value = new Date(value).toISOString()
if (typeof value === "string") {
value = new Date(value).toISOString()
} else if (isRangeSearchOperator(operator)) {
query[operator] ??= {}
query[operator][field] = value
return query
}
}
break
case FieldType.NUMBER:
@ -349,7 +355,6 @@ function buildCondition(filter?: SearchFilter): SearchFilters | undefined {
}
break
}
if (isRangeSearchOperator(operator)) {
const key = externalType as keyof typeof SqlNumberTypeRangeMap
const limits = SqlNumberTypeRangeMap[key] || {
@ -637,7 +642,6 @@ export function runQuery<T extends Record<string, any>>(
if (docValue == null || docValue === "") {
return false
}
if (isPlainObject(testValue.low) && isEmpty(testValue.low)) {
testValue.low = undefined
}

View File

@ -1,3 +1,5 @@
import { FieldType } from "@budibase/types"
export * from "./codeEditor"
export * from "./errors"
@ -83,3 +85,11 @@ export const enum ComponentContextScopes {
Local = "local",
Global = "global",
}
export type FilterConfig = {
active: boolean
field: string
label?: string
_id?: string
columnType?: FieldType
}