Allow view searching and sorting. Refactor grid logic to fix multiple issuies

This commit is contained in:
Andrew Kingston 2023-08-21 11:56:58 +01:00
parent b5546f8d9b
commit 0566644508
12 changed files with 250 additions and 168 deletions

View File

@ -4615,14 +4615,15 @@
"type": "field/sortable", "type": "field/sortable",
"label": "Sort by", "label": "Sort by",
"key": "sortColumn", "key": "sortColumn",
"placeholder": "None" "placeholder": "Default"
}, },
{ {
"type": "select", "type": "select",
"label": "Sort order", "label": "Sort order",
"key": "sortOrder", "key": "sortOrder",
"options": ["Ascending", "Descending"], "options": ["Ascending", "Descending"],
"defaultValue": "Ascending" "defaultValue": "Ascending",
"dependsOn": "sortColumn"
}, },
{ {
"type": "select", "type": "select",

View File

@ -31,6 +31,7 @@ export const buildViewV2Endpoints = API => ({
/** /**
* Fetches all rows in a view * Fetches all rows in a view
* @param viewId the id of the view * @param viewId the id of the view
* @param query the search query
* @param paginate whether to paginate or not * @param paginate whether to paginate or not
* @param limit page size * @param limit page size
* @param bookmark pagination cursor * @param bookmark pagination cursor
@ -40,6 +41,7 @@ export const buildViewV2Endpoints = API => ({
*/ */
fetch: async ({ fetch: async ({
viewId, viewId,
query,
paginate, paginate,
limit, limit,
bookmark, bookmark,
@ -50,6 +52,7 @@ export const buildViewV2Endpoints = API => ({
return await API.post({ return await API.post({
url: `/api/v2/views/${viewId}/search`, url: `/api/v2/views/${viewId}/search`,
body: { body: {
query,
paginate, paginate,
limit, limit,
bookmark, bookmark,

View File

@ -8,7 +8,6 @@
let anchor let anchor
$: columnOptions = getColumnOptions($stickyColumn, $columns) $: columnOptions = getColumnOptions($stickyColumn, $columns)
$: checkValidSortColumn($sort.column, $stickyColumn, $columns)
$: orderOptions = getOrderOptions($sort.column, columnOptions) $: orderOptions = getOrderOptions($sort.column, columnOptions)
const getColumnOptions = (stickyColumn, columns) => { const getColumnOptions = (stickyColumn, columns) => {
@ -46,8 +45,8 @@
const updateSortColumn = e => { const updateSortColumn = e => {
sort.update(state => ({ sort.update(state => ({
...state,
column: e.detail, column: e.detail,
order: e.detail ? state.order : "ascending",
})) }))
} }
@ -57,29 +56,6 @@
order: e.detail, order: e.detail,
})) }))
} }
// Ensure we never have a sort column selected that is not visible
const checkValidSortColumn = (sortColumn, stickyColumn, columns) => {
if (!sortColumn) {
return
}
if (
sortColumn !== stickyColumn?.name &&
!columns.some(col => col.name === sortColumn)
) {
if (stickyColumn) {
sort.update(state => ({
...state,
column: stickyColumn.name,
}))
} else {
sort.update(state => ({
...state,
column: columns[0]?.name,
}))
}
}
}
</script> </script>
<div bind:this={anchor}> <div bind:this={anchor}>
@ -98,21 +74,23 @@
<Popover bind:open {anchor} align="left"> <Popover bind:open {anchor} align="left">
<div class="content"> <div class="content">
<Select <Select
placeholder={null} placeholder="Default"
value={$sort.column} value={$sort.column}
options={columnOptions} options={columnOptions}
autoWidth autoWidth
on:change={updateSortColumn} on:change={updateSortColumn}
label="Column" label="Column"
/> />
{#if $sort.column}
<Select <Select
placeholder={null} placeholder={null}
value={$sort.order} value={$sort.order || "ascending"}
options={orderOptions} options={orderOptions}
autoWidth autoWidth
on:change={updateSortOrder} on:change={updateSortOrder}
label="Order" label="Order"
/> />
{/if}
</div> </div>
</Popover> </Popover>

View File

@ -1,4 +1,4 @@
import { derived } from "svelte/store" import { derived, get } from "svelte/store"
export const initialise = context => { export const initialise = context => {
const { scrolledRowCount, rows, visualRowCapacity } = context const { scrolledRowCount, rows, visualRowCapacity } = context
@ -11,13 +11,12 @@ export const initialise = context => {
[scrolledRowCount, rowCount, visualRowCapacity], [scrolledRowCount, rowCount, visualRowCapacity],
([$scrolledRowCount, $rowCount, $visualRowCapacity]) => { ([$scrolledRowCount, $rowCount, $visualRowCapacity]) => {
return Math.max(0, $rowCount - $scrolledRowCount - $visualRowCapacity) return Math.max(0, $rowCount - $scrolledRowCount - $visualRowCapacity)
}, }
100
) )
// Fetch next page when fewer than 25 remaining rows to scroll // Fetch next page when fewer than 25 remaining rows to scroll
remainingRows.subscribe(remaining => { remainingRows.subscribe(remaining => {
if (remaining < 25) { if (remaining < 25 && get(rowCount)) {
rows.actions.loadNextPage() rows.actions.loadNextPage()
} }
}) })

View File

@ -1,4 +1,4 @@
import { get } from "svelte/store" import { derived, get } from "svelte/store"
import { memo } from "../../../utils" import { memo } from "../../../utils"
export const createStores = context => { export const createStores = context => {
@ -17,13 +17,34 @@ export const createStores = context => {
} }
export const initialise = context => { export const initialise = context => {
const { sort, initialSortColumn, initialSortOrder } = context const { sort, initialSortColumn, initialSortOrder, definition } = context
// Reset sort when initial sort props change // Reset sort when initial sort props change
initialSortColumn.subscribe(newSortColumn => { initialSortColumn.subscribe(newSortColumn => {
sort.update(state => ({ ...state, column: newSortColumn })) sort.update(state => ({ ...state, column: newSortColumn }))
}) })
initialSortOrder.subscribe(newSortOrder => { initialSortOrder.subscribe(newSortOrder => {
sort.update(state => ({ ...state, order: newSortOrder })) sort.update(state => ({ ...state, order: newSortOrder || "ascending" }))
})
// Derive if the current sort column exists in the schema
const sortColumnExists = derived(
[sort, definition],
([$sort, $definition]) => {
if (!$sort?.column) {
return true
}
return $definition?.schema?.[$sort.column] != null
}
)
// Clear sort state if our sort column does not exist
sortColumnExists.subscribe(exists => {
if (!exists) {
sort.set({
column: null,
order: "ascending",
})
}
}) })
} }

View File

@ -59,51 +59,55 @@ export const createActions = context => {
} }
export const initialise = context => { export const initialise = context => {
const { datasource, fetch, filter, sort, definition } = context const { datasource, fetch, filter, sort, table } = context
// Wipe filter whenever table ID changes to avoid using stale filters // Keep a list of subscriptions so that we can clear them when the datasource
// config changes
let unsubscribers = []
// Observe datasource changes and apply logic for table datasources
datasource.subscribe($datasource => { datasource.subscribe($datasource => {
if ($datasource?.type !== "table") { // Clear previous subscriptions
unsubscribers?.forEach(unsubscribe => unsubscribe())
unsubscribers = []
if (!table.actions.isDatasourceValid($datasource)) {
return return
} }
// Wipe state
filter.set([]) filter.set([])
sort.set({
column: null,
order: "ascending",
}) })
// Update fetch when filter changes // Update fetch when filter changes
unsubscribers.push(
filter.subscribe($filter => { filter.subscribe($filter => {
if (get(datasource)?.type !== "table") { // Ensure we're updating the correct fetch
const $fetch = get(fetch)
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
return return
} }
get(fetch)?.update({ $fetch.update({
filter: $filter, filter: $filter,
}) })
}) })
)
// Update fetch when sorting changes // Update fetch when sorting changes
unsubscribers.push(
sort.subscribe($sort => { sort.subscribe($sort => {
if (get(datasource)?.type !== "table") { // Ensure we're updating the correct fetch
const $fetch = get(fetch)
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
return return
} }
get(fetch)?.update({ $fetch.update({
sortOrder: $sort.order, sortOrder: $sort.order || "ascending",
sortColumn: $sort.column, sortColumn: $sort.column,
}) })
}) })
)
// Ensure sorting UI reflects the fetch state whenever we reset the fetch,
// which triggers a new definition
definition.subscribe(() => {
if (get(datasource)?.type !== "table") {
return
}
const $fetch = get(fetch)
if (!$fetch) {
return
}
const { sortColumn, sortOrder } = get($fetch)
sort.set({
column: sortColumn,
order: sortOrder,
})
}) })
} }

View File

@ -69,24 +69,49 @@ export const createActions = context => {
} }
export const initialise = context => { export const initialise = context => {
const { definition, datasource, sort, rows, filter, subscribe } = context const { definition, datasource, sort, rows, filter, subscribe, viewV2 } =
context
// Keep a list of subscriptions so that we can clear them when the datasource
// config changes
let unsubscribers = []
// Observe datasource changes and apply logic for view V2 datasources
datasource.subscribe($datasource => {
// Clear previous subscriptions
unsubscribers?.forEach(unsubscribe => unsubscribe())
unsubscribers = []
if (!viewV2.actions.isDatasourceValid($datasource)) {
return
}
// Reset state for new view
filter.set([])
sort.set({
column: null,
order: "ascending",
})
// Keep sort and filter state in line with the view definition // Keep sort and filter state in line with the view definition
unsubscribers.push(
definition.subscribe($definition => { definition.subscribe($definition => {
if (!$definition || get(datasource)?.type !== "viewV2") { if ($definition?.id !== $datasource.id) {
return return
} }
sort.set({ sort.set({
column: $definition.sort?.field, column: $definition.sort?.field,
order: $definition.sort?.order, order: $definition.sort?.order || "ascending",
}) })
filter.set($definition.query || []) filter.set($definition.query || [])
}) })
)
// When sorting changes, ensure view definition is kept up to date // When sorting changes, ensure view definition is kept up to date
unsubscribers.push(
sort.subscribe(async $sort => { sort.subscribe(async $sort => {
// Ensure we're updating the correct view
const $view = get(definition) const $view = get(definition)
if (!$view || get(datasource)?.type !== "viewV2") { if ($view?.id !== $datasource.id) {
return return
} }
if ( if (
@ -97,17 +122,20 @@ export const initialise = context => {
...$view, ...$view,
sort: { sort: {
field: $sort.column, field: $sort.column,
order: $sort.order, order: $sort.order || "ascending",
}, },
}) })
await rows.actions.refreshData() await rows.actions.refreshData()
} }
}) })
)
// When filters change, ensure view definition is kept up to date // When filters change, ensure view definition is kept up to date
unsubscribers?.push(
filter.subscribe(async $filter => { filter.subscribe(async $filter => {
// Ensure we're updating the correct view
const $view = get(definition) const $view = get(definition)
if (!$view || get(datasource)?.type !== "viewV2") { if ($view?.id !== $datasource.id) {
return return
} }
if (JSON.stringify($filter) !== JSON.stringify($view.query)) { if (JSON.stringify($filter) !== JSON.stringify($view.query)) {
@ -118,13 +146,14 @@ export const initialise = context => {
await rows.actions.refreshData() await rows.actions.refreshData()
} }
}) })
)
// When hidden we show columns, we need to refresh data in order to fetch // When hidden we show columns, we need to refresh data in order to fetch
// values for those columns // values for those columns
unsubscribers.push(
subscribe("show-column", async () => { subscribe("show-column", async () => {
if (get(datasource)?.type !== "viewV2") {
return
}
await rows.actions.refreshData() await rows.actions.refreshData()
}) })
)
})
} }

View File

@ -110,6 +110,17 @@ export default class DataFetch {
return this.derivedStore.subscribe return this.derivedStore.subscribe
} }
/**
* Gets the default sort column for this datasource
*/
getDefaultSortColumn(definition, schema) {
if (definition?.primaryDisplay && schema[definition.primaryDisplay]) {
return definition.primaryDisplay
} else {
return Object.keys(schema)[0]
}
}
/** /**
* Fetches a fresh set of data from the server, resetting pagination * Fetches a fresh set of data from the server, resetting pagination
*/ */
@ -118,12 +129,6 @@ export default class DataFetch {
// Fetch datasource definition and extract sort properties if configured // Fetch datasource definition and extract sort properties if configured
const definition = await this.getDefinition(datasource) const definition = await this.getDefinition(datasource)
if (definition?.sort?.field) {
this.options.sortColumn = definition.sort.field
}
if (definition?.sort?.order) {
this.options.sortOrder = definition.sort.order
}
// Determine feature flags // Determine feature flags
const features = this.determineFeatureFlags(definition) const features = this.determineFeatureFlags(definition)
@ -140,32 +145,32 @@ export default class DataFetch {
return return
} }
// If no sort order, default to descending // If an invalid sort column is specified, delete it
if (this.options.sortColumn && !schema[this.options.sortColumn]) {
this.options.sortColumn = null
}
// If no sort column, get the default column for this datasource
if (!this.options.sortColumn) {
this.options.sortColumn = this.getDefaultSortColumn(definition, schema)
}
// If we don't have a sort column specified then just ensure we don't set
// any sorting params
if (!this.options.sortColumn) {
this.options.sortOrder = "ascending"
this.options.sortType = null
} else {
// Otherwise determine what sort type to use base on sort column
const type = schema?.[this.options.sortColumn]?.type
this.options.sortType =
type === "number" || type === "bigint" ? "number" : "string"
// If no sort order, default to ascending
if (!this.options.sortOrder) { if (!this.options.sortOrder) {
this.options.sortOrder = "ascending" this.options.sortOrder = "ascending"
} }
// If no sort column, or an invalid sort column is provided, use the primary
// display and fallback to first column
const sortValid = this.options.sortColumn && schema[this.options.sortColumn]
if (!sortValid) {
let newSortColumn
if (definition?.primaryDisplay && schema[definition.primaryDisplay]) {
newSortColumn = definition.primaryDisplay
} else {
newSortColumn = Object.keys(schema)[0]
} }
this.options.sortColumn = newSortColumn
}
const { sortOrder, sortColumn } = this.options
// Determine what sort type to use
let sortType = "string"
if (sortColumn) {
const type = schema?.[sortColumn]?.type
sortType = type === "number" || type === "bigint" ? "number" : "string"
}
this.options.sortType = sortType
// Build the lucene query // Build the lucene query
let query = this.options.query let query = this.options.query
@ -182,8 +187,6 @@ export default class DataFetch {
loading: true, loading: true,
cursors: [], cursors: [],
cursor: null, cursor: null,
sortOrder,
sortColumn,
})) }))
// Actually fetch data // Actually fetch data
@ -351,6 +354,14 @@ export default class DataFetch {
const entries = Object.entries(newOptions || {}) const entries = Object.entries(newOptions || {})
for (let [key, value] of entries) { for (let [key, value] of entries) {
if (JSON.stringify(value) !== JSON.stringify(this.options[key])) { if (JSON.stringify(value) !== JSON.stringify(this.options[key])) {
console.log(
key,
"is different",
"new",
value,
"vs old",
this.options[key]
)
refresh = true refresh = true
break break
} }

View File

@ -29,6 +29,10 @@ export default class QueryFetch extends DataFetch {
} }
} }
getDefaultSortColumn() {
return null
}
async getData() { async getData() {
const { datasource, limit, paginate } = this.options const { datasource, limit, paginate } = this.options
const { supportsPagination } = this.features const { supportsPagination } = this.features

View File

@ -4,9 +4,6 @@ import { get } from "svelte/store"
export default class ViewV2Fetch extends DataFetch { export default class ViewV2Fetch extends DataFetch {
determineFeatureFlags() { determineFeatureFlags() {
return { return {
// The API does not actually support dynamic filtering, but since views
// have filters built in we don't want to perform client side filtering
// which would happen if we marked this as false
supportsSearch: true, supportsSearch: true,
supportsSort: true, supportsSort: true,
supportsPagination: true, supportsPagination: true,
@ -33,18 +30,23 @@ export default class ViewV2Fetch extends DataFetch {
} }
} }
getDefaultSortColumn() {
return null
}
async getData() { async getData() {
const { datasource, limit, sortColumn, sortOrder, sortType, paginate } = const { datasource, limit, sortColumn, sortOrder, sortType, paginate } =
this.options this.options
const { cursor } = get(this.store) const { cursor, query } = get(this.store)
try { try {
const res = await this.API.viewV2.fetch({ const res = await this.API.viewV2.fetch({
viewId: datasource.id, viewId: datasource.id,
query,
paginate, paginate,
limit, limit,
bookmark: cursor, bookmark: cursor,
sort: sortColumn, sort: sortColumn,
sortOrder, sortOrder: sortOrder?.toLowerCase(),
sortType, sortType,
}) })
return { return {

View File

@ -6,9 +6,11 @@ import {
SearchViewRowRequest, SearchViewRowRequest,
RequiredKeys, RequiredKeys,
SearchParams, SearchParams,
SearchFilters,
} from "@budibase/types" } from "@budibase/types"
import { dataFilters } from "@budibase/shared-core" import { dataFilters } from "@budibase/shared-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { db } from "@budibase/backend-core"
export async function searchView( export async function searchView(
ctx: UserCtx<SearchViewRowRequest, SearchRowResponse> ctx: UserCtx<SearchViewRowRequest, SearchRowResponse>
@ -19,15 +21,37 @@ export async function searchView(
if (!view) { if (!view) {
ctx.throw(404, `View ${viewId} not found`) ctx.throw(404, `View ${viewId} not found`)
} }
if (view.version !== 2) { if (view.version !== 2) {
ctx.throw(400, `This method only supports viewsV2`) ctx.throw(400, `This method only supports viewsV2`)
} }
const viewFields = Object.keys(view.schema || {}) const viewFields = Object.keys(view.schema || {})
const { body } = ctx.request const { body } = ctx.request
const query = dataFilters.buildLuceneQuery(view.query || [])
// Enrich saved query with ephemeral query params.
// We prevent searching on any fields that are saved as part of the query, as
// that could let users find rows they should not be allowed to access.
let query = dataFilters.buildLuceneQuery(view.query || [])
if (body.query) {
// Extract existing fields
const existingFields =
view.query
?.filter(filter => filter.field)
.map(filter => db.removeKeyNumbering(filter.field)) || []
// Prevent using an "OR" search
delete body.query.allOr
// Carry over filters for unused fields
Object.keys(body.query).forEach(key => {
const operator = key as keyof Omit<SearchFilters, "allOr">
Object.keys(body.query[operator] || {}).forEach(field => {
if (!existingFields.includes(db.removeKeyNumbering(field))) {
query[operator]![field] = body.query[operator]![field]
}
})
})
}
const searchOptions: RequiredKeys<SearchViewRowRequest> & const searchOptions: RequiredKeys<SearchViewRowRequest> &
RequiredKeys<Pick<SearchParams, "tableId" | "query" | "fields">> = { RequiredKeys<Pick<SearchParams, "tableId" | "query" | "fields">> = {

View File

@ -16,7 +16,13 @@ export interface SearchRowRequest extends Omit<SearchParams, "tableId"> {}
export interface SearchViewRowRequest export interface SearchViewRowRequest
extends Pick< extends Pick<
SearchRowRequest, SearchRowRequest,
"sort" | "sortOrder" | "sortType" | "limit" | "bookmark" | "paginate" | "sort"
| "sortOrder"
| "sortType"
| "limit"
| "bookmark"
| "paginate"
| "query"
> {} > {}
export interface SearchRowResponse { export interface SearchRowResponse {