-
- {user.email}
+
+
+ {user.email}
+
+ {#if userGroups.length}
+
+
+ {itemCountText("group", userGroups.length)}
+
+
+
+ {/if}
diff --git a/packages/builder/src/pages/builder/portal/users/users/[userId].svelte b/packages/builder/src/pages/builder/portal/users/users/[userId].svelte
index 6c480d9ef8..f02c2fe058 100644
--- a/packages/builder/src/pages/builder/portal/users/users/[userId].svelte
+++ b/packages/builder/src/pages/builder/portal/users/users/[userId].svelte
@@ -98,7 +98,9 @@
$: privileged = sdk.users.isAdminOrGlobalBuilder(user)
$: nameLabel = getNameLabel(user)
$: filteredGroups = getFilteredGroups(internalGroups, searchTerm)
- $: availableApps = getAvailableApps($appsStore.apps, privileged, user?.roles)
+ $: availableApps = user
+ ? getApps(user, sdk.users.userAppAccessList(user, $groups || []))
+ : []
$: userGroups = $groups.filter(x => {
return x.users?.find(y => {
return y._id === userId
@@ -107,23 +109,19 @@
$: globalRole = users.getUserRole(user)
$: isTenantOwner = tenantOwner?.email && tenantOwner.email === user?.email
- const getAvailableApps = (appList, privileged, roles) => {
- let availableApps = appList.slice()
- if (!privileged) {
- availableApps = availableApps.filter(x => {
- let roleKeys = Object.keys(roles || {})
- return roleKeys.concat(user?.builder?.apps).find(y => {
- return x.appId === appsStore.extractAppId(y)
- })
- })
- }
+ const getApps = (user, appIds) => {
+ let availableApps = $appsStore.apps
+ .slice()
+ .filter(app =>
+ appIds.find(id => id === appsStore.getProdAppID(app.devId))
+ )
return availableApps.map(app => {
const prodAppId = appsStore.getProdAppID(app.devId)
return {
name: app.name,
devId: app.devId,
icon: app.icon,
- role: getRole(prodAppId, roles),
+ role: getRole(prodAppId, user),
}
})
}
@@ -136,7 +134,7 @@
return groups.filter(group => group.name?.toLowerCase().includes(search))
}
- const getRole = (prodAppId, roles) => {
+ const getRole = (prodAppId, user) => {
if (privileged) {
return Constants.Roles.ADMIN
}
@@ -145,7 +143,21 @@
return Constants.Roles.CREATOR
}
- return roles[prodAppId]
+ if (user?.roles[prodAppId]) {
+ return user.roles[prodAppId]
+ }
+
+ // check if access via group for creator
+ const foundGroup = $groups?.find(
+ group => group.roles[prodAppId] || group.builder?.apps[prodAppId]
+ )
+ if (foundGroup.builder?.apps[prodAppId]) {
+ return Constants.Roles.CREATOR
+ }
+ // can't tell how groups will control role
+ if (foundGroup.roles[prodAppId]) {
+ return Constants.Roles.GROUP
+ }
}
const getNameLabel = user => {
diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte
index 9429cfbc23..5adc38ebc6 100644
--- a/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte
+++ b/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte
@@ -15,7 +15,9 @@
}
-{#if value === Constants.Roles.CREATOR}
+{#if value === Constants.Roles.GROUP}
+ Controlled by group
+{:else if value === Constants.Roles.CREATOR}
Can edit
{:else}
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
- import { FieldType } from "@budibase/types"
+ import { FieldType, InternalTable } from "@budibase/types"
import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext } from "svelte"
import Field from "./Field.svelte"
import type {
SearchFilter,
RelationshipFieldMetadata,
- Table,
Row,
} from "@budibase/types"
- const { API } = getContext("sdk")
-
export let field: string | undefined = undefined
export let label: string | undefined = undefined
export let placeholder: any = undefined
@@ -20,10 +17,10 @@
export let readonly: boolean = false
export let validation: any
export let autocomplete: boolean = true
- export let defaultValue: string | undefined = undefined
+ export let defaultValue: string | string[] | undefined = undefined
export let onChange: any
export let filter: SearchFilter[]
- export let datasourceType: "table" | "user" | "groupUser" = "table"
+ export let datasourceType: "table" | "user" = "table"
export let primaryDisplay: string | undefined = undefined
export let span: number | undefined = undefined
export let helpText: string | undefined = undefined
@@ -32,191 +29,305 @@
| FieldType.BB_REFERENCE
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
- type RelationshipValue = { _id: string; [key: string]: any }
- type OptionObj = Record
- type OptionsObjType = Record
+ type BasicRelatedRow = { _id: string; primaryDisplay: string }
+ type OptionsMap = Record
+ const { API } = getContext("sdk")
+
+ // Field state
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
+ // Local UI state
+ let searchTerm: any
+ let open: boolean = false
+
+ // Options state
+ let options: BasicRelatedRow[] = []
+ let optionsMap: OptionsMap = {}
+ let loadingMissingOptions: boolean = false
+
+ // Determine if we can select multiple rows or not
$: multiselect =
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
fieldSchema?.relationshipType !== "one-to-many"
- $: linkedTableId = fieldSchema?.tableId!
- $: fetch = fetchData({
- API,
- datasource: {
- // 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,
- },
- options: {
- filter,
- limit: 100,
- },
- })
- $: tableDefinition = $fetch.definition
- $: selectedValue = multiselect
- ? flatten(fieldState?.value) ?? []
- : flatten(fieldState?.value)?.[0]
- $: component = multiselect ? CoreMultiselect : CoreSelect
- $: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay
+ // Get the proper string representation of the value
+ $: realValue = fieldState?.value
+ $: selectedValue = parseSelectedValue(realValue, multiselect)
+ $: selectedIDs = getSelectedIDs(selectedValue)
- let optionsObj: OptionsObjType = {}
- const debouncedFetchRows = Utils.debounce(fetchRows, 250)
+ // If writable, we use a fetch to load options
+ $: linkedTableId = fieldSchema?.tableId
+ $: writable = !disabled && !readonly
+ $: fetch = createFetch(writable, datasourceType, filter, linkedTableId)
- $: {
- if (primaryDisplay && fieldState && !optionsObj) {
- // Persist the initial values as options, allowing them to be present in the dropdown,
- // even if they are not in the inital fetch results
- let valueAsSafeArray = fieldState.value || []
- if (!Array.isArray(valueAsSafeArray)) {
- valueAsSafeArray = [fieldState.value]
- }
- optionsObj = valueAsSafeArray.reduce(
- (
- accumulator: OptionObj,
- value: { _id: string; primaryDisplay: any }
- ) => {
- // 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
+ // Attempt to determine the primary display field to use
+ $: tableDefinition = $fetch?.definition
+ $: primaryDisplayField = primaryDisplay || tableDefinition?.primaryDisplay
+
+ // Build our options map
+ $: rows = $fetch?.rows || []
+ $: processOptions(realValue, rows, primaryDisplayField)
+
+ // If we ever have a value selected for which we don't have an option, we must
+ // fetch those rows to ensure we can render them as options
+ $: missingIDs = selectedIDs.filter(id => !optionsMap[id])
+ $: loadMissingOptions(missingIDs, linkedTableId, primaryDisplayField)
+
+ // Convert our options map into an array for display
+ $: updateOptions(optionsMap)
+ $: !open && sortOptions()
+
+ // Search for new options when search term changes
+ $: debouncedSearchOptions(searchTerm || "", primaryDisplayField)
+
+ // Ensure backwards compatibility
+ $: enrichedDefaultValue = enrichDefaultValue(defaultValue)
+
+ // We need to cast value to pass it down, as those components aren't typed
+ $: emptyValue = multiselect ? [] : null
+ $: displayValue = missingIDs.length ? emptyValue : (selectedValue as any)
+
+ // Ensures that we flatten any objects so that only the IDs of the selected
+ // rows are passed down. Not sure how this can be an object to begin with?
+ const parseSelectedValue = (
+ value: any,
+ multiselect: boolean
+ ): undefined | string | string[] => {
+ return multiselect ? flatten(value) : flatten(value)[0]
+ }
+
+ // Where applicable, creates the fetch instance to load row options
+ const createFetch = (
+ writable: boolean,
+ dsType: typeof datasourceType,
+ filter: SearchFilter[],
+ linkedTableId?: string
+ ) => {
+ if (!linkedTableId) {
+ return undefined
+ }
+ const datasource =
+ datasourceType === "table"
+ ? {
+ type: datasourceType,
+ tableId: fieldSchema?.tableId!,
}
- accumulator[value._id] = {
- _id: value._id,
- [primaryDisplay]: value.primaryDisplay,
+ : {
+ type: datasourceType,
+ tableId: InternalTable.USER_METADATA,
}
- return accumulator
- },
- {}
- )
- }
- }
-
- $: enrichedOptions = enrichOptions(optionsObj, $fetch.rows)
- const enrichOptions = (optionsObj: OptionsObjType, fetchResults: Row[]) => {
- const result = (fetchResults || [])?.reduce((accumulator, row) => {
- if (!accumulator[row._id!]) {
- accumulator[row._id!] = row
- }
- return accumulator
- }, optionsObj || {})
-
- return Object.values(result)
- }
- $: {
- // We don't want to reorder while the dropdown is open, to avoid UX jumps
- if (!open && primaryDisplay) {
- enrichedOptions = enrichedOptions.sort((a: OptionObj, b: OptionObj) => {
- const selectedValues = flatten(fieldState?.value) || []
-
- const aIsSelected = selectedValues.find(
- (v: RelationshipValue) => v === a._id
- )
- const bIsSelected = selectedValues.find(
- (v: RelationshipValue) => v === b._id
- )
- if (aIsSelected && !bIsSelected) {
- return -1
- } else if (!aIsSelected && bIsSelected) {
- return 1
- }
-
- return (a[primaryDisplay] > b[primaryDisplay]) as unknown as number
- })
- }
- }
-
- $: {
- if (filter || defaultValue) {
- forceFetchRows()
- }
- }
- $: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
-
- const forceFetchRows = async () => {
- // if the filter has changed, then we need to reset the options, clear the selection, and re-fetch
- optionsObj = {}
- fieldApi?.setValue([])
- selectedValue = []
- debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
- }
- async function fetchRows(
- searchTerm: any,
- primaryDisplay: string,
- defaultVal: string | string[]
- ) {
- const allRowsFetched =
- $fetch.loaded &&
- !Object.keys($fetch.query?.string || {}).length &&
- !$fetch.hasNextPage
- // Don't request until we have the primary display or default value has been fetched
- if (allRowsFetched || !primaryDisplay) {
- return
- }
- // must be an array
- const defaultValArray: string[] = !defaultVal
- ? []
- : !Array.isArray(defaultVal)
- ? defaultVal.split(",")
- : defaultVal
-
- if (
- defaultVal &&
- optionsObj &&
- defaultValArray.some(val => !optionsObj[val])
- ) {
- await fetch.update({
- 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
- // @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({
- filter: [
- ...baseFilter,
- {
- // Use a big numeric prefix to avoid clashing with an existing filter
- field: `999:${primaryDisplay}`,
- operator: "string",
- value: searchTerm,
- },
- ],
+ return fetchData({
+ API,
+ datasource,
+ options: {
+ filter,
+ limit: writable ? 100 : 1,
+ },
})
}
- const flatten = (values: any | any[]) => {
+ // Small helper to represent the selected value as an array
+ const getSelectedIDs = (
+ selectedValue: undefined | string | string[]
+ ): string[] => {
+ if (!selectedValue) {
+ return []
+ }
+ return Array.isArray(selectedValue) ? selectedValue : [selectedValue]
+ }
+
+ // Builds a map of all available options, in a consistent structure
+ const processOptions = (
+ realValue: any | any[],
+ rows: Row[],
+ primaryDisplay?: string
+ ) => {
+ // First ensure that all options included in the value are present as valid
+ // options. These can be basic related row shapes which already include
+ // a value for primary display
+ if (realValue) {
+ const valueArray = Array.isArray(realValue) ? realValue : [realValue]
+ for (let val of valueArray) {
+ const option = parseOption(val, primaryDisplay)
+ if (option) {
+ optionsMap[option._id] = option
+ }
+ }
+ }
+
+ // Process all rows loaded from our fetch
+ for (let row of rows) {
+ const option = parseOption(row, primaryDisplay)
+ if (option) {
+ optionsMap[option._id] = option
+ }
+ }
+
+ // Reassign to trigger reactivity
+ optionsMap = optionsMap
+ }
+
+ // Parses a row-like structure into a properly shaped option
+ const parseOption = (
+ option: any | BasicRelatedRow | Row,
+ primaryDisplay?: string
+ ): BasicRelatedRow | null => {
+ if (!option || typeof option !== "object" || !option?._id) {
+ return null
+ }
+ // If this is a basic related row shape (_id and PD only) then just use
+ // that
+ if (Object.keys(option).length === 2 && "primaryDisplay" in option) {
+ return {
+ _id: option._id,
+ primaryDisplay: ensureString(option.primaryDisplay),
+ }
+ }
+ // Otherwise use the primary display field specified
+ if (primaryDisplay) {
+ return {
+ _id: option._id,
+ primaryDisplay: ensureString(
+ option[primaryDisplay as keyof typeof option]
+ ),
+ }
+ } else {
+ return {
+ _id: option._id,
+ primaryDisplay: option._id,
+ }
+ }
+ }
+
+ // Loads any rows which are selected and aren't present in the currently
+ // available option set. This is typically only IDs specified as default
+ // values.
+ const loadMissingOptions = async (
+ missingIDs: string[],
+ linkedTableId?: string,
+ primaryDisplay?: string
+ ) => {
+ if (
+ loadingMissingOptions ||
+ !missingIDs.length ||
+ !linkedTableId ||
+ !primaryDisplay
+ ) {
+ return
+ }
+ loadingMissingOptions = true
+ try {
+ const res = await API.searchTable(linkedTableId, {
+ query: {
+ oneOf: {
+ _id: missingIDs,
+ },
+ },
+ })
+ for (let row of res.rows) {
+ const option = parseOption(row, primaryDisplay)
+ if (option) {
+ optionsMap[option._id] = option
+ }
+ }
+
+ // Reassign to trigger reactivity
+ optionsMap = optionsMap
+ updateOptions(optionsMap)
+ } catch (error) {
+ console.error("Error loading missing row IDs", error)
+ } finally {
+ // Ensure we have some sort of option for all IDs
+ for (let id of missingIDs) {
+ if (!optionsMap[id]) {
+ optionsMap[id] = {
+ _id: id,
+ primaryDisplay: id,
+ }
+ }
+ }
+ loadingMissingOptions = false
+ }
+ }
+
+ // Updates the options list to reflect the currently available options
+ const updateOptions = (optionsMap: OptionsMap) => {
+ let newOptions = Object.values(optionsMap)
+
+ // Only override options if the quantity of options changes
+ if (newOptions.length !== options.length) {
+ options = newOptions
+ sortOptions()
+ }
+ }
+
+ // Sorts the options list by selected state, then by primary display
+ const sortOptions = () => {
+ // Create a quick lookup map so we can test whether options are selected
+ const selectedMap: Record = selectedIDs.reduce(
+ (map, id) => ({ ...map, [id]: true }),
+ {}
+ )
+ options.sort((a, b) => {
+ const aSelected = !!selectedMap[a._id]
+ const bSelected = !!selectedMap[b._id]
+ if (aSelected === bSelected) {
+ return a.primaryDisplay < b.primaryDisplay ? -1 : 1
+ } else {
+ return aSelected ? -1 : 1
+ }
+ })
+ }
+
+ // Util to ensure a value is stringified
+ const ensureString = (val: any): string => {
+ return typeof val === "string" ? val : JSON.stringify(val)
+ }
+
+ // We previously included logic to manually process default value, which
+ // should not be done as it is handled by the core form logic.
+ // This logic included handling a comma separated list of IDs, so for
+ // backwards compatibility we must now unfortunately continue to handle that
+ // at this level.
+ const enrichDefaultValue = (val: any) => {
+ if (!val || typeof val !== "string") {
+ return val
+ }
+ return val.includes(",") ? val.split(",") : val
+ }
+
+ // Searches for new options matching the given term
+ async function searchOptions(searchTerm: string, primaryDisplay?: string) {
+ if (!primaryDisplay) {
+ return
+ }
+
+ // Ensure we match all filters, rather than any
+ let newFilter: any = filter
+ if (searchTerm) {
+ // @ts-expect-error this doesn't fit types, but don't want to change it yet
+ newFilter = (newFilter || []).filter(x => x.operator !== "allOr")
+ newFilter.push({
+ // Use a big numeric prefix to avoid clashing with an existing filter
+ field: `999:${primaryDisplay}`,
+ operator: "string",
+ value: searchTerm,
+ })
+ }
+ await fetch?.update({
+ filter: newFilter,
+ })
+ }
+ const debouncedSearchOptions = Utils.debounce(searchOptions, 250)
+
+ // Flattens an array of row-like objects into a simple array of row IDs
+ const flatten = (values: any | any[]): string[] => {
if (!values) {
return []
}
-
if (!Array.isArray(values)) {
values = [values]
}
@@ -226,16 +337,11 @@
return values
}
- const getDisplayName = (row: Row) => {
- return row?.[primaryDisplay!] || "-"
- }
-
const handleChange = (e: any) => {
let value = e.detail
if (!multiselect) {
value = value == null ? [] : [value]
}
-
if (
type === FieldType.BB_REFERENCE_SINGLE &&
value &&
@@ -243,7 +349,6 @@
) {
value = value[0] || null
}
-
const changed = fieldApi.setValue(value)
if (onChange && changed) {
onChange({
@@ -251,12 +356,6 @@
})
}
}
-
- const loadMore = () => {
- if (!$fetch.loading) {
- fetch.nextPage()
- }
- }
{#if fieldState}
option.primaryDisplay}
getOptionValue={option => option._id}
+ {options}
{placeholder}
+ {autocomplete}
bind:searchTerm
- loading={$fetch.loading}
bind:open
+ on:change={handleChange}
+ on:loadMore={() => fetch?.nextPage()}
/>
{/if}
diff --git a/packages/client/src/components/error-states/ComponentErrorState.svelte b/packages/client/src/components/error-states/ComponentErrorState.svelte
index b2e7c92eae..1bcd5f21fa 100644
--- a/packages/client/src/components/error-states/ComponentErrorState.svelte
+++ b/packages/client/src/components/error-states/ComponentErrorState.svelte
@@ -1,7 +1,7 @@