1
0
Fork 0
mirror of synced 2024-07-03 13:30:46 +12:00

Merge branch 'master' into grid-enhancements

This commit is contained in:
Andrew Kingston 2024-04-17 14:54:04 +01:00 committed by GitHub
commit ba9485e781
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 420 additions and 643 deletions

View file

@ -8,19 +8,9 @@ import {
SearchParams, SearchParams,
WithRequired, WithRequired,
} from "@budibase/types" } from "@budibase/types"
import { dataFilters } from "@budibase/shared-core"
const QUERY_START_REGEX = /\d[0-9]*:/g export const removeKeyNumbering = dataFilters.removeKeyNumbering
export function removeKeyNumbering(key: any): string {
if (typeof key === "string" && key.match(QUERY_START_REGEX) != null) {
const parts = key.split(":")
// remove the number
parts.shift()
return parts.join(":")
} else {
return key
}
}
/** /**
* Class to build lucene query URLs. * Class to build lucene query URLs.

View file

@ -17,8 +17,8 @@ import {
ContextUser, ContextUser,
CouchFindOptions, CouchFindOptions,
DatabaseQueryOpts, DatabaseQueryOpts,
SearchQuery, SearchFilters,
SearchQueryOperators, SearchFilterOperator,
SearchUsersRequest, SearchUsersRequest,
User, User,
} from "@budibase/types" } from "@budibase/types"
@ -44,11 +44,11 @@ function removeUserPassword(users: User | User[]) {
return users return users
} }
export function isSupportedUserSearch(query: SearchQuery) { export function isSupportedUserSearch(query: SearchFilters) {
const allowed = [ const allowed = [
{ op: SearchQueryOperators.STRING, key: "email" }, { op: SearchFilterOperator.STRING, key: "email" },
{ op: SearchQueryOperators.EQUAL, key: "_id" }, { op: SearchFilterOperator.EQUAL, key: "_id" },
{ op: SearchQueryOperators.ONE_OF, key: "_id" }, { op: SearchFilterOperator.ONE_OF, key: "_id" },
] ]
for (let [key, operation] of Object.entries(query)) { for (let [key, operation] of Object.entries(query)) {
if (typeof operation !== "object") { if (typeof operation !== "object") {

View file

@ -14,6 +14,7 @@
notifications, notifications,
Checkbox, Checkbox,
DatePicker, DatePicker,
DrawerContent,
} from "@budibase/bbui" } from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation, tables } from "stores/builder" import { automationStore, selectedAutomation, tables } from "stores/builder"
@ -37,7 +38,7 @@
hbAutocomplete, hbAutocomplete,
EditorModes, EditorModes,
} from "components/common/CodeEditor" } from "components/common/CodeEditor"
import FilterDrawer from "components/design/settings/controls/FilterEditor/FilterDrawer.svelte" import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { LuceneUtils, Utils } from "@budibase/frontend-core" import { LuceneUtils, Utils } from "@budibase/frontend-core"
import { import {
getSchemaForDatasourcePlus, getSchemaForDatasourcePlus,
@ -442,15 +443,16 @@
<Button cta slot="buttons" on:click={() => saveFilters(key)}> <Button cta slot="buttons" on:click={() => saveFilters(key)}>
Save Save
</Button> </Button>
<FilterDrawer <DrawerContent slot="body">
slot="body" <FilterBuilder
{filters} {filters}
{bindings} {bindings}
{schemaFields} {schemaFields}
datasource={{ type: "table", tableId }} datasource={{ type: "table", tableId }}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
on:change={e => (tempFilters = e.detail)} on:change={e => (tempFilters = e.detail)}
/> />
</DrawerContent>
</Drawer> </Drawer>
{:else if value.customType === "password"} {:else if value.customType === "password"}
<Input <Input

View file

@ -1,7 +1,7 @@
<script> <script>
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { ActionButton, Modal, ModalContent } from "@budibase/bbui" import { ActionButton, Modal, ModalContent } from "@budibase/bbui"
import FilterDrawer from "components/design/settings/controls/FilterEditor/FilterDrawer.svelte" import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
export let schema export let schema
export let filters export let filters
@ -40,7 +40,7 @@
onConfirm={() => dispatch("change", tempValue)} onConfirm={() => dispatch("change", tempValue)}
> >
<div class="wrapper"> <div class="wrapper">
<FilterDrawer <FilterBuilder
allowBindings={false} allowBindings={false}
{filters} {filters}
{schemaFields} {schemaFields}

View file

@ -27,14 +27,6 @@
return [] return []
} }
} }
async function deleteAttachments(fileList) {
try {
return await API.deleteBuilderAttachments(fileList)
} catch (error) {
return []
}
}
</script> </script>
<Dropzone <Dropzone
@ -42,6 +34,5 @@
{label} {label}
{...$$restProps} {...$$restProps}
{processFiles} {processFiles}
{deleteAttachments}
{handleFileTooLarge} {handleFileTooLarge}
/> />

View file

@ -0,0 +1,84 @@
<script>
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { dataFilters } from "@budibase/shared-core"
import { FilterBuilder } from "@budibase/frontend-core"
import { createEventDispatcher, onMount } from "svelte"
export let schemaFields
export let filters = []
export let bindings = []
export let panel = ClientBindingPanel
export let allowBindings = true
export let datasource
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>
<FilterBuilder
bind:filters={rawFilters}
behaviourFilters={true}
{schemaFields}
{datasource}
{allowBindings}
>
<div slot="filtering-hero-content" />
<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>

View file

@ -1,8 +1,14 @@
<script> <script>
import { notifications, ActionButton, Button, Drawer } from "@budibase/bbui" import {
notifications,
ActionButton,
Button,
Drawer,
DrawerContent,
} from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding" import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding"
import FilterDrawer from "./FilterDrawer.svelte" import FilterBuilder from "./FilterBuilder.svelte"
import { selectedScreen } from "stores/builder" import { selectedScreen } from "stores/builder"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -40,14 +46,15 @@
</div> </div>
<Drawer bind:this={drawer} title="Filtering" on:drawerHide on:drawerShow> <Drawer bind:this={drawer} title="Filtering" on:drawerHide on:drawerShow>
<Button cta slot="buttons" on:click={saveFilter}>Save</Button> <Button cta slot="buttons" on:click={saveFilter}>Save</Button>
<FilterDrawer <DrawerContent slot="body">
slot="body" <FilterBuilder
filters={value} filters={value}
{bindings} {bindings}
{schemaFields} {schemaFields}
{datasource} {datasource}
on:change={e => (tempValue = e.detail)} on:change={e => (tempValue = e.detail)}
/> />
</DrawerContent>
</Drawer> </Drawer>
<style> <style>

View file

@ -1,216 +1,14 @@
<script> <script>
import { import { FilterBuilder } from "@budibase/frontend-core"
Body,
Button,
Combobox,
DatePicker,
Icon,
Input,
Layout,
Select,
} from "@budibase/bbui"
import { generate } from "shortid"
import { LuceneUtils, Constants } from "@budibase/frontend-core"
import { getContext } from "svelte"
export let schemaFields export let schemaFields
export let filters = [] export let filters = []
export let datasource export let datasource
const context = getContext("context")
const BannedTypes = ["link", "attachment", "json"]
$: fieldOptions = (schemaFields ?? [])
.filter(
field =>
!BannedTypes.includes(field.type) ||
(field.type === "formula" && field.formulaType === "static")
)
.map(field => ({
label: field.displayName || field.name,
value: field.name,
}))
const addFilter = () => {
filters = [
...filters,
{
id: generate(),
field: null,
operator: Constants.OperatorOptions.Equals.value,
value: null,
valueType: "Value",
},
]
}
const removeFilter = id => {
filters = filters.filter(field => field.id !== id)
}
const duplicateFilter = id => {
const existingFilter = filters.find(filter => filter.id === id)
const duplicate = { ...existingFilter, id: generate() }
filters = [...filters, duplicate]
}
const onFieldChange = (expression, field) => {
// Update the field type
expression.type = schemaFields.find(x => x.name === field)?.type
expression.externalType = schemaFields.find(
x => x.name === field
)?.externalType
// Ensure a valid operator is set
const validOperators = LuceneUtils.getValidOperatorsForType(
{ type: expression.type },
expression.field,
datasource
).map(x => x.value)
if (!validOperators.includes(expression.operator)) {
expression.operator =
validOperators[0] ?? Constants.OperatorOptions.Equals.value
onOperatorChange(expression, expression.operator)
}
// if changed to an array, change default value to empty array
const idx = filters.findIndex(x => x.field === field)
if (expression.type === "array") {
filters[idx].value = []
} else {
filters[idx].value = null
}
}
const onOperatorChange = (expression, operator) => {
const noValueOptions = [
Constants.OperatorOptions.Empty.value,
Constants.OperatorOptions.NotEmpty.value,
]
expression.noValue = noValueOptions.includes(operator)
if (expression.noValue) {
expression.value = null
}
}
const getFieldOptions = field => {
const schema = schemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
}
const getSchema = filter => {
return schemaFields.find(field => field.name === filter.field)
}
</script> </script>
<div class="container" class:mobile={$context.device.mobile}> <FilterBuilder bind:filters {schemaFields} {datasource} filtersLabel={null}>
<Layout noPadding> <div slot="filtering-hero-content">
<Body size="S"> Results are filtered to only those which match all of the following
{#if !filters?.length} constraints.
Add your first filter expression. </div>
{:else} </FilterBuilder>
Results are filtered to only those which match all of the following
constraints.
{/if}
</Body>
{#if filters?.length}
<div class="fields">
{#each filters as filter}
<Select
bind:value={filter.field}
options={fieldOptions}
on:change={e => onFieldChange(filter, e.detail)}
placeholder="Column"
/>
<Select
disabled={!filter.field}
options={LuceneUtils.getValidOperatorsForType(
{ type: filter.type, subtype: filter.subtype },
filter.field,
datasource
)}
bind:value={filter.operator}
on:change={e => onOperatorChange(filter, e.detail)}
placeholder={null}
/>
{#if ["string", "longform", "number", "bigint", "formula"].includes(filter.type)}
<Input disabled={filter.noValue} bind:value={filter.value} />
{:else if ["options", "array"].includes(filter.type)}
<Combobox
disabled={filter.noValue}
options={getFieldOptions(filter.field)}
bind:value={filter.value}
/>
{:else if filter.type === "boolean"}
<Combobox
disabled={filter.noValue}
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
bind:value={filter.value}
/>
{:else if filter.type === "datetime"}
<DatePicker
disabled={filter.noValue}
enableTime={!getSchema(filter).dateOnly}
timeOnly={getSchema(filter).timeOnly}
bind:value={filter.value}
/>
{: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>
{/if}
<div>
<Button icon="AddCircle" size="M" secondary on:click={addFilter}>
Add filter
</Button>
</div>
</Layout>
</div>
<style>
.container {
width: 100%;
max-width: 1000px;
margin: 0 auto;
}
.fields {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
align-items: center;
grid-template-columns: 1fr 120px 1fr auto auto;
}
.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);
}
</style>

View file

@ -58,17 +58,6 @@
} }
} }
const deleteAttachments = async fileList => {
try {
return await API.deleteAttachments({
keys: fileList,
tableId: formContext?.dataSource?.tableId,
})
} catch (error) {
return []
}
}
const handleChange = e => { const handleChange = e => {
const value = fieldApiMapper.set(e.detail) const value = fieldApiMapper.set(e.detail)
const changed = fieldApi.setValue(value) const changed = fieldApi.setValue(value)
@ -98,7 +87,6 @@
error={fieldState.error} error={fieldState.error}
on:change={handleChange} on:change={handleChange}
{processFiles} {processFiles}
{deleteAttachments}
{handleFileTooLarge} {handleFileTooLarge}
{handleTooManyFiles} {handleTooManyFiles}
{maximum} {maximum}

View file

@ -11,6 +11,7 @@
"@budibase/types": "0.0.0", "@budibase/types": "0.0.0",
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"lodash": "4.17.21", "lodash": "4.17.21",
"shortid": "2.2.15",
"socket.io-client": "^4.6.1" "socket.io-client": "^4.6.1"
} }
} }

View file

@ -61,34 +61,6 @@ export const buildAttachmentEndpoints = API => {
}) })
return { publicUrl } return { publicUrl }
}, },
/**
* Deletes attachments from the bucket.
* @param keys the attachments to delete
* @param tableId the associated table ID
*/
deleteAttachments: async ({ keys, tableId }) => {
return await API.post({
url: `/api/attachments/${tableId}/delete`,
body: {
keys,
},
})
},
/**
* Deletes attachments from the builder bucket.
* @param keys the attachments to delete
*/
deleteBuilderAttachments: async keys => {
return await API.post({
url: `/api/attachments/delete`,
body: {
keys,
},
})
},
/** /**
* Download an attachment from a row given its column name. * Download an attachment from a row given its column name.
* @param datasourceId the ID of the datasource to download from * @param datasourceId the ID of the datasource to download from

View file

@ -4,33 +4,36 @@
Button, Button,
Combobox, Combobox,
DatePicker, DatePicker,
DrawerContent,
Icon, Icon,
Input, Input,
Label,
Layout, Layout,
Multiselect,
Select, Select,
Label,
Multiselect,
} from "@budibase/bbui" } from "@budibase/bbui"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import { FieldType, SearchFilterOperator } from "@budibase/types"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { generate } from "shortid" import { generate } from "shortid"
import { Constants, LuceneUtils } from "@budibase/frontend-core" import { LuceneUtils, Constants } from "@budibase/frontend-core"
import { getFields } from "helpers/searchFields" import { getContext } from "svelte"
import { FieldType } from "@budibase/types"
import { createEventDispatcher, onMount } from "svelte"
import FilterUsers from "./FilterUsers.svelte" import FilterUsers from "./FilterUsers.svelte"
const { OperatorOptions } = Constants
export let schemaFields export let schemaFields
export let filters = [] export let filters = []
export let bindings = []
export let panel = ClientBindingPanel
export let allowBindings = true
export let datasource export let datasource
export let behaviourFilters = false
export let allowBindings = false
export let filtersLabel = "Filters"
$: 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 dispatch = createEventDispatcher()
const { OperatorOptions } = Constants
const KeyedFieldRegex = /\d[0-9]*:/g
const behaviourOptions = [ const behaviourOptions = [
{ value: "and", label: "Match all filters" }, { value: "and", label: "Match all filters" },
{ value: "or", label: "Match any filter" }, { value: "or", label: "Match any filter" },
@ -40,62 +43,18 @@
{ value: "none", label: "Return no rows" }, { value: "none", label: "Return no rows" },
] ]
let rawFilters const context = getContext("context")
let matchAny = false
let onEmptyFilter = "all"
$: parseFilters(filters) $: fieldOptions = (schemaFields ?? [])
$: dispatch("change", enrichFilters(rawFilters, matchAny, onEmptyFilter)) .filter(field => getValidOperatorsForType(field).length)
$: enrichedSchemaFields = getFields(schemaFields || [], { allowLinks: true }) .map(field => ({
$: fieldOptions = enrichedSchemaFields.map(field => field.name) || [] label: field.displayName || field.name,
$: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"] value: field.name,
}))
// Remove field key prefixes and determine which behaviours to use
const parseFilters = filters => {
matchAny = filters?.find(filter => filter.operator === "allOr") != null
onEmptyFilter =
filters?.find(filter => filter.onEmptyFilter)?.onEmptyFilter ?? "all"
rawFilters = (filters || [])
.filter(filter => filter.operator !== "allOr" && !filter.onEmptyFilter)
.map(filter => {
const { field } = filter
let newFilter = { ...filter }
delete newFilter.allOr
if (typeof field === "string" && field.match(KeyedFieldRegex) != null) {
const parts = field.split(":")
parts.shift()
newFilter.field = parts.join(":")
}
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, matchAny, onEmptyFilter) => {
let count = 1
return rawFilters
.filter(filter => filter.field)
.map(filter => ({
...filter,
field: `${count++}:${filter.field}`,
}))
.concat(matchAny ? [{ operator: "allOr" }] : [])
.concat([{ onEmptyFilter }])
}
const addFilter = () => { const addFilter = () => {
rawFilters = [ filters = [
...rawFilters, ...(filters || []),
{ {
id: generate(), id: generate(),
field: null, field: null,
@ -107,22 +66,57 @@
} }
const removeFilter = id => { const removeFilter = id => {
rawFilters = rawFilters.filter(field => field.id !== id) filters = filters.filter(field => field.id !== id)
} }
const duplicateFilter = id => { const duplicateFilter = id => {
const existingFilter = rawFilters.find(filter => filter.id === id) const existingFilter = filters.find(filter => filter.id === id)
const duplicate = { ...existingFilter, id: generate() } const duplicate = { ...existingFilter, id: generate() }
rawFilters = [...rawFilters, duplicate] 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 => { const getSchema = filter => {
return enrichedSchemaFields.find(field => field.name === filter.field) return schemaFields.find(field => field.name === filter.field)
} }
const getValidOperatorsForType = filter => {
if (!filter?.field && !filter?.name) {
return []
}
return LuceneUtils.getValidOperatorsForType(
filter,
filter.field || filter.name,
datasource
)
}
$: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"]
const sanitizeTypes = filter => { const sanitizeTypes = filter => {
// Update type based on field // Update type based on field
const fieldSchema = enrichedSchemaFields.find(x => x.name === filter.field) const fieldSchema = schemaFields.find(x => x.name === filter.field)
filter.type = fieldSchema?.type filter.type = fieldSchema?.type
filter.subtype = fieldSchema?.subtype filter.subtype = fieldSchema?.subtype
@ -154,88 +148,79 @@
// Ensure array values are properly set and cleared // Ensure array values are properly set and cleared
if (Array.isArray(filter.value)) { if (Array.isArray(filter.value)) {
if (filter.valueType !== "Value" || filter.type !== "array") { if (filter.valueType !== "Value" || filter.type !== FieldType.ARRAY) {
filter.value = null filter.value = null
} }
} else if (filter.type === "array" && filter.valueType === "Value") { } else if (
filter.type === FieldType.ARRAY &&
filter.valueType === "Value"
) {
filter.value = [] filter.value = []
} else if ( } else if (
previousType !== filter.type && previousType !== filter.type &&
(previousType === FieldType.BB_REFERENCE || (previousType === FieldType.BB_REFERENCE ||
filter.type === FieldType.BB_REFERENCE) filter.type === FieldType.BB_REFERENCE)
) { ) {
filter.value = filter.type === "array" ? [] : null filter.value = filter.type === FieldType.ARRAY ? [] : null
} }
} }
const onFieldChange = filter => { function handleAllOr(option) {
const previousType = filter.type filters = filters.filter(f => f.operator !== "allOr")
sanitizeTypes(filter) if (option === "or") {
sanitizeOperator(filter) filters.push({ operator: "allOr" })
sanitizeValue(filter, previousType)
}
const onOperatorChange = filter => {
sanitizeOperator(filter)
sanitizeValue(filter, filter.type)
}
const onValueTypeChange = filter => {
sanitizeValue(filter)
}
const getFieldOptions = field => {
const schema = enrichedSchemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
}
const getValidOperatorsForType = filter => {
if (!filter?.field) {
return []
} }
}
return LuceneUtils.getValidOperatorsForType( function handleOnEmptyFilter(value) {
{ type: filter.type, subtype: filter.subtype }, filters = filters?.filter(filter => !filter.onEmptyFilter)
filter.field, filters.push({ onEmptyFilter: value })
datasource
)
} }
</script> </script>
<DrawerContent> <div class="container" class:mobile={$context?.device?.mobile}>
<div class="container"> <Layout noPadding>
<Layout noPadding> {#if fieldOptions?.length}
{#if !rawFilters?.length} <Body size="S">
<Body size="S">Add your first filter expression.</Body> {#if !fieldFilters?.length}
{:else} Add your first filter expression.
<div class="fields"> {:else}
<Select <slot name="filtering-hero-content" />
label="Behaviour" {#if behaviourFilters}
value={matchAny ? "or" : "and"} <div class="behaviour-filters">
options={behaviourOptions} <Select
getOptionLabel={opt => opt.label} label="Behaviour"
getOptionValue={opt => opt.value} value={matchAny ? "or" : "and"}
on:change={e => (matchAny = e.detail === "or")} options={behaviourOptions}
placeholder={null} getOptionLabel={opt => opt.label}
/> getOptionValue={opt => opt.value}
{#if datasource?.type === "table"} on:change={e => handleAllOr(e.detail)}
<Select placeholder={null}
label="When filter empty" />
value={onEmptyFilter} {#if datasource?.type === "table"}
options={onEmptyOptions} <Select
getOptionLabel={opt => opt.label} label="When filter empty"
getOptionValue={opt => opt.value} value={onEmptyFilter}
on:change={e => (onEmptyFilter = e.detail)} options={onEmptyOptions}
placeholder={null} getOptionLabel={opt => opt.label}
/> getOptionValue={opt => opt.value}
on:change={e => handleOnEmptyFilter(e.detail)}
placeholder={null}
/>
{/if}
</div>
{/if} {/if}
</div> {/if}
</Body>
{#if fieldFilters?.length}
<div> <div>
<div class="filter-label"> {#if filtersLabel}
<Label>Filters</Label> <div class="filter-label">
</div> <Label>{filtersLabel}</Label>
<div class="fields"> </div>
{#each rawFilters as filter} {/if}
<div class="fields" class:with-bindings={allowBindings}>
{#each fieldFilters as filter}
<Select <Select
bind:value={filter.field} bind:value={filter.field}
options={fieldOptions} options={fieldOptions}
@ -249,38 +234,32 @@
on:change={() => onOperatorChange(filter)} on:change={() => onOperatorChange(filter)}
placeholder={null} placeholder={null}
/> />
<Select {#if allowBindings}
disabled={filter.noValue || !filter.field} <Select
options={valueTypeOptions} disabled={filter.noValue || !filter.field}
bind:value={filter.valueType} options={valueTypeOptions}
on:change={() => onValueTypeChange(filter)} bind:value={filter.valueType}
placeholder={null} on:change={() => onValueTypeChange(filter)}
/> placeholder={null}
{#if filter.field && filter.valueType === "Binding"}
<DrawerBindableInput
disabled={filter.noValue}
title={filter.field}
value={filter.value}
placeholder="Value"
{panel}
{bindings}
on:change={event => (filter.value = event.detail)}
/> />
{:else if ["string", "longform", "number", "bigint", "formula"].includes(filter.type)} {/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} /> <Input disabled={filter.noValue} bind:value={filter.value} />
{:else if filter.type === "array" || (filter.type === "options" && filter.operator === "oneOf")} {:else if filter.type === FieldType.ARRAY || (filter.type === FieldType.OPTIONS && filter.operator === SearchFilterOperator.ONE_OF)}
<Multiselect <Multiselect
disabled={filter.noValue} disabled={filter.noValue}
options={getFieldOptions(filter.field)} options={getFieldOptions(filter.field)}
bind:value={filter.value} bind:value={filter.value}
/> />
{:else if filter.type === "options"} {:else if filter.type === FieldType.OPTIONS}
<Combobox <Combobox
disabled={filter.noValue} disabled={filter.noValue}
options={getFieldOptions(filter.field)} options={getFieldOptions(filter.field)}
bind:value={filter.value} bind:value={filter.value}
/> />
{:else if filter.type === "boolean"} {:else if filter.type === FieldType.BOOLEAN}
<Combobox <Combobox
disabled={filter.noValue} disabled={filter.noValue}
options={[ options={[
@ -289,7 +268,7 @@
]} ]}
bind:value={filter.value} bind:value={filter.value}
/> />
{:else if filter.type === "datetime"} {:else if filter.type === FieldType.DATETIME}
<DatePicker <DatePicker
disabled={filter.noValue} disabled={filter.noValue}
enableTime={!getSchema(filter)?.dateOnly} enableTime={!getSchema(filter)?.dateOnly}
@ -306,32 +285,36 @@
disabled={filter.noValue} disabled={filter.noValue}
/> />
{:else} {:else}
<DrawerBindableInput disabled /> <Input disabled />
{/if} {/if}
<Icon <div class="controls">
name="Duplicate" <Icon
hoverable name="Duplicate"
size="S" hoverable
on:click={() => duplicateFilter(filter.id)} size="S"
/> on:click={() => duplicateFilter(filter.id)}
<Icon />
name="Close" <Icon
hoverable name="Close"
size="S" hoverable
on:click={() => removeFilter(filter.id)} size="S"
/> on:click={() => removeFilter(filter.id)}
/>
</div>
{/each} {/each}
</div> </div>
</div> </div>
{/if} {/if}
<div class="bottom"> <div>
<Button icon="AddCircle" size="M" secondary on:click={addFilter}> <Button icon="AddCircle" size="M" secondary on:click={addFilter}>
Add filter Add filter
</Button> </Button>
</div> </div>
</Layout> {:else}
</div> <Body size="S">None of the table column can be used for filtering.</Body>
</DrawerContent> {/if}
</Layout>
</div>
<style> <style>
.container { .container {
@ -339,22 +322,42 @@
max-width: 1000px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
} }
.fields { .fields {
display: grid; display: grid;
column-gap: var(--spacing-l); column-gap: var(--spacing-l);
row-gap: var(--spacing-s); row-gap: var(--spacing-s);
align-items: center; 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; 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 { .filter-label {
margin-bottom: var(--spacing-s); margin-bottom: var(--spacing-s);
} }
.bottom { .behaviour-filters {
display: flex; display: grid;
justify-content: space-between; column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
align-items: center; align-items: center;
grid-template-columns: minmax(150px, 1fr) 170px 120px minmax(150px, 1fr) 16px 16px;
} }
</style> </style>

View file

@ -1,9 +1,9 @@
<script> <script>
import { Select, Multiselect } from "@budibase/bbui" import { Select, Multiselect } from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core" import { fetchData } from "@budibase/frontend-core"
import { createAPIClient } from "../api"
import { API } from "api" export let API = createAPIClient()
export let value = null export let value = null
export let disabled export let disabled
export let multiselect = false export let multiselect = false

View file

@ -61,14 +61,6 @@
} }
} }
const deleteAttachments = async fileList => {
try {
return await API.deleteBuilderAttachments(fileList)
} catch (error) {
return []
}
}
onMount(() => { onMount(() => {
api = { api = {
focus: () => open(), focus: () => open(),
@ -101,7 +93,6 @@
on:change={e => onChange(e.detail)} on:change={e => onChange(e.detail)}
maximum={maximum || schema.constraints?.length?.maximum} maximum={maximum || schema.constraints?.length?.maximum}
{processFiles} {processFiles}
{deleteAttachments}
{handleFileTooLarge} {handleFileTooLarge}
/> />
</div> </div>

View file

@ -6,3 +6,4 @@ 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"

View file

@ -127,13 +127,6 @@ export const uploadFile = async function (
) )
} }
export const deleteObjects = async function (ctx: Ctx) {
ctx.body = await objectStore.deleteFiles(
ObjectStoreBuckets.APPS,
ctx.request.body.keys
)
}
const requiresMigration = async (ctx: Ctx) => { const requiresMigration = async (ctx: Ctx) => {
const appId = context.getAppId() const appId = context.getAppId()
if (!appId) { if (!appId) {

View file

@ -32,11 +32,6 @@ router
.get("/builder/:file*", controller.serveBuilder) .get("/builder/:file*", controller.serveBuilder)
.get("/api/assets/client", controller.serveClientLibrary) .get("/api/assets/client", controller.serveClientLibrary)
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile) .post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
.post(
"/api/attachments/delete",
authorized(BUILDER),
controller.deleteObjects
)
.post("/api/beta/:feature", controller.toggleBetaUiFeature) .post("/api/beta/:feature", controller.toggleBetaUiFeature)
.post( .post(
"/api/attachments/:tableId/upload", "/api/attachments/:tableId/upload",
@ -44,12 +39,6 @@ router
authorized(PermissionType.TABLE, PermissionLevel.WRITE), authorized(PermissionType.TABLE, PermissionLevel.WRITE),
controller.uploadFile controller.uploadFile
) )
.post(
"/api/attachments/:tableId/delete",
paramResource("tableId"),
authorized(PermissionType.TABLE, PermissionLevel.WRITE),
controller.deleteObjects
)
.get("/app/preview", authorized(BUILDER), controller.serveBuilderPreview) .get("/app/preview", authorized(BUILDER), controller.serveBuilderPreview)
.get("/app/:appUrl/:path*", controller.serveApp) .get("/app/:appUrl/:path*", controller.serveApp)
.get("/:appId/:path*", controller.serveApp) .get("/:appId/:path*", controller.serveApp)

View file

@ -8,7 +8,7 @@ import {
PermissionLevel, PermissionLevel,
QuotaUsageType, QuotaUsageType,
SaveTableRequest, SaveTableRequest,
SearchQueryOperators, SearchFilterOperator,
SortOrder, SortOrder,
SortType, SortType,
StaticQuotaName, StaticQuotaName,
@ -132,7 +132,7 @@ describe.each([
primaryDisplay: generator.word(), primaryDisplay: generator.word(),
query: [ query: [
{ {
operator: SearchQueryOperators.EQUAL, operator: SearchFilterOperator.EQUAL,
field: "field", field: "field",
value: "value", value: "value",
}, },
@ -236,7 +236,7 @@ describe.each([
...view, ...view,
query: [ query: [
{ {
operator: SearchQueryOperators.EQUAL, operator: SearchFilterOperator.EQUAL,
field: "newField", field: "newField",
value: "thatValue", value: "thatValue",
}, },
@ -263,7 +263,7 @@ describe.each([
primaryDisplay: generator.word(), primaryDisplay: generator.word(),
query: [ query: [
{ {
operator: SearchQueryOperators.EQUAL, operator: SearchFilterOperator.EQUAL,
field: generator.word(), field: generator.word(),
value: generator.word(), value: generator.word(),
}, },
@ -341,7 +341,7 @@ describe.each([
tableId: generator.guid(), tableId: generator.guid(),
query: [ query: [
{ {
operator: SearchQueryOperators.EQUAL, operator: SearchFilterOperator.EQUAL,
field: "newField", field: "newField",
value: "thatValue", value: "thatValue",
}, },
@ -671,7 +671,7 @@ describe.each([
name: generator.guid(), name: generator.guid(),
query: [ query: [
{ {
operator: SearchQueryOperators.EQUAL, operator: SearchFilterOperator.EQUAL,
field: "two", field: "two",
value: "bar2", value: "bar2",
}, },

View file

@ -160,7 +160,7 @@ describe("internal search", () => {
const response = await search.paginatedSearch( const response = await search.paginatedSearch(
{ {
contains: { contains: {
column: "a", column: ["a"],
colArr: [1, 2, 3], colArr: [1, 2, 3],
}, },
}, },
@ -168,7 +168,7 @@ describe("internal search", () => {
) )
checkLucene( checkLucene(
response, response,
`(*:* AND column:a AND colArr:(1 AND 2 AND 3))`, `(*:* AND column:(a) AND colArr:(1 AND 2 AND 3))`,
PARAMS PARAMS
) )
}) })

View file

@ -2,10 +2,11 @@ import {
Datasource, Datasource,
FieldSubtype, FieldSubtype,
FieldType, FieldType,
FormulaType,
SearchFilter, SearchFilter,
SearchQuery, SearchFilters,
SearchQueryFields, SearchQueryFields,
SearchQueryOperators, SearchFilterOperator,
SortDirection, SortDirection,
SortType, SortType,
} from "@budibase/types" } from "@budibase/types"
@ -19,9 +20,13 @@ const HBS_REGEX = /{{([^{].*?)}}/g
* Returns the valid operator options for a certain data type * Returns the valid operator options for a certain data type
*/ */
export const getValidOperatorsForType = ( export const getValidOperatorsForType = (
fieldType: { type: FieldType; subtype?: FieldSubtype }, fieldType: {
type: FieldType
subtype?: FieldSubtype
formulaType?: FormulaType
},
field: string, field: string,
datasource: Datasource & { tableId: any } // TODO: is this table id ever populated? datasource: Datasource & { tableId: any }
) => { ) => {
const Op = OperatorOptions const Op = OperatorOptions
const stringOps = [ const stringOps = [
@ -46,7 +51,7 @@ export const getValidOperatorsForType = (
value: string value: string
label: string label: string
}[] = [] }[] = []
const { type, subtype } = fieldType const { type, subtype, formulaType } = fieldType
if (type === FieldType.STRING) { if (type === FieldType.STRING) {
ops = stringOps ops = stringOps
} else if (type === FieldType.NUMBER || type === FieldType.BIGINT) { } else if (type === FieldType.NUMBER || type === FieldType.BIGINT) {
@ -61,7 +66,7 @@ export const getValidOperatorsForType = (
ops = stringOps ops = stringOps
} else if (type === FieldType.DATETIME) { } else if (type === FieldType.DATETIME) {
ops = numOps ops = numOps
} else if (type === FieldType.FORMULA) { } else if (type === FieldType.FORMULA && formulaType === FormulaType.STATIC) {
ops = stringOps.concat([Op.MoreThan, Op.LessThan]) ops = stringOps.concat([Op.MoreThan, Op.LessThan])
} else if (type === FieldType.BB_REFERENCE && subtype == FieldSubtype.USER) { } else if (type === FieldType.BB_REFERENCE && subtype == FieldSubtype.USER) {
ops = [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty, Op.In] ops = [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty, Op.In]
@ -94,18 +99,19 @@ export const NoEmptyFilterStrings = [
* Removes any fields that contain empty strings that would cause inconsistent * Removes any fields that contain empty strings that would cause inconsistent
* behaviour with how backend tables are filtered (no value means no filter). * behaviour with how backend tables are filtered (no value means no filter).
*/ */
const cleanupQuery = (query: SearchQuery) => { const cleanupQuery = (query: SearchFilters) => {
if (!query) { if (!query) {
return query return query
} }
for (let filterField of NoEmptyFilterStrings) { for (let filterField of NoEmptyFilterStrings) {
if (!query[filterField]) { const operator = filterField as SearchFilterOperator
if (!query[operator]) {
continue continue
} }
for (let [key, value] of Object.entries(query[filterField]!)) { for (let [key, value] of Object.entries(query[operator]!)) {
if (value == null || value === "") { if (value == null || value === "") {
delete query[filterField]![key] delete query[operator]![key]
} }
} }
} }
@ -115,9 +121,10 @@ const cleanupQuery = (query: SearchQuery) => {
/** /**
* Removes a numeric prefix on field names designed to give fields uniqueness * Removes a numeric prefix on field names designed to give fields uniqueness
*/ */
const removeKeyNumbering = (key: string) => { export const removeKeyNumbering = (key: string): string => {
if (typeof key === "string" && key.match(/\d[0-9]*:/g) != null) { if (typeof key === "string" && key.match(/\d[0-9]*:/g) != null) {
const parts = key.split(":") const parts = key.split(":")
// remove the number
parts.shift() parts.shift()
return parts.join(":") return parts.join(":")
} else { } else {
@ -130,7 +137,7 @@ const removeKeyNumbering = (key: string) => {
* @param filter the builder filter structure * @param filter the builder filter structure
*/ */
export const buildLuceneQuery = (filter: SearchFilter[]) => { export const buildLuceneQuery = (filter: SearchFilter[]) => {
let query: SearchQuery = { let query: SearchFilters = {
string: {}, string: {},
fuzzy: {}, fuzzy: {},
range: {}, range: {},
@ -151,6 +158,7 @@ export const buildLuceneQuery = (filter: SearchFilter[]) => {
filter.forEach(expression => { filter.forEach(expression => {
let { operator, field, type, value, externalType, onEmptyFilter } = let { operator, field, type, value, externalType, onEmptyFilter } =
expression expression
const queryOperator = operator as SearchFilterOperator
const isHbs = const isHbs =
typeof value === "string" && (value.match(HBS_REGEX) || []).length > 0 typeof value === "string" && (value.match(HBS_REGEX) || []).length > 0
// Parse all values into correct types // Parse all values into correct types
@ -165,8 +173,8 @@ export const buildLuceneQuery = (filter: SearchFilter[]) => {
if ( if (
type === "datetime" && type === "datetime" &&
!isHbs && !isHbs &&
operator !== "empty" && queryOperator !== "empty" &&
operator !== "notEmpty" queryOperator !== "notEmpty"
) { ) {
// Ensure date value is a valid date and parse into correct format // Ensure date value is a valid date and parse into correct format
if (!value) { if (!value) {
@ -179,7 +187,7 @@ export const buildLuceneQuery = (filter: SearchFilter[]) => {
} }
} }
if (type === "number" && typeof value === "string" && !isHbs) { if (type === "number" && typeof value === "string" && !isHbs) {
if (operator === "oneOf") { if (queryOperator === "oneOf") {
value = value.split(",").map(item => parseFloat(item)) value = value.split(",").map(item => parseFloat(item))
} else { } else {
value = parseFloat(value) value = parseFloat(value)
@ -219,24 +227,24 @@ export const buildLuceneQuery = (filter: SearchFilter[]) => {
) { ) {
query.range[field].high = value query.range[field].high = value
} }
} else if (query[operator] && 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.
// "equals false" needs to be "not equals true" // "equals false" needs to be "not equals true"
// "not equals false" needs to be "equals true" // "not equals false" needs to be "equals true"
if (operator === "equal" && value === false) { if (queryOperator === "equal" && value === false) {
query.notEqual = query.notEqual || {} query.notEqual = query.notEqual || {}
query.notEqual[field] = true query.notEqual[field] = true
} else if (operator === "notEqual" && value === false) { } else if (queryOperator === "notEqual" && value === false) {
query.equal = query.equal || {} query.equal = query.equal || {}
query.equal[field] = true query.equal[field] = true
} else { } else {
query[operator] = query[operator] || {} query[queryOperator] = query[queryOperator] || {}
query[operator]![field] = value query[queryOperator]![field] = value
} }
} else { } else {
query[operator] = query[operator] || {} query[queryOperator] = query[queryOperator] || {}
query[operator]![field] = value query[queryOperator]![field] = value
} }
} }
}) })
@ -249,7 +257,7 @@ export const buildLuceneQuery = (filter: SearchFilter[]) => {
* @param docs the data * @param docs the data
* @param query the JSON lucene query * @param query the JSON lucene query
*/ */
export const runLuceneQuery = (docs: any[], query?: SearchQuery) => { export const runLuceneQuery = (docs: any[], query?: SearchFilters) => {
if (!docs || !Array.isArray(docs)) { if (!docs || !Array.isArray(docs)) {
return [] return []
} }
@ -263,7 +271,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Iterates over a set of filters and evaluates a fail function against a doc // Iterates over a set of filters and evaluates a fail function against a doc
const match = const match =
( (
type: keyof SearchQueryFields, type: SearchFilterOperator,
failFn: (docValue: any, testValue: any) => boolean failFn: (docValue: any, testValue: any) => boolean
) => ) =>
(doc: any) => { (doc: any) => {
@ -280,7 +288,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process a string match (fails if the value does not start with the string) // Process a string match (fails if the value does not start with the string)
const stringMatch = match( const stringMatch = match(
SearchQueryOperators.STRING, SearchFilterOperator.STRING,
(docValue: string, testValue: string) => { (docValue: string, testValue: string) => {
return ( return (
!docValue || !docValue ||
@ -291,7 +299,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process a fuzzy match (treat the same as starts with when running locally) // Process a fuzzy match (treat the same as starts with when running locally)
const fuzzyMatch = match( const fuzzyMatch = match(
SearchQueryOperators.FUZZY, SearchFilterOperator.FUZZY,
(docValue: string, testValue: string) => { (docValue: string, testValue: string) => {
return ( return (
!docValue || !docValue ||
@ -302,7 +310,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process a range match // Process a range match
const rangeMatch = match( const rangeMatch = match(
SearchQueryOperators.RANGE, SearchFilterOperator.RANGE,
( (
docValue: string | number | null, docValue: string | number | null,
testValue: { low: number; high: number } testValue: { low: number; high: number }
@ -325,7 +333,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process an equal match (fails if the value is different) // Process an equal match (fails if the value is different)
const equalMatch = match( const equalMatch = match(
SearchQueryOperators.EQUAL, SearchFilterOperator.EQUAL,
(docValue: any, testValue: string | null) => { (docValue: any, testValue: string | null) => {
return testValue != null && testValue !== "" && docValue !== testValue return testValue != null && testValue !== "" && docValue !== testValue
} }
@ -333,7 +341,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process a not-equal match (fails if the value is the same) // Process a not-equal match (fails if the value is the same)
const notEqualMatch = match( const notEqualMatch = match(
SearchQueryOperators.NOT_EQUAL, SearchFilterOperator.NOT_EQUAL,
(docValue: any, testValue: string | null) => { (docValue: any, testValue: string | null) => {
return testValue != null && testValue !== "" && docValue === testValue return testValue != null && testValue !== "" && docValue === testValue
} }
@ -341,7 +349,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process an empty match (fails if the value is not empty) // Process an empty match (fails if the value is not empty)
const emptyMatch = match( const emptyMatch = match(
SearchQueryOperators.EMPTY, SearchFilterOperator.EMPTY,
(docValue: string | null) => { (docValue: string | null) => {
return docValue != null && docValue !== "" return docValue != null && docValue !== ""
} }
@ -349,7 +357,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process a not-empty match (fails is the value is empty) // Process a not-empty match (fails is the value is empty)
const notEmptyMatch = match( const notEmptyMatch = match(
SearchQueryOperators.NOT_EMPTY, SearchFilterOperator.NOT_EMPTY,
(docValue: string | null) => { (docValue: string | null) => {
return docValue == null || docValue === "" return docValue == null || docValue === ""
} }
@ -357,7 +365,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process an includes match (fails if the value is not included) // Process an includes match (fails if the value is not included)
const oneOf = match( const oneOf = match(
SearchQueryOperators.ONE_OF, SearchFilterOperator.ONE_OF,
(docValue: any, testValue: any) => { (docValue: any, testValue: any) => {
if (typeof testValue === "string") { if (typeof testValue === "string") {
testValue = testValue.split(",") testValue = testValue.split(",")
@ -370,28 +378,28 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
) )
const containsAny = match( const containsAny = match(
SearchQueryOperators.CONTAINS_ANY, SearchFilterOperator.CONTAINS_ANY,
(docValue: any, testValue: any) => { (docValue: any, testValue: any) => {
return !docValue?.includes(...testValue) return !docValue?.includes(...testValue)
} }
) )
const contains = match( const contains = match(
SearchQueryOperators.CONTAINS, SearchFilterOperator.CONTAINS,
(docValue: string | any[], testValue: any[]) => { (docValue: string | any[], testValue: any[]) => {
return !testValue?.every((item: any) => docValue?.includes(item)) return !testValue?.every((item: any) => docValue?.includes(item))
} }
) )
const notContains = match( const notContains = match(
SearchQueryOperators.NOT_CONTAINS, SearchFilterOperator.NOT_CONTAINS,
(docValue: string | any[], testValue: any[]) => { (docValue: string | any[], testValue: any[]) => {
return testValue?.every((item: any) => docValue?.includes(item)) return testValue?.every((item: any) => docValue?.includes(item))
} }
) )
const docMatch = (doc: any) => { const docMatch = (doc: any) => {
const filterFunctions: Record<SearchQueryOperators, (doc: any) => boolean> = const filterFunctions: Record<SearchFilterOperator, (doc: any) => boolean> =
{ {
string: stringMatch, string: stringMatch,
fuzzy: fuzzyMatch, fuzzy: fuzzyMatch,
@ -406,7 +414,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
notContains: notContains, notContains: notContains,
} }
const activeFilterKeys: SearchQueryOperators[] = Object.entries(query || {}) const activeFilterKeys: SearchFilterOperator[] = Object.entries(query || {})
.filter( .filter(
([key, value]: [string, any]) => ([key, value]: [string, any]) =>
!["allOr", "onEmptyFilter"].includes(key) && !["allOr", "onEmptyFilter"].includes(key) &&
@ -474,7 +482,7 @@ export const luceneLimit = (docs: any[], limit: string) => {
return docs.slice(0, numLimit) return docs.slice(0, numLimit)
} }
export const hasFilters = (query?: SearchQuery) => { export const hasFilters = (query?: SearchFilters) => {
if (!query) { if (!query) {
return false return false
} }

View file

@ -1,6 +1,6 @@
import { import {
SearchQuery, SearchFilters,
SearchQueryOperators, SearchFilterOperator,
FieldType, FieldType,
SearchFilter, SearchFilter,
} from "@budibase/types" } from "@budibase/types"
@ -46,8 +46,8 @@ describe("runLuceneQuery", () => {
}, },
] ]
function buildQuery(filters: { [filterKey: string]: any }): SearchQuery { function buildQuery(filters: { [filterKey: string]: any }): SearchFilters {
const query: SearchQuery = { const query: SearchFilters = {
string: {}, string: {},
fuzzy: {}, fuzzy: {},
range: {}, range: {},
@ -63,7 +63,7 @@ describe("runLuceneQuery", () => {
} }
for (const filterKey in filters) { for (const filterKey in filters) {
query[filterKey as SearchQueryOperators] = filters[filterKey] query[filterKey as SearchFilterOperator] = filters[filterKey]
} }
return query return query
@ -265,13 +265,13 @@ describe("buildLuceneQuery", () => {
it("should parseFloat if the type is a number, but the value is a numeric string", () => { it("should parseFloat if the type is a number, but the value is a numeric string", () => {
const filter: SearchFilter[] = [ const filter: SearchFilter[] = [
{ {
operator: SearchQueryOperators.EQUAL, operator: SearchFilterOperator.EQUAL,
field: "customer_id", field: "customer_id",
type: FieldType.NUMBER, type: FieldType.NUMBER,
value: "1212", value: "1212",
}, },
{ {
operator: SearchQueryOperators.ONE_OF, operator: SearchFilterOperator.ONE_OF,
field: "customer_id", field: "customer_id",
type: FieldType.NUMBER, type: FieldType.NUMBER,
value: "1000,1212,3400", value: "1000,1212,3400",
@ -299,13 +299,13 @@ describe("buildLuceneQuery", () => {
it("should not parseFloat if the type is a number, but the value is a handlebars binding string", () => { it("should not parseFloat if the type is a number, but the value is a handlebars binding string", () => {
const filter: SearchFilter[] = [ const filter: SearchFilter[] = [
{ {
operator: SearchQueryOperators.EQUAL, operator: SearchFilterOperator.EQUAL,
field: "customer_id", field: "customer_id",
type: FieldType.NUMBER, type: FieldType.NUMBER,
value: "{{ customer_id }}", value: "{{ customer_id }}",
}, },
{ {
operator: SearchQueryOperators.ONE_OF, operator: SearchFilterOperator.ONE_OF,
field: "customer_id", field: "customer_id",
type: FieldType.NUMBER, type: FieldType.NUMBER,
value: "{{ list_of_customer_ids }}", value: "{{ list_of_customer_ids }}",
@ -333,19 +333,19 @@ describe("buildLuceneQuery", () => {
it("should cast string to boolean if the type is boolean", () => { it("should cast string to boolean if the type is boolean", () => {
const filter: SearchFilter[] = [ const filter: SearchFilter[] = [
{ {
operator: SearchQueryOperators.EQUAL, operator: SearchFilterOperator.EQUAL,
field: "a", field: "a",
type: FieldType.BOOLEAN, type: FieldType.BOOLEAN,
value: "not_true", value: "not_true",
}, },
{ {
operator: SearchQueryOperators.NOT_EQUAL, operator: SearchFilterOperator.NOT_EQUAL,
field: "b", field: "b",
type: FieldType.BOOLEAN, type: FieldType.BOOLEAN,
value: "not_true", value: "not_true",
}, },
{ {
operator: SearchQueryOperators.EQUAL, operator: SearchFilterOperator.EQUAL,
field: "c", field: "c",
type: FieldType.BOOLEAN, type: FieldType.BOOLEAN,
value: "true", value: "true",
@ -374,19 +374,19 @@ describe("buildLuceneQuery", () => {
it("should split the string for contains operators", () => { it("should split the string for contains operators", () => {
const filter: SearchFilter[] = [ const filter: SearchFilter[] = [
{ {
operator: SearchQueryOperators.CONTAINS, operator: SearchFilterOperator.CONTAINS,
field: "description", field: "description",
type: FieldType.ARRAY, type: FieldType.ARRAY,
value: "Large box,Heavy box,Small box", value: "Large box,Heavy box,Small box",
}, },
{ {
operator: SearchQueryOperators.NOT_CONTAINS, operator: SearchFilterOperator.NOT_CONTAINS,
field: "description", field: "description",
type: FieldType.ARRAY, type: FieldType.ARRAY,
value: "Large box,Heavy box,Small box", value: "Large box,Heavy box,Small box",
}, },
{ {
operator: SearchQueryOperators.CONTAINS_ANY, operator: SearchFilterOperator.CONTAINS_ANY,
field: "description", field: "description",
type: FieldType.ARRAY, type: FieldType.ARRAY,
value: "Large box,Heavy box,Small box", value: "Large box,Heavy box,Small box",

View file

@ -1,68 +1,11 @@
import { FieldType } from "../../documents" import { FieldType } from "../../documents"
import { EmptyFilterOption } from "../../sdk" import { EmptyFilterOption, SearchFilters } from "../../sdk"
export type SearchFilter = { export type SearchFilter = {
operator: keyof SearchQuery operator: keyof SearchFilters | "rangeLow" | "rangeHigh"
onEmptyFilter?: EmptyFilterOption onEmptyFilter?: EmptyFilterOption
field: string field: string
type?: FieldType type?: FieldType
value: any value: any
externalType?: string externalType?: string
} }
export enum SearchQueryOperators {
STRING = "string",
FUZZY = "fuzzy",
RANGE = "range",
EQUAL = "equal",
NOT_EQUAL = "notEqual",
EMPTY = "empty",
NOT_EMPTY = "notEmpty",
ONE_OF = "oneOf",
CONTAINS = "contains",
NOT_CONTAINS = "notContains",
CONTAINS_ANY = "containsAny",
}
export type SearchQuery = {
allOr?: boolean
onEmptyFilter?: EmptyFilterOption
[SearchQueryOperators.STRING]?: {
[key: string]: string
}
[SearchQueryOperators.FUZZY]?: {
[key: string]: string
}
[SearchQueryOperators.RANGE]?: {
[key: string]: {
high: number | string
low: number | string
}
}
[SearchQueryOperators.EQUAL]?: {
[key: string]: any
}
[SearchQueryOperators.NOT_EQUAL]?: {
[key: string]: any
}
[SearchQueryOperators.EMPTY]?: {
[key: string]: any
}
[SearchQueryOperators.NOT_EMPTY]?: {
[key: string]: any
}
[SearchQueryOperators.ONE_OF]?: {
[key: string]: any[]
}
[SearchQueryOperators.CONTAINS]?: {
[key: string]: any[]
}
[SearchQueryOperators.NOT_CONTAINS]?: {
[key: string]: any[]
}
[SearchQueryOperators.CONTAINS_ANY]?: {
[key: string]: any[]
}
}
export type SearchQueryFields = Omit<SearchQuery, "allOr" | "onEmptyFilter">

View file

@ -1,5 +1,5 @@
import { User } from "../../documents" import { User } from "../../documents"
import { SearchQuery } from "./searchFilter" import { SearchFilters } from "../../sdk"
export interface SaveUserResponse { export interface SaveUserResponse {
_id: string _id: string
@ -55,7 +55,7 @@ export interface InviteUsersResponse {
export interface SearchUsersRequest { export interface SearchUsersRequest {
bookmark?: string bookmark?: string
query?: SearchQuery query?: SearchFilters
appId?: string appId?: string
limit?: number limit?: number
paginate?: boolean paginate?: boolean

View file

@ -3,47 +3,63 @@ import { Row, Table } from "../documents"
import { SortType } from "../api" import { SortType } from "../api"
import { Knex } from "knex" import { Knex } from "knex"
export enum SearchFilterOperator {
STRING = "string",
FUZZY = "fuzzy",
RANGE = "range",
EQUAL = "equal",
NOT_EQUAL = "notEqual",
EMPTY = "empty",
NOT_EMPTY = "notEmpty",
ONE_OF = "oneOf",
CONTAINS = "contains",
NOT_CONTAINS = "notContains",
CONTAINS_ANY = "containsAny",
}
export interface SearchFilters { export interface SearchFilters {
allOr?: boolean allOr?: boolean
onEmptyFilter?: EmptyFilterOption onEmptyFilter?: EmptyFilterOption
string?: { [SearchFilterOperator.STRING]?: {
[key: string]: string [key: string]: string
} }
fuzzy?: { [SearchFilterOperator.FUZZY]?: {
[key: string]: string [key: string]: string
} }
range?: { [SearchFilterOperator.RANGE]?: {
[key: string]: { [key: string]: {
high: number | string high: number | string
low: number | string low: number | string
} }
} }
equal?: { [SearchFilterOperator.EQUAL]?: {
[key: string]: any [key: string]: any
} }
notEqual?: { [SearchFilterOperator.NOT_EQUAL]?: {
[key: string]: any [key: string]: any
} }
empty?: { [SearchFilterOperator.EMPTY]?: {
[key: string]: any [key: string]: any
} }
notEmpty?: { [SearchFilterOperator.NOT_EMPTY]?: {
[key: string]: any [key: string]: any
} }
oneOf?: { [SearchFilterOperator.ONE_OF]?: {
[key: string]: any[] [key: string]: any[]
} }
contains?: { [SearchFilterOperator.CONTAINS]?: {
[key: string]: any[] | any
}
notContains?: {
[key: string]: any[] [key: string]: any[]
} }
containsAny?: { [SearchFilterOperator.NOT_CONTAINS]?: {
[key: string]: any[]
}
[SearchFilterOperator.CONTAINS_ANY]?: {
[key: string]: any[] [key: string]: any[]
} }
} }
export type SearchQueryFields = Omit<SearchFilters, "allOr" | "onEmptyFilter">
export interface SortJson { export interface SortJson {
[key: string]: { [key: string]: {
direction: SortDirection direction: SortDirection

View file

@ -4,7 +4,7 @@ import {
InviteUsersRequest, InviteUsersRequest,
User, User,
CreateAdminUserRequest, CreateAdminUserRequest,
SearchQuery, SearchFilters,
InviteUsersResponse, InviteUsersResponse,
} from "@budibase/types" } from "@budibase/types"
import structures from "../structures" import structures from "../structures"
@ -150,7 +150,7 @@ export class UserAPI extends TestAPI {
} }
searchUsers = ( searchUsers = (
{ query }: { query?: SearchQuery }, { query }: { query?: SearchFilters },
opts?: { status?: number; noHeaders?: boolean } opts?: { status?: number; noHeaders?: boolean }
) => { ) => {
const req = this.request const req = this.request