Merge pull request #12049 from Budibase/grid-inline-searching

Inline searching for grid and grid block
This commit is contained in:
Andrew Kingston 2023-10-25 13:56:36 +01:00 committed by GitHub
commit 91d6130f7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 352 additions and 82 deletions

View File

@ -4,6 +4,8 @@
import { Icon, Popover, Menu, MenuItem, clickOutside } from "@budibase/bbui" import { Icon, Popover, Menu, MenuItem, clickOutside } from "@budibase/bbui"
import GridCell from "./GridCell.svelte" import GridCell from "./GridCell.svelte"
import { getColumnIcon } from "../lib/utils" import { getColumnIcon } from "../lib/utils"
import { debounce } from "../../../utils/utils"
import { FieldType, FormulaTypes } from "@budibase/types"
export let column export let column
export let idx export let idx
@ -24,23 +26,69 @@
definition, definition,
datasource, datasource,
schema, schema,
focusedCellId,
filter,
inlineFilters,
} = getContext("grid") } = getContext("grid")
const searchableTypes = [
FieldType.STRING,
FieldType.OPTIONS,
FieldType.NUMBER,
FieldType.BIGINT,
FieldType.ARRAY,
FieldType.LONGFORM,
]
let anchor let anchor
let open = false let open = false
let editIsOpen = false let editIsOpen = false
let timeout let timeout
let popover let popover
let searchValue
let input
$: sortedBy = column.name === $sort.column $: sortedBy = column.name === $sort.column
$: canMoveLeft = orderable && idx > 0 $: canMoveLeft = orderable && idx > 0
$: canMoveRight = orderable && idx < $renderedColumns.length - 1 $: canMoveRight = orderable && idx < $renderedColumns.length - 1
$: ascendingLabel = ["number", "bigint"].includes(column.schema?.type) $: sortingLabels = getSortingLabels(column.schema?.type)
? "low-high" $: searchable = isColumnSearchable(column)
: "A-Z" $: resetSearchValue(column.name)
$: descendingLabel = ["number", "bigint"].includes(column.schema?.type) $: searching = searchValue != null
? "high-low" $: debouncedUpdateFilter(searchValue)
: "Z-A"
const getSortingLabels = type => {
switch (type) {
case FieldType.NUMBER:
case FieldType.BIGINT:
return {
ascending: "low-high",
descending: "high-low",
}
case FieldType.DATETIME:
return {
ascending: "old-new",
descending: "new-old",
}
default:
return {
ascending: "A-Z",
descending: "Z-A",
}
}
}
const resetSearchValue = name => {
searchValue = $inlineFilters?.find(x => x.id === `inline-${name}`)?.value
}
const isColumnSearchable = col => {
const { type, formulaType } = col.schema
return (
searchableTypes.includes(type) ||
(type === FieldType.FORMULA && formulaType === FormulaTypes.STATIC)
)
}
const editColumn = async () => { const editColumn = async () => {
editIsOpen = true editIsOpen = true
@ -141,12 +189,46 @@
}) })
} }
const startSearching = async () => {
$focusedCellId = null
searchValue = ""
await tick()
input?.focus()
}
const onInputKeyDown = e => {
if (e.key === "Enter") {
updateFilter()
} else if (e.key === "Escape") {
input?.blur()
}
}
const stopSearching = () => {
searchValue = null
updateFilter()
}
const onBlurInput = () => {
if (searchValue === "") {
searchValue = null
}
updateFilter()
}
const updateFilter = () => {
filter.actions.addInlineFilter(column, searchValue)
}
const debouncedUpdateFilter = debounce(updateFilter, 250)
onMount(() => subscribe("close-edit-column", cancelEdit)) onMount(() => subscribe("close-edit-column", cancelEdit))
</script> </script>
<div <div
class="header-cell" class="header-cell"
class:open class:open
class:searchable
class:searching
style="flex: 0 0 {column.width}px;" style="flex: 0 0 {column.width}px;"
bind:this={anchor} bind:this={anchor}
class:disabled={$isReordering || $isResizing} class:disabled={$isReordering || $isResizing}
@ -161,30 +243,49 @@
defaultHeight defaultHeight
center center
> >
<Icon {#if searching}
size="S" <input
name={getColumnIcon(column)} bind:this={input}
color={`var(--spectrum-global-color-gray-600)`} type="text"
/> bind:value={searchValue}
on:blur={onBlurInput}
on:click={() => focusedCellId.set(null)}
on:keydown={onInputKeyDown}
data-grid-ignore
/>
{/if}
<div class="column-icon">
<Icon size="S" name={getColumnIcon(column)} />
</div>
<div class="search-icon" on:click={startSearching}>
<Icon hoverable size="S" name="Search" />
</div>
<div class="name"> <div class="name">
{column.label} {column.label}
</div> </div>
{#if sortedBy}
<div class="sort-indicator"> {#if searching}
<Icon <div class="clear-icon" on:click={stopSearching}>
size="S" <Icon hoverable size="S" name="Close" />
name={$sort.order === "descending" ? "SortOrderDown" : "SortOrderUp"} </div>
color="var(--spectrum-global-color-gray-600)" {:else}
/> {#if sortedBy}
<div class="sort-indicator">
<Icon
hoverable
size="S"
name={$sort.order === "descending"
? "SortOrderDown"
: "SortOrderUp"}
/>
</div>
{/if}
<div class="more-icon" on:click={() => (open = true)}>
<Icon hoverable size="S" name="MoreVertical" />
</div> </div>
{/if} {/if}
<div class="more" on:click={() => (open = true)}>
<Icon
size="S"
name="MoreVertical"
color="var(--spectrum-global-color-gray-600)"
/>
</div>
</GridCell> </GridCell>
</div> </div>
@ -235,7 +336,7 @@
disabled={!canBeSortColumn(column.schema.type) || disabled={!canBeSortColumn(column.schema.type) ||
(column.name === $sort.column && $sort.order === "ascending")} (column.name === $sort.column && $sort.order === "ascending")}
> >
Sort {ascendingLabel} Sort {sortingLabels.ascending}
</MenuItem> </MenuItem>
<MenuItem <MenuItem
icon="SortOrderDown" icon="SortOrderDown"
@ -243,7 +344,7 @@
disabled={!canBeSortColumn(column.schema.type) || disabled={!canBeSortColumn(column.schema.type) ||
(column.name === $sort.column && $sort.order === "descending")} (column.name === $sort.column && $sort.order === "descending")}
> >
Sort {descendingLabel} Sort {sortingLabels.descending}
</MenuItem> </MenuItem>
<MenuItem disabled={!canMoveLeft} icon="ChevronLeft" on:click={moveLeft}> <MenuItem disabled={!canMoveLeft} icon="ChevronLeft" on:click={moveLeft}>
Move left Move left
@ -283,6 +384,29 @@
background: var(--grid-background-alt); background: var(--grid-background-alt);
} }
/* Icon colors */
.header-cell :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-600);
}
.header-cell :global(.spectrum-Icon.hoverable:hover) {
color: var(--spectrum-global-color-gray-800) !important;
cursor: pointer;
}
/* Search icon */
.search-icon {
display: none;
}
.header-cell.searchable:not(.open):hover .search-icon,
.header-cell.searchable.searching .search-icon {
display: block;
}
.header-cell.searchable:not(.open):hover .column-icon,
.header-cell.searchable.searching .column-icon {
display: none;
}
/* Main center content */
.name { .name {
flex: 1 1 auto; flex: 1 1 auto;
width: 0; width: 0;
@ -290,23 +414,45 @@
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
} }
.header-cell.searching .name {
opacity: 0;
pointer-events: none;
}
input {
display: none;
font-family: var(--font-sans);
outline: none;
border: 1px solid transparent;
background: transparent;
color: var(--spectrum-global-color-gray-800);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 0 30px;
border-radius: 2px;
}
input:focus {
border: 1px solid var(--accent-color);
}
input:not(:focus) {
background: var(--spectrum-global-color-gray-200);
}
.header-cell.searching input {
display: block;
}
.more { /* Right icons */
.more-icon {
display: none; display: none;
padding: 4px; padding: 4px;
margin: 0 -4px; margin: 0 -4px;
} }
.header-cell.open .more, .header-cell.open .more-icon,
.header-cell:hover .more { .header-cell:hover .more-icon {
display: block; display: block;
} }
.more:hover {
cursor: pointer;
}
.more:hover :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-800) !important;
}
.header-cell.open .sort-indicator, .header-cell.open .sort-indicator,
.header-cell:hover .sort-indicator { .header-cell:hover .sort-indicator {
display: none; display: none;

View File

@ -27,8 +27,10 @@
rowVerticalInversionIndex, rowVerticalInversionIndex,
columnHorizontalInversionIndex, columnHorizontalInversionIndex,
selectedRows, selectedRows,
loading, loaded,
refreshing,
config, config,
filter,
} = getContext("grid") } = getContext("grid")
let visible = false let visible = false
@ -153,7 +155,7 @@
<!-- New row FAB --> <!-- New row FAB -->
<TempTooltip <TempTooltip
text="Click here to create your first row" text="Click here to create your first row"
condition={hasNoRows && !$loading} condition={hasNoRows && $loaded && !$filter?.length && !$refreshing}
type={TooltipType.Info} type={TooltipType.Info}
> >
{#if !visible && !selectedRowCount && $config.canAddRows} {#if !visible && !selectedRowCount && $config.canAddRows}

View File

@ -21,6 +21,7 @@
const ignoredOriginSelectors = [ const ignoredOriginSelectors = [
".spectrum-Modal", ".spectrum-Modal",
"#builder-side-panel-container", "#builder-side-panel-container",
"[data-grid-ignore]",
] ]
// Global key listener which intercepts all key events // Global key listener which intercepts all key events

View File

@ -1,8 +1,9 @@
import { derived, get, writable } from "svelte/store" import { derived, get } from "svelte/store"
import { getDatasourceDefinition } from "../../../fetch" import { getDatasourceDefinition, getDatasourceSchema } from "../../../fetch"
import { memo } from "../../../utils"
export const createStores = () => { export const createStores = () => {
const definition = writable(null) const definition = memo(null)
return { return {
definition, definition,
@ -10,10 +11,15 @@ export const createStores = () => {
} }
export const deriveStores = context => { export const deriveStores = context => {
const { definition, schemaOverrides, columnWhitelist, datasource } = context const { API, definition, schemaOverrides, columnWhitelist, datasource } =
context
const schema = derived(definition, $definition => { const schema = derived(definition, $definition => {
let schema = $definition?.schema let schema = getDatasourceSchema({
API,
datasource: get(datasource),
definition: $definition,
})
if (!schema) { if (!schema) {
return null return null
} }

View File

@ -66,6 +66,8 @@ export const initialise = context => {
datasource, datasource,
sort, sort,
filter, filter,
inlineFilters,
allFilters,
nonPlus, nonPlus,
initialFilter, initialFilter,
initialSortColumn, initialSortColumn,
@ -87,6 +89,7 @@ export const initialise = context => {
// Wipe state // Wipe state
filter.set(get(initialFilter)) filter.set(get(initialFilter))
inlineFilters.set([])
sort.set({ sort.set({
column: get(initialSortColumn), column: get(initialSortColumn),
order: get(initialSortOrder) || "ascending", order: get(initialSortOrder) || "ascending",
@ -94,14 +97,14 @@ export const initialise = context => {
// Update fetch when filter changes // Update fetch when filter changes
unsubscribers.push( unsubscribers.push(
filter.subscribe($filter => { allFilters.subscribe($allFilters => {
// Ensure we're updating the correct fetch // Ensure we're updating the correct fetch
const $fetch = get(fetch) const $fetch = get(fetch)
if (!isSameDatasource($fetch?.options?.datasource, $datasource)) { if (!isSameDatasource($fetch?.options?.datasource, $datasource)) {
return return
} }
$fetch.update({ $fetch.update({
filter: $filter, filter: $allFilters,
}) })
}) })
) )

View File

@ -71,6 +71,8 @@ export const initialise = context => {
datasource, datasource,
fetch, fetch,
filter, filter,
inlineFilters,
allFilters,
sort, sort,
table, table,
initialFilter, initialFilter,
@ -93,6 +95,7 @@ export const initialise = context => {
// Wipe state // Wipe state
filter.set(get(initialFilter)) filter.set(get(initialFilter))
inlineFilters.set([])
sort.set({ sort.set({
column: get(initialSortColumn), column: get(initialSortColumn),
order: get(initialSortOrder) || "ascending", order: get(initialSortOrder) || "ascending",
@ -100,14 +103,14 @@ export const initialise = context => {
// Update fetch when filter changes // Update fetch when filter changes
unsubscribers.push( unsubscribers.push(
filter.subscribe($filter => { allFilters.subscribe($allFilters => {
// Ensure we're updating the correct fetch // Ensure we're updating the correct fetch
const $fetch = get(fetch) const $fetch = get(fetch)
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
return return
} }
$fetch.update({ $fetch.update({
filter: $filter, filter: $allFilters,
}) })
}) })
) )

View File

@ -73,6 +73,8 @@ export const initialise = context => {
sort, sort,
rows, rows,
filter, filter,
inlineFilters,
allFilters,
subscribe, subscribe,
viewV2, viewV2,
initialFilter, initialFilter,
@ -97,6 +99,7 @@ export const initialise = context => {
// Reset state for new view // Reset state for new view
filter.set(get(initialFilter)) filter.set(get(initialFilter))
inlineFilters.set([])
sort.set({ sort.set({
column: get(initialSortColumn), column: get(initialSortColumn),
order: get(initialSortOrder) || "ascending", order: get(initialSortOrder) || "ascending",
@ -143,21 +146,19 @@ export const initialise = context => {
order: $sort.order || "ascending", order: $sort.order || "ascending",
}, },
}) })
await rows.actions.refreshData()
} }
} }
// Otherwise just update the fetch
else { // Also update the fetch to ensure the new sort is respected.
// Ensure we're updating the correct fetch // Ensure we're updating the correct fetch.
const $fetch = get(fetch) const $fetch = get(fetch)
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
return return
}
$fetch.update({
sortOrder: $sort.order || "ascending",
sortColumn: $sort.column,
})
} }
$fetch.update({
sortOrder: $sort.order,
sortColumn: $sort.column,
})
}) })
) )
@ -176,20 +177,25 @@ export const initialise = context => {
...$view, ...$view,
query: $filter, query: $filter,
}) })
await rows.actions.refreshData()
} }
} }
// Otherwise just update the fetch })
else { )
// Ensure we're updating the correct fetch
const $fetch = get(fetch) // Keep fetch up to date with filters.
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { // If we're able to save filters against the view then we only need to apply
return // inline filters to the fetch, as saved filters are applied server side.
} // If we can't save filters, then all filters must be applied to the fetch.
$fetch.update({ unsubscribers.push(
filter: $filter, allFilters.subscribe($allFilters => {
}) // Ensure we're updating the correct fetch
const $fetch = get(fetch)
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
return
} }
$fetch.update({
filter: $allFilters,
})
}) })
) )

