Merge pull request #14546 from Budibase/feature/builder-filtering-update
Filter groups
This commit is contained in:
commit
ba4001e241
|
@ -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
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
>
|
{bindings}
|
||||||
<div slot="filtering-hero-content" />
|
on:change
|
||||||
|
/>
|
||||||
<DrawerBindableInput
|
|
||||||
let:filter
|
|
||||||
slot="binding"
|
|
||||||
disabled={filter.noValue}
|
|
||||||
title={filter.field}
|
|
||||||
value={filter.value}
|
|
||||||
placeholder="Value"
|
|
||||||
{panel}
|
|
||||||
{bindings}
|
|
||||||
on:change={event => {
|
|
||||||
const indexToUpdate = rawFilters.findIndex(f => f.id === filter.id)
|
|
||||||
rawFilters[indexToUpdate] = {
|
|
||||||
...rawFilters[indexToUpdate],
|
|
||||||
value: event.detail,
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FilterBuilder>
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,16 +59,25 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<DraggableList
|
|
||||||
on:change={e => columns.updateSortable(e.detail)}
|
{#if columns?.sortable?.length}
|
||||||
on:itemChange={e => columns.update(e.detail)}
|
<DraggableList
|
||||||
items={columns.sortable}
|
on:change={e => columns.updateSortable(e.detail)}
|
||||||
listItemKey={"_id"}
|
on:itemChange={e => columns.update(e.detail)}
|
||||||
listType={FieldSetting}
|
items={columns.sortable}
|
||||||
listTypeProps={{
|
listItemKey={"_id"}
|
||||||
bindings,
|
listType={FieldSetting}
|
||||||
}}
|
listTypeProps={{
|
||||||
/>
|
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 {
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
this.migrateSettings(instance)
|
|
||||||
|
try {
|
||||||
|
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 = {}
|
||||||
|
|
|
@ -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 || {}),
|
||||||
|
}
|
||||||
|
})
|
|
@ -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)
|
||||||
|
|
|
@ -5129,7 +5129,8 @@
|
||||||
{
|
{
|
||||||
"type": "filter",
|
"type": "filter",
|
||||||
"label": "Filtering",
|
"label": "Filtering",
|
||||||
"key": "filter"
|
"key": "filter",
|
||||||
|
"resetOn": "dataSource"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "field/sortable",
|
"type": "field/sortable",
|
||||||
|
|
|
@ -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,
|
||||||
})
|
}
|
||||||
})
|
|
||||||
return extendedQuery
|
// If there are no conditions applied at all, clear the request.
|
||||||
|
return extended[LogicalOperator.AND]?.conditions?.length > 0
|
||||||
|
? extended
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
const setUpAutoRefresh = autoRefresh => {
|
const setUpAutoRefresh = autoRefresh => {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
|
@ -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>
|
|
@ -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}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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",
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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[]
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
31
yarn.lock
31
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue