1
0
Fork 0
mirror of synced 2024-09-06 12:41:24 +12:00

Merge pull request #11856 from Budibase/chore/field_with_large_relationships

Fix display of field with large relationships
This commit is contained in:
Adria Navarro 2023-09-25 21:50:02 +02:00 committed by GitHub
commit 8729e068a2
6 changed files with 152 additions and 192 deletions

View file

@ -14,12 +14,12 @@
export let autocomplete = false export let autocomplete = false
export let sort = false export let sort = false
export let autoWidth = false export let autoWidth = false
export let fetchTerm = null export let searchTerm = null
export let useFetch = false
export let customPopoverHeight export let customPopoverHeight
export let customPopoverOffsetBelow export let customPopoverOffsetBelow
export let customPopoverMaxHeight export let customPopoverMaxHeight
export let open = false export let open = false
export let loading
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -82,6 +82,7 @@
</script> </script>
<Picker <Picker
on:loadMore
{id} {id}
{error} {error}
{disabled} {disabled}
@ -90,9 +91,8 @@
{options} {options}
isPlaceholder={!arrayValue.length} isPlaceholder={!arrayValue.length}
{autocomplete} {autocomplete}
bind:fetchTerm bind:searchTerm
bind:open bind:open
{useFetch}
{isOptionSelected} {isOptionSelected}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
@ -102,4 +102,5 @@
{customPopoverHeight} {customPopoverHeight}
{customPopoverOffsetBelow} {customPopoverOffsetBelow}
{customPopoverMaxHeight} {customPopoverMaxHeight}
{loading}
/> />

View file

@ -2,7 +2,7 @@
import "@spectrum-css/picker/dist/index-vars.css" import "@spectrum-css/picker/dist/index-vars.css"
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
import "@spectrum-css/menu/dist/index-vars.css" import "@spectrum-css/menu/dist/index-vars.css"
import { createEventDispatcher } from "svelte" import { createEventDispatcher, onDestroy } from "svelte"
import clickOutside from "../../Actions/click_outside" import clickOutside from "../../Actions/click_outside"
import Search from "./Search.svelte" import Search from "./Search.svelte"
import Icon from "../../Icon/Icon.svelte" import Icon from "../../Icon/Icon.svelte"
@ -10,6 +10,7 @@
import Popover from "../../Popover/Popover.svelte" import Popover from "../../Popover/Popover.svelte"
import Tags from "../../Tags/Tags.svelte" import Tags from "../../Tags/Tags.svelte"
import Tag from "../../Tags/Tag.svelte" import Tag from "../../Tags/Tag.svelte"
import ProgressCircle from "../../ProgressCircle/ProgressCircle.svelte"
export let id = null export let id = null
export let disabled = false export let disabled = false
@ -35,19 +36,20 @@
export let autoWidth = false export let autoWidth = false
export let autocomplete = false export let autocomplete = false
export let sort = false export let sort = false
export let fetchTerm = null export let searchTerm = null
export let useFetch = false
export let customPopoverHeight export let customPopoverHeight
export let customPopoverOffsetBelow export let customPopoverOffsetBelow
export let customPopoverMaxHeight export let customPopoverMaxHeight
export let align = "left" export let align = "left"
export let footer = null export let footer = null
export let customAnchor = null export let customAnchor = null
export let loading
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let searchTerm = null
let button let button
let popover let popover
let component
$: sortedOptions = getSortedOptions(options, getOptionLabel, sort) $: sortedOptions = getSortedOptions(options, getOptionLabel, sort)
$: filteredOptions = getFilteredOptions( $: filteredOptions = getFilteredOptions(
@ -82,7 +84,7 @@
} }
const getFilteredOptions = (options, term, getLabel) => { const getFilteredOptions = (options, term, getLabel) => {
if (autocomplete && term && !fetchTerm) { if (autocomplete && term) {
const lowerCaseTerm = term.toLowerCase() const lowerCaseTerm = term.toLowerCase()
return options.filter(option => { return options.filter(option => {
return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm) return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm)
@ -90,6 +92,20 @@
} }
return options return options
} }
const onScroll = e => {
const scrollPxThreshold = 100
const scrollPositionFromBottom =
e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop
if (scrollPositionFromBottom < scrollPxThreshold) {
dispatch("loadMore")
}
}
$: component?.addEventListener("scroll", onScroll)
onDestroy(() => {
component?.removeEventListener("scroll", null)
})
</script> </script>
<button <button
@ -163,14 +179,13 @@
> >
{#if autocomplete} {#if autocomplete}
<Search <Search
value={useFetch ? fetchTerm : searchTerm} value={searchTerm}
on:change={event => on:change={event => (searchTerm = event.detail)}
useFetch ? (fetchTerm = event.detail) : (searchTerm = event.detail)}
{disabled} {disabled}
placeholder="Search" placeholder="Search"
/> />
{/if} {/if}
<ul class="spectrum-Menu" role="listbox"> <ul class="spectrum-Menu" role="listbox" bind:this={component}>
{#if placeholderOption} {#if placeholderOption}
<li <li
class="spectrum-Menu-item placeholder" class="spectrum-Menu-item placeholder"
@ -248,6 +263,12 @@
{/if} {/if}
</ul> </ul>
{#if loading}
<div class="loading" class:loading--withAutocomplete={autocomplete}>
<ProgressCircle size="S" />
</div>
{/if}
{#if footer} {#if footer}
<div class="footer"> <div class="footer">
{footer} {footer}
@ -323,18 +344,19 @@
/* Search styles inside popover */ /* Search styles inside popover */
.popover-content :global(.spectrum-Search) { .popover-content :global(.spectrum-Search) {
margin-top: -1px; margin-top: -1px;
margin-left: -1px; width: 100%;
width: calc(100% + 2px);
} }
.popover-content :global(.spectrum-Search input) { .popover-content :global(.spectrum-Search input) {
height: auto; height: auto;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
border-left: 0;
border-right: 0;
padding-top: var(--spectrum-global-dimension-size-100); padding-top: var(--spectrum-global-dimension-size-100);
padding-bottom: var(--spectrum-global-dimension-size-100); padding-bottom: var(--spectrum-global-dimension-size-100);
} }
.popover-content :global(.spectrum-Search .spectrum-ClearButton) { .popover-content :global(.spectrum-Search .spectrum-ClearButton) {
right: 1px; right: 2px;
top: 2px; top: 2px;
} }
.popover-content :global(.spectrum-Search .spectrum-Textfield-icon) { .popover-content :global(.spectrum-Search .spectrum-Textfield-icon) {
@ -359,4 +381,14 @@
.option-tag :global(.spectrum-Tags-item > .spectrum-Icon) { .option-tag :global(.spectrum-Tags-item > .spectrum-Icon) {
margin-top: 2px; margin-top: 2px;
} }
.loading {
position: fixed;
justify-content: center;
right: var(--spacing-s);
top: var(--spacing-s);
}
.loading--withAutocomplete {
top: calc(34px + var(--spacing-m));
}
</style> </style>

View file

@ -25,6 +25,8 @@
export let tag = null export let tag = null
export let customPopoverOffsetBelow export let customPopoverOffsetBelow
export let customPopoverMaxHeight export let customPopoverMaxHeight
export let searchTerm = null
export let loading
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -65,6 +67,8 @@
<Picker <Picker
on:click on:click
bind:open bind:open
bind:searchTerm
on:loadMore
{quiet} {quiet}
{id} {id}
{error} {error}
@ -92,4 +96,5 @@
placeholderOption={placeholder === false ? null : placeholder} placeholderOption={placeholder === false ? null : placeholder}
isOptionSelected={option => option === value} isOptionSelected={option => option === value}
onSelectOption={selectOption} onSelectOption={selectOption}
{loading}
/> />

View file

@ -16,8 +16,7 @@
export let sort = false export let sort = false
export let autoWidth = false export let autoWidth = false
export let autocomplete = false export let autocomplete = false
export let fetchTerm = null export let searchTerm = null
export let useFetch = false
export let customPopoverHeight export let customPopoverHeight
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -41,8 +40,7 @@
{autoWidth} {autoWidth}
{autocomplete} {autocomplete}
{customPopoverHeight} {customPopoverHeight}
bind:fetchTerm bind:searchTerm
{useFetch}
on:change={onChange} on:change={onChange}
on:click on:click
/> />

View file

@ -86,8 +86,16 @@
$: userPage = $userPageInfo.page $: userPage = $userPageInfo.page
$: logsPage = $logsPageInfo.page $: logsPage = $logsPageInfo.page
let usersObj = {}
$: usersObj = {
...usersObj,
...$users.data?.reduce((accumulator, user) => {
accumulator[user._id] = user
return accumulator
}, {}),
}
$: sortedUsers = sort( $: sortedUsers = sort(
enrich($users.data || [], selectedUsers, "_id"), enrich(Object.values(usersObj), selectedUsers, "_id"),
"email" "email"
) )
$: sortedEvents = sort( $: sortedEvents = sort(
@ -256,8 +264,7 @@
<div class="controls"> <div class="controls">
<div class="select"> <div class="select">
<Multiselect <Multiselect
bind:fetchTerm={userSearchTerm} bind:searchTerm={userSearchTerm}
useFetch
placeholder="All users" placeholder="All users"
label="Users" label="Users"
autocomplete autocomplete

View file

@ -1,11 +1,6 @@
<script> <script>
import { import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
CoreSelect, import { fetchData } from "@budibase/frontend-core"
CoreMultiselect,
Input,
ProgressCircle,
} from "@budibase/bbui"
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 { FieldTypes } from "../../../constants" import { FieldTypes } from "../../../constants"
@ -26,16 +21,8 @@
let fieldApi let fieldApi
let fieldSchema let fieldSchema
let tableDefinition let tableDefinition
let primaryDisplay let searchTerm
let options let open
let selectedOptions = []
let isOpen = false
let hasFilter
let searchResults
let searchString
let searching = false
let lastSearchId
$: multiselect = fieldSchema?.relationshipType !== "one-to-many" $: multiselect = fieldSchema?.relationshipType !== "one-to-many"
$: linkedTableId = fieldSchema?.tableId $: linkedTableId = fieldSchema?.tableId
@ -50,54 +37,74 @@
limit: 100, limit: 100,
}, },
}) })
$: hasFilter = !!filter?.filter(f => !!f.field)?.length
$: fetch.update({ filter })
$: {
options = searchResults ? searchResults : $fetch.rows
const nonMatchingOptions = selectedOptions.filter(
option => !options.map(opt => opt._id).includes(option._id)
)
// Append initially selected options if there is no filter
// and hasn't already been appended
if (!hasFilter) {
options = [...options, ...nonMatchingOptions]
}
}
$: tableDefinition = $fetch.definition $: tableDefinition = $fetch.definition
$: primaryDisplay = tableDefinition?.primaryDisplay || "_id" $: selectedValue = multiselect
$: singleValue = flatten(fieldState?.value)?.[0] ? flatten(fieldState?.value) ?? []
$: multiValue = flatten(fieldState?.value) ?? [] : flatten(fieldState?.value)?.[0]
$: component = multiselect ? CoreMultiselect : CoreSelect $: component = multiselect ? CoreMultiselect : CoreSelect
$: expandedDefaultValue = expand(defaultValue) $: expandedDefaultValue = expand(defaultValue)
$: debouncedSearch(searchString) $: primaryDisplay = tableDefinition?.primaryDisplay
let optionsObj = {}
let initialValuesProcessed
$: { $: {
if (searching) { if (!initialValuesProcessed && primaryDisplay) {
isOpen = true // Persist the initial values as options, allowing them to be present in the dropdown,
// even if they are not in the inital fetch results
initialValuesProcessed = true
optionsObj = fieldState?.value?.reduce((accumulator, value) => {
accumulator[value._id] = {
_id: value._id,
[primaryDisplay]: value.primaryDisplay,
}
return accumulator
}, optionsObj)
} }
} }
// Fetch the initially selected values $: enrichedOptions = enrichOptions(optionsObj, $fetch.rows)
// as they may not be within the first 100 records const enrichOptions = (optionsObj, fetchResults) => {
const result = (fetchResults || [])?.reduce((accumulator, row) => {
if (!accumulator[row._id]) {
accumulator[row._id] = row
}
return accumulator
}, optionsObj)
return Object.values(result)
}
$: { $: {
if ( // We don't want to reorder while the dropdown is open, to avoid UX jumps
primaryDisplay !== "_id" && if (!open) {
fieldState?.value?.length && enrichedOptions = enrichedOptions.sort((a, b) => {
!selectedOptions?.length const selectedValues = flatten(fieldState?.value) || []
) {
API.searchTable({ const aIsSelected = selectedValues.find(v => v === a._id)
paginate: false, const bIsSelected = selectedValues.find(v => v === b._id)
tableId: linkedTableId, if (aIsSelected && !bIsSelected) {
limit: 100, return -1
query: { } else if (!aIsSelected && bIsSelected) {
oneOf: { return 1
[`1:${primaryDisplay}`]: fieldState?.value?.map( }
value => value.primaryDisplay
), return a[primaryDisplay] > b[primaryDisplay]
}, })
}, }
}).then(response => { }
const value = multiselect ? multiValue : singleValue
selectedOptions = response.rows.filter(row => value.includes(row._id)) $: fetchRows(searchTerm, primaryDisplay)
const fetchRows = (searchTerm, primaryDisplay) => {
const allRowsFetched =
$fetch.loaded &&
!Object.keys($fetch.query?.string || {}).length &&
!$fetch.hasNextPage
// Don't request until we have the primary display
if (!allRowsFetched && primaryDisplay) {
fetch.update({
query: { string: { [primaryDisplay]: searchTerm } },
}) })
} }
} }
@ -113,7 +120,7 @@
} }
const getDisplayName = row => { const getDisplayName = row => {
return row?.[tableDefinition?.primaryDisplay || "_id"] || "-" return row?.[primaryDisplay] || "-"
} }
const singleHandler = e => { const singleHandler = e => {
@ -136,66 +143,16 @@
const handleChange = value => { const handleChange = value => {
const changed = fieldApi.setValue(value) const changed = fieldApi.setValue(value)
selectedOptions = value.map(val => ({
_id: val,
[primaryDisplay]: options.find(option => option._id === val)[
primaryDisplay
],
}))
if (onChange && changed) { if (onChange && changed) {
onChange({ value }) onChange({ value })
} }
} }
// Search for rows based on the search string const loadMore = () => {
const search = async searchString => { if (!$fetch.loading) {
// Reset state if this search is invalid fetch.nextPage()
if (!linkedTableId || !searchString) {
searchResults = null
return
} }
// If a filter exists, then do a client side search
if (hasFilter) {
searchResults = $fetch.rows.filter(option =>
option[primaryDisplay].startsWith(searchString)
)
isOpen = true
return
} }
// Search for results, using IDs to track invocations and ensure we're
// handling the latest update
lastSearchId = Math.random()
searching = true
const thisSearchId = lastSearchId
const results = await API.searchTable({
paginate: false,
tableId: linkedTableId,
limit: 100,
query: {
string: {
[`1:${primaryDisplay}`]: searchString || "",
},
},
})
searching = false
// In case searching takes longer than our debounced update, abandon these
// results
if (thisSearchId !== lastSearchId) {
return
}
// Process results
searchResults = results.rows?.map(row => ({
...row,
primaryDisplay: row[primaryDisplay],
}))
}
// Debounced version of searching
const debouncedSearch = Utils.debounce(search, 250)
</script> </script>
<Field <Field
@ -210,63 +167,23 @@
bind:fieldSchema bind:fieldSchema
> >
{#if fieldState} {#if fieldState}
<div class={autocomplete ? "field-with-search" : ""}>
<svelte:component <svelte:component
this={component} this={component}
bind:open={isOpen} options={enrichedOptions}
{options} {autocomplete}
autocomplete={false} value={selectedValue}
value={multiselect ? multiValue : singleValue}
on:change={multiselect ? multiHandler : singleHandler} on:change={multiselect ? multiHandler : singleHandler}
on:loadMore={loadMore}
id={fieldState.fieldId} id={fieldState.fieldId}
disabled={fieldState.disabled} disabled={fieldState.disabled}
error={fieldState.error} error={fieldState.error}
getOptionLabel={getDisplayName} getOptionLabel={getDisplayName}
getOptionValue={option => option._id} getOptionValue={option => option._id}
{placeholder} {placeholder}
customPopoverOffsetBelow={autocomplete ? 32 : null} bind:searchTerm
customPopoverMaxHeight={autocomplete ? 240 : null} loading={$fetch.loading}
sort={true} bind:open
customPopoverMaxHeight={400}
/> />
{#if autocomplete}
<div class="search">
<Input
autofocus
quiet
type="text"
bind:value={searchString}
placeholder={primaryDisplay ? `Search by ${primaryDisplay}` : null}
/>
{#if searching}
<div>
<ProgressCircle size="S" />
</div>
{/if}
</div>
{/if}
</div>
{/if} {/if}
</Field> </Field>
<style>
.search {
flex: 0 0 calc(var(--default-row-height) - 1px);
display: flex;
align-items: center;
margin: 4px var(--cell-padding);
width: calc(100% - 2 * var(--cell-padding));
}
.search :global(.spectrum-Textfield) {
min-width: 0;
width: 100%;
}
.search :global(.spectrum-Textfield-input) {
font-size: 13px;
}
.search :global(.spectrum-Form-item) {
flex: 1 1 auto;
}
.field-with-search {
min-height: 80px;
}
</style>