View File

@ -1,13 +1,79 @@
import { writable, get } from "svelte/store" import { writable, get, derived } from "svelte/store"
import { FieldType } from "@budibase/types"
export const createStores = context => { export const createStores = context => {
const { props } = context const { props } = context
// Initialise to default props // Initialise to default props
const filter = writable(get(props).initialFilter) const filter = writable(get(props).initialFilter)
const inlineFilters = writable([])
return { return {
filter, filter,
inlineFilters,
}
}
export const deriveStores = context => {
const { filter, inlineFilters } = context
const allFilters = derived(
[filter, inlineFilters],
([$filter, $inlineFilters]) => {
return [...($filter || []), ...$inlineFilters]
}
)
return {
allFilters,
}
}
export const createActions = context => {
const { filter, inlineFilters } = context
const addInlineFilter = (column, value) => {
const filterId = `inline-${column.name}`
const type = column.schema.type
let inlineFilter = {
field: column.name,
id: filterId,
operator: "string",
valueType: "value",
type,
value,
}
// Add overrides specific so the certain column type
if (type === FieldType.NUMBER) {
inlineFilter.value = parseFloat(value)
inlineFilter.operator = "equal"
} else if (type === FieldType.BIGINT) {
inlineFilter.operator = "equal"
} else if (type === FieldType.ARRAY) {
inlineFilter.operator = "contains"
}
// Add this filter
inlineFilters.update($inlineFilters => {
// Remove any existing inline filter for this column
$inlineFilters = $inlineFilters?.filter(x => x.id !== filterId)
// Add new one if a value exists
if (value) {
$inlineFilters.push(inlineFilter)
}
return $inlineFilters
})
}
return {
filter: {
...filter,
actions: {
addInlineFilter,
},
},
} }
} }

