Merge pull request #15553 from Budibase/fix/relationship-select

Relationship field - make sure selected value correctly displays
This commit is contained in:
Michael Drury 2025-02-17 14:51:28 +00:00 committed by GitHub
commit 1e979e4050
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 145 additions and 87 deletions

View File

@ -14,7 +14,7 @@
export let sort = false export let sort = false
export let autoWidth = false export let autoWidth = false
export let searchTerm = null export let searchTerm = null
export let customPopoverHeight export let customPopoverHeight = undefined
export let open = false export let open = false
export let loading export let loading
export let onOptionMouseenter = () => {} export let onOptionMouseenter = () => {}

View File

@ -5,7 +5,7 @@
const component = getContext("component") const component = getContext("component")
const block = getContext("block") const block = getContext("block")
export let text export let text = undefined
</script> </script>
{#if $builderStore.inBuilder} {#if $builderStore.inBuilder}

View File

@ -1,4 +1,4 @@
<script> <script lang="ts">
import { getContext, onDestroy } from "svelte" import { getContext, onDestroy } from "svelte"
import { writable } from "svelte/store" import { writable } from "svelte/store"
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
@ -6,33 +6,33 @@
import Placeholder from "../Placeholder.svelte" import Placeholder from "../Placeholder.svelte"
import InnerForm from "./InnerForm.svelte" import InnerForm from "./InnerForm.svelte"
export let label export let label: string | undefined = undefined
export let field export let field: string | undefined = undefined
export let fieldState export let fieldState: any
export let fieldApi export let fieldApi: any
export let fieldSchema export let fieldSchema: any
export let defaultValue export let defaultValue: string | undefined = undefined
export let type export let type: any
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let validation export let validation: any
export let span = 6 export let span = 6
export let helpText = null export let helpText: string | undefined = undefined
// Get contexts // Get contexts
const formContext = getContext("form") const formContext: any = getContext("form")
const formStepContext = getContext("form-step") const formStepContext: any = getContext("form-step")
const fieldGroupContext = getContext("field-group") const fieldGroupContext: any = getContext("field-group")
const { styleable, builderStore, Provider } = getContext("sdk") const { styleable, builderStore, Provider } = getContext("sdk")
const component = getContext("component") const component: any = getContext("component")
// Register field with form // Register field with form
const formApi = formContext?.formApi const formApi = formContext?.formApi
const labelPos = fieldGroupContext?.labelPosition || "above" const labelPos = fieldGroupContext?.labelPosition || "above"
let formField let formField: any
let touched = false let touched = false
let labelNode let labelNode: any
// Memoize values required to register the field to avoid loops // Memoize values required to register the field to avoid loops
const formStep = formStepContext || writable(1) const formStep = formStepContext || writable(1)
@ -65,7 +65,7 @@
$: $component.editing && labelNode?.focus() $: $component.editing && labelNode?.focus()
// Update form properties in parent component on every store change // Update form properties in parent component on every store change
$: unsubscribe = formField?.subscribe(value => { $: unsubscribe = formField?.subscribe((value: any) => {
fieldState = value?.fieldState fieldState = value?.fieldState
fieldApi = value?.fieldApi fieldApi = value?.fieldApi
fieldSchema = value?.fieldSchema fieldSchema = value?.fieldSchema
@ -74,7 +74,7 @@
// Determine label class from position // Determine label class from position
$: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}` $: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}`
const registerField = info => { const registerField = (info: any) => {
formField = formApi?.registerField( formField = formApi?.registerField(
info.field, info.field,
info.type, info.type,
@ -86,8 +86,9 @@
) )
} }
const updateLabel = e => { const updateLabel = (e: any) => {
if (touched) { if (touched) {
// @ts-expect-error and TODO updateProp isn't recognised - need builder TS conversion
builderStore.actions.updateProp("label", e.target.textContent) builderStore.actions.updateProp("label", e.target.textContent)
} }
touched = false touched = false

View File

@ -4,13 +4,13 @@
import { createValidatorFromConstraints } from "./validation" import { createValidatorFromConstraints } from "./validation"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
export let dataSource export let dataSource = undefined
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let initialValues export let initialValues = undefined
export let size export let size = undefined
export let schema export let schema = undefined
export let definition export let definition = undefined
export let disableSchemaValidation = false export let disableSchemaValidation = false
export let editAutoColumns = false export let editAutoColumns = false

View File

@ -1,43 +1,61 @@
<script> <script lang="ts">
import { CoreSelect, CoreMultiselect } from "@budibase/bbui" import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import { FieldType } from "@budibase/types" import { FieldType } 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 {
SearchFilter,
RelationshipFieldMetadata,
Table,
Row,
} from "@budibase/types"
const { API } = getContext("sdk") const { API } = getContext("sdk")
export let field export let field: string | undefined = undefined
export let label export let label: string | undefined = undefined
export let placeholder export let placeholder: any = undefined
export let disabled = false export let disabled: boolean = false
export let readonly = false export let readonly: boolean = false
export let validation export let validation: any
export let autocomplete = true export let autocomplete: boolean = true
export let defaultValue export let defaultValue: string | undefined = undefined
export let onChange export let onChange: any
export let filter export let filter: SearchFilter[]
export let datasourceType = "table" export let datasourceType: "table" | "user" | "groupUser" = "table"
export let primaryDisplay export let primaryDisplay: string | undefined = undefined
export let span export let span: number | undefined = undefined
export let helpText = null export let helpText: string | undefined = undefined
export let type = FieldType.LINK export let type:
| FieldType.LINK
| FieldType.BB_REFERENCE
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
let fieldState type RelationshipValue = { _id: string; [key: string]: any }
let fieldApi type OptionObj = Record<string, RelationshipValue>
let fieldSchema type OptionsObjType = Record<string, OptionObj>
let tableDefinition
let searchTerm
let open
let fieldState: any
let fieldApi: any
let fieldSchema: RelationshipFieldMetadata | undefined
let tableDefinition: Table | null | undefined
let searchTerm: any
let open: boolean
let selectedValue: string[] | string
// need a cast version of this for reactivity, components below aren't typed
$: castSelectedValue = selectedValue as any
$: multiselect = $: multiselect =
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) && [FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
fieldSchema?.relationshipType !== "one-to-many" fieldSchema?.relationshipType !== "one-to-many"
$: linkedTableId = fieldSchema?.tableId $: linkedTableId = fieldSchema?.tableId!
$: fetch = fetchData({ $: fetch = fetchData({
API, API,
datasource: { datasource: {
type: datasourceType, // typing here doesn't seem correct - we have the correct datasourceType options
// but when we configure the fetchData, it seems to think only "table" is valid
type: datasourceType as any,
tableId: linkedTableId, tableId: linkedTableId,
}, },
options: { options: {
@ -53,7 +71,8 @@
$: component = multiselect ? CoreMultiselect : CoreSelect $: component = multiselect ? CoreMultiselect : CoreSelect
$: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay $: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay
let optionsObj let optionsObj: OptionsObjType = {}
const debouncedFetchRows = Utils.debounce(fetchRows, 250)
$: { $: {
if (primaryDisplay && fieldState && !optionsObj) { if (primaryDisplay && fieldState && !optionsObj) {
@ -63,27 +82,33 @@
if (!Array.isArray(valueAsSafeArray)) { if (!Array.isArray(valueAsSafeArray)) {
valueAsSafeArray = [fieldState.value] valueAsSafeArray = [fieldState.value]
} }
optionsObj = valueAsSafeArray.reduce((accumulator, value) => { optionsObj = valueAsSafeArray.reduce(
// fieldState has to be an array of strings to be valid for an update (
// therefore we cannot guarantee value will be an object accumulator: OptionObj,
// https://linear.app/budibase/issue/BUDI-7577/refactor-the-relationshipfield-component-to-have-better-support-for value: { _id: string; primaryDisplay: any }
if (!value._id) { ) => {
// fieldState has to be an array of strings to be valid for an update
// therefore we cannot guarantee value will be an object
// https://linear.app/budibase/issue/BUDI-7577/refactor-the-relationshipfield-component-to-have-better-support-for
if (!value._id) {
return accumulator
}
accumulator[value._id] = {
_id: value._id,
[primaryDisplay]: value.primaryDisplay,
}
return accumulator return accumulator
} },
accumulator[value._id] = { {}
_id: value._id, )
[primaryDisplay]: value.primaryDisplay,
}
return accumulator
}, {})
} }
} }
$: enrichedOptions = enrichOptions(optionsObj, $fetch.rows) $: enrichedOptions = enrichOptions(optionsObj, $fetch.rows)
const enrichOptions = (optionsObj, fetchResults) => { const enrichOptions = (optionsObj: OptionsObjType, fetchResults: Row[]) => {
const result = (fetchResults || [])?.reduce((accumulator, row) => { const result = (fetchResults || [])?.reduce((accumulator, row) => {
if (!accumulator[row._id]) { if (!accumulator[row._id!]) {
accumulator[row._id] = row accumulator[row._id!] = row
} }
return accumulator return accumulator
}, optionsObj || {}) }, optionsObj || {})
@ -92,24 +117,32 @@
} }
$: { $: {
// We don't want to reorder while the dropdown is open, to avoid UX jumps // We don't want to reorder while the dropdown is open, to avoid UX jumps
if (!open) { if (!open && primaryDisplay) {
enrichedOptions = enrichedOptions.sort((a, b) => { enrichedOptions = enrichedOptions.sort((a: OptionObj, b: OptionObj) => {
const selectedValues = flatten(fieldState?.value) || [] const selectedValues = flatten(fieldState?.value) || []
const aIsSelected = selectedValues.find(v => v === a._id) const aIsSelected = selectedValues.find(
const bIsSelected = selectedValues.find(v => v === b._id) (v: RelationshipValue) => v === a._id
)
const bIsSelected = selectedValues.find(
(v: RelationshipValue) => v === b._id
)
if (aIsSelected && !bIsSelected) { if (aIsSelected && !bIsSelected) {
return -1 return -1
} else if (!aIsSelected && bIsSelected) { } else if (!aIsSelected && bIsSelected) {
return 1 return 1
} }
return a[primaryDisplay] > b[primaryDisplay] return (a[primaryDisplay] > b[primaryDisplay]) as unknown as number
}) })
} }
} }
$: forceFetchRows(filter) $: {
if (filter || defaultValue) {
forceFetchRows()
}
}
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue) $: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
const forceFetchRows = async () => { const forceFetchRows = async () => {
@ -119,7 +152,11 @@
selectedValue = [] selectedValue = []
debouncedFetchRows(searchTerm, primaryDisplay, defaultValue) debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
} }
const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => { async function fetchRows(
searchTerm: any,
primaryDisplay: string,
defaultVal: string | string[]
) {
const allRowsFetched = const allRowsFetched =
$fetch.loaded && $fetch.loaded &&
!Object.keys($fetch.query?.string || {}).length && !Object.keys($fetch.query?.string || {}).length &&
@ -129,17 +166,39 @@
return return
} }
// must be an array // must be an array
if (defaultVal && !Array.isArray(defaultVal)) { const defaultValArray: string[] = !defaultVal
defaultVal = defaultVal.split(",") ? []
} : !Array.isArray(defaultVal)
if (defaultVal && optionsObj && defaultVal.some(val => !optionsObj[val])) { ? defaultVal.split(",")
: defaultVal
if (
defaultVal &&
optionsObj &&
defaultValArray.some(val => !optionsObj[val])
) {
await fetch.update({ await fetch.update({
query: { oneOf: { _id: defaultVal } }, query: { oneOf: { _id: defaultValArray } },
})
}
if (
(Array.isArray(selectedValue) &&
selectedValue.some(val => !optionsObj[val])) ||
(selectedValue && !optionsObj[selectedValue as string])
) {
await fetch.update({
query: {
oneOf: {
_id: Array.isArray(selectedValue) ? selectedValue : [selectedValue],
},
},
}) })
} }
// Ensure we match all filters, rather than any // Ensure we match all filters, rather than any
const baseFilter = (filter || []).filter(x => x.operator !== "allOr") // @ts-expect-error this doesn't fit types, but don't want to change it yet
const baseFilter: any = (filter || []).filter(x => x.operator !== "allOr")
await fetch.update({ await fetch.update({
filter: [ filter: [
...baseFilter, ...baseFilter,
@ -152,9 +211,8 @@
], ],
}) })
} }
const debouncedFetchRows = Utils.debounce(fetchRows, 250)
const flatten = values => { const flatten = (values: any | any[]) => {
if (!values) { if (!values) {
return [] return []
} }
@ -162,17 +220,17 @@
if (!Array.isArray(values)) { if (!Array.isArray(values)) {
values = [values] values = [values]
} }
values = values.map(value => values = values.map((value: any) =>
typeof value === "object" ? value._id : value typeof value === "object" ? value._id : value
) )
return values return values
} }
const getDisplayName = row => { const getDisplayName = (row: Row) => {
return row?.[primaryDisplay] || "-" return row?.[primaryDisplay!] || "-"
} }
const handleChange = e => { const handleChange = (e: any) => {
let value = e.detail let value = e.detail
if (!multiselect) { if (!multiselect) {
value = value == null ? [] : [value] value = value == null ? [] : [value]
@ -220,13 +278,12 @@
this={component} this={component}
options={enrichedOptions} options={enrichedOptions}
{autocomplete} {autocomplete}
value={selectedValue} value={castSelectedValue}
on:change={handleChange} on:change={handleChange}
on:loadMore={loadMore} on:loadMore={loadMore}
id={fieldState.fieldId} id={fieldState.fieldId}
disabled={fieldState.disabled} disabled={fieldState.disabled}
readonly={fieldState.readonly} readonly={fieldState.readonly}
error={fieldState.error}
getOptionLabel={getDisplayName} getOptionLabel={getDisplayName}
getOptionValue={option => option._id} getOptionValue={option => option._id}
{placeholder} {placeholder}