Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/bindx-dataview/src/columnLeaf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export interface ColumnLeafProps {
readonly sortingField: string | null
readonly filterName: string | null
readonly filterHandler: FilterHandler<FilterArtifact> | undefined
/**
* Where the filter starts when nothing is stored for it. Defaults to the
* handler's (inactive) default artifact.
*/
readonly filterInitialArtifact?: FilterArtifact
readonly isTextSearchable: boolean
/**
* The leaf exists only to register its field — for full-text search, or to
Expand Down
13 changes: 11 additions & 2 deletions packages/bindx-dataview/src/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,15 @@ interface EnumExtraProps<T extends string> {
options: readonly T[] | Record<T, ReactNode>
}

export interface DataGridEnumColumnProps<T extends string> extends DataGridScalarColumnPropsBase<T>, EnumExtraProps<NoInfer<T>> {}
export interface DataGridEnumColumnProps<T extends string> extends DataGridScalarColumnPropsBase<T>, EnumExtraProps<NoInfer<T>> {
/** Where the column's filter starts when nothing is stored for it. Defaults to no value picked. */
filterInitialArtifact?: EnumFilterArtifact<NoInfer<T>>
}

export interface DataGridEnumListColumnProps<T extends string> extends DataGridScalarColumnPropsBase<T>, EnumExtraProps<NoInfer<T>> {}
export interface DataGridEnumListColumnProps<T extends string> extends DataGridScalarColumnPropsBase<T>, EnumExtraProps<NoInfer<T>> {
/** Where the column's filter starts when nothing is stored for it. Defaults to no value picked. */
filterInitialArtifact?: EnumListFilterArtifact<NoInfer<T>>
}