View File

@ -8,6 +8,7 @@ export const createStores = () => {
const rows = writable([]) const rows = writable([])
const loading = writable(false) const loading = writable(false)
const loaded = writable(false) const loaded = writable(false)
const refreshing = writable(false)
const rowChangeCache = writable({}) const rowChangeCache = writable({})
const inProgressChanges = writable({}) const inProgressChanges = writable({})
const hasNextPage = writable(false) const hasNextPage = writable(false)
@ -53,6 +54,7 @@ export const createStores = () => {
fetch, fetch,
rowLookupMap, rowLookupMap,
loaded, loaded,
refreshing,
loading, loading,
rowChangeCache, rowChangeCache,
inProgressChanges, inProgressChanges,
@ -66,7 +68,7 @@ export const createActions = context => {
rows, rows,
rowLookupMap, rowLookupMap,
definition, definition,
filter, allFilters,
loading, loading,
sort, sort,
datasource, datasource,
@ -82,6 +84,7 @@ export const createActions = context => {
notifications, notifications,
fetch, fetch,
isDatasourcePlus, isDatasourcePlus,
refreshing,
} = context } = context
const instanceLoaded = writable(false) const instanceLoaded = writable(false)
@ -108,7 +111,7 @@ export const createActions = context => {
// Tick to allow other reactive logic to update stores when datasource changes // Tick to allow other reactive logic to update stores when datasource changes
// before proceeding. This allows us to wipe filters etc if needed. // before proceeding. This allows us to wipe filters etc if needed.
await tick() await tick()
const $filter = get(filter) const $allFilters = get(allFilters)
const $sort = get(sort) const $sort = get(sort)
// Determine how many rows to fetch per page // Determine how many rows to fetch per page
@ -120,7 +123,7 @@ export const createActions = context => {
API, API,
datasource: $datasource, datasource: $datasource,
options: { options: {
filter: $filter, filter: $allFilters,
sortColumn: $sort.column, sortColumn: $sort.column,
sortOrder: $sort.order, sortOrder: $sort.order,
limit, limit,
@ -176,6 +179,9 @@ export const createActions = context => {
// Notify that we're loaded // Notify that we're loaded
loading.set(false) loading.set(false)
} }
// Update refreshing state
refreshing.set($fetch.loading)
}) })
fetch.set(newFetch) fetch.set(newFetch)

View File

@ -35,9 +35,28 @@ export default class ViewV2Fetch extends DataFetch {
} }
async getData() { async getData() {
const { datasource, limit, sortColumn, sortOrder, sortType, paginate } = const {
this.options datasource,
const { cursor, query } = get(this.store) limit,
sortColumn,
sortOrder,
sortType,
paginate,
filter,
} = this.options
const { cursor, query, definition } = get(this.store)
// If sort/filter params are not defined, update options to store the
// params built in to this view. This ensures that we can accurately
// compare old and new params and skip a redundant API call.
if (!sortColumn && definition.sort?.field) {
this.options.sortColumn = definition.sort.field
this.options.sortOrder = definition.sort.order
}
if (!filter?.length && definition.query?.length) {
this.options.filter = definition.query
}
try { try {
const res = await this.API.viewV2.fetch({ const res = await this.API.viewV2.fetch({
viewId: datasource.id, viewId: datasource.id,

View File

@ -32,12 +32,24 @@ export const fetchData = ({ API, datasource, options }) => {
return new Fetch({ API, datasource, ...options }) return new Fetch({ API, datasource, ...options })
} }
// Fetches the definition of any type of datasource // Creates an empty fetch instance with no datasource configured, so no data
export const getDatasourceDefinition = async ({ API, datasource }) => { // will initially be loaded
const createEmptyFetchInstance = ({ API, datasource }) => {
const handler = DataFetchMap[datasource?.type] const handler = DataFetchMap[datasource?.type]
if (!handler) { if (!handler) {
return null return null
} }
const instance = new handler({ API }) return new handler({ API })
return await instance.getDefinition(datasource) }
// Fetches the definition of any type of datasource
export const getDatasourceDefinition = async ({ API, datasource }) => {
const instance = createEmptyFetchInstance({ API, datasource })
return await instance?.getDefinition(datasource)
}
// Fetches the schema of any type of datasource
export const getDatasourceSchema = ({ API, datasource, definition }) => {
const instance = createEmptyFetchInstance({ API, datasource })
return instance?.getSchema(datasource, definition)
} }