Merge branch 'BUDI-9127/oauth2-config-validation' into BUDI-9127/edit-delete-configs-frontend

This commit is contained in:
Adria Navarro 2025-03-18 18:43:10 +01:00
commit adb4e8e9ca
19 changed files with 528 additions and 126 deletions

View File

@ -89,7 +89,7 @@
/* Selection is only meant for standalone list items (non stacked) so we just set a fixed border radius */ /* Selection is only meant for standalone list items (non stacked) so we just set a fixed border radius */
.list-item.selected { .list-item.selected {
background-color: var(--spectrum-global-color-blue-100); background-color: var(--spectrum-global-color-blue-100);
border-color: var(--spectrum-global-color-blue-100); border: none;
} }
.list-item.selected:after { .list-item.selected:after {
content: ""; content: "";
@ -100,7 +100,7 @@
pointer-events: none; pointer-events: none;
top: 0; top: 0;
left: 0; left: 0;
border-radius: 4px; border-radius: inherit;
box-sizing: border-box; box-sizing: border-box;
z-index: 1; z-index: 1;
opacity: 0.5; opacity: 0.5;

View File

@ -1,16 +1,18 @@
<script> <script lang="ts">
import { Modal, ModalContent, Body } from "@budibase/bbui" import { Modal, ModalContent, Body } from "@budibase/bbui"
export let title = "" export let title: string = ""
export let body = "" export let body: string = ""
export let okText = "Confirm" export let okText: string = "Confirm"
export let cancelText = "Cancel" export let cancelText: string = "Cancel"
export let onOk = undefined export let size: "S" | "M" | "L" | "XL" | undefined = undefined
export let onCancel = undefined export let onOk: (() => void) | undefined = undefined
export let warning = true export let onCancel: (() => void) | undefined = undefined
export let disabled = false export let onClose: (() => void) | undefined = undefined
export let warning: boolean = true
export let disabled: boolean = false
let modal let modal: Modal
export const show = () => { export const show = () => {
modal.show() modal.show()
@ -20,14 +22,16 @@
} }
</script> </script>
<Modal bind:this={modal} on:hide={onCancel}> <Modal bind:this={modal} on:hide={onClose ?? onCancel}>
<ModalContent <ModalContent
onConfirm={onOk} onConfirm={onOk}
{onCancel}
{title} {title}
confirmText={okText} confirmText={okText}
{cancelText} {cancelText}
{warning} {warning}
{disabled} {disabled}
{size}
> >
<Body size="S"> <Body size="S">
{body} {body}

View File

@ -1,5 +1,5 @@
<script> <script>
import { goto, params } from "@roxi/routify" import { beforeUrlChange, goto, params } from "@roxi/routify"
import { datasources, flags, integrations, queries } from "@/stores/builder" import { datasources, flags, integrations, queries } from "@/stores/builder"
import { environment } from "@/stores/portal" import { environment } from "@/stores/portal"
import { import {
@ -25,7 +25,7 @@
EditorModes, EditorModes,
} from "@/components/common/CodeMirrorEditor.svelte" } from "@/components/common/CodeMirrorEditor.svelte"
import RestBodyInput from "./RestBodyInput.svelte" import RestBodyInput from "./RestBodyInput.svelte"
import { capitalise } from "@/helpers" import { capitalise, confirm } from "@/helpers"
import { onMount } from "svelte" import { onMount } from "svelte"
import restUtils from "@/helpers/data/utils" import restUtils from "@/helpers/data/utils"
import { import {
@ -50,6 +50,7 @@
toBindingsArray, toBindingsArray,
} from "@/dataBinding" } from "@/dataBinding"
import ConnectedQueryScreens from "./ConnectedQueryScreens.svelte" import ConnectedQueryScreens from "./ConnectedQueryScreens.svelte"
import AuthPicker from "./rest/AuthPicker.svelte"
export let queryId export let queryId
@ -63,6 +64,7 @@
let nestedSchemaFields = {} let nestedSchemaFields = {}
let saving let saving
let queryNameLabel let queryNameLabel
let mounted = false
$: staticVariables = datasource?.config?.staticVariables || {} $: staticVariables = datasource?.config?.staticVariables || {}
@ -104,8 +106,10 @@
$: runtimeUrlQueries = readableToRuntimeMap(mergedBindings, breakQs) $: runtimeUrlQueries = readableToRuntimeMap(mergedBindings, breakQs)
$: originalQuery = originalQuery ?? cloneDeep(query)
$: builtQuery = buildQuery(query, runtimeUrlQueries, requestBindings) $: builtQuery = buildQuery(query, runtimeUrlQueries, requestBindings)
$: originalQuery = mounted
? originalQuery ?? cloneDeep(builtQuery)
: undefined
$: isModified = JSON.stringify(originalQuery) !== JSON.stringify(builtQuery) $: isModified = JSON.stringify(originalQuery) !== JSON.stringify(builtQuery)
function getSelectedQuery() { function getSelectedQuery() {
@ -208,11 +212,14 @@
originalQuery = null originalQuery = null
queryNameLabel.disableEditingState() queryNameLabel.disableEditingState()
return { ok: true }
} catch (err) { } catch (err) {
notifications.error(`Error saving query`) notifications.error(`Error saving query`)
} finally { } finally {
saving = false saving = false
} }
return { ok: false }
} }
const validateQuery = async () => { const validateQuery = async () => {
@ -474,6 +481,38 @@
staticVariables, staticVariables,
restBindings restBindings
) )
mounted = true
})
$beforeUrlChange(async () => {
if (!isModified) {
return true
}
return await confirm({
title: "Some updates are not saved",
body: "Some of your changes are not yet saved. Do you want to save them before leaving?",
okText: "Save and continue",
cancelText: "Discard and continue",
size: "M",
onConfirm: async () => {
const saveResult = await saveQuery()
if (!saveResult.ok) {
// We can't leave as the query was not properly saved
return false
}
return true
},
onCancel: () => {
// Leave without saving anything
return true
},
onClose: () => {
return false
},
})
}) })
</script> </script>
@ -642,15 +681,12 @@
<div class="auth-container"> <div class="auth-container">
<div /> <div />
<!-- spacer --> <!-- spacer -->
<div class="auth-select">
<Select <AuthPicker
label="Auth" bind:authConfigId={query.fields.authConfigId}
labelPosition="left" {authConfigs}
placeholder="None" datasourceId={datasource._id}
bind:value={query.fields.authConfigId} />
options={authConfigs}
/>
</div>
</div> </div>
</Tabs> </Tabs>
</Layout> </Layout>
@ -853,10 +889,6 @@
justify-content: space-between; justify-content: space-between;
} }
.auth-select {
width: 200px;
}
.pagination { .pagination {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;

View File

@ -0,0 +1,71 @@
<script lang="ts">
import {
ActionButton,
Body,
Button,
List,
ListItem,
PopoverAlignment,
} from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { appStore } from "@/stores/builder"
import DetailPopover from "@/components/common/DetailPopover.svelte"
export let authConfigId: string | undefined
export let authConfigs: { label: string; value: string }[]
export let datasourceId: string
let popover: DetailPopover
$: authConfig = authConfigs.find(c => c.value === authConfigId)
function addBasicConfiguration() {
$goto(
`/builder/app/${$appStore.appId}/data/datasource/${datasourceId}?&tab=Authentication`
)
}
function selectConfiguration(id: string) {
if (authConfigId === id) {
authConfigId = undefined
} else {
authConfigId = id
}
popover.hide()
}
$: title = !authConfig ? "Authentication" : `Auth: ${authConfig.label}`
</script>
<DetailPopover bind:this={popover} {title} align={PopoverAlignment.Right}>
<div slot="anchor">
<ActionButton icon="LockClosed" quiet selected>
{#if !authConfig}
Authentication
{:else}
Auth: {authConfig.label}
{/if}
</ActionButton>
</div>
<Body size="S" color="var(--spectrum-global-color-gray-700)">
Basic & Bearer Authentication
</Body>
{#if authConfigs.length}
<List>
{#each authConfigs as config}
<ListItem
title={config.label}
on:click={() => selectConfiguration(config.value)}
selected={config.value === authConfigId}
/>
{/each}
</List>
{/if}
<div>
<Button secondary icon="Add" on:click={addBasicConfiguration}
>Add config</Button
>
</div>
</DetailPopover>

View File

@ -0,0 +1,41 @@
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
export enum ConfirmOutput {}
export async function confirm(props: {
title: string
body?: string
okText?: string
cancelText?: string
size?: "S" | "M" | "L" | "XL"
onConfirm?: () => void
onCancel?: () => void
onClose?: () => void
}) {
return await new Promise(resolve => {
const dialog = new ConfirmDialog({
target: document.body,
props: {
title: props.title,
body: props.body,
okText: props.okText,
cancelText: props.cancelText,
size: props.size,
warning: false,
onOk: () => {
dialog.$destroy()
resolve(props.onConfirm?.() || true)
},
onCancel: () => {
dialog.$destroy()
resolve(props.onCancel?.() || false)
},
onClose: () => {
dialog.$destroy()
resolve(props.onClose?.() || false)
},
},
})
dialog.show()
})
}

View File

@ -11,3 +11,4 @@ export {
} from "./helpers" } from "./helpers"
export * as featureFlag from "./featureFlags" export * as featureFlag from "./featureFlags"
export * as bindings from "./bindings" export * as bindings from "./bindings"
export * from "./confirm"

View File

@ -1,4 +1,5 @@
<script> <script>
import { params } from "@roxi/routify"
import { Tabs, Tab, Heading, Body, Layout } from "@budibase/bbui" import { Tabs, Tab, Heading, Body, Layout } from "@budibase/bbui"
import { datasources, integrations } from "@/stores/builder" import { datasources, integrations } from "@/stores/builder"
import ICONS from "@/components/backend/DatasourceNavigator/icons" import ICONS from "@/components/backend/DatasourceNavigator/icons"
@ -15,7 +16,7 @@
import { admin } from "@/stores/portal" import { admin } from "@/stores/portal"
import { IntegrationTypes } from "@/constants/backend" import { IntegrationTypes } from "@/constants/backend"
let selectedPanel = null let selectedPanel = $params.tab ?? null
let panelOptions = [] let panelOptions = []
$: datasource = $datasources.selected $: datasource = $datasources.selected

View File

@ -79,6 +79,7 @@
<Heading size="M">Reset your password</Heading> <Heading size="M">Reset your password</Heading>
<Body size="M">Must contain at least 12 characters</Body> <Body size="M">Must contain at least 12 characters</Body>
<PasswordRepeatInput <PasswordRepeatInput
bind:passwordForm={form}
bind:password bind:password
bind:error={passwordError} bind:error={passwordError}
minLength={$admin.passwordMinLength || 12} minLength={$admin.passwordMinLength || 12}

View File

@ -41,4 +41,15 @@
div :global(img) { div :global(img) {
max-width: 100%; max-width: 100%;
} }
div :global(.editor-preview-full) {
height: auto;
}
div :global(h1),
div :global(h2),
div :global(h3),
div :global(h4),
div :global(h5),
div :global(h6) {
font-weight: 600;
}
</style> </style>

View File

@ -1,17 +1,27 @@
<script context="module" lang="ts">
type ValueType = string | string[]
type BasicRelatedRow = { _id: string; primaryDisplay: string }
type OptionsMap = Record<string, BasicRelatedRow>
</script>
<script lang="ts"> <script lang="ts">
import { CoreSelect, CoreMultiselect } from "@budibase/bbui" import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import { BasicOperator, FieldType, InternalTable } from "@budibase/types" import {
BasicOperator,
EmptyFilterOption,
FieldType,
InternalTable,
UILogicalOperator,
type LegacyFilter,
type SearchFilterGroup,
type UISearchFilter,
} from "@budibase/types"
import { fetchData, Utils } from "@budibase/frontend-core" import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext } from "svelte" import { getContext } from "svelte"
import Field from "./Field.svelte" import Field from "./Field.svelte"
import type { import type { RelationshipFieldMetadata, Row } from "@budibase/types"
SearchFilter,
RelationshipFieldMetadata,
Row,
} from "@budibase/types"
import type { FieldApi, FieldState, FieldValidation } from "@/types" import type { FieldApi, FieldState, FieldValidation } from "@/types"
import { utils } from "@budibase/shared-core"
type ValueType = string | string[]
export let field: string | undefined = undefined export let field: string | undefined = undefined
export let label: string | undefined = undefined export let label: string | undefined = undefined
@ -22,7 +32,7 @@
export let autocomplete: boolean = true export let autocomplete: boolean = true
export let defaultValue: ValueType | undefined = undefined export let defaultValue: ValueType | undefined = undefined
export let onChange: (_props: { value: ValueType }) => void export let onChange: (_props: { value: ValueType }) => void
export let filter: SearchFilter[] export let filter: UISearchFilter | LegacyFilter[] | undefined = undefined
export let datasourceType: "table" | "user" = "table" export let datasourceType: "table" | "user" = "table"
export let primaryDisplay: string | undefined = undefined export let primaryDisplay: string | undefined = undefined
export let span: number | undefined = undefined export let span: number | undefined = undefined
@ -32,14 +42,10 @@
| FieldType.BB_REFERENCE | FieldType.BB_REFERENCE
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK | FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
type BasicRelatedRow = { _id: string; primaryDisplay: string }
type OptionsMap = Record<string, BasicRelatedRow>
const { API } = getContext("sdk") const { API } = getContext("sdk")
// Field state // Field state
let fieldState: FieldState<string | string[]> | undefined let fieldState: FieldState<string | string[]> | undefined
let fieldApi: FieldApi let fieldApi: FieldApi
let fieldSchema: RelationshipFieldMetadata | undefined let fieldSchema: RelationshipFieldMetadata | undefined
@ -52,6 +58,9 @@
let optionsMap: OptionsMap = {} let optionsMap: OptionsMap = {}
let loadingMissingOptions: boolean = false let loadingMissingOptions: boolean = false
// Reset the available options when our base filter changes
$: filter, (optionsMap = {})
// Determine if we can select multiple rows or not // Determine if we can select multiple rows or not
$: multiselect = $: multiselect =
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) && [FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
@ -65,7 +74,13 @@
// If writable, we use a fetch to load options // If writable, we use a fetch to load options
$: linkedTableId = fieldSchema?.tableId $: linkedTableId = fieldSchema?.tableId
$: writable = !disabled && !readonly $: writable = !disabled && !readonly
$: fetch = createFetch(writable, datasourceType, filter, linkedTableId) $: migratedFilter = migrateFilter(filter)
$: fetch = createFetch(
writable,
datasourceType,
migratedFilter,
linkedTableId
)
// Attempt to determine the primary display field to use // Attempt to determine the primary display field to use
$: tableDefinition = $fetch?.definition $: tableDefinition = $fetch?.definition
@ -90,8 +105,8 @@
// Ensure backwards compatibility // Ensure backwards compatibility
$: enrichedDefaultValue = enrichDefaultValue(defaultValue) $: enrichedDefaultValue = enrichDefaultValue(defaultValue)
$: emptyValue = multiselect ? [] : undefined
// We need to cast value to pass it down, as those components aren't typed // We need to cast value to pass it down, as those components aren't typed
$: emptyValue = multiselect ? [] : undefined
$: displayValue = (missingIDs.length ? emptyValue : selectedValue) as any $: displayValue = (missingIDs.length ? emptyValue : selectedValue) as any
// Ensures that we flatten any objects so that only the IDs of the selected // Ensures that we flatten any objects so that only the IDs of the selected
@ -107,7 +122,7 @@
const createFetch = ( const createFetch = (
writable: boolean, writable: boolean,
dsType: typeof datasourceType, dsType: typeof datasourceType,
filter: SearchFilter[], filter: UISearchFilter | undefined,
linkedTableId?: string linkedTableId?: string
) => { ) => {
const datasource = const datasource =
@ -176,9 +191,16 @@
option: string | BasicRelatedRow | Row, option: string | BasicRelatedRow | Row,
primaryDisplay?: string primaryDisplay?: string
): BasicRelatedRow | null => { ): BasicRelatedRow | null => {
// For plain strings, check if we already have this option available
if (typeof option === "string" && optionsMap[option]) {
return optionsMap[option]
}
// Otherwise ensure we have a valid option object
if (!option || typeof option !== "object" || !option?._id) { if (!option || typeof option !== "object" || !option?._id) {
return null return null
} }
// If this is a basic related row shape (_id and PD only) then just use // If this is a basic related row shape (_id and PD only) then just use
// that // that
if (Object.keys(option).length === 2 && "primaryDisplay" in option) { if (Object.keys(option).length === 2 && "primaryDisplay" in option) {
@ -300,24 +322,54 @@
return val.includes(",") ? val.split(",") : val return val.includes(",") ? val.split(",") : val
} }
// We may need to migrate the filter structure, in the case of this being
// an old app with LegacyFilter[] saved
const migrateFilter = (
filter: UISearchFilter | LegacyFilter[] | undefined
): UISearchFilter | undefined => {
if (Array.isArray(filter)) {
return utils.processSearchFilters(filter)
}
return filter
}
// Searches for new options matching the given term // Searches for new options matching the given term
async function searchOptions(searchTerm: string, primaryDisplay?: string) { async function searchOptions(searchTerm: string, primaryDisplay?: string) {
if (!primaryDisplay) { if (!primaryDisplay) {
return return
} }
let newFilter: UISearchFilter | undefined = undefined
// Ensure we match all filters, rather than any let searchFilter: SearchFilterGroup = {
let newFilter = filter logicalOperator: UILogicalOperator.ALL,
if (searchTerm) { filters: [
// @ts-expect-error this doesn't fit types, but don't want to change it yet {
newFilter = (newFilter || []).filter(x => x.operator !== "allOr") field: primaryDisplay,
newFilter.push({ operator: BasicOperator.STRING,
// Use a big numeric prefix to avoid clashing with an existing filter value: searchTerm,
field: `999:${primaryDisplay}`, },
operator: BasicOperator.STRING, ],
value: searchTerm,
})
} }
// Determine the new filter to apply to the fetch
if (searchTerm && migratedFilter) {
// If we have both a search term and existing filter, filter by both
newFilter = {
logicalOperator: UILogicalOperator.ALL,
groups: [searchFilter, migratedFilter],
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
}
} else if (searchTerm) {
// If we just have a search term them use that
newFilter = {
logicalOperator: UILogicalOperator.ALL,
groups: [searchFilter],
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
}
} else {
// Otherwise use the supplied filter untouched
newFilter = migratedFilter
}
await fetch?.update({ await fetch?.update({
filter: newFilter, filter: newFilter,
}) })
@ -389,7 +441,6 @@
bind:searchTerm bind:searchTerm
bind:open bind:open
on:change={handleChange} on:change={handleChange}
on:loadMore={() => fetch?.nextPage()}
/> />
{/if} {/if}
</Field> </Field>

View File

@ -1,13 +1,14 @@
<script> <script lang="ts">
import { FancyForm, FancyInput } from "@budibase/bbui" import { FancyForm, FancyInput } from "@budibase/bbui"
import { createValidationStore, requiredValidator } from "../utils/validation" import { createValidationStore, requiredValidator } from "../utils/validation"
export let password export let passwordForm: FancyForm | undefined = undefined
export let error export let password: string
export let error: string
export let minLength = "12" export let minLength = "12"
const validatePassword = value => { const validatePassword = (value: string | undefined) => {
if (!value || value.length < minLength) { if (!value || value.length < parseInt(minLength)) {
return `Please enter at least ${minLength} characters. We recommend using machine generated or random passwords.` return `Please enter at least ${minLength} characters. We recommend using machine generated or random passwords.`
} }
return null return null
@ -35,7 +36,7 @@
firstPasswordError firstPasswordError
</script> </script>
<FancyForm> <FancyForm bind:this={passwordForm}>
<FancyInput <FancyInput
label="Password" label="Password"
type="password" type="password"

View File

@ -465,7 +465,7 @@
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
font-weight: bold; font-weight: 600;
} }
.header-cell.searching .name { .header-cell.searching .name {
opacity: 0; opacity: 0;

View File

@ -219,7 +219,7 @@
--grid-background-alt: var(--spectrum-global-color-gray-100); --grid-background-alt: var(--spectrum-global-color-gray-100);
--header-cell-background: var( --header-cell-background: var(
--custom-header-cell-background, --custom-header-cell-background,
var(--grid-background-alt) var(--spectrum-global-color-gray-100)
); );
--cell-background: var(--grid-background); --cell-background: var(--grid-background);
--cell-background-hover: var(--grid-background-alt); --cell-background-hover: var(--grid-background-alt);

View File

@ -397,14 +397,19 @@ export function parseFilter(filter: UISearchFilter) {
const update = cloneDeep(filter) const update = cloneDeep(filter)
update.groups = update.groups if (update.groups) {
?.map(group => { update.groups = update.groups
group.filters = group.filters?.filter((filter: any) => { .map(group => {
return filter.field && filter.operator if (group.filters) {
group.filters = group.filters.filter((filter: any) => {
return filter.field && filter.operator
})
return group.filters?.length ? group : null
}
return group
}) })
return group.filters?.length ? group : null .filter((group): group is SearchFilterGroup => !!group)
}) }
.filter((group): group is SearchFilterGroup => !!group)
return update return update
} }

View File

@ -358,8 +358,8 @@ async function performAppCreate(
}, },
theme: DefaultAppTheme, theme: DefaultAppTheme,
customTheme: { customTheme: {
primaryColor: "var(--spectrum-global-color-static-blue-1200)", primaryColor: "var(--spectrum-global-color-blue-700)",
primaryColorHover: "var(--spectrum-global-color-static-blue-800)", primaryColorHover: "var(--spectrum-global-color-blue-600)",
buttonBorderRadius: "16px", buttonBorderRadius: "16px",
}, },
features: { features: {

View File

@ -381,32 +381,37 @@ export class RestIntegration implements IntegrationBase {
authConfigId?: string, authConfigId?: string,
authConfigType?: RestAuthType authConfigType?: RestAuthType
): Promise<{ [key: string]: any }> { ): Promise<{ [key: string]: any }> {
let headers: any = {} if (!authConfigId) {
return {}
}
if (authConfigId) { if (authConfigType === RestAuthType.OAUTH2) {
if (authConfigType === RestAuthType.OAUTH2) { return { Authorization: await sdk.oauth2.generateToken(authConfigId) }
headers.Authorization = await sdk.oauth2.generateToken(authConfigId) }
} else if (this.config.authConfigs) {
const authConfig = this.config.authConfigs.filter( if (!this.config.authConfigs) {
c => c._id === authConfigId return {}
)[0] }
// check the config still exists before proceeding
// if not - do nothing let headers: any = {}
if (authConfig) { const authConfig = this.config.authConfigs.filter(
const { type, config } = authConfig c => c._id === authConfigId
switch (type) { )[0]
case RestAuthType.BASIC: // check the config still exists before proceeding
headers.Authorization = `Basic ${Buffer.from( // if not - do nothing
`${config.username}:${config.password}` if (authConfig) {
).toString("base64")}` const { type, config } = authConfig
break switch (type) {
case RestAuthType.BEARER: case RestAuthType.BASIC:
headers.Authorization = `Bearer ${config.token}` headers.Authorization = `Basic ${Buffer.from(
break `${config.username}:${config.password}`
default: ).toString("base64")}`
throw utils.unreachable(type) break
} case RestAuthType.BEARER:
} headers.Authorization = `Bearer ${config.token}`
break
default:
throw utils.unreachable(type)
} }
} }

View File

@ -1,30 +1,30 @@
import { import {
Datasource, ArrayOperator,
BasicOperator,
BBReferenceFieldSubType, BBReferenceFieldSubType,
Datasource,
EmptyFilterOption,
FieldConstraints,
FieldType, FieldType,
FormulaType, FormulaType,
isArraySearchOperator,
isBasicSearchOperator,
isLogicalSearchOperator,
isRangeSearchOperator,
LegacyFilter, LegacyFilter,
LogicalOperator,
RangeOperator,
RowSearchParams,
SearchFilter,
SearchFilterOperator,
SearchFilters, SearchFilters,
SearchQueryFields, SearchQueryFields,
ArrayOperator,
SearchFilterOperator,
SortType,
FieldConstraints,
SortOrder,
RowSearchParams,
EmptyFilterOption,
SearchResponse, SearchResponse,
SortOrder,
SortType,
Table, Table,
BasicOperator,
RangeOperator,
LogicalOperator,
isLogicalSearchOperator,
UISearchFilter,
UILogicalOperator, UILogicalOperator,
isBasicSearchOperator, UISearchFilter,
isArraySearchOperator,
isRangeSearchOperator,
SearchFilter,
} from "@budibase/types" } from "@budibase/types"
import dayjs from "dayjs" import dayjs from "dayjs"
import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants" import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants"
@ -444,6 +444,7 @@ export function buildQuery(
return {} return {}
} }
// Migrate legacy filters if required
if (Array.isArray(filter)) { if (Array.isArray(filter)) {
filter = processSearchFilters(filter) filter = processSearchFilters(filter)
if (!filter) { if (!filter) {
@ -451,10 +452,7 @@ export function buildQuery(
} }
} }
const operator = logicalOperatorFromUI( // Determine top level empty filter behaviour
filter.logicalOperator || UILogicalOperator.ALL
)
const query: SearchFilters = {} const query: SearchFilters = {}
if (filter.onEmptyFilter) { if (filter.onEmptyFilter) {
query.onEmptyFilter = filter.onEmptyFilter query.onEmptyFilter = filter.onEmptyFilter
@ -462,8 +460,24 @@ export function buildQuery(
query.onEmptyFilter = EmptyFilterOption.RETURN_ALL query.onEmptyFilter = EmptyFilterOption.RETURN_ALL
} }
// Default to matching all groups/filters
const operator = logicalOperatorFromUI(
filter.logicalOperator || UILogicalOperator.ALL
)
query[operator] = { query[operator] = {
conditions: (filter.groups || []).map(group => { conditions: (filter.groups || []).map(group => {
// Check if we contain more groups
if (group.groups) {
const searchFilter = buildQuery(group)
// We don't define this properly in the types, but certain fields should
// not be present in these nested search filters
delete searchFilter.onEmptyFilter
return searchFilter
}
// Otherwise handle filters
const { allOr, onEmptyFilter, filters } = splitFiltersArray( const { allOr, onEmptyFilter, filters } = splitFiltersArray(
group.filters || [] group.filters || []
) )
@ -471,7 +485,7 @@ export function buildQuery(
query.onEmptyFilter = onEmptyFilter query.onEmptyFilter = onEmptyFilter
} }
// logicalOperator takes precendence over allOr // logicalOperator takes precedence over allOr
let operator = allOr ? LogicalOperator.OR : LogicalOperator.AND let operator = allOr ? LogicalOperator.OR : LogicalOperator.AND
if (group.logicalOperator) { if (group.logicalOperator) {
operator = logicalOperatorFromUI(group.logicalOperator) operator = logicalOperatorFromUI(group.logicalOperator)

View File

@ -0,0 +1,156 @@
import { buildQuery } from "../filters"
import {
BasicOperator,
EmptyFilterOption,
FieldType,
UILogicalOperator,
UISearchFilter,
} from "@budibase/types"
describe("filter to query conversion", () => {
it("handles a filter with 1 group", () => {
const filter: UISearchFilter = {
logicalOperator: UILogicalOperator.ALL,
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
groups: [
{
logicalOperator: UILogicalOperator.ALL,
filters: [
{
field: "city",
operator: BasicOperator.STRING,
value: "lon",
},
],
},
],
}
const query = buildQuery(filter)
expect(query).toEqual({
onEmptyFilter: "none",
$and: {
conditions: [
{
$and: {
conditions: [
{
string: {
city: "lon",
},
},
],
},
},
],
},
})
})
it("handles an empty filter", () => {
const filter = undefined
const query = buildQuery(filter)
expect(query).toEqual({})
})
it("handles legacy filters", () => {
const filter = [
{
field: "city",
operator: BasicOperator.STRING,
value: "lon",
},
]
const query = buildQuery(filter)
expect(query).toEqual({
onEmptyFilter: "all",
$and: {
conditions: [
{
$and: {
conditions: [
{
string: {
city: "lon",
},
},
],
},
},
],
},
})
})
it("handles nested groups", () => {
const filter: UISearchFilter = {
logicalOperator: UILogicalOperator.ALL,
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
groups: [
{
logicalOperator: UILogicalOperator.ALL,
filters: [
{
field: "city",
operator: BasicOperator.STRING,
value: "lon",
},
],
},
{
logicalOperator: UILogicalOperator.ALL,
groups: [
{
logicalOperator: UILogicalOperator.ANY,
filters: [
{
valueType: "Binding",
field: "country.country_name",
type: FieldType.STRING,
operator: BasicOperator.EQUAL,
noValue: false,
value: "England",
},
],
},
],
},
],
}
const query = buildQuery(filter)
expect(query).toEqual({
onEmptyFilter: "none",
$and: {
conditions: [
{
$and: {
conditions: [
{
string: {
city: "lon",
},
},
],
},
},
{
$and: {
conditions: [
{
$or: {
conditions: [
{
equal: {
"country.country_name": "England",
},
},
],
},
},
],
},
},
],
},
})
})
})

View File

@ -38,11 +38,19 @@ export type SearchFilter = {
// involved. We convert this to a SearchFilters before use with the search SDK. // involved. We convert this to a SearchFilters before use with the search SDK.
export type LegacyFilter = AllOr | OnEmptyFilter | SearchFilter export type LegacyFilter = AllOr | OnEmptyFilter | SearchFilter
// A search filter group should either contain groups or filters, but not both
export type SearchFilterGroup = { export type SearchFilterGroup = {
logicalOperator?: UILogicalOperator logicalOperator?: UILogicalOperator
groups?: SearchFilterGroup[] } & (
filters?: LegacyFilter[] | {
} groups?: (SearchFilterGroup | UISearchFilter)[]
filters?: never
}
| {
filters?: LegacyFilter[]
groups?: never
}
)
// As of v3, this is the format that the frontend always sends when search // As of v3, this is the format that the frontend always sends when search
// filters are involved. We convert this to SearchFilters before use with the // filters are involved. We convert this to SearchFilters before use with the