// ============================================================================
// Scalar Columns via createColumn()
Expand Down Expand Up @@ -233,6 +239,8 @@ export interface DataGridColumnProps<T> {
sortable?: boolean
filter?: boolean
filterHandler?: FilterHandler<FilterArtifact>
/** Where the column's filter starts when nothing is stored for it. Defaults to the handler's inactive artifact. */
filterInitialArtifact?: FilterArtifact
/** Register the field without showing a column (e.g. to make it full-text searchable). */
virtual?: boolean
children?: (value: T | null, accessor: EntityAccessor<object>) => React.ReactNode
Expand Down Expand Up @@ -262,6 +270,7 @@ export const DataGridColumn = Object.assign(
filterHandler: filterEnabled && fieldName
? (customHandler ?? textColumnDef.createFilterHandler(fieldName) as FilterHandler<FilterArtifact>)
: undefined,
filterInitialArtifact: props['filterInitialArtifact'] as FilterArtifact | undefined,
isTextSearchable: false,
header,
renderCell: (accessor: EntityAccessor<object>) => {
Expand Down
19 changes: 13 additions & 6 deletions packages/bindx-dataview/src/createColumn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ export interface CreateColumnConfig<TValue, TFilterArtifact extends FilterArtifa
// Column Component Props
// ============================================================================

export interface ColumnComponentProps<TValue = unknown> {
export interface ColumnComponentProps<TValue = unknown, TFilterArtifact extends FilterArtifact = FilterArtifact> {
field: FieldRef<TValue>
/** Column identity. Defaults to the bound field name — pass it when two columns bind one field. */
name?: string
header?: React.ReactNode
sortable?: boolean
filter?: boolean
/** Where the column's filter starts when nothing is stored for it. Defaults to the handler's inactive artifact. */
filterInitialArtifact?: TFilterArtifact
/** Register the field without showing a column (e.g. to make it full-text searchable). */
virtual?: boolean
children?: (value: TValue | null, accessor: EntityAccessor<object>) => React.ReactNode
Expand All @@ -59,8 +61,12 @@ export interface ColumnComponentProps<TValue = unknown> {
// Factory
// ============================================================================

export interface ColumnComponent<TExtraProps = object> {
<T>(props: ColumnComponentProps<T> & TExtraProps): null
/**
* `TFilterArtifact` defaults to `never`: a column whose artifact type is not known takes no
* `filterInitialArtifact`, and every column `createColumn` builds is assignable to it.
*/
export interface ColumnComponent<TExtraProps = object, TFilterArtifact extends FilterArtifact = never> {
<T>(props: ColumnComponentProps<T, TFilterArtifact> & TExtraProps): null
staticRender: (props: Record<string, unknown>) => React.ReactNode
}

Expand Down Expand Up @@ -119,6 +125,7 @@ export function createColumnStaticRender<TValue, TFilterArtifact extends FilterA
filterHandler: filterEnabled && fieldName
? columnType.createFilterHandler(fieldName) as FilterHandler<FilterArtifact>
: undefined,
filterInitialArtifact: props['filterInitialArtifact'] as FilterArtifact | undefined,
isTextSearchable: columnType.isTextSearchable,
columnType: columnType.name as ColumnLeafProps['columnType'],
enumName,
Expand All @@ -140,10 +147,10 @@ export function createColumnStaticRender<TValue, TFilterArtifact extends FilterA
export function createColumn<TValue, TFilterArtifact extends FilterArtifact, TExtraProps = object>(
columnType: ColumnTypeDef<TValue, TFilterArtifact>,
config: CreateColumnConfig<TValue, TFilterArtifact>,
): ColumnComponent<TExtraProps> {
function Column(_props: ColumnComponentProps<unknown> & TExtraProps): null {
): ColumnComponent<TExtraProps, TFilterArtifact> {
function Column(_props: ColumnComponentProps<unknown, TFilterArtifact> & TExtraProps): null {
return null
}
Column.staticRender = createColumnStaticRender(columnType, config)
return Column as ColumnComponent<TExtraProps>
return Column as ColumnComponent<TExtraProps, TFilterArtifact>
}
5 changes: 4 additions & 1 deletion packages/bindx-dataview/src/createRelationColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import React from 'react'
import type { FieldRef, FilterArtifact, FilterHandler, EntityAccessor, SelectionMeta } from '@contember/bindx'
import type { FieldRef, FilterArtifact, FilterHandler, EntityAccessor, RelationFilterArtifact, SelectionMeta } from '@contember/bindx'
import { SelectionScope } from '@contember/bindx'
import { createCollectorProxy, collectSelection as collectJsxSelection, SCOPE_REF } from '@contember/bindx-react'
import type { ColumnTypeDef } from './columnTypes.js'
Expand Down Expand Up @@ -64,6 +64,8 @@ export interface RelationColumnProps<TEntity, TSelected> {
name?: string
header?: React.ReactNode
filter?: boolean
/** Where the column's filter starts when nothing is stored for it. Defaults to no related row picked. */
filterInitialArtifact?: RelationFilterArtifact
/** Register the relation without showing a column. */
virtual?: boolean
renderCellWrapper?: (content: React.ReactNode, item: EntityAccessor<object>) => React.ReactNode
Expand Down Expand Up @@ -162,6 +164,7 @@ export function createRelationColumn<TFilterArtifact extends FilterArtifact>(
filterHandler: filterName
? columnType.createFilterHandler(fieldName!) as FilterHandler<FilterArtifact>
: undefined,
filterInitialArtifact: props['filterInitialArtifact'] as FilterArtifact | undefined,
isTextSearchable: false,
columnType: columnType.name as ColumnLeafProps['columnType'],
relatedEntityName,
Expand Down
6 changes: 3 additions & 3 deletions packages/bindx-dataview/src/useDataGridSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,12 @@ export function useDataGridSetup<TEntity extends object>({
}, [entityType, schemaRegistry, children, layouts])

// ---- Phase 2: State management ----
const filterDefs = useMemo((): ReadonlyMap<string, { handler: FilterHandler<FilterArtifact> }> => {
const map = new Map<string, { handler: FilterHandler<FilterArtifact> }>()
const filterDefs = useMemo((): ReadonlyMap<string, { handler: FilterHandler<FilterArtifact>; initialArtifact?: FilterArtifact }> => {
const map = new Map<string, { handler: FilterHandler<FilterArtifact>; initialArtifact?: FilterArtifact }>()
const textFieldPaths: string[] = []
for (const col of columns) {
if (col.filterName && col.filterHandler) {
map.set(col.filterName, { handler: col.filterHandler })
map.set(col.filterName, { handler: col.filterHandler, initialArtifact: col.filterInitialArtifact })
}
if (col.isTextSearchable && col.fieldName) {
textFieldPaths.push(col.fieldName)
Expand Down
31 changes: 23 additions & 8 deletions packages/bindx-dataview/src/useDataViewState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,18 @@ export type FilterArtifactUpdate =

export interface FilteringState {
readonly filters: ReadonlyMap<string, RegisteredFilter>
/** The filter's current artifact: the stored one, else its initial artifact. `undefined` only for an unregistered filter. */
getArtifact(name: string): FilterArtifact | undefined
/** An updater sees the live artifact, not a render snapshot; returning `undefined` resets the filter to its default. */
setArtifact(name: string, artifact: FilterArtifactUpdate): void
/** Replaces the whole artifact record at once — for restoring a saved filter preset. */
/**
* Replaces the whole artifact record at once — for restoring a saved filter preset.
* A registered filter the record does not name reads as its initial artifact.
*/
setAllArtifacts(artifacts: Record<string, FilterArtifact>): void
/** Clears one filter: sets it to its handler's default (inactive) artifact. */
resetFilter(name: string): void
/** Puts every filter back where it starts: its initial artifact, else the handler default. */
resetAll(): void
readonly hasActiveFilters: boolean
readonly resolvedWhere: Record<string, unknown> | undefined
Expand All @@ -62,18 +68,26 @@ export interface UseFilteringOptions {
export function useFilteringState(options: UseFilteringOptions): FilteringState {
const { filters: filterDefs, stateStorage = 'null', storageKey = 'dataview' } = options

const defaultArtifacts = useMemo((): Record<string, FilterArtifact> => {
const initialArtifacts = useMemo((): Record<string, FilterArtifact> => {
const result: Record<string, FilterArtifact> = {}
for (const [name, def] of filterDefs) {
result[name] = def.initialArtifact ?? def.handler.defaultArtifact()
}
return result
}, [filterDefs])

const [artifacts, setArtifacts] = useStoredState<Record<string, FilterArtifact>>(
const [storedArtifacts, setArtifacts] = useStoredState<Record<string, FilterArtifact>>(
stateStorage,
[storageKey, 'filters'],
(stored) => stored ?? defaultArtifacts,
(stored) => stored ?? initialArtifacts,
)

// A registered filter the record does not name (a record stored before the filter
// existed, or a restored preset that omits it) is where it would start with nothing
// stored. Every view below reads this one record, so the UI and the query agree.
const artifacts = useMemo(
(): Record<string, FilterArtifact> => ({ ...initialArtifacts, ...storedArtifacts }),
[initialArtifacts, storedArtifacts],
)

const getArtifact = useCallback(
Expand All @@ -85,12 +99,12 @@ export function useFilteringState(options: UseFilteringOptions): FilteringState
const setArtifact = useCallback(
(name: string, artifact: FilterArtifactUpdate): void => {
setArtifacts(current => {
const next = typeof artifact === 'function' ? artifact(current[name]) : artifact
const next = typeof artifact === 'function' ? artifact(current[name] ?? initialArtifacts[name]) : artifact
const resolved = next ?? filterDefs.get(name)?.handler.defaultArtifact()
return resolved === undefined ? current : { ...current, [name]: resolved }
})
},
[filterDefs, setArtifacts],
[filterDefs, initialArtifacts, setArtifacts],
)

const resetFilter = useCallback(
Expand All @@ -103,8 +117,8 @@ export function useFilteringState(options: UseFilteringOptions): FilteringState
)

const resetAll = useCallback((): void => {
setArtifacts(defaultArtifacts)
}, [defaultArtifacts, setArtifacts])
setArtifacts(initialArtifacts)
}, [initialArtifacts, setArtifacts])

const hasActiveFilters = useMemo((): boolean => {
for (const [name, def] of filterDefs) {
Expand Down Expand Up @@ -135,6 +149,7 @@ export function useFilteringState(options: UseFilteringOptions): FilteringState
map.set(name, {
name,
handler: def.handler,
// Stored JSON is not validated, so a `null` entry falls back to the handler default.
artifact: artifacts[name] ?? def.handler.defaultArtifact(),
})
}
Expand Down
6 changes: 5 additions & 1 deletion packages/bindx-ui/src/datagrid/columns/enum-column.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { type ReactElement, type ReactNode } from 'react'
import type { EntityAccessor, FieldRef } from '@contember/bindx'
import type { EntityAccessor, EnumFilterArtifact, EnumListFilterArtifact, FieldRef } from '@contember/bindx'
import {
createColumn,
createColumnStaticRender,
Expand Down Expand Up @@ -67,6 +67,8 @@ export const DataGridEnumColumn = Object.assign(
header?: ReactNode
sortable?: boolean
filter?: boolean
/** Where the column's filter starts when nothing is stored for it. Defaults to no value picked. */
filterInitialArtifact?: EnumFilterArtifact<ExtractEnum<F>>
children?: (value: ExtractEnum<F> | null, accessor: EntityAccessor<object>) => ReactNode
options?: { [K in ExtractEnum<F>]?: ReactNode }
}): ReactNode => null,
Expand All @@ -88,6 +90,8 @@ export const DataGridEnumListColumn = Object.assign(
header?: ReactNode
sortable?: boolean
filter?: boolean
/** Where the column's filter starts when nothing is stored for it. Defaults to no value picked. */
filterInitialArtifact?: EnumListFilterArtifact<ExtractEnumList<F>>
children?: (value: ExtractEnumList<F>[] | null, accessor: EntityAccessor<object>) => ReactNode
options?: { [K in ExtractEnumList<F>]?: ReactNode }
}): ReactNode => null,
Expand Down
12 changes: 6 additions & 6 deletions packages/bindx/src/dataview/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ export interface BooleanFilterArtifact {
/**
* Enum filter artifact
*/
export interface EnumFilterArtifact {
readonly values?: readonly string[]
readonly notValues?: readonly string[]
export interface EnumFilterArtifact<TValue extends string = string> {
readonly values?: readonly TValue[]
readonly notValues?: readonly TValue[]
readonly nullCondition?: boolean
}

Expand All @@ -78,9 +78,9 @@ export interface RelationFilterArtifact {
/**
* Enum list filter artifact - for array enum fields (uses 'includes' condition)
*/
export interface EnumListFilterArtifact {
readonly values?: readonly string[]
readonly notValues?: readonly string[]
export interface EnumListFilterArtifact<TValue extends string = string> {
readonly values?: readonly TValue[]
readonly notValues?: readonly TValue[]
readonly nullCondition?: boolean
}

Expand Down
4 changes: 2 additions & 2 deletions tests/react/dataview/dataViewStateBatching.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,15 +257,15 @@ describe('bulk state setters', () => {
})
expect(result.current.hasActiveFilters).toBe(true)

// A saved preset arrives as a whole record; a filter it omits must end up unset.
// A saved preset arrives as a whole record; a filter it omits drops its previous value and reads where it starts.
act(() => {
result.current.setAllArtifacts({
title: { mode: 'startsWith', query: 'beta' } satisfies TextFilterArtifact,
})
})

expect(result.current.getArtifact('title')).toEqual({ mode: 'startsWith', query: 'beta' })
expect(result.current.getArtifact('status')).toBeUndefined()
expect(result.current.getArtifact('status')).toEqual({})
expect(result.current.resolvedWhere).toEqual({ title: { startsWithCI: 'beta' } })
})
})
Loading
Loading