diff --git a/packages/bindx-dataview/src/DataGrid.tsx b/packages/bindx-dataview/src/DataGrid.tsx index 7989ba84..4ec88392 100644 --- a/packages/bindx-dataview/src/DataGrid.tsx +++ b/packages/bindx-dataview/src/DataGrid.tsx @@ -23,6 +23,7 @@ import { import { useDataViewKey } from './DataViewKeyProvider.js' import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from './DataViewContext.js' import { useDataGridSetup, QUERY_FILTER_NAME } from './useDataGridSetup.js' +import { useListFetchAllData } from './useListFetchAllData.js' export { QUERY_FILTER_NAME } @@ -152,6 +153,14 @@ function DataGridImpl>({ setHighlightIndex(null) }, [items]) + // ---- Unpaged fetch of the same list the grid loads ---- + const fetchAllData = useListFetchAllData({ + entityType, + filter: setup.combinedFilter, + orderBy: setup.sorting.resolvedOrderBy, + selection: setup.selection, + }) + const contextValue = useMemo((): DataViewContextValue => ({ filtering: setup.filtering, sorting: setup.sorting, @@ -166,10 +175,11 @@ function DataGridImpl>({ highlightIndex, setHighlightIndex, selectionMeta: setup.selection, + fetchAllData, toolbarContent: setup.toolbarContent, layoutRenders: setup.layoutRenders, layoutElements: setup.layoutElements, - }), [setup.filtering, setup.sorting, setup.paging, setup.selectionState, setup.columns, entityType, items, itemCount, loaderState, reload, highlightIndex, setup.selection, setup.toolbarContent, setup.layoutRenders, setup.layoutElements]) + }), [setup.filtering, setup.sorting, setup.paging, setup.selectionState, setup.columns, entityType, items, itemCount, loaderState, reload, highlightIndex, setup.selection, fetchAllData, setup.toolbarContent, setup.layoutRenders, setup.layoutElements]) return ( diff --git a/packages/bindx-dataview/src/DataViewContext.tsx b/packages/bindx-dataview/src/DataViewContext.tsx index 4fc5e147..71906483 100644 --- a/packages/bindx-dataview/src/DataViewContext.tsx +++ b/packages/bindx-dataview/src/DataViewContext.tsx @@ -18,6 +18,9 @@ export interface DataViewElementData { readonly fallback?: boolean } +/** Fetches every row in the view's scope, unpaged. Resolves to null when the query returned an unexpected result. */ +export type DataViewFetchAllData = () => Promise[] | null> + export interface DataViewContextValue { readonly filtering: FilteringState readonly sorting: SortingStateResult @@ -32,6 +35,8 @@ export interface DataViewContextValue { readonly highlightIndex: number | null readonly setHighlightIndex: (index: number | null) => void readonly selectionMeta: SelectionMeta + /** Owned by each view, so the query keeps the scope the view loads with (static filter, parent relation). */ + readonly fetchAllData: DataViewFetchAllData readonly toolbarContent?: React.ReactNode /** Named layout render callbacks — analyzed during collection, called per item at runtime */ readonly layoutRenders: ReadonlyMap React.ReactNode> diff --git a/packages/bindx-dataview/src/HasManyDataGrid.tsx b/packages/bindx-dataview/src/HasManyDataGrid.tsx index a245eb09..2c6188e7 100644 --- a/packages/bindx-dataview/src/HasManyDataGrid.tsx +++ b/packages/bindx-dataview/src/HasManyDataGrid.tsx @@ -30,7 +30,6 @@ import type { import { FIELD_REF_META } from '@contember/bindx' import { buildQueryFromSelection, - generateHasManyAlias, EntityHandle, setEntityData, } from '@contember/bindx' @@ -40,8 +39,9 @@ import { useBindxContext, } from '@contember/bindx-react' import { useDataViewKey } from './DataViewKeyProvider.js' -import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from './DataViewContext.js' +import { DataViewProvider, type DataViewContextValue, type DataViewFetchAllData, type DataViewLoaderState } from './DataViewContext.js' import { useDataGridSetup } from './useDataGridSetup.js' +import { buildHasManyRelationQuery, extractHasManyRelationRows } from './hasManyRelationQuery.js' // ============================================================================ // Props @@ -133,17 +133,6 @@ function HasManyDataGridImpl({ const [loaderState, setLoaderState] = useState('initial') const [listState, setListState] = useState(INITIAL_LIST_STATE) - // ---- Build relation query spec ---- - const alias = useMemo( - () => generateHasManyAlias(fieldName, { - filter: setup.combinedFilter, - orderBy: setup.sorting.resolvedOrderBy as unknown[], - limit: setup.paging.queryLimit, - offset: setup.paging.queryOffset, - }), - [fieldName, setup.combinedFilter, setup.sorting.resolvedOrderBy, setup.paging.queryLimit, setup.paging.queryOffset], - ) - const optionsKey = useMemo( () => JSON.stringify({ filter: setup.combinedFilter ?? {}, @@ -161,7 +150,6 @@ function HasManyDataGridImpl({ const fetchData = async (): Promise => { try { - const targetSpec = buildQueryFromSelection(setup.selection) const currentOptions = JSON.parse(optionsKey) as { filter: Record orderBy: readonly Record[] @@ -169,32 +157,18 @@ function HasManyDataGridImpl({ offset?: number } - const parentSpec = { - fields: [ - { name: 'id', sourcePath: ['id'] }, - { - name: alias, - sourcePath: [fieldName], - isArray: true as const, - totalCount: true, - filter: Object.keys(currentOptions.filter).length > 0 ? currentOptions.filter : undefined, - orderBy: currentOptions.orderBy.length > 0 ? currentOptions.orderBy : undefined, - limit: currentOptions.limit, - offset: currentOptions.offset, - nested: targetSpec, - }, - ], - } + const { alias, query } = buildHasManyRelationQuery({ + parentEntityType, + parentEntityId, + fieldName, + filter: currentOptions.filter, + orderBy: currentOptions.orderBy, + limit: currentOptions.limit, + offset: currentOptions.offset, + targetSpec: buildQueryFromSelection(setup.selection), + }) - const result = await batcher.enqueue( - { - type: 'get', - entityType: parentEntityType, - by: { id: parentEntityId }, - spec: parentSpec, - }, - { signal: abortController.signal }, - ) + const result = await batcher.enqueue(query, { signal: abortController.signal }) if (abortController.signal.aborted) return @@ -203,17 +177,13 @@ function HasManyDataGridImpl({ return } - const relationData = (result.data[alias] ?? result.data[fieldName]) as Array> | undefined - const totalCount = Array.isArray(relationData) && 'totalCount' in relationData - ? (relationData as Array> & { totalCount: number }).totalCount - : undefined - - if (!Array.isArray(relationData)) { - setListState({ status: 'ready', items: [], totalCount }) + const relation = extractHasManyRelationRows(result.data, { alias, fieldName }) + if (!relation) { + setListState({ status: 'ready', items: [] }) return } - const items = relationData.map((data: Record) => { + const items = relation.rows.map((data: Record) => { const id = data['id'] as string dispatcher.dispatch( setEntityData(targetEntityType, id, data, true), @@ -221,7 +191,7 @@ function HasManyDataGridImpl({ return { id, data: data as object } }) - setListState({ status: 'ready', items, totalCount }) + setListState({ status: 'ready', items, totalCount: relation.totalCount }) } catch (error) { if (abortController.signal.aborted) return setListState({ status: 'error', items: [] }) @@ -233,7 +203,7 @@ function HasManyDataGridImpl({ return () => { abortController.abort() } - }, [parentEntityType, parentEntityId, fieldName, targetEntityType, alias, optionsKey, setup.selection, batcher, dispatcher, store]) + }, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, setup.selection, batcher, dispatcher, store]) // ---- Build items from state ---- const items = useMemo((): EntityAccessor[] => { @@ -285,6 +255,23 @@ function HasManyDataGridImpl({ setHighlightIndex(null) }, [items]) + // ---- Unpaged read of the same parent-scoped relation ---- + const fetchAllData = useCallback(async () => { + const { alias, query } = buildHasManyRelationQuery({ + parentEntityType, + parentEntityId, + fieldName, + filter: setup.combinedFilter, + orderBy: setup.sorting.resolvedOrderBy, + targetSpec: buildQueryFromSelection(setup.selection), + }) + + const result = await batcher.enqueue(query) + if (result.type !== 'get' || !result.data) return null + + return extractHasManyRelationRows(result.data, { alias, fieldName })?.rows ?? null + }, [parentEntityType, parentEntityId, fieldName, setup.combinedFilter, setup.sorting.resolvedOrderBy, setup.selection, batcher]) + const contextValue = useMemo((): DataViewContextValue => ({ filtering: setup.filtering, sorting: setup.sorting, @@ -299,10 +286,11 @@ function HasManyDataGridImpl({ highlightIndex, setHighlightIndex, selectionMeta: setup.selection, + fetchAllData, toolbarContent: setup.toolbarContent, layoutRenders: setup.layoutRenders, layoutElements: setup.layoutElements, - }), [setup.filtering, setup.sorting, setup.paging, setup.selectionState, setup.columns, targetEntityType, items, itemCount, loaderState, reload, highlightIndex, setup.selection, setup.toolbarContent, setup.layoutRenders, setup.layoutElements]) + }), [setup.filtering, setup.sorting, setup.paging, setup.selectionState, setup.columns, targetEntityType, items, itemCount, loaderState, reload, highlightIndex, setup.selection, fetchAllData, setup.toolbarContent, setup.layoutRenders, setup.layoutElements]) return ( diff --git a/packages/bindx-dataview/src/export.tsx b/packages/bindx-dataview/src/export.tsx index 765acb3d..fa61768f 100644 --- a/packages/bindx-dataview/src/export.tsx +++ b/packages/bindx-dataview/src/export.tsx @@ -15,9 +15,6 @@ import React, { forwardRef, type ReactElement, useCallback, useState } from 'rea import { Slot } from '@radix-ui/react-slot' import { composeEventHandlers } from '@radix-ui/primitive' import { useDataViewContext } from './DataViewContext.js' -import { useBindxContext } from '@contember/bindx-react' -import { buildQueryFromSelection } from '@contember/bindx' -import type { ListQuery, ListQueryResult } from '@contember/bindx' // ============================================================================ // Export Factory Interface @@ -117,12 +114,11 @@ export interface DataViewExportTriggerProps { export const DataViewExportTrigger = forwardRef( ({ baseName, exportFactory = defaultExportFactory, onlyVisible = false, ...props }, ref) => { - const { columns, entityType, filtering, selection, selectionMeta } = useDataViewContext() - const { adapter } = useBindxContext() + const { columns, entityType, fetchAllData, selection } = useDataViewContext() const [isExporting, setIsExporting] = useState(false) const handleExport = useCallback(async (): Promise => { - if (!adapter || isExporting) return + if (isExporting) return setIsExporting(true) try { @@ -130,19 +126,8 @@ export const DataViewExportTrigger = forwardRef selection.isVisible(c.fieldName ?? `col-${i}`)) : columns - const listQuery: ListQuery = { - type: 'list', - entityType, - filter: filtering.resolvedWhere, - orderBy: undefined, - limit: undefined, - offset: undefined, - spec: buildQueryFromSelection(selectionMeta), - } - - const results = await adapter.query([listQuery]) - const result = results[0] - if (!result || result.type !== 'list') { + const data = await fetchAllData() + if (!data) { console.error('Export failed: unexpected result') return } @@ -152,7 +137,7 @@ export const DataViewExportTrigger = forwardRef ({ name: String(c.header ?? c.fieldName ?? ''), fieldName: c.fieldName })) const { blob, extension } = exportFactory.create({ - data: (result as ListQueryResult).data, + data, columns: exportColumns, }) @@ -169,7 +154,7 @@ export const DataViewExportTrigger = forwardRef @@ -195,25 +180,10 @@ export interface FetchAllDataResult { } export function useDataViewFetchAllData(): () => Promise { - const { entityType, filtering, selectionMeta } = useDataViewContext() - const { adapter } = useBindxContext() + const { fetchAllData } = useDataViewContext() return useCallback(async (): Promise => { - if (!adapter) return null - - const listQuery: ListQuery = { - type: 'list', - entityType, - filter: filtering.resolvedWhere, - orderBy: undefined, - limit: undefined, - offset: undefined, - spec: buildQueryFromSelection(selectionMeta), - } - - const results = await adapter.query([listQuery]) - const result = results[0] - if (!result || result.type !== 'list') return null - return { data: (result as ListQueryResult).data } - }, [adapter, entityType, filtering.resolvedWhere, selectionMeta]) + const data = await fetchAllData() + return data ? { data } : null + }, [fetchAllData]) } diff --git a/packages/bindx-dataview/src/hasManyRelationQuery.ts b/packages/bindx-dataview/src/hasManyRelationQuery.ts new file mode 100644 index 00000000..c2dd4cf8 --- /dev/null +++ b/packages/bindx-dataview/src/hasManyRelationQuery.ts @@ -0,0 +1,92 @@ +/** + * Parent-scoped relation query used by HasManyDataGrid. + * + * The grid reads its rows through the parent record, so every read of the + * relation — paged load and unpaged export alike — has to go through it. + */ + +import { generateHasManyAlias } from '@contember/bindx' +import type { GetQuery, QuerySpec } from '@contember/bindx' + +export interface HasManyRelationQueryOptions { + readonly parentEntityType: string + readonly parentEntityId: string + readonly fieldName: string + readonly filter: Record | undefined + readonly orderBy: readonly Record[] | undefined + readonly limit?: number + readonly offset?: number + readonly targetSpec: QuerySpec +} + +export interface HasManyRelationQuery { + /** Alias the relation rows are returned under */ + readonly alias: string + readonly query: GetQuery +} + +export interface HasManyRelationRows { + readonly rows: readonly Record[] + readonly totalCount: number | undefined +} + +export function buildHasManyRelationQuery({ + parentEntityType, + parentEntityId, + fieldName, + filter, + orderBy, + limit, + offset, + targetSpec, +}: HasManyRelationQueryOptions): HasManyRelationQuery { + const relationFilter = filter && Object.keys(filter).length > 0 ? filter : undefined + const relationOrderBy = orderBy && orderBy.length > 0 ? orderBy : undefined + const alias = generateHasManyAlias(fieldName, { + filter: relationFilter, + orderBy: relationOrderBy, + limit, + offset, + }) + + const spec: QuerySpec = { + fields: [ + { name: 'id', sourcePath: ['id'] }, + { + name: alias, + sourcePath: [fieldName], + isArray: true, + totalCount: true, + filter: relationFilter, + orderBy: relationOrderBy, + limit, + offset, + nested: targetSpec, + }, + ], + } + + return { + alias, + query: { + type: 'get', + entityType: parentEntityType, + by: { id: parentEntityId }, + spec, + }, + } +} + +export function extractHasManyRelationRows( + data: Record, + { alias, fieldName }: { alias: string; fieldName: string }, +): HasManyRelationRows | null { + const value = data[alias] ?? data[fieldName] + if (!isRecordArray(value)) return null + const totalCount = 'totalCount' in value && typeof value.totalCount === 'number' ? value.totalCount : undefined + return { rows: value, totalCount } +} + +function isRecordArray(value: unknown): value is readonly Record[] { + return Array.isArray(value) && value.every(item => typeof item === 'object' && item !== null) +} diff --git a/packages/bindx-dataview/src/index.ts b/packages/bindx-dataview/src/index.ts index ea3faad7..68f9b730 100644 --- a/packages/bindx-dataview/src/index.ts +++ b/packages/bindx-dataview/src/index.ts @@ -127,6 +127,7 @@ export { useOptionalDataViewContext, DataViewProvider, type DataViewContextValue, + type DataViewFetchAllData, type DataViewItem, type DataViewLoaderState as DataViewLoaderStateType, type DataViewElementData, diff --git a/packages/bindx-dataview/src/select/SelectDataView.tsx b/packages/bindx-dataview/src/select/SelectDataView.tsx index 2f10cdaf..d81c4019 100644 --- a/packages/bindx-dataview/src/select/SelectDataView.tsx +++ b/packages/bindx-dataview/src/select/SelectDataView.tsx @@ -45,6 +45,7 @@ import { import { useSelectOptions } from './selectContext.js' import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from '../DataViewContext.js' import { useFilteringState, useSortingState, usePagingState, useSelectionState } from '../useDataViewState.js' +import { useListFetchAllData } from '../useListFetchAllData.js' export interface SelectDataViewProps { /** Children rendered inside the DataView context */ @@ -212,6 +213,13 @@ function SelectDataViewImpl({ const emptyMap = useMemo(() => new Map(), []) + const fetchAllData = useListFetchAllData({ + entityType, + filter: combinedFilter, + orderBy: sorting.resolvedOrderBy, + selection, + }) + const contextValue = useMemo((): DataViewContextValue => ({ filtering, sorting, @@ -226,10 +234,11 @@ function SelectDataViewImpl({ highlightIndex, setHighlightIndex, selectionMeta: selection, + fetchAllData, toolbarContent: undefined, layoutRenders: emptyMap, layoutElements: emptyMap, - }), [filtering, sorting, paging, selectionState, entityType, items, itemCount, loaderState, reload, highlightIndex, selection, emptyMap]) + }), [filtering, sorting, paging, selectionState, entityType, items, itemCount, loaderState, reload, highlightIndex, selection, fetchAllData, emptyMap]) return ( diff --git a/packages/bindx-dataview/src/useListFetchAllData.ts b/packages/bindx-dataview/src/useListFetchAllData.ts new file mode 100644 index 00000000..0c2938a6 --- /dev/null +++ b/packages/bindx-dataview/src/useListFetchAllData.ts @@ -0,0 +1,38 @@ +/** + * Unpaged fetch-all for views that load through a root list query. + */ + +import { useCallback } from 'react' +import { buildQueryFromSelection } from '@contember/bindx' +import type { ListQuery, SelectionMeta } from '@contember/bindx' +import { useBindxContext } from '@contember/bindx-react' +import type { DataViewFetchAllData } from './DataViewContext.js' + +export interface ListFetchAllDataOptions { + /** Entity the view lists */ + readonly entityType: string + /** Full scope of the view — static filter combined with the user filters */ + readonly filter: Record | undefined + readonly orderBy: readonly Record[] | undefined + readonly selection: SelectionMeta +} + +export function useListFetchAllData({ entityType, filter, orderBy, selection }: ListFetchAllDataOptions): DataViewFetchAllData { + const { adapter } = useBindxContext() + + return useCallback(async (): Promise[] | null> => { + const listQuery: ListQuery = { + type: 'list', + entityType, + filter, + orderBy, + limit: undefined, + offset: undefined, + spec: buildQueryFromSelection(selection), + } + + const results = await adapter.query([listQuery]) + const result = results[0] + return result?.type === 'list' ? result.data : null + }, [adapter, entityType, filter, orderBy, selection]) +} diff --git a/packages/bindx-form/src/components/FormInput.tsx b/packages/bindx-form/src/components/FormInput.tsx index c7a311b0..3c6b76d1 100644 --- a/packages/bindx-form/src/components/FormInput.tsx +++ b/packages/bindx-form/src/components/FormInput.tsx @@ -3,7 +3,7 @@ import { SlotInput } from './SlotInput.js' import { useFormFieldState } from '../contexts.js' import { useFormInputHandler } from '../hooks/useFormInputHandler.js' import { useFormInputValidationHandler } from '../hooks/useFormInputValidationHandler.js' -import type { FormInputProps } from '../types.js' +import type { FormInputHandlerContext, FormInputProps } from '../types.js' import { useField } from '@contember/bindx-react' /** @@ -13,6 +13,13 @@ function dataAttribute(value: boolean): '' | undefined { return value ? '' : undefined } +/** + * Collects the error a handler reports for the current input. + */ +interface HandlerErrorReport { + message: string | null +} + /** * Binds a field handle to an input element using Radix Slot pattern. * @@ -35,6 +42,7 @@ export function FormInput({ children, formatValue: formatValueProp, parseValue: parseValueProp, + handler: handlerProp, }: FormInputProps): ReactElement { const formState = useFormFieldState() const id = formState?.htmlId @@ -47,15 +55,26 @@ export function FormInput({ formatValue: formatValueProp as ((value: unknown) => string) | undefined, parseValue: parseValueProp as ((value: string) => unknown) | undefined, columnType: formState?.field?.columnType as import('../types.js').ColumnType | undefined, + handler: handlerProp, }) // Get validation handler for HTML5 validation + touch tracking const validation = useFormInputValidationHandler(field) - const handlerContext = { state: handlerState, setState: setHandlerState } - const accessor = useField(field) + const createHandlerContext = useCallback( + (report: HandlerErrorReport): FormInputHandlerContext => ({ + state: handlerState, + setState: setHandlerState, + currentValue: accessor.value, + setError: message => { + report.message = message + }, + }), + [handlerState, accessor.value], + ) + // Compute derived state const hasErrors = (formState?.errors.length ?? field.errors.length) > 0 const dirty = formState?.dirty ?? accessor.isDirty @@ -63,15 +82,19 @@ export function FormInput({ const touched = field.isTouched // Format current value for display - const displayValue = handler.formatValue(accessor.value, handlerContext) + const displayValue = handler.formatValue(accessor.value, createHandlerContext({ message: null })) // Handle input changes const handleChange = useCallback>( (e) => { - const parsedValue = handler.parseValue(e.target.value, handlerContext) + const report: HandlerErrorReport = { message: null } + const parsedValue = handler.parseValue(e.target.value, createHandlerContext(report)) field.setValue(parsedValue as T | null) + if (report.message !== null) { + field.addError(report.message) + } }, - [field, handler, handlerContext], + [field, handler, createHandlerContext], ) // Combine focus handler with validation @@ -86,8 +109,13 @@ export function FormInput({ const handleBlur = useCallback>( (e) => { validation.onBlur(e) + const report: HandlerErrorReport = { message: null } + handler.onBlur?.(createHandlerContext(report)) + if (report.message !== null) { + field.addError(report.message) + } }, - [validation], + [field, handler, createHandlerContext, validation], ) return ( diff --git a/packages/bindx-form/src/handlers/createJsonHandler.ts b/packages/bindx-form/src/handlers/createJsonHandler.ts new file mode 100644 index 00000000..834ea1b9 --- /dev/null +++ b/packages/bindx-form/src/handlers/createJsonHandler.ts @@ -0,0 +1,90 @@ +import type { FormInputHandler, FormInputHandlerContext, JSONValue } from '../types.js' + +/** + * Options for createJsonHandler + */ +export interface JsonHandlerOptions { + /** Value written when the input is empty. Default `null`. */ + readonly emptyValue?: JSONValue | null + /** Shape validation beyond JSON syntax. Returns an error message or null. */ + readonly validate?: (value: JSONValue) => string | null + /** Pretty-print the raw input when the field loses focus. Default false. */ + readonly formatOnBlur?: boolean +} + +/** + * Raw input kept next to the pretty-printed form of the value it produced, + * so half-typed JSON is not reformatted under the user's cursor. + */ +interface JsonHandlerState { + readonly rawValue: string + readonly formatted: string + readonly error: string | null +} + +function formatJson(value: unknown): string { + if (value === null || value === undefined) return '' + return JSON.stringify(value, null, 2) +} + +function readState(state: unknown): JsonHandlerState | undefined { + if (typeof state !== 'object' || state === null) return undefined + if (!('rawValue' in state) || !('formatted' in state) || !('error' in state)) return undefined + const { rawValue, formatted, error } = state + if (typeof rawValue !== 'string' || typeof formatted !== 'string') return undefined + if (error !== null && typeof error !== 'string') return undefined + return { rawValue, formatted, error } +} + +function describeParseError(error: unknown): string { + return `Invalid JSON: ${error instanceof Error ? error.message : String(error)}` +} + +/** + * Handler for Json columns. + * + * Parses the input into a JSON value, keeps the previous value when the input does + * not parse, and reports the parse or validation failure as a field error, which + * blocks persist until the input parses again. + */ +export function createJsonHandler(options: JsonHandlerOptions = {}): FormInputHandler { + const { emptyValue = null, validate, formatOnBlur = false } = options + + return { + parseValue: (value: string, ctx: FormInputHandlerContext): unknown => { + if (value.trim() === '') { + ctx.setState({ rawValue: value, formatted: formatJson(emptyValue), error: null }) + return emptyValue + } + let parsed: JSONValue + try { + parsed = JSON.parse(value) + } catch (error) { + const message = describeParseError(error) + ctx.setState({ rawValue: value, formatted: formatJson(ctx.currentValue), error: message }) + ctx.setError(message) + return ctx.currentValue + } + const validationError = validate?.(parsed) ?? null + ctx.setState({ rawValue: value, formatted: formatJson(parsed), error: validationError }) + if (validationError !== null) { + ctx.setError(validationError) + } + return parsed + }, + formatValue: (value: unknown, ctx: FormInputHandlerContext): string => { + const state = readState(ctx.state) + const formatted = formatJson(value) + return state !== undefined && state.formatted === formatted ? state.rawValue : formatted + }, + onBlur: (ctx: FormInputHandlerContext): void => { + const state = readState(ctx.state) + if (state === undefined) return + // The error parseValue added now survives the blur, so re-reporting it would double it. + if (state.error !== null) return + if (formatOnBlur && state.rawValue !== state.formatted) { + ctx.setState({ ...state, rawValue: state.formatted }) + } + }, + } +} diff --git a/packages/bindx-form/src/hooks/index.ts b/packages/bindx-form/src/hooks/index.ts index 14a95ca8..bce94758 100644 --- a/packages/bindx-form/src/hooks/index.ts +++ b/packages/bindx-form/src/hooks/index.ts @@ -1,2 +1,2 @@ export { useFormInputHandler, getDefaultInputProps, type UseFormInputHandlerOptions } from './useFormInputHandler.js' -export { useFormInputValidationHandler, type ValidationHandlerResult } from './useFormInputValidationHandler.js' +export { useFormInputValidationHandler, HTML5_VALIDATION_ERROR_CODE, type ValidationHandlerResult } from './useFormInputValidationHandler.js' diff --git a/packages/bindx-form/src/hooks/useFormInputHandler.ts b/packages/bindx-form/src/hooks/useFormInputHandler.ts index 49700470..a9a5d67d 100644 --- a/packages/bindx-form/src/hooks/useFormInputHandler.ts +++ b/packages/bindx-form/src/hooks/useFormInputHandler.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' import type { FormInputHandler, FormInputHandlerContext, ColumnType } from '../types.js' +import { createJsonHandler } from '../handlers/createJsonHandler.js' /** * Default handler for string fields @@ -146,6 +147,7 @@ const defaultTypeHandlers: Partial> = { Time: createTimeHandler, Uuid: createStringHandler, Enum: createStringHandler, + Json: () => createJsonHandler(), } function resolveColumnType(columnType: string | undefined): ColumnType | undefined { @@ -163,6 +165,8 @@ export interface UseFormInputHandlerOptions { formatValue?: FormInputHandler['formatValue'] /** Column type for auto-detection */ columnType?: ColumnType + /** Base handler, takes precedence over the column-type handler */ + handler?: FormInputHandler } /** @@ -170,20 +174,21 @@ export interface UseFormInputHandlerOptions { * Can be overridden with custom parse/format functions. */ export function useFormInputHandler(options: UseFormInputHandlerOptions = {}): FormInputHandler { - const { parseValue, formatValue, columnType } = options + const { parseValue, formatValue, columnType, handler } = options return useMemo(() => { - // Get base handler from column type or default to string + // Get base handler from the override, the column type, or default to string const resolved = resolveColumnType(columnType) const factory = (resolved && defaultTypeHandlers[resolved]) || createStringHandler - const baseHandler = factory() + const baseHandler = handler ?? factory() return { parseValue: parseValue ?? baseHandler.parseValue, formatValue: formatValue ?? baseHandler.formatValue, defaultInputProps: baseHandler.defaultInputProps, + onBlur: baseHandler.onBlur, } - }, [parseValue, formatValue, columnType]) + }, [parseValue, formatValue, columnType, handler]) } /** diff --git a/packages/bindx-form/src/hooks/useFormInputValidationHandler.ts b/packages/bindx-form/src/hooks/useFormInputValidationHandler.ts index 0e67d277..7b2cd20b 100644 --- a/packages/bindx-form/src/hooks/useFormInputValidationHandler.ts +++ b/packages/bindx-form/src/hooks/useFormInputValidationHandler.ts @@ -1,5 +1,20 @@ import { useRef, useState, useCallback, useEffect, type RefObject, type FocusEventHandler } from 'react' -import type { FieldRef } from '@contember/bindx' +import type { FieldErrorFilter, FieldRef } from '@contember/bindx' + +/** + * Code carried by the errors this hook adds, so they can be told apart from + * server errors and from validation raised elsewhere. + */ +export const HTML5_VALIDATION_ERROR_CODE = 'html5-validity' + +const ownErrors: FieldErrorFilter = { source: 'client', code: HTML5_VALIDATION_ERROR_CODE } + +function syncValidationError(field: FieldRef, message: string | undefined): void { + field.clearErrors(ownErrors) + if (message) { + field.addError({ message, code: HTML5_VALIDATION_ERROR_CODE }) + } +} /** * Result from useFormInputValidationHandler hook. @@ -20,7 +35,8 @@ export interface ValidationHandlerResult { * - Marks field as touched on blur * - Reads HTML5 validation message on blur * - Syncs validation errors with field handle - * - Clears validation errors on focus + * - Only ever clears the error it added itself, so server errors and validation + * raised by other code survive a blur * * @example * ```tsx @@ -59,10 +75,7 @@ export function useFormInputValidationHandler( const message = input.validity.valid ? undefined : input.validationMessage validationMessageRef.current = message - field.clearErrors() - if (message) { - field.addError(message) - } + syncValidationError(field, message) }, [field]) // Effect to sync validation state when value changes @@ -73,10 +86,7 @@ export function useFormInputValidationHandler( const message = input.validity.valid ? undefined : input.validationMessage if (message !== validationMessageRef.current) { validationMessageRef.current = message - field.clearErrors() - if (message) { - field.addError(message) - } + syncValidationError(field, message) } }) diff --git a/packages/bindx-form/src/index.ts b/packages/bindx-form/src/index.ts index 6bda9dee..740ee948 100644 --- a/packages/bindx-form/src/index.ts +++ b/packages/bindx-form/src/index.ts @@ -11,6 +11,10 @@ export type { FormInputHandlerContext, FormInputHandlerFactory, ColumnType, + JSONPrimitive, + JSONValue, + JSONObject, + JSONArray, TypeHandlerMap, FormFieldScopeProps, FormFieldStateProviderProps, @@ -36,9 +40,13 @@ export { getDefaultInputProps, type UseFormInputHandlerOptions, useFormInputValidationHandler, + HTML5_VALIDATION_ERROR_CODE, type ValidationHandlerResult, } from './hooks/index.js' +// Handlers +export { createJsonHandler, type JsonHandlerOptions } from './handlers/createJsonHandler.js' + // Components export { SlotInput, diff --git a/packages/bindx-form/src/types.ts b/packages/bindx-form/src/types.ts index 9ddc9783..fea57647 100644 --- a/packages/bindx-form/src/types.ts +++ b/packages/bindx-form/src/types.ts @@ -28,6 +28,10 @@ export interface FormFieldState { export interface FormInputHandlerContext { readonly state?: State readonly setState: (state: State) => void + /** Current field value, so a handler can keep it when the input does not parse */ + readonly currentValue: unknown + /** Reports a validation error; the input adds it to the field after writing the value */ + readonly setError: (message: string) => void } /** @@ -40,6 +44,8 @@ export interface FormInputHandler { readonly formatValue: (value: unknown, ctx: FormInputHandlerContext) => string /** Default HTML input attributes for this type */ readonly defaultInputProps?: InputHTMLAttributes + /** Called when the input loses focus, to reformat the raw input or re-report an error */ + readonly onBlur?: (ctx: FormInputHandlerContext) => void } /** @@ -47,6 +53,14 @@ export interface FormInputHandler { */ export type FormInputHandlerFactory = () => FormInputHandler +/** + * JSON column value. Structurally identical to the JSONValue emitted by bindx-generator. + */ +export type JSONPrimitive = string | number | boolean | null +export type JSONValue = JSONPrimitive | JSONObject | JSONArray +export type JSONObject = { readonly [K in string]?: JSONValue } +export type JSONArray = readonly JSONValue[] + /** * Column types from Contember schema */ @@ -99,6 +113,8 @@ export interface FormInputProps { readonly formatValue?: (value: T | null) => string /** Custom value parser */ readonly parseValue?: (value: string) => T | null + /** Handler override, takes precedence over the column-type handler */ + readonly handler?: FormInputHandler } /** diff --git a/packages/bindx-form/tests/formInputValidation.test.tsx b/packages/bindx-form/tests/formInputValidation.test.tsx new file mode 100644 index 00000000..d04a9583 --- /dev/null +++ b/packages/bindx-form/tests/formInputValidation.test.tsx @@ -0,0 +1,147 @@ +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup, fireEvent } from '@testing-library/react' +import React from 'react' +import { BindxProvider, useBindxContext } from '@contember/bindx-react' +import { addFieldError, createServerError } from '@contember/bindx' +import { FormError, FormFieldScope, FormInput } from '../src/index.js' +import { + useEntity, + entityDefs, + schema, + getAllByTestId, + getByTestId, + queryByTestId, + createAdapter, +} from './testUtils.js' + +afterEach(() => { + cleanup() +}) + +function TestForm(): React.ReactElement { + const { dispatcher } = useBindxContext() + const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, e => e.title()) + + if (article.$isLoading) return
Loading...
+ if (article.$isError) return
Error
+ + return ( +
+ + + + + errors.map(error => error.message)}> + + + + + +
+ ) +} + +async function renderForm(): Promise<{ input: HTMLInputElement; container: Element }> { + const { container } = render( + + + , + ) + await waitFor(() => { + expect(queryByTestId(container, 'input')).not.toBeNull() + }) + const input = getByTestId(container, 'input') + if (!(input instanceof HTMLInputElement)) { + throw new Error('Rendered element is not an input') + } + return { input, container } +} + +function errorsOf(container: Element): string { + return getAllByTestId(container, 'error').map(element => element.textContent).join('|') +} + +/** happy-dom computes validity but leaves validationMessage empty, so the message is set explicitly. */ +function makeInvalid(input: HTMLInputElement, message: string): void { + fireEvent.change(input, { target: { value: '' } }) + input.setCustomValidity(message) +} + +function makeValid(input: HTMLInputElement): void { + input.setCustomValidity('') + fireEvent.change(input, { target: { value: 'A valid title' } }) +} + +describe('useFormInputValidationHandler', () => { + test('keeps a server error on blur', async () => { + const { input, container } = await renderForm() + + fireEvent.click(getByTestId(container, 'add-server-error')) + expect(errorsOf(container)).toBe('Title is already taken') + + fireEvent.focus(input) + fireEvent.blur(input) + + expect(errorsOf(container)).toBe('Title is already taken') + }) + + test('keeps a server error when the validity changes after blur', async () => { + const { input, container } = await renderForm() + + makeInvalid(input, 'Title is required') + fireEvent.focus(input) + fireEvent.blur(input) + expect(errorsOf(container)).toBe('Title is required') + + fireEvent.click(getByTestId(container, 'add-server-error')) + makeValid(input) + + await waitFor(() => { + expect(errorsOf(container)).toBe('Title is already taken') + }) + }) + + test('keeps a client error raised by other code on blur', async () => { + const { input, container } = await renderForm() + + fireEvent.change(input, { target: { value: 'not json' } }) + fireEvent.click(getByTestId(container, 'add-client-error')) + expect(errorsOf(container)).toBe('Invalid JSON') + + fireEvent.focus(input) + fireEvent.blur(input) + + expect(errorsOf(container)).toBe('Invalid JSON') + }) + + test('reports the HTML5 validation message on blur and clears it once valid', async () => { + const { input, container } = await renderForm() + + makeInvalid(input, 'Title is required') + fireEvent.focus(input) + fireEvent.blur(input) + + expect(errorsOf(container)).toBe('Title is required') + + makeValid(input) + + await waitFor(() => { + expect(errorsOf(container)).toBe('') + }) + }) +}) diff --git a/packages/bindx-form/tests/jsonInput.test.tsx b/packages/bindx-form/tests/jsonInput.test.tsx new file mode 100644 index 00000000..518e6936 --- /dev/null +++ b/packages/bindx-form/tests/jsonInput.test.tsx @@ -0,0 +1,278 @@ +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, act, cleanup, fireEvent } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + MockAdapter, + defineSchema, + entityDef, + scalar, + useEntity, + usePersist, +} from '@contember/bindx-react' +import { + FormFieldScope, + FormInput, + createJsonHandler, + type JsonHandlerOptions, + type JSONValue, +} from '../src/index.js' +import type { FieldError } from '@contember/bindx' +import { getByTestId, queryByTestId } from './testUtils.js' + +afterEach(() => { + cleanup() +}) + +interface Settings { + id: string + name: string + payload: JSONValue | null +} + +const settingsSchema = defineSchema<{ Settings: Settings }>({ + entities: { + Settings: { + fields: { + id: scalar(), + name: scalar(), + payload: { type: 'scalar', columnType: 'Json' }, + }, + }, + }, +}) + +const settingsDef = entityDef('Settings') + +/** Reads the field errors as the store holds them; the rendered span can lag behind. */ +let readFieldErrors: (() => readonly FieldError[]) | undefined + +interface JsonFormProps { + /** Omitted to let FormInput resolve the handler from the Json column type */ + readonly handlerOptions?: JsonHandlerOptions +} + +function JsonForm({ handlerOptions }: JsonFormProps): React.ReactNode { + const settings = useEntity(settingsDef, { by: { id: 'settings-1' } }, e => e.name().payload()) + const { persist } = usePersist() + const [persistResult, setPersistResult] = React.useState('') + const handler = React.useMemo( + () => (handlerOptions === undefined ? undefined : createJsonHandler(handlerOptions)), + [handlerOptions], + ) + + if (settings.$isLoading) return
Loading...
+ if (settings.$isError || settings.$isNotFound) return
Error
+ + readFieldErrors = () => settings.payload.errors + + return ( +
+ + +