diff --git a/Source/DataPage/DataPage.tsx b/Source/DataPage/DataPage.tsx index 0ddb8d6..999b5ce 100644 --- a/Source/DataPage/DataPage.tsx +++ b/Source/DataPage/DataPage.tsx @@ -66,13 +66,21 @@ export const Columns = ({ children }: ColumnProps) => { if (context.query.prototype instanceof QueryFor) { return ( - + {children} ); } else { return ( - + {children} ); } @@ -80,7 +88,7 @@ export const Columns = ({ children }: ColumnProps) => { export interface IDetailsComponentProps { item: TDataType; - + onRefresh?: () => void; } interface IDataPageContext extends DataPageProps { @@ -148,6 +156,16 @@ export interface DataPageProps | IObservable * Default filters to use */ defaultFilters?: DataTableFilterMeta; + + /** + * When true, filtering is performed client-side only + */ + clientFiltering?: boolean; + + /** + * Callback triggered to signal data refresh + */ + onRefresh?(): void; } /** @@ -176,7 +194,7 @@ const DataPage = | IObservableQueryFor {props.detailsComponent && selectedItem && - + } diff --git a/Source/ObjectContentEditor/ObjectContentEditor.tsx b/Source/ObjectContentEditor/ObjectContentEditor.tsx index fda5b4d..901dd2d 100644 --- a/Source/ObjectContentEditor/ObjectContentEditor.tsx +++ b/Source/ObjectContentEditor/ObjectContentEditor.tsx @@ -2,20 +2,92 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { Tooltip } from 'primereact/tooltip'; -import React, { useState, useCallback, useMemo } from 'react'; +import React, { useState, useCallback, useMemo, useEffect } from 'react'; import * as faIcons from 'react-icons/fa6'; import { ObjectNavigationalBar } from '../ObjectNavigationalBar'; import { Json, JsonSchema, JsonSchemaProperty } from '../types/JsonSchema'; import { getValueAtPath } from './objectHelpers'; +import { InputText } from 'primereact/inputtext'; +import { InputNumber } from 'primereact/inputnumber'; +import { Checkbox } from 'primereact/checkbox'; +import { Calendar } from 'primereact/calendar'; +import { InputTextarea } from 'primereact/inputtextarea'; export interface ObjectContentEditorProps { object: Json; timestamp?: Date; schema: JsonSchema; + /** + * When true, renders editable input fields for each property respecting type/format + */ + editMode?: boolean; + /** + * Called with the updated object after any field edit + */ + onChange?: (object: Json) => void; + /** + * Called when the validation state changes + */ + onValidationChange?: (hasErrors: boolean) => void; } -export const ObjectContentEditor = ({ object, timestamp, schema }: ObjectContentEditorProps) => { +export const ObjectContentEditor = ({ object, timestamp, schema, editMode = false, onChange, onValidationChange }: ObjectContentEditorProps) => { const [navigationPath, setNavigationPath] = useState([]); + const [validationErrors, setValidationErrors] = useState>({}); + + const validateValue = useCallback((propertyName: string, value: Json, property: JsonSchemaProperty): string | undefined => { + if (editMode) { + if (value === null || value === undefined || value === '') { + return 'This field is required'; + } + } else { + const isRequired = schema.required?.includes(propertyName); + if (isRequired && (value === null || value === undefined || value === '')) { + return 'This field is required'; + } + } + + if (property.type === 'string' && typeof value === 'string') { + if (property.format === 'email' && value && !value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { + return 'Invalid email format'; + } + if (property.format === 'uri' && value && !value.match(/^https?:\/\/.+/)) { + return 'Invalid URI format'; + } + } + + if (property.type === 'number' || property.type === 'integer') { + if (value !== null && value !== undefined && value !== '' && isNaN(Number(value))) { + return 'Must be a valid number'; + } + } + + return undefined; + }, [schema, editMode]); + + useEffect(() => { + if (!editMode || navigationPath.length > 0) return; + + const errors: Record = {}; + const properties = schema.properties || {}; + + Object.entries(properties).forEach(([propertyName, property]) => { + const value = (object as Record)[propertyName]; + const error = validateValue(propertyName, value, property as JsonSchemaProperty); + if (error) { + errors[propertyName] = error; + } + }); + + setValidationErrors(errors); + }, [object, schema, editMode, navigationPath, validateValue]); + + useEffect(() => { + if (editMode && onValidationChange) { + const hasErrors = Object.keys(validationErrors).length > 0; + onValidationChange(hasErrors); + } + }, [validationErrors, editMode, onValidationChange]); const navigateToProperty = useCallback((key: string) => { setNavigationPath([...navigationPath, key]); @@ -81,6 +153,7 @@ export const ObjectContentEditor = ({ object, timestamp, schema }: ObjectContent textAlign: 'left', fontWeight: 500, width: '140px', + whiteSpace: 'nowrap', }; const valueStyle: React.CSSProperties = { @@ -90,10 +163,141 @@ export const ObjectContentEditor = ({ object, timestamp, schema }: ObjectContent }; const infoIconStyle: React.CSSProperties = { - marginLeft: '6px', - fontSize: '12px', - color: 'rgba(100, 150, 255, 0.6)', - cursor: 'help', + fontSize: '0.875rem', + color: 'var(--text-color-secondary)', + flexShrink: 0, + }; + + const updateValue = useCallback((propertyName: string, newValue: Json) => { + if (!onChange) return; + + const updatedObject = { ...(object as Record) }; + updatedObject[propertyName] = newValue; + onChange(updatedObject); + }, [object, onChange]); + + const renderEditField = (propertyName: string, property: JsonSchemaProperty, value: Json) => { + const error = validationErrors[propertyName]; + + const handleChange = (newValue: Json) => { + updateValue(propertyName, newValue); + const validationError = validateValue(propertyName, newValue, property); + setValidationErrors(prev => { + const newErrors = { ...prev }; + if (validationError) { + newErrors[propertyName] = validationError; + } else { + delete newErrors[propertyName]; + } + return newErrors; + }); + }; + + const inputStyle = { + width: '100%', + ...(error ? { borderColor: 'var(--red-500)' } : {}) + }; + + if (property.type === 'boolean') { + return ( +
+ handleChange(e.checked ?? false)} + /> + {error && {error}} +
+ ); + } + + if (property.type === 'number' || property.type === 'integer') { + return ( +
+ handleChange(e.value ?? null)} + mode="decimal" + useGrouping={false} + style={inputStyle} + /> + {error && {error}} +
+ ); + } + + if (property.type === 'string' && property.format === 'date-time') { + const dateValue = value ? new Date(value as string) : null; + return ( +
+ handleChange(e.value instanceof Date ? e.value.toISOString() : null)} + showTime + showIcon + style={inputStyle} + /> + {error && {error}} +
+ ); + } + + if (property.type === 'string' && property.format === 'date') { + const dateValue = value ? new Date(value as string) : null; + return ( +
+ handleChange(e.value instanceof Date ? e.value.toISOString().split('T')[0] : null)} + showIcon + style={inputStyle} + /> + {error && {error}} +
+ ); + } + + if (property.type === 'array') { + return ( +
+ Array editing not yet supported +
+ ); + } + + if (property.type === 'object') { + return ( +
+ Object editing not yet supported +
+ ); + } + + const isLongText = (value as string)?.length > 50; + + if (isLongText) { + return ( +
+ handleChange(e.target.value)} + rows={3} + style={inputStyle} + /> + {error && {error}} +
+ ); + } + + return ( +
+ handleChange(e.target.value)} + style={inputStyle} + /> + {error && {error}} +
+ ); }; const renderValue = (value: Json, propertyName: string) => { @@ -184,22 +388,31 @@ export const ObjectContentEditor = ({ object, timestamp, schema }: ObjectContent const value = (currentData as Record)[propertyName]; const isSchemaProperty = navigationPath.length === 0; - const description = isSchemaProperty && typeof propertyDef === 'object' && propertyDef !== null && 'description' in propertyDef - ? (propertyDef as JsonSchemaProperty).description - : undefined; + const property = isSchemaProperty && typeof propertyDef === 'object' && propertyDef !== null && 'type' in propertyDef + ? (propertyDef as JsonSchemaProperty) + : null; + const description = property?.description; return ( - {propertyName} - {description && ( - - )} + + {propertyName} + {description && ( + + )} + + + + {editMode && property + ? renderEditField(propertyName, property, value) + : renderValue(value as Json, propertyName) + } - {renderValue(value as Json, propertyName)} ); })} @@ -210,7 +423,7 @@ export const ObjectContentEditor = ({ object, timestamp, schema }: ObjectContent return (
- +