Merge pull request #14546 from Budibase/feature/builder-filtering-update

Filter groups
This commit is contained in:
deanhannigan 2024-09-19 10:33:52 +01:00 committed by GitHub
commit ba4001e241
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1500 additions and 606 deletions

View File

@ -17,11 +17,8 @@ import {
ContextUser, ContextUser,
CouchFindOptions, CouchFindOptions,
DatabaseQueryOpts, DatabaseQueryOpts,
SearchFilters,
SearchUsersRequest, SearchUsersRequest,
User, User,
BasicOperator,
ArrayOperator,
} from "@budibase/types" } from "@budibase/types"
import * as context from "../context" import * as context from "../context"
import { getGlobalDB } from "../context" import { getGlobalDB } from "../context"
@ -45,32 +42,6 @@ function removeUserPassword(users: User | User[]) {
return users return users
} }
export function isSupportedUserSearch(query: SearchFilters) {
const allowed = [
{ op: BasicOperator.STRING, key: "email" },
{ op: BasicOperator.EQUAL, key: "_id" },
{ op: ArrayOperator.ONE_OF, key: "_id" },
]
for (let [key, operation] of Object.entries(query)) {
if (typeof operation !== "object") {
return false
}
const fields = Object.keys(operation || {})
// this filter doesn't contain options - ignore
if (fields.length === 0) {
continue
}
const allowedOperation = allowed.find(
allow =>
allow.op === key && fields.length === 1 && fields[0] === allow.key
)
if (!allowedOperation) {
return false
}
}
return true
}
export async function bulkGetGlobalUsersById( export async function bulkGetGlobalUsersById(
userIds: string[], userIds: string[],
opts?: GetOpts opts?: GetOpts

View File

@ -62,6 +62,7 @@
} from "@budibase/types" } from "@budibase/types"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
import PropField from "./PropField.svelte" import PropField from "./PropField.svelte"
import { utils } from "@budibase/shared-core"
export let block export let block
export let testData export let testData
@ -95,8 +96,14 @@
$: memoEnvVariables.set($environment.variables) $: memoEnvVariables.set($environment.variables)
$: memoBlock.set(block) $: memoBlock.set(block)
$: filters = lookForFilters(schemaProperties) || [] $: filters = lookForFilters(schemaProperties)
$: tempFilters = filters $: filterCount =
filters?.groups?.reduce((acc, group) => {
acc = acc += group?.filters?.length || 0
return acc
}, 0) || 0
$: tempFilters = cloneDeep(filters)
$: stepId = $memoBlock.stepId $: stepId = $memoBlock.stepId
$: automationBindings = getAvailableBindings( $: automationBindings = getAvailableBindings(
@ -780,14 +787,13 @@
break break
} }
} }
return filters || [] return utils.processSearchFilters(filters)
} }
function saveFilters(key) { function saveFilters(key) {
const filters = QueryUtils.buildQuery(tempFilters) const query = QueryUtils.buildQuery(tempFilters)
onChange({ onChange({
[key]: filters, [key]: query,
[`${key}-def`]: tempFilters, // need to store the builder definition in the automation [`${key}-def`]: tempFilters, // need to store the builder definition in the automation
}) })
@ -1016,18 +1022,24 @@
</div> </div>
</div> </div>
{:else if value.customType === AutomationCustomIOType.FILTERS || value.customType === AutomationCustomIOType.TRIGGER_FILTER} {:else if value.customType === AutomationCustomIOType.FILTERS || value.customType === AutomationCustomIOType.TRIGGER_FILTER}
<ActionButton fullWidth on:click={drawer.show} <ActionButton fullWidth on:click={drawer.show}>
>{filters.length > 0 {filterCount > 0 ? "Update Filter" : "No Filter set"}
? "Update Filter" </ActionButton>
: "No Filter set"}</ActionButton <Drawer
bind:this={drawer}
title="Filtering"
forceModal
on:drawerShow={() => {
tempFilters = filters
}}
> >
<Drawer bind:this={drawer} title="Filtering">
<Button cta slot="buttons" on:click={() => saveFilters(key)}> <Button cta slot="buttons" on:click={() => saveFilters(key)}>
Save Save
</Button> </Button>
<DrawerContent slot="body"> <DrawerContent slot="body">
<FilterBuilder <FilterBuilder
{filters} filters={tempFilters}
{bindings} {bindings}
{schemaFields} {schemaFields}
datasource={{ type: "table", tableId }} datasource={{ type: "table", tableId }}

View File

@ -233,6 +233,14 @@
) )
dispatch("change", result) dispatch("change", result)
} }
/**
* Converts arrays into strings. The CodeEditor expects a string or encoded JS
* @param{object} fieldValue
*/
const drawerValue = fieldValue => {
return Array.isArray(fieldValue) ? fieldValue.join(",") : fieldValue
}
</script> </script>
{#each schemaFields || [] as [field, schema]} {#each schemaFields || [] as [field, schema]}
@ -257,7 +265,7 @@
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={schema.type} type={schema.type}
{schema} {schema}
value={editableRow[field]} value={drawerValue(editableRow[field])}
on:change={e => on:change={e =>
onChange({ onChange({
row: { row: {

View File

@ -5,6 +5,7 @@
import { getUserBindings } from "dataBinding" import { getUserBindings } from "dataBinding"
import { makePropSafe } from "@budibase/string-templates" import { makePropSafe } from "@budibase/string-templates"
import { search } from "@budibase/frontend-core" import { search } from "@budibase/frontend-core"
import { utils } from "@budibase/shared-core"
import { tables } from "stores/builder" import { tables } from "stores/builder"
export let schema export let schema
@ -16,15 +17,19 @@
let drawer let drawer
$: tempValue = filters || [] $: localFilters = utils.processSearchFilters(filters)
$: schemaFields = search.getFields( $: schemaFields = search.getFields(
$tables.list, $tables.list,
Object.values(schema || {}), Object.values(schema || {}),
{ allowLinks: true } { allowLinks: true }
) )
$: text = getText(filters) $: filterCount =
$: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0 localFilters?.groups?.reduce((acc, group) => {
return (acc += group.filters.filter(filter => filter.field).length)
}, 0) || 0
$: bindings = [ $: bindings = [
{ {
type: "context", type: "context",
@ -38,10 +43,6 @@
}, },
...getUserBindings(), ...getUserBindings(),
] ]
const getText = filters => {
const count = filters?.filter(filter => filter.field)?.length
return count ? `Filter (${count})` : "Filter"
}
</script> </script>
<ActionButton <ActionButton
@ -49,24 +50,26 @@
quiet quiet
{disabled} {disabled}
on:click={drawer.show} on:click={drawer.show}
{selected} selected={filterCount > 0}
accentColor="#004EA6" accentColor="#004EA6"
> >
{text} {filterCount ? `Filter (${filterCount})` : "Filter"}
</ActionButton> </ActionButton>
<Drawer <Drawer
bind:this={drawer} bind:this={drawer}
title="Filtering" title="Filtering"
on:drawerHide on:drawerHide
on:drawerShow on:drawerShow={() => {
localFilters = utils.processSearchFilters(filters)
}}
forceModal forceModal
> >
<Button <Button
cta cta
slot="buttons" slot="buttons"
on:click={() => { on:click={() => {
dispatch("change", tempValue) dispatch("change", localFilters)
drawer.hide() drawer.hide()
}} }}
> >
@ -74,10 +77,10 @@
</Button> </Button>
<DrawerContent slot="body"> <DrawerContent slot="body">
<FilterBuilder <FilterBuilder
{filters} filters={localFilters}
{schemaFields} {schemaFields}
datasource={{ type: "table", tableId }} datasource={{ type: "table", tableId }}
on:change={e => (tempValue = e.detail)} on:change={e => (localFilters = e.detail)}
{bindings} {bindings}
/> />
</DrawerContent> </DrawerContent>

View File

@ -1,87 +1,32 @@
<script> <script>
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { CoreFilterBuilder } from "@budibase/frontend-core"
import { dataFilters } from "@budibase/shared-core"
import { FilterBuilder } from "@budibase/frontend-core"
import { tables } from "stores/builder" import { tables } from "stores/builder"
import {
import { createEventDispatcher, onMount } from "svelte" runtimeToReadableBinding,
readableToRuntimeBinding,
} from "dataBinding"
export let schemaFields export let schemaFields
export let filters = [] export let filters
export let bindings = [] export let bindings = []
export let panel = ClientBindingPanel export let panel = ClientBindingPanel
export let allowBindings = true export let allowBindings = true
export let datasource export let datasource
export let showFilterEmptyDropdown export let showFilterEmptyDropdown
const dispatch = createEventDispatcher()
let rawFilters
$: parseFilters(rawFilters)
$: dispatch("change", enrichFilters(rawFilters))
// Remove field key prefixes and determine which behaviours to use
const parseFilters = filters => {
rawFilters = (filters || []).map(filter => {
const { field } = filter
let newFilter = { ...filter }
delete newFilter.allOr
newFilter.field = dataFilters.removeKeyNumbering(field)
return newFilter
})
}
onMount(() => {
parseFilters(filters)
rawFilters.forEach(filter => {
filter.type =
schemaFields.find(field => field.name === filter.field)?.type ||
filter.type
})
})
// Add field key prefixes and a special metadata filter object to indicate
// how to handle filter behaviour
const enrichFilters = rawFilters => {
let count = 1
return rawFilters
.filter(filter => filter.field)
.map(filter => ({
...filter,
field: `${count++}:${filter.field}`,
}))
.concat(...rawFilters.filter(filter => !filter.field))
}
</script> </script>
<FilterBuilder <CoreFilterBuilder
bind:filters={rawFilters} toReadable={runtimeToReadableBinding}
toRuntime={readableToRuntimeBinding}
behaviourFilters={true} behaviourFilters={true}
tables={$tables.list} tables={$tables.list}
{filters}
{panel}
{schemaFields} {schemaFields}
{datasource} {datasource}
{allowBindings} {allowBindings}
{showFilterEmptyDropdown} {showFilterEmptyDropdown}
>
<div slot="filtering-hero-content" />
<DrawerBindableInput
let:filter
slot="binding"
disabled={filter.noValue}
title={filter.field}
value={filter.value}
placeholder="Value"
{panel}
{bindings} {bindings}
on:change={event => { on:change
const indexToUpdate = rawFilters.findIndex(f => f.id === filter.id)
rawFilters[indexToUpdate] = {
...rawFilters[indexToUpdate],
value: event.detail,
}
}}
/> />
</FilterBuilder>

View File

@ -5,6 +5,7 @@
Button, Button,
Drawer, Drawer,
DrawerContent, DrawerContent,
Helpers,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding" import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding"
@ -21,7 +22,7 @@
let drawer let drawer
$: tempValue = value $: localFilters = Helpers.cloneDeep(value)
$: datasource = getDatasourceForProvider($selectedScreen, componentInstance) $: datasource = getDatasourceForProvider($selectedScreen, componentInstance)
$: dsSchema = getSchemaForDatasource($selectedScreen, datasource)?.schema $: dsSchema = getSchemaForDatasource($selectedScreen, datasource)?.schema
$: schemaFields = search.getFields( $: schemaFields = search.getFields(
@ -29,19 +30,24 @@
Object.values(schema || dsSchema || {}), Object.values(schema || dsSchema || {}),
{ allowLinks: true } { allowLinks: true }
) )
$: text = getText(value?.filter(filter => filter.field))
$: text = getText(value?.groups)
async function saveFilter() { async function saveFilter() {
dispatch("change", tempValue) dispatch("change", localFilters)
notifications.success("Filters saved") notifications.success("Filters saved")
drawer.hide() drawer.hide()
} }
const getText = filters => { const getText = (filterGroups = []) => {
if (!filters?.length) { const allFilters = filterGroups.reduce((acc, group) => {
return (acc += group.filters.filter(filter => filter.field).length)
}, 0)
if (allFilters === 0) {
return "No filters set" return "No filters set"
} else { } else {
return `${filters.length} filter${filters.length === 1 ? "" : "s"} set` return `${allFilters} filter${allFilters === 1 ? "" : "s"} set`
} }
} }
</script> </script>
@ -49,15 +55,25 @@
<div class="filter-editor"> <div class="filter-editor">
<ActionButton on:click={drawer.show}>{text}</ActionButton> <ActionButton on:click={drawer.show}>{text}</ActionButton>
</div> </div>
<Drawer bind:this={drawer} title="Filtering" on:drawerHide on:drawerShow> <Drawer
bind:this={drawer}
title="Filtering"
on:drawerHide
on:drawerShow={() => {
// Reset to the currently available value.
localFilters = Helpers.cloneDeep(value)
}}
>
<Button cta slot="buttons" on:click={saveFilter}>Save</Button> <Button cta slot="buttons" on:click={saveFilter}>Save</Button>
<DrawerContent slot="body"> <DrawerContent slot="body">
<FilterBuilder <FilterBuilder
filters={value} filters={localFilters}
{bindings} {bindings}
{schemaFields} {schemaFields}
{datasource} {datasource}
on:change={e => (tempValue = e.detail)} on:change={e => {
localFilters = e.detail
}}
/> />
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>

View File

@ -6,6 +6,7 @@
import FieldSetting from "./FieldSetting.svelte" import FieldSetting from "./FieldSetting.svelte"
import PrimaryColumnFieldSetting from "./PrimaryColumnFieldSetting.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 export let value
export let componentInstance export let componentInstance
@ -58,6 +59,8 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if columns?.sortable?.length}
<DraggableList <DraggableList
on:change={e => columns.updateSortable(e.detail)} on:change={e => columns.updateSortable(e.detail)}
on:itemChange={e => columns.update(e.detail)} on:itemChange={e => columns.update(e.detail)}
@ -68,6 +71,13 @@
bindings, bindings,
}} }}
/> />
{:else}
<InfoDisplay
body={datasource?.type !== "custom"
? "No available columns"
: "No available columns for JSON/CSV data sources"}
/>
{/if}
<style> <style>
.right-content { .right-content {

View File

@ -243,11 +243,14 @@
{/if} {/if}
{:else if (type === FieldType.BB_REFERENCE || type === FieldType.BB_REFERENCE_SINGLE) && condition.valueType === type} {:else if (type === FieldType.BB_REFERENCE || type === FieldType.BB_REFERENCE_SINGLE) && condition.valueType === type}
<FilterUsers <FilterUsers
bind:value={condition.referenceValue} value={condition.referenceValue}
multiselect={[ multiselect={[
Constants.OperatorOptions.In.value, Constants.OperatorOptions.In.value,
Constants.OperatorOptions.ContainsAny.value, Constants.OperatorOptions.ContainsAny.value,
].includes(condition.operator)} ].includes(condition.operator)}
on:change={e => {
condition.referenceValue = e.detail
}}
disabled={condition.noValue} disabled={condition.noValue}
type={condition.valueType} type={condition.valueType}
/> />

View File

@ -31,6 +31,7 @@ import {
import BudiStore from "../BudiStore" import BudiStore from "../BudiStore"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { FieldType } from "@budibase/types" import { FieldType } from "@budibase/types"
import { utils } from "@budibase/shared-core"
export const INITIAL_COMPONENTS_STATE = { export const INITIAL_COMPONENTS_STATE = {
components: {}, components: {},
@ -196,6 +197,25 @@ export class ComponentStore extends BudiStore {
} }
} }
if (!enrichedComponent?._component) {
return migrated
}
const def = this.getDefinition(enrichedComponent?._component)
const filterableTypes = def?.settings?.filter(setting =>
setting?.type?.startsWith("filter")
)
for (let setting of filterableTypes || []) {
const isLegacy = Array.isArray(enrichedComponent[setting.key])
if (isLegacy) {
const processedSetting = utils.processSearchFilters(
enrichedComponent[setting.key]
)
enrichedComponent[setting.key] = processedSetting
migrated = true
}
}
return migrated return migrated
} }
@ -405,7 +425,13 @@ export class ComponentStore extends BudiStore {
screen: get(selectedScreen), screen: get(selectedScreen),
useDefaultValues: true, useDefaultValues: true,
}) })
try {
this.migrateSettings(instance) this.migrateSettings(instance)
} catch (e) {
console.error(e)
throw e
}
// Custom post processing for creation only // Custom post processing for creation only
let extras = {} let extras = {}

View File

@ -0,0 +1,14 @@
import { derived } from "svelte/store"
import { auth } from "stores/portal"
export const INITIAL_FEATUREFLAG_STATE = {
SQS: false,
DEFAULT_VALUES: false,
}
export const featureFlags = derived([auth], ([$auth]) => {
return {
...INITIAL_FEATUREFLAG_STATE,
...($auth?.user?.flags || {}),
}
})

View File

@ -19,5 +19,6 @@ export { features } from "./features"
export { themeStore } from "./theme" export { themeStore } from "./theme"
export { temporalStore } from "./temporal" export { temporalStore } from "./temporal"
export { navigation } from "./navigation" export { navigation } from "./navigation"
export { featureFlags } from "./featureFlags"
export const sideBarCollapsed = writable(false) export const sideBarCollapsed = writable(false)

View File

@ -5129,7 +5129,8 @@
{ {
"type": "filter", "type": "filter",
"label": "Filtering", "label": "Filtering",
"key": "filter" "key": "filter",
"resetOn": "dataSource"
}, },
{ {
"type": "field/sortable", "type": "field/sortable",

View File

@ -2,6 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import { Pagination, ProgressCircle } from "@budibase/bbui" import { Pagination, ProgressCircle } from "@budibase/bbui"
import { fetchData, QueryUtils } from "@budibase/frontend-core" import { fetchData, QueryUtils } from "@budibase/frontend-core"
import { LogicalOperator, EmptyFilterOption } from "@budibase/types"
export let dataSource export let dataSource
export let filter export let filter
@ -17,9 +18,10 @@
let interval let interval
let queryExtensions = {} let queryExtensions = {}
$: defaultQuery = QueryUtils.buildQuery(filter)
// We need to manage our lucene query manually as we want to allow components // We need to manage our lucene query manually as we want to allow components
// to extend it // to extend it
$: defaultQuery = QueryUtils.buildQuery(filter)
$: query = extendQuery(defaultQuery, queryExtensions) $: query = extendQuery(defaultQuery, queryExtensions)
$: fetch = createFetch(dataSource) $: fetch = createFetch(dataSource)
$: fetch.update({ $: fetch.update({
@ -124,17 +126,20 @@
} }
const extendQuery = (defaultQuery, extensions) => { const extendQuery = (defaultQuery, extensions) => {
const extensionValues = Object.values(extensions || {}) const extended = {
let extendedQuery = { ...defaultQuery } [LogicalOperator.AND]: {
extensionValues.forEach(extension => { conditions: [
Object.entries(extension || {}).forEach(([operator, fields]) => { ...(defaultQuery ? [defaultQuery] : []),
extendedQuery[operator] = { ...Object.values(extensions || {}),
...extendedQuery[operator], ],
...fields, },
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
} }
})
}) // If there are no conditions applied at all, clear the request.
return extendedQuery return extended[LogicalOperator.AND]?.conditions?.length > 0
? extended
: null
} }
const setUpAutoRefresh = autoRefresh => { const setUpAutoRefresh = autoRefresh => {

View File

@ -1,9 +1,12 @@
<script> <script>
import { get } from "svelte/store" import { get } from "svelte/store"
import { getContext, onDestroy } from "svelte" import { getContext, onDestroy } from "svelte"
import { ModalContent, Modal } from "@budibase/bbui" import { ModalContent, Modal, Helpers } from "@budibase/bbui"
import FilterModal from "./FilterModal.svelte" import {
import { QueryUtils, Constants } from "@budibase/frontend-core" QueryUtils,
Constants,
CoreFilterBuilder,
} from "@budibase/frontend-core"
import Button from "../Button.svelte" import Button from "../Button.svelte"
export let dataProvider export let dataProvider
@ -15,14 +18,15 @@
getContext("sdk") getContext("sdk")
let modal let modal
let tmpFilters = [] let editableFilters
let filters = [] let filters
let schemaLoaded = false, let schemaLoaded = false,
schema schema
$: dataProviderId = dataProvider?.id $: dataProviderId = dataProvider?.id
$: datasource = dataProvider?.datasource $: datasource = dataProvider?.datasource
$: isDSPlus = ["table", "link", "viewV2"].includes(datasource?.type) $: isDSPlus = ["table", "link", "viewV2"].includes(datasource?.type)
$: addExtension = getAction( $: addExtension = getAction(
dataProviderId, dataProviderId,
ActionTypes.AddDataProviderQueryExtension ActionTypes.AddDataProviderQueryExtension
@ -34,10 +38,15 @@
$: fetchSchema(datasource) $: fetchSchema(datasource)
$: schemaFields = getSchemaFields(schema, allowedFields) $: schemaFields = getSchemaFields(schema, allowedFields)
// Add query extension to data provider $: filterCount = filters?.groups?.reduce((acc, group) => {
acc += group?.filters?.length || 0
return acc
}, 0)
$: { $: {
if (filters?.length) { if (filterCount) {
const queryExtension = QueryUtils.buildQuery(filters) const queryExtension = QueryUtils.buildQuery(filters)
delete queryExtension.onEmptyFilter
addExtension?.($component.id, queryExtension) addExtension?.($component.id, queryExtension)
} else { } else {
removeExtension?.($component.id) removeExtension?.($component.id)
@ -80,12 +89,13 @@
if (get(builderStore).inBuilder) { if (get(builderStore).inBuilder) {
return return
} }
tmpFilters = [...filters] editableFilters = filters ? Helpers.cloneDeep(filters) : null
modal.show() modal.show()
} }
const updateQuery = () => { const updateQuery = () => {
filters = [...tmpFilters] filters = editableFilters
} }
onDestroy(() => { onDestroy(() => {
@ -101,12 +111,20 @@
{size} {size}
type="secondary" type="secondary"
quiet quiet
active={filters?.length > 0} active={filters?.groups?.length > 0}
/> />
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ModalContent title="Edit filters" size="XL" onConfirm={updateQuery}> <ModalContent title="Edit filters" size="XL" onConfirm={updateQuery}>
<FilterModal bind:filters={tmpFilters} {schemaFields} {datasource} /> <CoreFilterBuilder
on:change={e => {
editableFilters = e.detail
}}
filters={editableFilters}
{schemaFields}
{datasource}
filtersLabel={null}
/>
</ModalContent> </ModalContent>
</Modal> </Modal>
{/if} {/if}

View File

@ -1,14 +0,0 @@
<script>
import { FilterBuilder } from "@budibase/frontend-core"
export let schemaFields
export let filters = []
export let datasource
</script>
<FilterBuilder bind:filters {schemaFields} {datasource} filtersLabel={null}>
<div slot="filtering-hero-content">
Results are filtered to only those which match all of the following
constraints.
</div>
</FilterBuilder>

View File

@ -40,7 +40,7 @@ export const buildTableEndpoints = API => ({
sortType, sortType,
paginate, paginate,
}) => { }) => {
if (!tableId || !query) { if (!tableId) {
return { return {
rows: [], rows: [],
} }
@ -48,7 +48,7 @@ export const buildTableEndpoints = API => ({
return await API.post({ return await API.post({
url: `/api/${tableId}/search`, url: `/api/${tableId}/search`,
body: { body: {
query, ...(query ? { query } : {}),
bookmark, bookmark,
limit, limit,
sort, sort,

View File

@ -0,0 +1,520 @@
<script>
import {
Body,
Button,
Icon,
Layout,
Select,
Helpers,
ActionButton,
} from "@budibase/bbui"
import {
FieldType,
FilterGroupLogicalOperator,
EmptyFilterOption,
} from "@budibase/types"
import { QueryUtils, Constants } from "@budibase/frontend-core"
import { getContext, createEventDispatcher } from "svelte"
import FilterField from "./FilterField.svelte"
const dispatch = createEventDispatcher()
const {
OperatorOptions,
DEFAULT_BB_DATASOURCE_ID,
FilterOperator,
OnEmptyFilter,
FilterValueType,
} = Constants
export let schemaFields
export let filters
export let tables = []
export let datasource
export let behaviourFilters = false
export let allowBindings = false
// Review
export let bindings
export let panel
export let toReadable
export let toRuntime
$: editableFilters = filters ? Helpers.cloneDeep(filters) : null
$: {
if (
tables.find(
table =>
table._id === datasource?.tableId &&
table.sourceId === DEFAULT_BB_DATASOURCE_ID
) &&
!schemaFields.some(field => field.name === "_id")
) {
schemaFields = [...schemaFields, { name: "_id", type: "string" }]
}
}
const filterOperatorOptions = Object.values(FilterOperator).map(entry => {
return { value: entry, label: Helpers.capitalise(entry) }
})
const onEmptyLabelling = {
[OnEmptyFilter.RETURN_ALL]: "All rows",
[OnEmptyFilter.RETURN_NONE]: "No rows",
}
const onEmptyOptions = Object.values(OnEmptyFilter).map(entry => {
return { value: entry, label: onEmptyLabelling[entry] }
})
const context = getContext("context")
$: fieldOptions = (schemaFields || []).map(field => ({
label: field.displayName || field.name,
value: field.name,
}))
const onFieldChange = filter => {
const previousType = filter.type
sanitizeTypes(filter)
sanitizeOperator(filter)
sanitizeValue(filter, previousType)
}
const onOperatorChange = filter => {
sanitizeOperator(filter)
sanitizeValue(filter, filter.type)
}
const getSchema = filter => {
return schemaFields.find(field => field.name === filter.field)
}
const getValidOperatorsForType = filter => {
if (!filter?.field && !filter?.name) {
return []
}
return QueryUtils.getValidOperatorsForType(
filter,
filter.field || filter.name,
datasource
)
}
const sanitizeTypes = filter => {
// Update type based on field
const fieldSchema = schemaFields.find(x => x.name === filter.field)
filter.type = fieldSchema?.type
filter.subtype = fieldSchema?.subtype
filter.formulaType = fieldSchema?.formulaType
filter.constraints = fieldSchema?.constraints
// Update external type based on field
filter.externalType = getSchema(filter)?.externalType
}
const sanitizeOperator = filter => {
// Ensure a valid operator is selected
const operators = getValidOperatorsForType(filter).map(x => x.value)
if (!operators.includes(filter.operator)) {
filter.operator = operators[0] ?? OperatorOptions.Equals.value
}
// Update the noValue flag if the operator does not take a value
const noValueOptions = [
OperatorOptions.Empty.value,
OperatorOptions.NotEmpty.value,
]
filter.noValue = noValueOptions.includes(filter.operator)
}
const sanitizeValue = (filter, previousType) => {
// Check if the operator allows a value at all
if (filter.noValue) {
filter.value = null
return
}
// Ensure array values are properly set and cleared
if (Array.isArray(filter.value)) {
if (filter.valueType !== "Value" || filter.type !== FieldType.ARRAY) {
filter.value = null
}
} else if (
filter.type === FieldType.ARRAY &&
filter.valueType === "Value"
) {
filter.value = []
} else if (
previousType !== filter.type &&
(previousType === FieldType.BB_REFERENCE ||
filter.type === FieldType.BB_REFERENCE)
) {
filter.value = filter.type === FieldType.ARRAY ? [] : null
}
}
const getGroupPrefix = groupIdx => {
if (groupIdx == 0) {
return "When"
}
const operatorMapping = {
[FilterOperator.ANY]: "or",
[FilterOperator.ALL]: "and",
}
return operatorMapping[editableFilters.logicalOperator]
}
const onFilterFieldUpdate = (filter, groupIdx, filterIdx) => {
const updated = Helpers.cloneDeep(filter)
handleFilterChange({
groupIdx,
filterIdx,
filter: { ...updated },
})
}
const handleFilterChange = req => {
const {
groupIdx,
filterIdx,
filter,
group,
addFilter,
addGroup,
deleteGroup,
deleteFilter,
logicalOperator,
onEmptyFilter,
} = req
let editable = Helpers.cloneDeep(editableFilters)
let targetGroup = editable?.groups?.[groupIdx]
let targetFilter = targetGroup?.filters?.[filterIdx]
if (targetFilter) {
if (deleteFilter) {
targetGroup.filters.splice(filterIdx, 1)
// Clear the group entirely if no valid filters remain
if (targetGroup.filters.length === 0) {
editable.groups.splice(groupIdx, 1)
}
} else if (filter) {
targetGroup.filters[filterIdx] = filter
}
} else if (targetGroup) {
if (deleteGroup) {
editable.groups.splice(groupIdx, 1)
} else if (addFilter) {
targetGroup.filters.push({
valueType: FilterValueType.VALUE,
})
} else if (group) {
editable.groups[groupIdx] = {
...targetGroup,
...group,
}
}
} else if (addGroup) {
if (!editable?.groups?.length) {
editable = {
logicalOperator: FilterGroupLogicalOperator.ALL,
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
groups: [],
}
}
editable.groups.push({
logicalOperator: Constants.FilterOperator.ANY,
filters: [
{
valueType: FilterValueType.VALUE,
},
],
})
} else if (logicalOperator) {
editable = {
...editable,
logicalOperator,
}
} else if (onEmptyFilter) {
editable = {
...editable,
onEmptyFilter,
}
}
// Set the request to null if the groups are emptied
editable = editable.groups.length ? editable : null
dispatch("change", editable)
}
</script>
<div class="container" class:mobile={$context?.device?.mobile}>
<Layout noPadding>
{#if fieldOptions?.length}
<slot name="filtering-hero-content" />
{#if editableFilters?.groups?.length}
<div class="global-filter-header">
<span>Show data which matches</span>
<span class="operator-picker">
<Select
value={editableFilters?.logicalOperator}
options={filterOperatorOptions}
getOptionLabel={opt => opt.label}
getOptionValue={opt => opt.value}
on:change={e => {
handleFilterChange({
logicalOperator: e.detail,
})
}}
placeholder={false}
/>
</span>
<span>of the following filter groups:</span>
</div>
{/if}
{#if editableFilters?.groups?.length}
<div class="filter-groups">
{#each editableFilters?.groups as group, groupIdx}
<div class="group">
<div class="group-header">
<div class="group-options">
<span>
{getGroupPrefix(groupIdx, editableFilters.logicalOperator)}
</span>
<span class="operator-picker">
<Select
value={group?.logicalOperator}
options={filterOperatorOptions}
getOptionLabel={opt => opt.label}
getOptionValue={opt => opt.value}
on:change={e => {
handleFilterChange({
groupIdx,
group: {
logicalOperator: e.detail,
},
})
}}
placeholder={false}
/>
</span>
<span>of the following filters are matched:</span>
</div>
<div class="group-actions">
<Icon
name="Add"
hoverable
hoverColor="var(--ink)"
on:click={() => {
handleFilterChange({
groupIdx,
addFilter: true,
})
}}
/>
<Icon
name="Delete"
hoverable
hoverColor="var(--ink)"
on:click={() => {
handleFilterChange({
groupIdx,
deleteGroup: true,
})
}}
/>
</div>
</div>
<div class="filters">
{#each group.filters as filter, filterIdx}
<div class="filter">
<Select
value={filter.field}
options={fieldOptions}
on:change={e => {
const updated = { ...filter, field: e.detail }
onFieldChange(updated)
onFilterFieldUpdate(updated, groupIdx, filterIdx)
}}
placeholder="Column"
/>
<Select
value={filter.operator}
disabled={!filter.field}
options={getValidOperatorsForType(filter)}
on:change={e => {
const updated = { ...filter, operator: e.detail }
onOperatorChange(updated)
onFilterFieldUpdate(updated, groupIdx, filterIdx)
}}
placeholder={false}
/>
<FilterField
placeholder="Value"
{allowBindings}
{filter}
{schemaFields}
{bindings}
{panel}
{toReadable}
{toRuntime}
on:change={e => {
onFilterFieldUpdate(
{ ...filter, ...e.detail },
groupIdx,
filterIdx
)
}}
/>
<ActionButton
size="M"
icon="Delete"
on:click={() => {
handleFilterChange({
groupIdx,
filterIdx,
deleteFilter: true,
})
}}
/>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
<div class="filters-footer">
<Layout noPadding>
{#if behaviourFilters && editableFilters?.groups?.length}
<div class="empty-filter">
<span>Return</span>
<span class="empty-filter-picker">
<Select
value={editableFilters?.onEmptyFilter}
options={onEmptyOptions}
getOptionLabel={opt => opt.label}
getOptionValue={opt => opt.value}
on:change={e => {
handleFilterChange({
onEmptyFilter: e.detail,
})
}}
placeholder={false}
/>
</span>
<span>when all filters are empty</span>
</div>
{/if}
<div class="add-group">
<Button
icon="AddCircle"
size="M"
secondary
on:click={() => {
handleFilterChange({
addGroup: true,
})
}}
>
Add filter group
</Button>
<a
href="https://docs.budibase.com/docs/searchfilter-data"
target="_blank"
>
<Icon
name="HelpOutline"
color="var(--spectrum-global-color-gray-600)"
/>
</a>
</div>
</Layout>
</div>
{:else}
<Body size="S">None of the table column can be used for filtering.</Body>
{/if}
</Layout>
</div>
<style>
.group-actions {
display: flex;
gap: var(--spacing-m);
}
.global-filter-header,
.empty-filter,
.group-options {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.operator-picker {
width: 72px;
}
.empty-filter-picker {
width: 92px;
}
.filter-groups {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
}
.group {
display: flex;
flex-direction: column;
gap: var(--spacing-l);
border: 1px solid var(--spectrum-global-color-gray-400);
border-radius: 4px;
padding: var(--spacing-xl);
}
.filters {
display: flex;
flex-direction: column;
gap: var(--spacing-l);
}
.filter {
display: grid;
gap: var(--spacing-l);
grid-template-columns: minmax(150px, 1fr) 170px minmax(200px, 1fr) 40px 40px;
}
.filters-footer {
display: flex;
gap: var(--spacing-xl);
flex-direction: column;
}
.add-group {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
.container {
width: 100%;
}
</style>

View File

@ -1,379 +0,0 @@
<script>
import {
Body,
Button,
Combobox,
DatePicker,
Icon,
Input,
Layout,
Select,
Label,
Multiselect,
} from "@budibase/bbui"
import { ArrayOperator, FieldType } from "@budibase/types"
import { generate } from "shortid"
import { QueryUtils, Constants } from "@budibase/frontend-core"
import { getContext } from "svelte"
import FilterUsers from "./FilterUsers.svelte"
const { OperatorOptions, DEFAULT_BB_DATASOURCE_ID } = Constants
export let schemaFields
export let filters = []
export let tables = []
export let datasource
export let behaviourFilters = false
export let allowBindings = false
export let filtersLabel = "Filters"
export let showFilterEmptyDropdown = true
$: {
if (
tables.find(
table =>
table._id === datasource?.tableId &&
table.sourceId === DEFAULT_BB_DATASOURCE_ID
) &&
!schemaFields.some(field => field.name === "_id")
) {
schemaFields = [...schemaFields, { name: "_id", type: "string" }]
}
}
$: matchAny = filters?.find(filter => filter.operator === "allOr") != null
$: onEmptyFilter =
filters?.find(filter => filter.onEmptyFilter)?.onEmptyFilter ?? "all"
$: fieldFilters = filters.filter(
filter => filter.operator !== "allOr" && !filter.onEmptyFilter
)
const behaviourOptions = [
{ value: "and", label: "Match all filters" },
{ value: "or", label: "Match any filter" },
]
const onEmptyOptions = [
{ value: "all", label: "Return all table rows" },
{ value: "none", label: "Return no rows" },
]
const context = getContext("context")
$: fieldOptions = (schemaFields || []).map(field => ({
label: field.displayName || field.name,
value: 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)
// Clear all filters when no fields are specified
if (filters.length === 1 && filters[0].onEmptyFilter) {
filters = []
}
}
const duplicateFilter = id => {
const existingFilter = filters.find(filter => filter.id === id)
const duplicate = { ...existingFilter, id: generate() }
filters = [...filters, duplicate]
}
const onFieldChange = filter => {
const previousType = filter.type
sanitizeTypes(filter)
sanitizeOperator(filter)
sanitizeValue(filter, previousType)
}
const onOperatorChange = filter => {
sanitizeOperator(filter)
sanitizeValue(filter, filter.type)
}
const onValueTypeChange = filter => {
sanitizeValue(filter)
}
const getFieldOptions = field => {
const schema = schemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
}
const getSchema = filter => {
return schemaFields.find(field => field.name === filter.field)
}
const getValidOperatorsForType = filter => {
if (!filter?.field && !filter?.name) {
return []
}
return QueryUtils.getValidOperatorsForType(
filter,
filter.field || filter.name,
datasource
)
}
$: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"]
const sanitizeTypes = filter => {
// Update type based on field
const fieldSchema = schemaFields.find(x => x.name === filter.field)
filter.type = fieldSchema?.type
filter.subtype = fieldSchema?.subtype
filter.formulaType = fieldSchema?.formulaType
filter.constraints = fieldSchema?.constraints
// Update external type based on field
filter.externalType = getSchema(filter)?.externalType
}
const sanitizeOperator = filter => {
// Ensure a valid operator is selected
const operators = getValidOperatorsForType(filter).map(x => x.value)
if (!operators.includes(filter.operator)) {
filter.operator = operators[0] ?? OperatorOptions.Equals.value
}
// Update the noValue flag if the operator does not take a value
const noValueOptions = [
OperatorOptions.Empty.value,
OperatorOptions.NotEmpty.value,
]
filter.noValue = noValueOptions.includes(filter.operator)
}
const sanitizeValue = (filter, previousType) => {
// Check if the operator allows a value at all
if (filter.noValue) {
filter.value = null
return
}
// Ensure array values are properly set and cleared
if (Array.isArray(filter.value)) {
if (filter.valueType !== "Value" || filter.type !== FieldType.ARRAY) {
filter.value = null
}
} else if (
filter.type === FieldType.ARRAY &&
filter.valueType === "Value"
) {
filter.value = []
} else if (
previousType !== filter.type &&
(previousType === FieldType.BB_REFERENCE ||
filter.type === FieldType.BB_REFERENCE)
) {
filter.value = filter.type === FieldType.ARRAY ? [] : null
}
}
function handleAllOr(option) {
filters = filters.filter(f => f.operator !== "allOr")
if (option === "or") {
filters.push({ operator: "allOr" })
}
}
function handleOnEmptyFilter(value) {
filters = filters?.filter(filter => !filter.onEmptyFilter)
filters.push({ onEmptyFilter: value })
}
</script>
<div class="container" class:mobile={$context?.device?.mobile}>
<Layout noPadding>
{#if fieldOptions?.length}
<Body size="S">
{#if !fieldFilters?.length}
Add your first filter expression.
{:else}
<slot name="filtering-hero-content" />
{#if behaviourFilters}
<div class="behaviour-filters">
<Select
label="Behaviour"
value={matchAny ? "or" : "and"}
options={behaviourOptions}
getOptionLabel={opt => opt.label}
getOptionValue={opt => opt.value}
on:change={e => handleAllOr(e.detail)}
placeholder={null}
/>
{#if datasource?.type === "table" && showFilterEmptyDropdown}
<Select
label="When filter empty"
value={onEmptyFilter}
options={onEmptyOptions}
getOptionLabel={opt => opt.label}
getOptionValue={opt => opt.value}
on:change={e => handleOnEmptyFilter(e.detail)}
placeholder={null}
/>
{/if}
</div>
{/if}
{/if}
</Body>
{#if fieldFilters?.length}
<div>
{#if filtersLabel}
<div class="filter-label">
<Label>{filtersLabel}</Label>
</div>
{/if}
<div class="fields" class:with-bindings={allowBindings}>
{#each fieldFilters as filter}
<Select
bind:value={filter.field}
options={fieldOptions}
on:change={() => onFieldChange(filter)}
placeholder="Column"
/>
<Select
disabled={!filter.field}
options={getValidOperatorsForType(filter)}
bind:value={filter.operator}
on:change={() => onOperatorChange(filter)}
placeholder={null}
/>
{#if allowBindings}
<Select
disabled={filter.noValue || !filter.field}
options={valueTypeOptions}
bind:value={filter.valueType}
on:change={() => onValueTypeChange(filter)}
placeholder={null}
/>
{/if}
{#if allowBindings && filter.field && filter.valueType === "Binding"}
<slot name="binding" {filter} />
{:else if [FieldType.STRING, FieldType.LONGFORM, FieldType.NUMBER, FieldType.BIGINT, FieldType.FORMULA].includes(filter.type)}
<Input disabled={filter.noValue} bind:value={filter.value} />
{:else if filter.type === FieldType.ARRAY || (filter.type === FieldType.OPTIONS && filter.operator === ArrayOperator.ONE_OF)}
<Multiselect
disabled={filter.noValue}
options={getFieldOptions(filter.field)}
bind:value={filter.value}
/>
{:else if filter.type === FieldType.OPTIONS}
<Combobox
disabled={filter.noValue}
options={getFieldOptions(filter.field)}
bind:value={filter.value}
/>
{:else if filter.type === FieldType.BOOLEAN}
<Combobox
disabled={filter.noValue}
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
bind:value={filter.value}
/>
{:else if filter.type === FieldType.DATETIME}
<DatePicker
disabled={filter.noValue}
enableTime={!getSchema(filter)?.dateOnly}
timeOnly={getSchema(filter)?.timeOnly}
bind:value={filter.value}
/>
{:else if [FieldType.BB_REFERENCE, FieldType.BB_REFERENCE_SINGLE].includes(filter.type)}
<FilterUsers
bind:value={filter.value}
multiselect={[
OperatorOptions.In.value,
OperatorOptions.ContainsAny.value,
].includes(filter.operator)}
disabled={filter.noValue}
type={filter.valueType}
/>
{:else}
<Input disabled />
{/if}
<div class="controls">
<Icon
name="Duplicate"
hoverable
size="S"
on:click={() => duplicateFilter(filter.id)}
/>
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeFilter(filter.id)}
/>
</div>
{/each}
</div>
</div>
{/if}
<div>
<Button icon="AddCircle" size="M" secondary on:click={addFilter}>
Add filter
</Button>
</div>
{:else}
<Body size="S">None of the table column can be used for filtering.</Body>
{/if}
</Layout>
</div>
<style>
.container {
width: 100%;
}
.fields {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
align-items: center;
grid-template-columns: 1fr 120px 1fr auto auto;
}
.fields.with-bindings {
grid-template-columns: minmax(150px, 1fr) 170px 120px minmax(150px, 1fr) 16px 16px;
}
.controls {
display: contents;
}
.container.mobile .fields {
grid-template-columns: 1fr;
}
.container.mobile .controls {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
padding: var(--spacing-s) 0;
gap: var(--spacing-s);
}
.filter-label {
margin-bottom: var(--spacing-s);
}
.behaviour-filters {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
align-items: center;
grid-template-columns: minmax(150px, 1fr) 170px 120px minmax(150px, 1fr) 16px 16px;
}
</style>

View File

@ -0,0 +1,319 @@
<script>
import {
Combobox,
DatePicker,
Input,
Multiselect,
Icon,
Drawer,
Button,
} from "@budibase/bbui"
import FilterUsers from "./FilterUsers.svelte"
import { FieldType, ArrayOperator } from "@budibase/types"
import * as Constants from "../constants"
import { isJSBinding, findHBSBlocks } from "@budibase/string-templates"
import { createEventDispatcher } from "svelte"
export let filter
export let disabled = false
export let bindings = []
export let allowBindings = false
export let schemaFields
export let panel
export let toReadable
export let toRuntime
const dispatch = createEventDispatcher()
const { OperatorOptions, FilterValueType } = Constants
let bindingDrawer
let fieldValue
$: fieldValue = filter?.value
$: readableValue = toReadable ? toReadable(bindings, fieldValue) : fieldValue
$: drawerValue = toDrawerValue(fieldValue)
$: isJS = isJSBinding(fieldValue)
$: fieldIsValid = isValid(fieldValue)
const getFieldOptions = field => {
const schema = schemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
}
const getSchema = filter => {
return schemaFields.find(field => field.name === filter.field)
}
const drawerOnChange = e => {
drawerValue = e.detail
}
const onChange = e => {
fieldValue = e.detail
dispatch("change", {
value: toRuntime ? toRuntime(bindings, fieldValue) : fieldValue,
})
}
const onConfirmBinding = () => {
dispatch("change", {
value: toRuntime ? toRuntime(bindings, drawerValue) : drawerValue,
valueType: drawerValue ? FilterValueType.BINDING : FilterValueType.VALUE,
})
}
const isValidDate = value => {
return !value || !isNaN(new Date(value).valueOf())
}
const hasValidOptions = value => {
let links = []
if (Array.isArray(value)) {
links = value
} else if (value && typeof value === "string") {
links = value.split(",")
} else {
return !value
}
return links.every(link =>
getSchema(filter)?.constraints?.inclusion?.includes(link)
)
}
const isValidBoolean = value => {
return value === "false" || value === "true" || value == ""
}
const hasValidLinks = value => {
let links = []
if (Array.isArray(value)) {
links = value
} else if (value && typeof value === "string") {
links = value.split(",")
} else {
return !value
}
return links.every(link => link.startsWith("ro_"))
}
const validationMap = {
date: isValidDate,
datetime: isValidDate,
bb_reference: hasValidLinks,
bb_reference_single: hasValidLinks,
array: hasValidOptions,
longform: value => !isJSBinding(value),
options: value => !isJSBinding(value) && !findHBSBlocks(value)?.length,
boolean: isValidBoolean,
}
const isValid = value => {
const validate = validationMap[filter.type]
return validate ? validate(value) : true
}
/**
* Converts arrays into strings. The CodeEditor expects a string or encoded JS
* FilterValueType.VALUE values are emptied when first loaded in the binding drawer.
*
* @param{string} fieldValue
*/
const toDrawerValue = fieldValue => {
if (filter.valueType == FilterValueType.VALUE) {
return ""
}
return Array.isArray(fieldValue) ? fieldValue.join(",") : readableValue
}
</script>
<div>
<Drawer
on:drawerHide
on:drawerShow
bind:this={bindingDrawer}
title={filter.field}
forceModal
>
<Button
cta
slot="buttons"
on:click={() => {
onConfirmBinding()
bindingDrawer.hide()
}}
>
Confirm
</Button>
<svelte:component
this={panel}
slot="body"
value={drawerValue}
allowJS
allowHelpers
allowHBS
on:change={drawerOnChange}
{bindings}
/>
</Drawer>
<div
class="field-wrap"
class:bindings={allowBindings}
class:valid={fieldIsValid}
>
<div class="field">
{#if filter.valueType === FilterValueType.BINDING}
<Input
disabled={filter.noValue}
readonly={true}
value={isJS ? "(JavaScript function)" : readableValue}
on:change={onChange}
/>
{:else}
<div>
{#if [FieldType.STRING, FieldType.LONGFORM, FieldType.NUMBER, FieldType.BIGINT, FieldType.FORMULA].includes(filter.type)}
<Input
disabled={filter.noValue}
value={readableValue}
on:change={onChange}
/>
{:else if filter.type === FieldType.ARRAY || (filter.type === FieldType.OPTIONS && filter.operator === ArrayOperator.ONE_OF)}
<Multiselect
disabled={filter.noValue}
options={getFieldOptions(filter.field)}
value={readableValue}
on:change={onChange}
/>
{:else if filter.type === FieldType.OPTIONS}
<Combobox
disabled={filter.noValue}
options={getFieldOptions(filter.field)}
value={readableValue}
on:change={onChange}
/>
{:else if filter.type === FieldType.BOOLEAN}
<Combobox
disabled={filter.noValue}
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
value={readableValue}
on:change={onChange}
/>
{:else if filter.type === FieldType.DATETIME}
<DatePicker
disabled={filter.noValue}
enableTime={!getSchema(filter)?.dateOnly}
timeOnly={getSchema(filter)?.timeOnly}
value={readableValue}
on:change={onChange}
/>
{:else if [FieldType.BB_REFERENCE, FieldType.BB_REFERENCE_SINGLE].includes(filter.type)}
<FilterUsers
multiselect={[
OperatorOptions.In.value,
OperatorOptions.ContainsAny.value,
].includes(filter.operator)}
disabled={filter.noValue}
value={readableValue}
on:change={onChange}
/>
{:else}
<Input disabled />
{/if}
</div>
{/if}
</div>
<div class="binding-control">
<!-- needs field, operator -->
{#if !disabled && allowBindings && filter.field && !filter.noValue}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="icon"
class:binding={filter.valueType === "Binding"}
on:click={() => {
bindingDrawer.show()
}}
>
<Icon size="S" name="FlashOn" />
</div>
{/if}
</div>
</div>
</div>
<style>
.field-wrap {
display: flex;
}
.field {
flex: 1;
}
.field-wrap.bindings .field :global(.spectrum-Form-itemField),
.field-wrap.bindings .field :global(input),
.field-wrap.bindings .field :global(.spectrum-Picker) {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
.field-wrap.bindings
.field
:global(.spectrum-InputGroup.spectrum-Datepicker) {
min-width: unset;
border-radius: 0px;
}
.field-wrap.bindings
.field
:global(
.spectrum-InputGroup.spectrum-Datepicker
.spectrum-Textfield-input.spectrum-InputGroup-input
) {
width: 100%;
}
.binding-control .icon {
border: 1px solid
var(
--spectrum-textfield-m-border-color,
var(--spectrum-alias-border-color)
);
border-left: 0px;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m));
}
.binding-control .icon.binding {
color: var(--yellow);
}
.binding-control .icon:hover {
cursor: pointer;
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
color: var(--spectrum-alias-text-color-hover);
}
.binding-control .icon.binding:hover {
color: var(--yellow);
}
</style>

View File

@ -27,7 +27,8 @@
<div class="user-control"> <div class="user-control">
<svelte:component <svelte:component
this={component} this={component}
bind:value {value}
on:change
autocomplete autocomplete
{options} {options}
getOptionLabel={option => option.email} getOptionLabel={option => option.email}

View File

@ -1,5 +1,5 @@
import { writable, get, derived } from "svelte/store" import { writable, get, derived } from "svelte/store"
import { FieldType } from "@budibase/types" import { FieldType, FilterGroupLogicalOperator } from "@budibase/types"
export const createStores = context => { export const createStores = context => {
const { props } = context const { props } = context
@ -16,11 +16,22 @@ export const createStores = context => {
export const deriveStores = context => { export const deriveStores = context => {
const { filter, inlineFilters } = context const { filter, inlineFilters } = context
const allFilters = derived( const allFilters = derived(
[filter, inlineFilters], [filter, inlineFilters],
([$filter, $inlineFilters]) => { ([$filter, $inlineFilters]) => {
return [...($filter || []), ...$inlineFilters] const inlineFilterGroup = $inlineFilters?.length
? {
logicalOperator: FilterGroupLogicalOperator.ALL,
filters: [...($inlineFilters || [])],
}
: null
return inlineFilterGroup
? {
logicalOperator: FilterGroupLogicalOperator.ALL,
groups: [...($filter?.groups || []), inlineFilterGroup],
}
: $filter
} }
) )
@ -54,7 +65,6 @@ export const createActions = context => {
inlineFilter.operator = "contains" inlineFilter.operator = "contains"
} }
// Add this filter
inlineFilters.update($inlineFilters => { inlineFilters.update($inlineFilters => {
// Remove any existing inline filter for this column // Remove any existing inline filter for this column
$inlineFilters = $inlineFilters?.filter(x => x.id !== filterId) $inlineFilters = $inlineFilters?.filter(x => x.id !== filterId)

View File

@ -7,5 +7,5 @@ export { default as UserAvatars } from "./UserAvatars.svelte"
export { default as Updating } from "./Updating.svelte" export { default as Updating } from "./Updating.svelte"
export { Grid } from "./grid" export { Grid } from "./grid"
export { default as ClientAppSkeleton } from "./ClientAppSkeleton.svelte" export { default as ClientAppSkeleton } from "./ClientAppSkeleton.svelte"
export { default as FilterBuilder } from "./FilterBuilder.svelte" export { default as CoreFilterBuilder } from "./CoreFilterBuilder.svelte"
export { default as FilterUsers } from "./FilterUsers.svelte" export { default as FilterUsers } from "./FilterUsers.svelte"

View File

@ -175,3 +175,24 @@ export const TypeIconMap = {
export const OptionColours = [...new Array(12).keys()].map(idx => { export const OptionColours = [...new Array(12).keys()].map(idx => {
return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, 0.3)` return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, 0.3)`
}) })
export const FilterOperator = {
ANY: "any",
ALL: "all",
}
export const OnEmptyFilter = {
RETURN_ALL: "all",
RETURN_NONE: "none",
}
export const FilterValueType = {
BINDING: "Binding",
VALUE: "Value",
}
export const FieldPermissions = {
WRITABLE: "writable",
READONLY: "readonly",
HIDDEN: "hidden",
}

View File

@ -178,7 +178,8 @@ export default class DataFetch {
// Build the query // Build the query
let query = this.options.query let query = this.options.query
if (!query) {
if (!query && this.features.supportsSearch) {
query = buildQuery(filter) query = buildQuery(filter)
} }

View File

@ -1,7 +1,7 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import DataFetch from "./DataFetch.js" import DataFetch from "./DataFetch.js"
import { TableNames } from "../constants" import { TableNames } from "../constants"
import { QueryUtils } from "../utils" import { utils } from "@budibase/shared-core"
export default class UserFetch extends DataFetch { export default class UserFetch extends DataFetch {
constructor(opts) { constructor(opts) {
@ -32,12 +32,12 @@ export default class UserFetch extends DataFetch {
const { cursor, query } = get(this.store) const { cursor, query } = get(this.store)
let finalQuery let finalQuery
// convert old format to new one - we now allow use of the lucene format // convert old format to new one - we now allow use of the lucene format
const { appId, paginated, ...rest } = query const { appId, paginated, ...rest } = query || {}
if (!QueryUtils.hasFilters(query) && rest.email != null) {
finalQuery = { string: { email: rest.email } } finalQuery = utils.isSupportedUserSearch(rest)
} else { ? query
finalQuery = rest : { string: { email: null } }
}
try { try {
const opts = { const opts = {
bookmark: cursor, bookmark: cursor,

View File

@ -1,5 +1,6 @@
import DataFetch from "./DataFetch.js" import DataFetch from "./DataFetch.js"
import { get } from "svelte/store" import { get } from "svelte/store"
import { utils } from "@budibase/shared-core"
export default class ViewV2Fetch extends DataFetch { export default class ViewV2Fetch extends DataFetch {
determineFeatureFlags() { determineFeatureFlags() {
@ -53,14 +54,17 @@ export default class ViewV2Fetch extends DataFetch {
this.options.sortColumn = definition.sort.field this.options.sortColumn = definition.sort.field
this.options.sortOrder = definition.sort.order this.options.sortOrder = definition.sort.order
} }
if (!filter?.length && definition.query?.length) {
const parsed = utils.processSearchFilters(filter)
if (!parsed?.groups?.length && definition.query?.groups?.length) {
this.options.filter = definition.query this.options.filter = definition.query
} }
try { try {
const res = await this.API.viewV2.fetch({ const res = await this.API.viewV2.fetch({
viewId: datasource.id, viewId: datasource.id,
query, ...(query ? { query } : {}),
paginate, paginate,
limit, limit,
bookmark: cursor, bookmark: cursor,

View File

@ -7,6 +7,7 @@ import {
RowSearchParams, RowSearchParams,
SearchFilterKey, SearchFilterKey,
LogicalOperator, LogicalOperator,
SearchFilter,
} from "@budibase/types" } from "@budibase/types"
import { dataFilters } from "@budibase/shared-core" import { dataFilters } from "@budibase/shared-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
@ -19,7 +20,7 @@ export async function searchView(
) { ) {
const { viewId } = ctx.params const { viewId } = ctx.params
const view = await sdk.views.get(viewId) const view: ViewV2 = await sdk.views.get(viewId)
if (!view) { if (!view) {
ctx.throw(404, `View ${viewId} not found`) ctx.throw(404, `View ${viewId} not found`)
} }
@ -32,21 +33,32 @@ export async function searchView(
.map(([key]) => key) .map(([key]) => key)
const { body } = ctx.request const { body } = ctx.request
const sqsEnabled = await features.flags.isEnabled("SQS")
const supportsLogicalOperators = isExternalTableID(view.tableId) || sqsEnabled
// Enrich saved query with ephemeral query params. // Enrich saved query with ephemeral query params.
// We prevent searching on any fields that are saved as part of the query, as // We prevent searching on any fields that are saved as part of the query, as
// that could let users find rows they should not be allowed to access. // that could let users find rows they should not be allowed to access.
let query = dataFilters.buildQuery(view.query || []) let query = supportsLogicalOperators
? dataFilters.buildQuery(view.query)
: dataFilters.buildQueryLegacy(view.query)
delete query?.onEmptyFilter
if (body.query) { if (body.query) {
// Delete extraneous search params that cannot be overridden // Delete extraneous search params that cannot be overridden
delete body.query.onEmptyFilter delete body.query.onEmptyFilter
if ( if (!supportsLogicalOperators) {
!isExternalTableID(view.tableId) && // In the unlikely event that a Grouped Filter is in a non-SQS environment
!(await features.flags.isEnabled("SQS")) // It needs to be ignored entirely
) { let queryFilters: SearchFilter[] = Array.isArray(view.query)
? view.query
: []
// Extract existing fields // Extract existing fields
const existingFields = const existingFields =
view.query queryFilters
?.filter(filter => filter.field) ?.filter(filter => filter.field)
.map(filter => db.removeKeyNumbering(filter.field)) || [] .map(filter => db.removeKeyNumbering(filter.field)) || []
@ -54,15 +66,16 @@ export async function searchView(
Object.keys(body.query).forEach(key => { Object.keys(body.query).forEach(key => {
const operator = key as Exclude<SearchFilterKey, LogicalOperator> const operator = key as Exclude<SearchFilterKey, LogicalOperator>
Object.keys(body.query[operator] || {}).forEach(field => { Object.keys(body.query[operator] || {}).forEach(field => {
if (!existingFields.includes(db.removeKeyNumbering(field))) { if (query && !existingFields.includes(db.removeKeyNumbering(field))) {
query[operator]![field] = body.query[operator]![field] query[operator]![field] = body.query[operator]![field]
} }
}) })
}) })
} else { } else {
const conditions = query ? [query] : []
query = { query = {
$and: { $and: {
conditions: [query, body.query], conditions: [...conditions, body.query],
}, },
} }
} }
@ -70,7 +83,7 @@ export async function searchView(
await context.ensureSnippetContext(true) await context.ensureSnippetContext(true)
const enrichedQuery = await enrichSearchContext(query, { const enrichedQuery = await enrichSearchContext(query || {}, {
user: sdk.users.getUserContextBindings(ctx.user), user: sdk.users.getUserContextBindings(ctx.user),
}) })

View File

@ -15,7 +15,9 @@ export const removeInvalidFilters = (
const result = cloneDeep(filters) const result = cloneDeep(filters)
validFields = validFields.map(f => f.toLowerCase()) validFields = validFields.map(f => f.toLowerCase())
for (const filterKey of Object.keys(result) as (keyof SearchFilters)[]) { for (const filterKey of Object.keys(
result || {}
) as (keyof SearchFilters)[]) {
const filter = result[filterKey] const filter = result[filterKey]
if (!filter || typeof filter !== "object") { if (!filter || typeof filter !== "object") {
continue continue
@ -24,7 +26,7 @@ export const removeInvalidFilters = (
const resultingConditions: SearchFilters[] = [] const resultingConditions: SearchFilters[] = []
for (const condition of filter.conditions) { for (const condition of filter.conditions) {
const resultingCondition = removeInvalidFilters(condition, validFields) const resultingCondition = removeInvalidFilters(condition, validFields)
if (Object.keys(resultingCondition).length) { if (Object.keys(resultingCondition || {}).length) {
resultingConditions.push(resultingCondition) resultingConditions.push(resultingCondition)
} }
} }

View File

@ -19,9 +19,12 @@ import {
RangeOperator, RangeOperator,
LogicalOperator, LogicalOperator,
isLogicalSearchOperator, isLogicalSearchOperator,
SearchFilterGroup,
FilterGroupLogicalOperator,
} from "@budibase/types" } from "@budibase/types"
import dayjs from "dayjs" import dayjs from "dayjs"
import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants" import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants"
import { processSearchFilters } from "./utils"
import { deepGet, schema } from "./helpers" import { deepGet, schema } from "./helpers"
import { isPlainObject, isEmpty } from "lodash" import { isPlainObject, isEmpty } from "lodash"
import { decodeNonAscii } from "./helpers/schema" import { decodeNonAscii } from "./helpers/schema"
@ -304,10 +307,138 @@ export class ColumnSplitter {
} }
/** /**
* Builds a JSON query from the filter structure generated in the builder * Builds a JSON query from the filter a SearchFilter definition
* @param filter the builder filter structure * @param filter the builder filter structure
*/ */
export const buildQuery = (filter: SearchFilter[]) => {
const buildCondition = (expression: SearchFilter) => {
// Filter body
let query: SearchFilters = {
string: {},
fuzzy: {},
range: {},
equal: {},
notEqual: {},
empty: {},
notEmpty: {},
contains: {},
notContains: {},
oneOf: {},
containsAny: {},
}
let { operator, field, type, value, externalType, onEmptyFilter } = expression
if (!operator || !field) {
return
}
const queryOperator = operator as SearchFilterOperator
const isHbs =
typeof value === "string" && (value.match(HBS_REGEX) || []).length > 0
// Parse all values into correct types
if (operator === "allOr") {
query.allOr = true
return
}
if (onEmptyFilter) {
query.onEmptyFilter = onEmptyFilter
return
}
// Default the value for noValue fields to ensure they are correctly added
// to the final query
if (queryOperator === "empty" || queryOperator === "notEmpty") {
value = null
}
if (
type === "datetime" &&
!isHbs &&
queryOperator !== "empty" &&
queryOperator !== "notEmpty"
) {
// Ensure date value is a valid date and parse into correct format
if (!value) {
return
}
try {
value = new Date(value).toISOString()
} catch (error) {
return
}
}
if (type === "number" && typeof value === "string" && !isHbs) {
if (queryOperator === "oneOf") {
value = value.split(",").map(item => parseFloat(item))
} else {
value = parseFloat(value)
}
}
if (type === "boolean") {
value = `${value}`?.toLowerCase() === "true"
}
if (
["contains", "notContains", "containsAny"].includes(
operator.toLocaleString()
) &&
type === "array" &&
typeof value === "string"
) {
value = value.split(",")
}
if (operator.toLocaleString().startsWith("range") && query.range) {
const minint =
SqlNumberTypeRangeMap[externalType as keyof typeof SqlNumberTypeRangeMap]
?.min || Number.MIN_SAFE_INTEGER
const maxint =
SqlNumberTypeRangeMap[externalType as keyof typeof SqlNumberTypeRangeMap]
?.max || Number.MAX_SAFE_INTEGER
if (!query.range[field]) {
query.range[field] = {
low: type === "number" ? minint : "0000-00-00T00:00:00.000Z",
high: type === "number" ? maxint : "9999-00-00T00:00:00.000Z",
}
}
if (operator === "rangeLow" && value != null && value !== "") {
query.range[field] = {
...query.range[field],
low: value,
}
} else if (operator === "rangeHigh" && value != null && value !== "") {
query.range[field] = {
...query.range[field],
high: value,
}
}
} else if (isLogicalSearchOperator(queryOperator)) {
// TODO
} else if (query[queryOperator] && operator !== "onEmptyFilter") {
if (type === "boolean") {
// Transform boolean filters to cope with null.
// "equals false" needs to be "not equals true"
// "not equals false" needs to be "equals true"
if (queryOperator === "equal" && value === false) {
query.notEqual = query.notEqual || {}
query.notEqual[field] = true
} else if (queryOperator === "notEqual" && value === false) {
query.equal = query.equal || {}
query.equal[field] = true
} else {
query[queryOperator] ??= {}
query[queryOperator]![field] = value
}
} else {
query[queryOperator] ??= {}
query[queryOperator]![field] = value
}
}
return query
}
export const buildQueryLegacy = (
filter?: SearchFilterGroup | SearchFilter[]
): SearchFilters | undefined => {
let query: SearchFilters = { let query: SearchFilters = {
string: {}, string: {},
fuzzy: {}, fuzzy: {},
@ -368,13 +499,15 @@ export const buildQuery = (filter: SearchFilter[]) => {
value = `${value}`?.toLowerCase() === "true" value = `${value}`?.toLowerCase() === "true"
} }
if ( if (
["contains", "notContains", "containsAny"].includes(operator) && ["contains", "notContains", "containsAny"].includes(
operator.toLocaleString()
) &&
type === "array" && type === "array" &&
typeof value === "string" typeof value === "string"
) { ) {
value = value.split(",") value = value.split(",")
} }
if (operator.startsWith("range") && query.range) { if (operator.toLocaleString().startsWith("range") && query.range) {
const minint = const minint =
SqlNumberTypeRangeMap[ SqlNumberTypeRangeMap[
externalType as keyof typeof SqlNumberTypeRangeMap externalType as keyof typeof SqlNumberTypeRangeMap
@ -401,7 +534,7 @@ export const buildQuery = (filter: SearchFilter[]) => {
} }
} }
} else if (isLogicalSearchOperator(queryOperator)) { } else if (isLogicalSearchOperator(queryOperator)) {
// TODO // ignore
} else if (query[queryOperator] && operator !== "onEmptyFilter") { } else if (query[queryOperator] && operator !== "onEmptyFilter") {
if (type === "boolean") { if (type === "boolean") {
// Transform boolean filters to cope with null. // Transform boolean filters to cope with null.
@ -423,14 +556,68 @@ export const buildQuery = (filter: SearchFilter[]) => {
} }
} }
}) })
return query return query
} }
/**
* Converts a **SearchFilterGroup** filter definition into a grouped
* search query of type **SearchFilters**
*
* Legacy support remains for the old **SearchFilter[]** format.
* These will be migrated to an appropriate **SearchFilters** object, if encountered
*
* @param filter
*
* @returns {SearchFilters}
*/
export const buildQuery = (
filter?: SearchFilterGroup | SearchFilter[]
): SearchFilters | undefined => {
const parsedFilter: SearchFilterGroup | undefined =
processSearchFilters(filter)
if (!parsedFilter) {
return
}
const operatorMap: { [key in FilterGroupLogicalOperator]: LogicalOperator } =
{
[FilterGroupLogicalOperator.ALL]: LogicalOperator.AND,
[FilterGroupLogicalOperator.ANY]: LogicalOperator.OR,
}
const globalOnEmpty = parsedFilter.onEmptyFilter
? parsedFilter.onEmptyFilter
: null
const globalOperator: LogicalOperator =
operatorMap[parsedFilter.logicalOperator as FilterGroupLogicalOperator]
const coreRequest: SearchFilters = {
...(globalOnEmpty ? { onEmptyFilter: globalOnEmpty } : {}),
[globalOperator]: {
conditions: parsedFilter.groups?.map((group: SearchFilterGroup) => {
return {
[operatorMap[group.logicalOperator]]: {
conditions: group.filters
?.map(x => buildCondition(x))
.filter(filter => filter),
},
}
}),
},
}
return coreRequest
}
// The frontend can send single values for array fields sometimes, so to handle // The frontend can send single values for array fields sometimes, so to handle
// this we convert them to arrays at the controller level so that nothing below // this we convert them to arrays at the controller level so that nothing below
// this has to worry about the non-array values. // this has to worry about the non-array values.
export function fixupFilterArrays(filters: SearchFilters) { export function fixupFilterArrays(filters: SearchFilters) {
if (!filters) {
return filters
}
for (const searchField of Object.values(ArrayOperator)) { for (const searchField of Object.values(ArrayOperator)) {
const field = filters[searchField] const field = filters[searchField]
if (field == null || !isPlainObject(field)) { if (field == null || !isPlainObject(field)) {

View File

@ -1,4 +1,13 @@
import {
SearchFilter,
SearchFilterGroup,
FilterGroupLogicalOperator,
SearchFilters,
BasicOperator,
ArrayOperator,
} from "@budibase/types"
import * as Constants from "./constants" import * as Constants from "./constants"
import { removeKeyNumbering } from "./filters"
export function unreachable( export function unreachable(
value: never, value: never,
@ -77,3 +86,129 @@ export function trimOtherProps(object: any, allowedProps: string[]) {
) )
return result return result
} }
/**
* Processes the filter config. Filters are migrated from
* SearchFilter[] to SearchFilterGroup
*
* If filters is not an array, the migration is skipped
*
* @param {SearchFilter[] | SearchFilterGroup} filters
*/
export const processSearchFilters = (
filters: SearchFilter[] | SearchFilterGroup | undefined
): SearchFilterGroup | undefined => {
if (!filters) {
return
}
// Base search config.
const defaultCfg: SearchFilterGroup = {
logicalOperator: FilterGroupLogicalOperator.ALL,
groups: [],
}
const filterWhitelistKeys = [
"field",
"operator",
"value",
"type",
"externalType",
"valueType",
"noValue",
"formulaType",
]
if (Array.isArray(filters)) {
let baseGroup: SearchFilterGroup = {
filters: [],
logicalOperator: FilterGroupLogicalOperator.ALL,
}
const migratedSetting: SearchFilterGroup = filters.reduce(
(acc: SearchFilterGroup, filter: SearchFilter) => {
// Sort the properties for easier debugging
const filterEntries = Object.entries(filter)
.sort((a, b) => {
return a[0].localeCompare(b[0])
})
.filter(x => x[1] ?? false)
if (filterEntries.length == 1) {
const [key, value] = filterEntries[0]
// Global
if (key === "onEmptyFilter") {
// unset otherwise
acc.onEmptyFilter = value
} else if (key === "operator" && value === "allOr") {
// Group 1 logical operator
baseGroup.logicalOperator = FilterGroupLogicalOperator.ANY
}
return acc
}
const whiteListedFilterSettings: [string, any][] = filterEntries.reduce(
(acc: [string, any][], entry: [string, any]) => {
const [key, value] = entry
if (filterWhitelistKeys.includes(key)) {
if (key === "field") {
acc.push([key, removeKeyNumbering(value)])
} else {
acc.push([key, value])
}
}
return acc
},
[]
)
const migratedFilter: SearchFilter = Object.fromEntries(
whiteListedFilterSettings
) as SearchFilter
baseGroup.filters!.push(migratedFilter)
if (!acc.groups || !acc.groups.length) {
// init the base group
acc.groups = [baseGroup]
}
return acc
},
defaultCfg
)
return migratedSetting
} else if (!filters?.groups) {
return
}
return filters
}
export function isSupportedUserSearch(query: SearchFilters) {
const allowed = [
{ op: BasicOperator.STRING, key: "email" },
{ op: BasicOperator.EQUAL, key: "_id" },
{ op: ArrayOperator.ONE_OF, key: "_id" },
]
for (let [key, operation] of Object.entries(query)) {
if (typeof operation !== "object") {
return false
}
const fields = Object.keys(operation || {})
// this filter doesn't contain options - ignore
if (fields.length === 0) {
continue
}
const allowedOperation = allowed.find(
allow =>
allow.op === key && fields.length === 1 && fields[0] === allow.key
)
if (!allowedOperation) {
return false
}
}
return true
}

View File

@ -1,5 +1,9 @@
import { FieldType } from "../../documents" import { FieldType } from "../../documents"
import { EmptyFilterOption, SearchFilters } from "../../sdk" import {
EmptyFilterOption,
FilterGroupLogicalOperator,
SearchFilters,
} from "../../sdk"
export type SearchFilter = { export type SearchFilter = {
operator: keyof SearchFilters | "rangeLow" | "rangeHigh" operator: keyof SearchFilters | "rangeLow" | "rangeHigh"
@ -9,3 +13,10 @@ export type SearchFilter = {
value: any value: any
externalType?: string externalType?: string
} }
export type SearchFilterGroup = {
logicalOperator: FilterGroupLogicalOperator
onEmptyFilter?: EmptyFilterOption
groups?: SearchFilterGroup[]
filters?: SearchFilter[]
}

View File

@ -1,4 +1,4 @@
import { SearchFilter, SortOrder, SortType } from "../../api" import { SearchFilter, SearchFilterGroup, SortOrder, SortType } from "../../api"
import { UIFieldMetadata } from "./table" import { UIFieldMetadata } from "./table"
import { Document } from "../document" import { Document } from "../document"
import { DBView } from "../../sdk" import { DBView } from "../../sdk"
@ -61,7 +61,7 @@ export interface ViewV2 {
name: string name: string
primaryDisplay?: string primaryDisplay?: string
tableId: string tableId: string
query?: SearchFilter[] query?: SearchFilter[] | SearchFilterGroup
sort?: { sort?: {
field: string field: string
order?: SortOrder order?: SortOrder

View File

@ -191,6 +191,11 @@ export enum EmptyFilterOption {
RETURN_NONE = "none", RETURN_NONE = "none",
} }
export enum FilterGroupLogicalOperator {
ALL = "all",
ANY = "any",
}
export enum SqlClient { export enum SqlClient {
MS_SQL = "mssql", MS_SQL = "mssql",
POSTGRES = "pg", POSTGRES = "pg",

View File

@ -37,7 +37,7 @@ import {
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { checkAnyUserExists } from "../../../utilities/users" import { checkAnyUserExists } from "../../../utilities/users"
import { isEmailConfigured } from "../../../utilities/email" import { isEmailConfigured } from "../../../utilities/email"
import { BpmStatusKey, BpmStatusValue } from "@budibase/shared-core" import { BpmStatusKey, BpmStatusValue, utils } from "@budibase/shared-core"
const MAX_USERS_UPLOAD_LIMIT = 1000 const MAX_USERS_UPLOAD_LIMIT = 1000
@ -256,7 +256,7 @@ export const search = async (ctx: Ctx<SearchUsersRequest>) => {
} }
} }
// Validate we aren't trying to search on any illegal fields // Validate we aren't trying to search on any illegal fields
if (!userSdk.core.isSupportedUserSearch(body.query)) { if (!utils.isSupportedUserSearch(body.query)) {
ctx.throw(400, "Can only search by string.email, equal._id or oneOf._id") ctx.throw(400, "Can only search by string.email, equal._id or oneOf._id")
} }
} }

View File

@ -20690,7 +20690,16 @@ string-similarity@^4.0.4:
resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b" resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b"
integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ== integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: "string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
version "4.2.3" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -20781,7 +20790,7 @@ stringify-object@^3.2.1:
is-obj "^1.0.1" is-obj "^1.0.1"
is-regexp "^1.0.0" is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: "strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -20795,6 +20804,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
dependencies: dependencies:
ansi-regex "^4.1.0" ansi-regex "^4.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1: strip-ansi@^7.0.1:
version "7.0.1" version "7.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2"
@ -22755,7 +22771,7 @@ worker-farm@1.7.0:
dependencies: dependencies:
errno "~0.1.7" errno "~0.1.7"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -22773,6 +22789,15 @@ wrap-ansi@^5.1.0:
string-width "^3.0.0" string-width "^3.0.0"
strip-ansi "^5.0.0" strip-ansi "^5.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0: wrap-ansi@^8.1.0:
version "8.1.0" version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"