Merge pull request #15255 from Budibase/typing/stores-grid-rows

Typing grid rows stores
This commit is contained in:
Adria Navarro 2024-12-31 11:34:05 +01:00 committed by GitHub
commit 92f92d2c62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 278 additions and 80 deletions

View File

@ -2,6 +2,7 @@
"extends": "./tsconfig.build.json",
"compilerOptions": {
"outDir": "./dist",
"lib": ["ESNext"],
"baseUrl": ".",
"paths": {
"@budibase/*": [

View File

@ -16,7 +16,16 @@ interface DerivedColumnStore {
hasNonAutoColumn: Readable<boolean>
}
export type Store = ColumnStore & DerivedColumnStore
interface ColumnActions {
columns: ColumnStore["columns"] & {
actions: {
changeAllColumnWidths: (width: number) => Promise<void>
isReadonly: (column: UIColumn) => boolean
}
}
}
export type Store = ColumnStore & DerivedColumnStore & ColumnActions
export const createStores = (): ColumnStore => {
const columns = writable<UIColumn[]>([])
@ -95,7 +104,7 @@ export const deriveStores = (context: StoreContext): DerivedColumnStore => {
}
}
export const createActions = (context: StoreContext) => {
export const createActions = (context: StoreContext): ColumnActions => {
const { columns, datasource } = context
// Updates the width of all columns

View File

@ -3,12 +3,12 @@ import { getDatasourceDefinition, getDatasourceSchema } from "../../../fetch"
import { enrichSchemaWithRelColumns, memo } from "../../../utils"
import { cloneDeep } from "lodash"
import {
Row,
SaveRowRequest,
SaveTableRequest,
UIDatasource,
UIFieldMutation,
UIFieldSchema,
UIRow,
UpdateViewRequest,
ViewV2Type,
} from "@budibase/types"
@ -327,7 +327,7 @@ export const createActions = (context: StoreContext): ActionDatasourceStore => {
}
// Deletes rows from the datasource
const deleteRows = async (rows: Row[]) => {
const deleteRows = async (rows: UIRow[]) => {
return await getAPI()?.actions.deleteRows(rows)
}

View File

@ -1,8 +1,8 @@
import {
Row,
SaveRowRequest,
SaveTableRequest,
UIDatasource,
UIRow,
UpdateViewRequest,
} from "@budibase/types"
@ -10,10 +10,10 @@ interface DatasourceBaseActions<
TSaveDefinitionRequest = UpdateViewRequest | SaveTableRequest
> {
saveDefinition: (newDefinition: TSaveDefinitionRequest) => Promise<void>
addRow: (row: SaveRowRequest) => Promise<Row | void>
updateRow: (row: SaveRowRequest) => Promise<Row | void>
deleteRows: (rows: Row[]) => Promise<void>
getRow: (id: string) => Promise<Row | void>
addRow: (row: SaveRowRequest) => Promise<UIRow | undefined>
updateRow: (row: SaveRowRequest) => Promise<UIRow | undefined>
deleteRows: (rows: UIRow[]) => Promise<void>
getRow: (id: string) => Promise<UIRow | void>
isDatasourceValid: (datasource: UIDatasource) => boolean | void
canUseColumn: (name: string) => boolean | void
}

View File

@ -106,7 +106,7 @@ export const initialise = (context: StoreContext) => {
if (!isSameDatasource($fetch?.options?.datasource, $datasource)) {
return
}
$fetch.update({
$fetch?.update({
filter: $allFilters,
})
})
@ -120,7 +120,7 @@ export const initialise = (context: StoreContext) => {
if (!isSameDatasource($fetch?.options?.datasource, $datasource)) {
return
}
$fetch.update({
$fetch?.update({
sortOrder: $sort.order || SortOrder.ASCENDING,
sortColumn: $sort.column,
})

View File

@ -31,7 +31,8 @@ export const createActions = (context: StoreContext): TableActions => {
...row,
tableId: get(datasource)?.tableId,
}
return await API.saveRow(row, SuppressErrors)
const newRow = await API.saveRow(row, SuppressErrors)
return { ...newRow, _id: newRow._id! }
}
const deleteRows = async (rows: Row[]) => {
@ -52,7 +53,12 @@ export const createActions = (context: StoreContext): TableActions => {
},
paginate: false,
})
return res?.rows?.[0]
const row = res?.rows?.[0]
if (!row) {
return
}
return { ...row, _id: row._id! }
}
const canUseColumn = (name: string) => {

View File

@ -34,8 +34,10 @@ export const createActions = (context: StoreContext): ViewActions => {
tableId: $datasource?.tableId,
_viewId: $datasource?.id,
}
const newRow = await API.saveRow(row, SuppressErrors)
return {
...(await API.saveRow(row, SuppressErrors)),
...newRow,
_id: newRow._id!,
_viewId: row._viewId,
}
}
@ -54,7 +56,12 @@ export const createActions = (context: StoreContext): ViewActions => {
},
paginate: false,
})
return res?.rows?.[0]
const row = res?.rows?.[0]
if (!row) {
return
}
return { ...row, _id: row._id! }
}
const isDatasourceValid = (datasource: UIDatasource) => {

View File

@ -69,12 +69,10 @@ export type Store = BaseStore &
Clipboard.Store &
Scroll.Store & {
// TODO while typing the rest of stores
fetch: Writable<any>
sort: Writable<any>
initialFilter: Writable<any>
initialSortColumn: Writable<any>
initialSortOrder: Writable<any>
rows: Writable<any> & { actions: any }
subscribe: any
config: Writable<any>
dispatch: (event: string, data: any) => any
@ -82,13 +80,11 @@ export type Store = BaseStore &
schemaOverrides: Writable<any>
gridID: string
props: Writable<any>
rowLookupMap: Writable<any>
width: Writable<number>
fixedRowHeight: Writable<number>
rowChangeCache: Readable<any>
bounds: Readable<any>
height: Readable<number>
}
} & Rows.Store
export const attachStores = (context: Store): Store => {
// Atomic store creation

View File

@ -1,4 +1,4 @@
import { writable, derived, get } from "svelte/store"
import { writable, derived, get, Writable, Readable } from "svelte/store"
import { fetchData } from "../../../fetch"
import { NewRowID, RowPageSize } from "../lib/constants"
import {
@ -10,10 +10,74 @@ import {
import { tick } from "svelte"
import { Helpers } from "@budibase/bbui"
import { sleep } from "../../../utils/utils"
import { FieldType } from "@budibase/types"
import { FieldType, Row, UIFetchAPI, UIRow } from "@budibase/types"
import { getRelatedTableValues } from "../../../utils"
import { Store as StoreContext } from "."
export const createStores = () => {
interface IndexedUIRow extends UIRow {
__idx: number
}
interface RowStore {
rows: Writable<UIRow[]>
fetch: Writable<UIFetchAPI | null>
loaded: Writable<boolean>
refreshing: Writable<boolean>
loading: Writable<boolean>
rowChangeCache: Writable<Record<string, Record<string, any>>>
inProgressChanges: Writable<Record<string, number>>
hasNextPage: Writable<boolean>
error: Writable<string | null>
}
interface RowDerivedStore {
rows: RowStore["rows"]
rowLookupMap: Readable<Record<string, IndexedUIRow>>
}
interface RowActionStore {
rows: RowStore["rows"] & {
actions: {
addRow: (params: {
row: Row
idx: number
bubble: boolean
notify: boolean
}) => Promise<UIRow | undefined>
duplicateRow: (row: UIRow) => Promise<UIRow | undefined>
bulkDuplicate: (
rowsToDupe: UIRow[],
progressCallback: (progressPercentage: number) => void
) => Promise<UIRow[]>
updateValue: (params: {
rowId: string
column: string
value: any
apply: boolean
}) => Promise<void>
applyRowChanges: (params: {
rowId: string
changes?: Record<string, any>
updateState?: boolean
handleErrors?: boolean
}) => Promise<UIRow | undefined>
deleteRows: (rowsToDelete: UIRow[]) => Promise<void>
loadNextPage: () => void
refreshRow: (id: string) => Promise<void>
replaceRow: (id: string, row: UIRow | undefined) => void
refreshData: () => Promise<void>
cleanRow: (row: UIRow) => Row
bulkUpdate: (
changeMap: Record<string, Record<string, any>>,
progressCallback: (progressPercentage: number) => void
) => Promise<void>
}
}
}
export type Store = RowStore & RowDerivedStore & RowActionStore
export const createStores = (): RowStore => {
const rows = writable([])
const loading = writable(false)
const loaded = writable(false)
@ -47,7 +111,7 @@ export const createStores = () => {
}
}
export const deriveStores = context => {
export const deriveStores = (context: StoreContext): RowDerivedStore => {
const { rows, enrichedSchema } = context
// Enrich rows with an index property and any pending changes
@ -57,12 +121,16 @@ export const deriveStores = context => {
const customColumns = Object.values($enrichedSchema || {}).filter(
f => f.related
)
return $rows.map((row, idx) => ({
return $rows.map<IndexedUIRow>((row, idx) => ({
...row,
__idx: idx,
...customColumns.reduce((map, column) => {
const fromField = $enrichedSchema[column.related.field]
map[column.name] = getRelatedTableValues(row, column, fromField)
...customColumns.reduce<Record<string, string>>((map, column) => {
const fromField = $enrichedSchema![column.related!.field]
map[column.name] = getRelatedTableValues(
row,
{ ...column, related: column.related! },
fromField
)
return map
}, {}),
}))
@ -71,7 +139,7 @@ export const deriveStores = context => {
// Generate a lookup map to quick find a row by ID
const rowLookupMap = derived(enrichedRows, $enrichedRows => {
let map = {}
let map: Record<string, IndexedUIRow> = {}
for (let i = 0; i < $enrichedRows.length; i++) {
map[$enrichedRows[i]._id] = $enrichedRows[i]
}
@ -87,7 +155,7 @@ export const deriveStores = context => {
}
}
export const createActions = context => {
export const createActions = (context: StoreContext): RowActionStore => {
const {
rows,
rowLookupMap,
@ -114,11 +182,11 @@ export const createActions = context => {
const instanceLoaded = writable(false)
// Local cache of row IDs to speed up checking if a row exists
let rowCacheMap = {}
let rowCacheMap: Record<string, boolean> = {}
// Reset everything when datasource changes
let unsubscribe = null
let lastResetKey = null
let unsubscribe: (() => void) | null = null
let lastResetKey: string | null = null
datasource.subscribe(async $datasource => {
// Unsub from previous fetch if one exists
unsubscribe?.()
@ -157,7 +225,7 @@ export const createActions = context => {
})
// Subscribe to changes of this fetch model
unsubscribe = newFetch.subscribe(async $fetch => {
unsubscribe = newFetch.subscribe(async ($fetch: UIFetchAPI) => {
if ($fetch.error) {
// Present a helpful error to the user
let message = "An unknown error occurred"
@ -214,7 +282,15 @@ export const createActions = context => {
// Handles validation errors from the rows API and updates local validation
// state, storing error messages against relevant cells
const handleValidationError = (rowId, error) => {
const handleValidationError = (
rowId: string,
error:
| string
| {
message?: string
json: { validationErrors: Record<string, string | undefined> }
}
) => {
let errorString
if (typeof error === "string") {
errorString = error
@ -224,7 +300,11 @@ export const createActions = context => {
// If the server doesn't reply with a valid error, assume that the source
// of the error is the focused cell's column
if (!error?.json?.validationErrors && errorString) {
if (
typeof error !== "string" &&
!error?.json?.validationErrors &&
errorString
) {
const { field: focusedColumn } = parseCellID(get(focusedCellId))
if (focusedColumn) {
error = {
@ -236,7 +316,7 @@ export const createActions = context => {
}
}
}
if (error?.json?.validationErrors) {
if (typeof error !== "string" && error?.json?.validationErrors) {
// Normal validation errors
const keys = Object.keys(error.json.validationErrors)
const $columns = get(columns)
@ -251,11 +331,11 @@ export const createActions = context => {
missingColumns.push(column)
}
}
const { json } = error
// Process errors for columns that we have
for (let column of erroredColumns) {
// Ensure we have a valid error to display
let err = error.json.validationErrors[column]
let err = json.validationErrors[column]
if (Array.isArray(err)) {
err = err[0]
}
@ -287,9 +367,19 @@ export const createActions = context => {
}
// Adds a new row
const addRow = async ({ row, idx, bubble = false, notify = true }) => {
const addRow = async ({
row,
idx,
bubble = false,
notify = true,
}: {
row: Row
idx: number
bubble: boolean
notify: boolean
}) => {
try {
const newRow = await datasource.actions.addRow(row)
const newRow = (await datasource.actions.addRow(row))!
// Update state
if (idx != null) {
@ -306,7 +396,7 @@ export const createActions = context => {
get(notifications).success("Row created successfully")
}
return newRow
} catch (error) {
} catch (error: any) {
if (bubble) {
throw error
} else {
@ -317,7 +407,7 @@ export const createActions = context => {
}
// Duplicates a row, inserting the duplicate row after the existing one
const duplicateRow = async row => {
const duplicateRow = async (row: UIRow) => {
let clone = cleanRow(row)
delete clone._id
delete clone._rev
@ -330,17 +420,20 @@ export const createActions = context => {
})
get(notifications).success("Duplicated 1 row")
return duped
} catch (error) {
} catch (error: any) {
handleValidationError(row._id, error)
validation.actions.focusFirstRowError(row._id)
}
}
// Duplicates multiple rows, inserting them after the last source row
const bulkDuplicate = async (rowsToDupe, progressCallback) => {
const bulkDuplicate = async (
rowsToDupe: UIRow[],
progressCallback: (progressPercentage: number) => void
) => {
// Find index of last row
const $rowLookupMap = get(rowLookupMap)
const indices = rowsToDupe.map(row => $rowLookupMap[row._id]?.__idx)
const indices = rowsToDupe.map(row => $rowLookupMap[row._id!]?.__idx)
const index = Math.max(...indices)
const count = rowsToDupe.length
@ -357,8 +450,9 @@ export const createActions = context => {
let failed = 0
for (let i = 0; i < count; i++) {
try {
saved.push(await datasource.actions.addRow(clones[i]))
rowCacheMap[saved._id] = true
const newRow = (await datasource.actions.addRow(clones[i]))!
saved.push(newRow)
rowCacheMap[newRow._id] = true
await sleep(50) // Small sleep to ensure we avoid rate limiting
} catch (error) {
failed++
@ -385,7 +479,7 @@ export const createActions = context => {
// Replaces a row in state with the newly defined row, handling updates,
// addition and deletion
const replaceRow = (id, row) => {
const replaceRow = (id: string, row: UIRow | undefined) => {
// Get index of row to check if it exists
const $rows = get(rows)
const $rowLookupMap = get(rowLookupMap)
@ -410,22 +504,22 @@ export const createActions = context => {
}
// Refreshes a specific row
const refreshRow = async id => {
const refreshRow = async (id: string) => {
try {
const row = await datasource.actions.getRow(id)
replaceRow(id, row)
replaceRow(id, row!)
} catch {
// Do nothing - we probably just don't support refreshing individual rows
}
}
// Refreshes all data
const refreshData = () => {
get(fetch)?.getInitialData()
const refreshData = async () => {
await get(fetch)?.getInitialData()
}
// Checks if a changeset for a row actually mutates the row or not
const changesAreValid = (row, changes) => {
const changesAreValid = (row: UIRow, changes: Record<string, any>) => {
const columns = Object.keys(changes || {})
if (!row || !columns.length) {
return false
@ -437,7 +531,7 @@ export const createActions = context => {
// Patches a row with some changes in local state, and returns whether a
// valid pending change was made or not
const stashRowChanges = (rowId, changes) => {
const stashRowChanges = (rowId: string, changes: Record<string, any>) => {
const $rowLookupMap = get(rowLookupMap)
const $columnLookupMap = get(columnLookupMap)
const row = $rowLookupMap[rowId]
@ -477,13 +571,18 @@ export const createActions = context => {
changes = null,
updateState = true,
handleErrors = true,
}: {
rowId: string
changes?: Record<string, any> | null
updateState?: boolean
handleErrors?: boolean
}) => {
const $rowLookupMap = get(rowLookupMap)
const row = $rowLookupMap[rowId]
if (row == null) {
return
}
let savedRow
let savedRow: UIRow | undefined = undefined
// Save change
try {
@ -496,13 +595,13 @@ export const createActions = context => {
// Update row
const stashedChanges = get(rowChangeCache)[rowId]
const newRow = { ...cleanRow(row), ...stashedChanges, ...changes }
savedRow = await datasource.actions.updateRow(newRow)
savedRow = (await datasource.actions.updateRow(newRow))!
// Update row state after a successful change
if (savedRow?._id) {
if (updateState) {
rows.update(state => {
state[row.__idx] = savedRow
state[row.__idx] = savedRow!
return state.slice()
})
}
@ -521,7 +620,7 @@ export const createActions = context => {
})
return state
})
} catch (error) {
} catch (error: any) {
if (handleErrors) {
handleValidationError(rowId, error)
validation.actions.focusFirstRowError(rowId)
@ -537,14 +636,27 @@ export const createActions = context => {
}
// Updates a value of a row
const updateValue = async ({ rowId, column, value, apply = true }) => {
const updateValue = async ({
rowId,
column,
value,
apply = true,
}: {
rowId: string
column: string
value: any
apply: boolean
}) => {
const success = stashRowChanges(rowId, { [column]: value })
if (success && apply) {
await applyRowChanges({ rowId })
}
}
const bulkUpdate = async (changeMap, progressCallback) => {
const bulkUpdate = async (
changeMap: Record<string, Record<string, any>>,
progressCallback: (progressPercentage: number) => void
) => {
const rowIds = Object.keys(changeMap || {})
const count = rowIds.length
if (!count) {
@ -613,7 +725,7 @@ export const createActions = context => {
}
// Deletes an array of rows
const deleteRows = async rowsToDelete => {
const deleteRows = async (rowsToDelete: UIRow[]) => {
if (!rowsToDelete?.length) {
return
}
@ -628,7 +740,7 @@ export const createActions = context => {
// Local handler to process new rows inside the fetch, and append any new
// rows to state that we haven't encountered before
const handleNewRows = (newRows, resetRows) => {
const handleNewRows = (newRows: Row[], resetRows?: boolean) => {
if (resetRows) {
rowCacheMap = {}
}
@ -645,20 +757,20 @@ export const createActions = context => {
newRow._id = generateRowID()
}
if (!rowCacheMap[newRow._id]) {
rowCacheMap[newRow._id] = true
if (!rowCacheMap[newRow._id!]) {
rowCacheMap[newRow._id!] = true
rowsToAppend.push(newRow)
}
}
if (resetRows) {
rows.set(rowsToAppend)
rows.set(rowsToAppend as UIRow[])
} else if (rowsToAppend.length) {
rows.update(state => [...state, ...rowsToAppend])
rows.update(state => [...state, ...(rowsToAppend as UIRow[])])
}
}
// Local handler to remove rows from state
const handleRemoveRows = rowsToRemove => {
const handleRemoveRows = (rowsToRemove: UIRow[]) => {
const deletedIds = rowsToRemove.map(row => row._id)
// We deliberately do not remove IDs from the cache map as the data may
@ -675,11 +787,11 @@ export const createActions = context => {
// Cleans a row by removing any internal grid metadata from it.
// Call this before passing a row to any sort of external flow.
const cleanRow = row => {
let clone = { ...row }
const cleanRow = (row: UIRow) => {
let clone: Row = { ...row }
delete clone.__idx
delete clone.__metadata
if (!get(hasBudibaseIdentifiers) && isGeneratedRowID(clone._id)) {
if (!get(hasBudibaseIdentifiers) && isGeneratedRowID(clone._id!)) {
delete clone._id
}
return clone
@ -706,7 +818,7 @@ export const createActions = context => {
}
}
export const initialise = context => {
export const initialise = (context: StoreContext) => {
const {
rowChangeCache,
inProgressChanges,
@ -733,7 +845,9 @@ export const initialise = context => {
if (!id) {
return
}
const { rowId, field } = parseCellID(id)
let { rowId, field } = parseCellID(id)
rowId = rowId!
field = field!
const hasChanges = field in (get(rowChangeCache)[rowId] || {})
const hasErrors = validation.actions.rowHasErrors(rowId)
const isSavingChanges = get(inProgressChanges)[rowId]

View File

@ -40,12 +40,21 @@ export interface UIDerivedStore {
compact: Readable<boolean>
selectedRowCount: Readable<number>
isSelectingCells: Readable<boolean>
selectedCells: Readable<string[][]> & { actions: any }
selectedCells: Readable<string[][]>
selectedCellMap: Readable<Record<string, boolean>>
selectedCellCount: Readable<number>
}
export type Store = UIStore & UIDerivedStore
interface UIActionStore {
selectedCells: UIDerivedStore["selectedCells"] & {
actions: {
clear: () => void
selectRange: (source: string | null, target: string | null) => void
}
}
}
export type Store = UIStore & UIDerivedStore & UIActionStore
export const createStores = (context: StoreContext): UIStore => {
const { props } = context
@ -82,7 +91,7 @@ export const createStores = (context: StoreContext): UIStore => {
}
}
export const deriveStores = (context: StoreContext) => {
export const deriveStores = (context: StoreContext): UIDerivedStore => {
const {
focusedCellId,
rows,
@ -97,14 +106,14 @@ export const deriveStores = (context: StoreContext) => {
// Derive the current focused row ID
const focusedRowId = derived(focusedCellId, $focusedCellId => {
return parseCellID($focusedCellId).rowId
return parseCellID($focusedCellId).rowId ?? null
})
// Derive the row that contains the selected cell
const focusedRow = derived(
[focusedRowId, rowLookupMap],
([$focusedRowId, $rowLookupMap]) => {
if ($focusedRowId === undefined) {
if ($focusedRowId === null) {
return
}

View File

@ -10,7 +10,17 @@ interface DerivedValidationStore {
validationRowLookupMap: Readable<Record<string, string[]>>
}
export type Store = ValidationStore & DerivedValidationStore
interface ValidationActions {
validation: ValidationStore["validation"] & {
actions: {
setError: (cellId: string | undefined, error: string) => void
rowHasErrors: (rowId: string) => boolean
focusFirstRowError: (rowId: string) => void
}
}
}
export type Store = ValidationStore & DerivedValidationStore & ValidationActions
// Normally we would break out actions into the explicit "createActions"
// function, but for validation all these actions are pure so can go into
@ -49,7 +59,7 @@ export const deriveStores = (context: StoreContext): DerivedValidationStore => {
}
}
export const createActions = (context: StoreContext) => {
export const createActions = (context: StoreContext): ValidationActions => {
const { validation, focusedCellId, validationRowLookupMap } = context
const setError = (cellId: string | undefined, error: string) => {

View File

@ -2,6 +2,7 @@
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext"],
"module": "preserve",
"moduleResolution": "bundler",
"outDir": "./dist",

View File

@ -0,0 +1,38 @@
import {
Row,
SortOrder,
UIDatasource,
UILegacyFilter,
UISearchFilter,
} from "@budibase/types"
export interface UIFetchAPI {
definition: UIDatasource
getInitialData: () => Promise<void>
loading: any
loaded: boolean
resetKey: string | null
error: any
hasNextPage: boolean
nextPage: () => Promise<void>
rows: Row[]
options?: {
datasource?: {
tableId: string
id: string
}
}
update: ({
sortOrder,
sortColumn,
}: {
sortOrder?: SortOrder
sortColumn?: string
filter?: UILegacyFilter[] | UISearchFilter
}) => any
}

View File

@ -4,3 +4,5 @@ export * from "./table"
export * from "./view"
export * from "./user"
export * from "./filters"
export * from "./rows"
export * from "./fetch"

View File

@ -0,0 +1,5 @@
import { Row } from "@budibase/types"
export type UIRow = Row & {
_id: string
}