diff --git a/README.md b/README.md index 6131138..fbf54f9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,18 @@ [ci-url]: https://github.com/PADAS/react-native-jsonforms-formatter/actions/workflows/npm-build.yml/badge.svg -A Node.js library for validating JSONSchema and generating UISchema for a custom ReactNative JSONForms element. +A Node.js library for validating JSONSchema and generating UISchema for a custom ReactNative JSONForms element. Supports both v1 (legacy) and v2 (modern) schema formats with full backward compatibility. + +## Features + +- ✅ **JSON Schema Validation**: Validates and sanitizes JSON schema strings +- 🎨 **UI Schema Generation**: Creates UI schemas compatible with JSONForms +- 📱 **React Native Ready**: Optimized for React Native applications +- 🔄 **Dual Version Support**: V1 (legacy) and V2 (modern) schema formats + +## Architecture + +For detailed component information, see [component-diagram.md](./component-diagram.md). ## Installation @@ -24,26 +35,67 @@ npm install --save react-native-jsonforms-formatter ## Usage -The library provides two main functions: `validateJSONSchema` and `generateUISchema`. +The library supports two schema formats: **V1 (legacy)** and **V2 (modern)**. Choose the appropriate version based on your schema format. + +### Version Support -### Validating JSONSchema +- **V1 (Default/Legacy)**: Traditional JSONSchema format with `schema` and `definition` properties +- **V2 (Modern)**: New format with `json` and `ui` properties, following JSON Schema Draft 2020-12 + +### Client Integration + +#### Default Import (V1 - Backward Compatible) + +```typescript +import { validateJSONSchema, generateUISchema } from "react-native-jsonforms-formatter"; +// Uses V1 implementation by default +``` -You can use the `validateJSONSchema` function to validate a JSONSchema string: +#### Explicit Version Imports + +```typescript +// V1 specific imports +import { v1 } from "react-native-jsonforms-formatter"; +const { validateJSONSchema, generateUISchema } = v1; + +// V2 specific imports +import { v2 } from "react-native-jsonforms-formatter"; +const { generateUISchema } = v2; + +// Or direct imports +import { v1, v2 } from "react-native-jsonforms-formatter"; +``` + +## V1 Schema Format (Legacy) + +### Validating V1 JSONSchema ```typescript import { validateJSONSchema } from "react-native-jsonforms-formatter"; const stringSchema = ` { - "type": "object", - "properties": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "age": { + "type": "integer", + "title": "Age" + } + } + }, + "definition": { "name": { - "type": "string", - "title": "Name" + "inputType": "text", + "placeholder": "Enter your name" }, "age": { - "type": "integer", - "title": "Age" + "inputType": "number", + "placeholder": "Enter your age" } } } @@ -52,11 +104,9 @@ const stringSchema = ` const jsonSchema = validateJSONSchema(stringSchema); ``` -The `validateJSONSchema` function returns a valid JSONSchema object if the input string is a valid JSONSchema. If the input is not valid, it will throw an error. - -### Generating UISchema +**Returns**: A validated V1 schema object with normalized decimal separators and cleaned properties. -You can use the `generateUISchema` function to generate a UISchema object from a valid JSONSchema: +### Generating V1 UI Schema ```typescript import { generateUISchema } from "react-native-jsonforms-formatter"; @@ -64,11 +114,187 @@ import { generateUISchema } from "react-native-jsonforms-formatter"; const uiSchema = generateUISchema(jsonSchema); ``` -The `generateUISchema` function returns a `UISchema` object that can be used with the ReactNative JSONForms library. +**Returns**: JSONForms-compatible UI schema optimized for React Native: + +```typescript +{ + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/name", + label: "Name", + options: { + placeholder: "Enter your name" + } + }, + { + type: "Control", + scope: "#/properties/age", + label: "Age", + options: { + format: "number", + placeholder: "Enter your age" + } + } + ] +} +``` + +## V2 Schema Format + +V2 schemas use a new format with `json` and `ui` properties, following JSON Schema Draft 2020-12. + +### Generating V2 UI Schema + +```typescript +import { v2 } from "react-native-jsonforms-formatter"; + +const v2Schema = { + "json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "patrol_leader": { + "deprecated": false, + "description": "Name of the patrol leader", + "title": "Patrol Leader", + "type": "string" + }, + "patrol_size": { + "deprecated": false, + "description": "Number of people in the patrol", + "title": "Patrol Size", + "type": "number", + "minimum": 1, + "maximum": 20 + }, + "patrol_location": { + "deprecated": false, + "description": "GPS coordinates of patrol location", + "title": "Patrol Location", + "type": "object", + "properties": { + "latitude": { "type": "number", "minimum": -90, "maximum": 90 }, + "longitude": { "type": "number", "minimum": -180, "maximum": 180 } + } + } + }, + "required": ["patrol_leader", "patrol_size"], + "type": "object" + }, + "ui": { + "fields": { + "patrol_leader": { + "inputType": "SHORT_TEXT", + "parent": "section-details", + "placeholder": "Enter patrol leader name", + "type": "TEXT" + }, + "patrol_size": { + "parent": "section-details", + "placeholder": "5", + "type": "NUMERIC" + }, + "patrol_location": { + "parent": "section-location", + "type": "LOCATION" + } + }, + "order": ["section-details", "section-location"], + "sections": { + "section-details": { + "columns": 1, + "isActive": true, + "label": "Patrol Details", + "leftColumn": [ + { "name": "patrol_leader", "type": "field" }, + { "name": "patrol_size", "type": "field" } + ], + "rightColumn": [] + }, + "section-location": { + "columns": 1, + "isActive": true, + "label": "Location", + "leftColumn": [ + { "name": "patrol_location", "type": "field" } + ], + "rightColumn": [] + } + } + } +}; + +const uiSchema = v2.generateUISchema(v2Schema); +``` + +**Returns**: JSONForms-compatible UI schema with advanced field types and section management: + +```typescript +{ + type: "VerticalLayout", + elements: [ + { + type: "VerticalLayout", + label: "Patrol Details", + elements: [ + { + type: "Control", + scope: "#/properties/patrol_leader", + label: "Patrol Leader", + options: { + placeholder: "Enter patrol leader name", + description: "Name of the patrol leader" + } + }, + { + type: "Control", + scope: "#/properties/patrol_size", + label: "Patrol Size", + options: { + format: "number", + placeholder: "5", + description: "Number of people in the patrol" + } + } + ] + }, + { + type: "VerticalLayout", + label: "Location", + elements: [ + { + type: "Control", + scope: "#/properties/patrol_location", + label: "Patrol Location", + options: { + format: "location", + display: "map", + description: "GPS coordinates of patrol location" + } + } + ] + } + ] +} +``` + +### V2 Field Types + +V2 supports advanced field types: -## Putting it all together +- **TEXT**: `SHORT_TEXT`, `LONG_TEXT` (multi-line) +- **NUMERIC**: Number fields with validation +- **CHOICE_LIST**: Dropdowns and list selections +- **DATE_TIME**: Date and time pickers +- **LOCATION**: GPS coordinate fields with map display +- **COLLECTION**: Arrays with nested field structures +- **ATTACHMENT**: File upload fields -Here's an example of how you can use the library in a ReactNative application: +## Complete React Native Examples + +### V1 Schema Example ```typescript import { JsonForms } from "@jsonforms/react-native"; @@ -79,31 +305,44 @@ import { validateJSONSchema, } from "react-native-jsonforms-formatter"; -const stringSchema = ` +const v1StringSchema = ` { - "type": "object", - "properties": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "age": { + "type": "integer", + "title": "Age" + } + } + }, + "definition": { "name": { - "type": "string", - "title": "Name" + "inputType": "text", + "placeholder": "Enter your name" }, "age": { - "type": "integer", - "title": "Age" + "inputType": "number", + "placeholder": "Enter your age" } } } `; -const jsonSchema = validateJSONSchema(stringSchema); -const uiSchema = generateUISchema(jsonSchema); - -const App = () => { +const V1App = () => { const [data, setData] = React.useState({ name: "John Doe", age: 30 }); + + // Validate and generate UI schema + const jsonSchema = validateJSONSchema(v1StringSchema); + const uiSchema = generateUISchema(jsonSchema); return ( { /> ); }; - -export default App; ``` -## Contributors +### V2 Schema Example -Contributions are welcome! If you find a bug or have a feature request, please open an issue. +```typescript +import { JsonForms } from "@jsonforms/react-native"; +import { RNCells, RNRenderers } from "@jsonforms/react-native-renderers"; +import React from "react"; +import { v2 } from "react-native-jsonforms-formatter"; + +const v2Schema = { + "json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "patrol_leader": { + "deprecated": false, + "description": "Name of the patrol leader", + "title": "Patrol Leader", + "type": "string" + }, + "patrol_size": { + "deprecated": false, + "description": "Number of people in the patrol", + "title": "Patrol Size", + "type": "number", + "minimum": 1, + "maximum": 20 + } + }, + "required": ["patrol_leader", "patrol_size"], + "type": "object" + }, + "ui": { + "fields": { + "patrol_leader": { + "inputType": "SHORT_TEXT", + "parent": "section-details", + "placeholder": "Enter patrol leader name", + "type": "TEXT" + }, + "patrol_size": { + "parent": "section-details", + "placeholder": "5", + "type": "NUMERIC" + } + }, + "order": ["section-details"], + "sections": { + "section-details": { + "columns": 1, + "isActive": true, + "label": "Patrol Details", + "leftColumn": [ + { "name": "patrol_leader", "type": "field" }, + { "name": "patrol_size", "type": "field" } + ], + "rightColumn": [] + } + } + } +}; - - - +const V2App = () => { + const [data, setData] = React.useState({ + patrol_leader: "John Smith", + patrol_size: 5 + }); + + // Generate UI schema using V2 + const uiSchema = v2.generateUISchema(v2Schema); + return ( + setData(event.data)} + /> + ); +}; +``` ## Licensing A copy of the license is available in the repository's [LICENSE](LICENSE) file. + diff --git a/component-diagram.md b/component-diagram.md new file mode 100644 index 0000000..01ca96c --- /dev/null +++ b/component-diagram.md @@ -0,0 +1,206 @@ +i# React Native JSONForms Formatter - Component Diagram + +```mermaid +graph TD + %% External consumers + RN[React Native App] + + %% Main library interface + LIB[📦 @earthranger/react-native-jsonforms-formatter] + + %% V1 API (Original) + V1_API[🔷 V1 API] + VALIDATE[🔍 validateJSONSchema] + GENERATE_V1[🎨 generateUISchema V1] + + %% V2 API (New Schema Format) + V2_API[🔶 V2 API] + GENERATE_V2[🎨 generateUISchema V2] + + %% V1 Processing modules + JSON_PROC[📝 JSON Processing] + SCHEMA_VAL[✅ Schema Validation] + NUMBER_NORM[🔢 Number Normalization] + FIELD_PROC[🏗️ Field Processing] + UI_GEN_V1[🖼️ UI Generation V1] + + %% V2 Processing modules + FIELD_VISIBILITY[👁️ Field Visibility Processing] + FIELD_GROUPING[📋 Field Grouping by Section] + SECTION_ORDER[📑 Section Order Processing] + CONTROL_CREATION[🎛️ Field Control Creation] + LAYOUT_CREATION[🏗️ Section Layout Creation] + + %% V1 Utility functions + UTILS_V1[🛠️ V1 Utils] + TRAVERSE[traverseSchema] + NORMALIZE[normalizeDecimalSeparators] + DUPLICATE[hasDuplicatedItems] + REQUIRED[isRequiredProperty] + CHECKBOX[isCheckbox] + FIELDSET[isFieldSet] + + %% V2 Utility functions + UTILS_V2[🛠️ V2 Utils] + IS_VISIBLE[isFieldVisible] + GET_VISIBLE[getVisibleFields] + GROUP_FIELDS[groupFieldsBySection] + CREATE_CONTROL[createControl] + CREATE_HEADER[createHeaderLabel] + CREATE_SECTION[createSectionLayout] + COLLECTION_INTERNAL[generateCollectionUISchemaInternal] + + %% Shared Schema Utilities + SCHEMA_UTILS[🔧 Schema Utils] + DETECT_VERSION[detectSchemaVersion] + PROCESS_SCHEMA[processSchema] + + %% Data flow + RN --> LIB + + LIB --> V1_API + LIB --> V2_API + LIB --> SCHEMA_UTILS + + %% V1 Flow + V1_API --> VALIDATE + V1_API --> GENERATE_V1 + + VALIDATE --> JSON_PROC + VALIDATE --> SCHEMA_VAL + VALIDATE --> NUMBER_NORM + VALIDATE --> FIELD_PROC + + GENERATE_V1 --> UI_GEN_V1 + + JSON_PROC --> UTILS_V1 + SCHEMA_VAL --> UTILS_V1 + NUMBER_NORM --> UTILS_V1 + FIELD_PROC --> UTILS_V1 + UI_GEN_V1 --> UTILS_V1 + + UTILS_V1 --> TRAVERSE + UTILS_V1 --> NORMALIZE + UTILS_V1 --> DUPLICATE + UTILS_V1 --> REQUIRED + UTILS_V1 --> CHECKBOX + UTILS_V1 --> FIELDSET + + %% V2 Flow + V2_API --> GENERATE_V2 + + GENERATE_V2 --> FIELD_VISIBILITY + GENERATE_V2 --> FIELD_GROUPING + GENERATE_V2 --> SECTION_ORDER + GENERATE_V2 --> CONTROL_CREATION + GENERATE_V2 --> LAYOUT_CREATION + + FIELD_VISIBILITY --> UTILS_V2 + FIELD_GROUPING --> UTILS_V2 + SECTION_ORDER --> UTILS_V2 + CONTROL_CREATION --> UTILS_V2 + LAYOUT_CREATION --> UTILS_V2 + + UTILS_V2 --> IS_VISIBLE + UTILS_V2 --> GET_VISIBLE + UTILS_V2 --> GROUP_FIELDS + UTILS_V2 --> CREATE_CONTROL + UTILS_V2 --> CREATE_HEADER + UTILS_V2 --> CREATE_SECTION + UTILS_V2 --> COLLECTION_INTERNAL + + SCHEMA_UTILS --> DETECT_VERSION + SCHEMA_UTILS --> PROCESS_SCHEMA + + %% Styling + classDef publicAPI fill:#e1f5fe,stroke:#0277bd,stroke-width:3px + classDef v1Internal fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px + classDef v2Internal fill:#fff3e0,stroke:#ef6c00,stroke-width:2px + classDef v1Utility fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px + classDef v2Utility fill:#fce4ec,stroke:#c2185b,stroke-width:2px + classDef sharedUtility fill:#f0f4c3,stroke:#827717,stroke-width:2px + + class LIB,V1_API,V2_API,VALIDATE,GENERATE_V1,GENERATE_V2 publicAPI + class JSON_PROC,SCHEMA_VAL,NUMBER_NORM,FIELD_PROC,UI_GEN_V1 v1Internal + class FIELD_VISIBILITY,FIELD_GROUPING,SECTION_ORDER,CONTROL_CREATION,LAYOUT_CREATION v2Internal + class UTILS_V1,TRAVERSE,NORMALIZE,DUPLICATE,REQUIRED,CHECKBOX,FIELDSET v1Utility + class UTILS_V2,IS_VISIBLE,GET_VISIBLE,GROUP_FIELDS,CREATE_CONTROL,CREATE_HEADER,CREATE_SECTION,COLLECTION_INTERNAL v2Utility + class SCHEMA_UTILS,DETECT_VERSION,PROCESS_SCHEMA sharedUtility +``` + +## Component Description + +### 🔵 Public API (Blue) +- **Main Library**: Entry point for consumers +- **V1 API**: Original schema format processing + - **validateJSONSchema**: Validates and normalizes JSON schemas + - **generateUISchema V1**: Generates UI schema from validated JSON schema +- **V2 API**: New EarthRanger schema format processing + - **generateUISchema V2**: Generates UI schema from V2 schema format (automatically handles collections) + +### 🟣 V1 Internal Processing (Purple) +- **JSON Processing**: Handles JSON parsing, cleaning, and formatting +- **Schema Validation**: Validates schema structure and detects errors +- **Number Normalization**: Converts comma decimal separators to periods +- **Field Processing**: Processes fieldsets, checkboxes, and other field types +- **UI Generation V1**: Creates UI schema elements from JSON schema + +### 🟠 V2 Internal Processing (Orange) +- **Field Visibility Processing**: Filters deprecated and invisible fields using getVisibleFields +- **Field Grouping by Section**: Groups visible fields by their parent sections using groupFieldsBySection +- **Section Order Processing**: Processes sections in the order specified by the schema's ui.order array +- **Field Control Creation**: Creates JSONForms controls for each visible field with field-type-specific options +- **Section Layout Creation**: Creates single-column VerticalLayout sections with ordered elements (leftColumn first, then rightColumn) + +### 🟢 V1 Utilities (Green) +- **V1 Utils Module**: Core utility functions for V1 processing +- **traverseSchema**: Recursively processes nested schema objects +- **normalizeDecimalSeparators**: Converts comma decimals (12,99 → 12.99) +- **hasDuplicatedItems**: Detects duplicate items in arrays +- **isRequiredProperty**: Checks if a property is required +- **isCheckbox**: Identifies checkbox field types +- **isFieldSet**: Identifies fieldset structures + +### 🔴 V2 Utilities (Pink) +- **V2 Utils Module**: Specialized utility functions for V2 processing +- **isFieldVisible**: Checks if field should be rendered (not deprecated) +- **getVisibleFields**: Filters and returns all visible fields from schema +- **groupFieldsBySection**: Groups fields by their parent section for layout processing +- **createControl**: Creates JSONForms control elements with field-type-specific options and collection embedding +- **createHeaderLabel**: Creates header/label elements for sections +- **createSectionLayout**: Creates single-column VerticalLayout optimized for React Native with ordered elements +- **generateCollectionUISchemaInternal**: Internal function for automatic collection item UI schema generation + +### 🟡 Shared Schema Utilities (Yellow-Green) +- **Schema Utils Module**: Cross-version utility functions for schema processing +- **detectSchemaVersion**: Analyzes schema structure to determine if it's V1 or V2 format +- **processSchema**: Processes schema string and returns version-appropriate data structure + +## Data Flow + +### V1 Flow (Original) +1. Consumer apps import the library +2. Raw JSON schema strings are passed to validation functions +3. V1 processors clean, validate, and normalize the data +4. V1 utility functions handle specific transformations +5. Clean, validated schemas are returned to consumers + +### V2 Flow (New Schema Format) +1. Consumer apps import V2 functions +2. Structured V2 schema objects are passed to generateUISchema +3. **Field Visibility Processing**: getVisibleFields filters out deprecated fields +4. **Field Grouping**: groupFieldsBySection organizes fields by their parent sections +5. **Section Processing**: Iterate through ui.order array to process sections in specified order +6. **Control Creation**: createControl generates JSONForms controls with field-type-specific options +7. **Layout Creation**: createSectionLayout builds single-column VerticalLayout with ordered elements +8. React Native optimized UI schemas with embedded collection details are returned to consumers + +## Key Differences +- **V1**: Processes raw JSON strings, requires validation and normalization +- **V2**: Processes structured schema objects, optimized for React Native single-column layouts +- **V2**: Supports advanced features like headers, sections, collections, and field visibility +- **V2**: Automatically embeds collection item UI schemas (no separate function calls needed) +- **V2**: Handles all EarthRanger custom field types (TEXT, NUMERIC, CHOICE_LIST, DATE_TIME, LOCATION, COLLECTION, ATTACHMENT) +- **V2**: Processes field constraints (maxItems/minItems, leftColumn/rightColumn layout) +- **V2**: Mobile-first design with always-vertical layouts for React Native +- **V2**: Compatible with JSONForms React Native custom renderers diff --git a/package.json b/package.json index 5034ecd..491c203 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,34 @@ { "name": "@earthranger/react-native-jsonforms-formatter", - "version": "1.0.7", + "version": "2.0.0-beta.5", "description": "Converts JTD into JSON Schema ", "main": "./dist/bundle.js", - "types": "./dist/src/index.d.ts", - "module": "./dist/src/index.js", + "types": "./dist/index.d.ts", + "module": "./dist/index.js", "browser": "./dist/bundle.js", "exports": { ".": { - "types": "./dist/src/index.d.ts", - "import": "./dist/src/index.js", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", "require": "./dist/bundle.js" }, "./v1": { - "types": "./dist/src/v1.d.ts", - "import": "./dist/src/v1.js", + "types": "./dist/v1.d.ts", + "import": "./dist/v1.js", + "require": "./dist/bundle.js" + }, + "./v2": { + "types": "./dist/v2/index.d.ts", + "import": "./dist/v2/index.js", "require": "./dist/bundle.js" } }, "react-native": "./dist/bundle.js", "scripts": { "preinstall": "git config core.hooksPath .githooks", - "build": "npx webpack", + "build": "npm run clean && npm run build:lib && npm run build:bundle", + "build:lib": "tsc", + "build:bundle": "npx webpack", "clean": "rm -rf dist", "lint": "eslint src/ --ext .js,.jsx,.ts,.tsx", "lint:test": "eslint 'test/**/*.{ts,tsx}'", @@ -36,8 +43,7 @@ "access": "public" }, "files": [ - "./dist/src", - "./dist/bundle.js" + "dist" ], "keywords": [ "json", diff --git a/src/common/schemaUtils.ts b/src/common/schemaUtils.ts new file mode 100644 index 0000000..8d75b45 --- /dev/null +++ b/src/common/schemaUtils.ts @@ -0,0 +1,70 @@ +/** + * Utility functions for schema version detection and handling + */ + +export type SchemaVersion = 'v1' | 'v2'; + +export interface ParsedSchema { + version: SchemaVersion; + data: any; +} + +/** + * Detects whether a schema string contains v1 or v2 format + * + * v1 schema structure: { schema: {...}, definition: [...] } or { data: { schema: {...}, definition: [...] } } + * v2 schema structure: { json: {...}, ui: {...} } or { data: { json: {...}, ui: {...} } } + */ +export const detectSchemaVersion = (schemaString: string): ParsedSchema => { + try { + const parsed = JSON.parse(schemaString); + + // Handle wrapped responses (API responses with data wrapper) + const actualSchema = parsed.data || parsed; + + // Check for v2 schema structure + if (actualSchema.json && actualSchema.ui) { + return { + version: 'v2', + data: actualSchema + }; + } + + // Check for v1 schema structure + if (actualSchema.schema && actualSchema.definition) { + return { + version: 'v1', + data: actualSchema + }; + } + + // If we have a schema property but no ui property, assume v1 + if (actualSchema.schema && !actualSchema.ui) { + return { + version: 'v1', + data: actualSchema + }; + } + + // Default to v1 for backward compatibility + return { + version: 'v1', + data: actualSchema + }; + + } catch (error) { + throw new Error(`Invalid JSON schema: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +}; + +/** + * Processes a schema string and returns the appropriate data structure for the detected version + */ +export const processSchema = (schemaString: string): { version: SchemaVersion; processedData: string } => { + const { version, data } = detectSchemaVersion(schemaString); + + return { + version, + processedData: JSON.stringify(data) + }; +}; \ No newline at end of file diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 0000000..24cafa6 --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,143 @@ +export interface ValidationOptions { + version?: 'v1' | 'v2' | 'auto'; + strictMode?: boolean; + locale?: string; +} + +// V1 Schema Types +export interface V1Schema { + schema: { + type: string; + properties: Record; + required?: string[]; + [key: string]: any; + }; + definition: any[]; +} + +// V2 Schema Types +export interface V2Schema { + json: { + $schema: string; + additionalProperties: boolean; + properties: Record; + required: string[]; + type: string; + }; + ui: { + fields: Record; + headers: Record; + order: string[]; + sections: Record; + }; +} + +// Base property interface for nested properties (doesn't require deprecated/title) +export interface V2BaseProperty { + type: 'string' | 'number' | 'array' | 'object'; + description?: string; + default?: any; + minimum?: number; + maximum?: number; + format?: 'date-time' | 'date' | 'time' | 'uri'; + anyOf?: Array<{ $ref: string }>; + items?: V2BaseProperty; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + unevaluatedItems?: boolean; + maxItems?: number; + minItems?: number; + uniqueItems?: boolean; +} + +// Top-level property interface (extends base with required fields) +export interface V2Property extends V2BaseProperty { + deprecated: boolean; + title: string; + items?: V2BaseProperty; + properties?: Record; +} + +export interface V2UIField { + type: 'TEXT' | 'NUMERIC' | 'CHOICE_LIST' | 'DATE_TIME' | 'LOCATION' | 'COLLECTION' | 'ATTACHMENT'; + parent: string; + inputType?: 'SHORT_TEXT' | 'LONG_TEXT' | 'DROPDOWN' | 'LIST'; + placeholder?: string; + choices?: V2Choices; + buttonText?: string; + columns?: number; + itemIdentifier?: string; + itemName?: string; + leftColumn?: string[]; + rightColumn?: string[]; + allowableFileTypes?: string[]; +} + +export interface V2Choices { + type: 'EXISTING_CHOICE_LIST' | 'MY_DATA'; + eventTypeCategories: string[]; + existingChoiceList: string[]; + featureCategories: string[]; + myDataType: string; + subjectGroups: string[]; + subjectSubtypes: string[]; +} + +export interface V2Header { + label: string; + section: string; + size: 'LARGE' | 'MEDIUM' | 'SMALL'; +} + +export interface V2Section { + columns: 1 | 2; + isActive: boolean; + label: string; + leftColumn: V2ColumnItem[]; + rightColumn: V2ColumnItem[]; +} + +export interface V2ColumnItem { + name: string; + type: 'field' | 'header'; +} + +// JSONForms UI Schema Types +export interface JSONFormsUIElement { + type: string; + scope?: string; + label?: string; + options?: Record; + elements?: JSONFormsUIElement[]; +} + +export interface JSONFormsControl extends JSONFormsUIElement { + type: 'Control'; + scope: string; + label: string; + options?: { + format?: string; + display?: string; + placeholder?: string; + multi?: boolean; + [key: string]: any; + }; +} + +export interface JSONFormsLayout extends JSONFormsUIElement { + type: 'VerticalLayout' | 'HorizontalLayout' | 'Group' | 'Categorization'; + elements: JSONFormsUIElement[]; + label?: string; +} + +export interface JSONFormsLabel extends JSONFormsUIElement { + type: 'Label'; + text: string; + options?: { + size?: 'LARGE' | 'MEDIUM' | 'SMALL'; + [key: string]: any; + }; +} + +export type JSONFormsUISchema = JSONFormsUIElement; diff --git a/src/index.ts b/src/index.ts index 1198a38..1dba9f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,7 @@ export { generateUISchema, validateJSONSchema } from './v1/index'; // Versioned exports │ export * as v1 from './v1/index'; +export * as v2 from './v2/index'; + +// Schema utilities +export { detectSchemaVersion, processSchema, type SchemaVersion, type ParsedSchema } from './common/schemaUtils'; diff --git a/src/v2/generateUISchema.ts b/src/v2/generateUISchema.ts new file mode 100644 index 0000000..43698ab --- /dev/null +++ b/src/v2/generateUISchema.ts @@ -0,0 +1,62 @@ +import { + V2Schema, + JSONFormsUISchema, + JSONFormsLayout +} from '../common/types'; + +import { + createControl, + createSectionLayout, + getVisibleFields, + groupFieldsBySection +} from './utils'; + +/** + * Generates a JSONForms-compatible UI schema from a EarthRanger V2 schema format + * + * React Native Optimization: + * - All layouts are converted to single-column VerticalLayout + * - Multi-column sections are flattened: leftColumn fields first, then rightColumn fields + * - Optimized for mobile form rendering + * + * @param schema - EarthRanger V2 schema with json and ui properties + * @returns JSONForms UI schema with single-column VerticalLayout for React Native + */ +export const generateUISchema = (schema: V2Schema): JSONFormsUISchema => { + // Get all visible (non-deprecated) fields + const visibleFields = getVisibleFields(schema); + + // Group fields by their parent sections + const fieldsBySection = groupFieldsBySection(visibleFields, schema.ui.sections); + + // Create section layouts in the specified order + const sectionLayouts: JSONFormsLayout[] = []; + + schema.ui.order.forEach(sectionId => { + const section = schema.ui.sections[sectionId]; + const sectionFields = fieldsBySection[sectionId] || []; + + // Check if section has any content (fields or headers) + const hasFields = sectionFields.length > 0; + const hasHeaders = [...section.leftColumn, ...section.rightColumn] + .some(item => item.type === 'header' && schema.ui.headers[item.name]); + + // Only create layout if section is active and has content + if (section?.isActive && (hasFields || hasHeaders)) { + const sectionControls = sectionFields.map(({ name, property, uiField }) => + createControl(name, property, uiField) + ); + + const sectionLayout = createSectionLayout(sectionId, section, sectionControls, schema.ui.headers); + sectionLayouts.push(sectionLayout); + } + }); + + const uiSchema: JSONFormsUISchema = { + type: 'VerticalLayout', + elements: sectionLayouts + }; + + return uiSchema; +}; + diff --git a/src/v2/index.ts b/src/v2/index.ts new file mode 100644 index 0000000..bfbea9c --- /dev/null +++ b/src/v2/index.ts @@ -0,0 +1,12 @@ +// V2 API exports - new JSONForms v2 schema format +export { generateUISchema } from './generateUISchema'; + +// Re-export types for convenience +export type { + V2Schema, + V2Property, + V2UIField, + V2Section, + V2Header, + JSONFormsUISchema +} from '../common/types'; diff --git a/src/v2/mockData.ts b/src/v2/mockData.ts new file mode 100644 index 0000000..4ca477f --- /dev/null +++ b/src/v2/mockData.ts @@ -0,0 +1,337 @@ +// Mock data for V2 testing + +import { V2Schema } from '../common/types'; + +export const mockV2Schema = { + "json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "patrol_leader": { + "deprecated": false, + "description": "Name of the patrol leader", + "title": "Patrol Leader", + "type": "string", + "default": "" + }, + "patrol_size": { + "deprecated": false, + "description": "Number of people in the patrol", + "title": "Patrol Size", + "type": "number", + "minimum": 1, + "maximum": 20 + }, + "patrol_activity": { + "deprecated": false, + "description": "Type of patrol activity", + "title": "Patrol Activity", + "type": "string", + "anyOf": [ + { + "$ref": "http://localhost:3000/api/v2.0/schemas/choices.json?field=patrol_activity" + } + ] + }, + "patrol_notes": { + "deprecated": false, + "description": "Additional notes about the patrol", + "title": "Patrol Notes", + "type": "string", + "default": "" + }, + "patrol_date": { + "deprecated": false, + "description": "Date and time of patrol", + "title": "Patrol Date", + "type": "string", + "format": "date-time" + }, + "patrol_location": { + "deprecated": false, + "description": "GPS coordinates of patrol location", + "title": "Patrol Location", + "type": "object", + "properties": { + "latitude": { + "maximum": 90, + "minimum": -90, + "type": "number" + }, + "longitude": { + "maximum": 180, + "minimum": -180, + "type": "number" + } + } + }, + "equipment_used": { + "deprecated": false, + "title": "Equipment Used", + "type": "array", + "items": { + "additionalProperties": false, + "properties": { + "item_name": { + "deprecated": false, + "title": "Item Name", + "type": "string", + "default": "" + }, + "item_condition": { + "deprecated": false, + "title": "Item Condition", + "type": "string", + "anyOf": [ + { + "$ref": "http://localhost:3000/api/v2.0/schemas/choices.json?field=item_condition" + } + ] + } + }, + "required": ["item_name"], + "type": "object" + }, + "unevaluatedItems": false + }, + "deprecated_field": { + "deprecated": true, + "title": "Deprecated Field", + "type": "string" + } + }, + "required": ["patrol_leader", "patrol_size", "patrol_date"], + "type": "object" + }, + "ui": { + "fields": { + "patrol_leader": { + "inputType": "SHORT_TEXT", + "parent": "section-details", + "placeholder": "Enter patrol leader name", + "type": "TEXT" + }, + "patrol_size": { + "parent": "section-details", + "placeholder": "5", + "type": "NUMERIC" + }, + "patrol_activity": { + "choices": { + "eventTypeCategories": [], + "existingChoiceList": ["patrol_activity"], + "featureCategories": [], + "myDataType": "", + "subjectGroups": [], + "subjectSubtypes": [], + "type": "EXISTING_CHOICE_LIST" + }, + "inputType": "DROPDOWN", + "parent": "section-details", + "placeholder": "Select activity type", + "type": "CHOICE_LIST" + }, + "patrol_notes": { + "inputType": "LONG_TEXT", + "parent": "section-details", + "placeholder": "Enter additional notes", + "type": "TEXT" + }, + "patrol_date": { + "parent": "section-details", + "type": "DATE_TIME" + }, + "patrol_location": { + "parent": "section-location", + "type": "LOCATION" + }, + "equipment_used": { + "buttonText": "Add Equipment", + "columns": 1, + "itemIdentifier": "item_name", + "itemName": "Equipment Item", + "leftColumn": ["item_name", "item_condition"], + "rightColumn": [], + "parent": "section-location", + "type": "COLLECTION" + }, + "item_name": { + "inputType": "SHORT_TEXT", + "parent": "equipment_used", + "placeholder": "Equipment name", + "type": "TEXT" + }, + "item_condition": { + "choices": { + "eventTypeCategories": [], + "existingChoiceList": ["item_condition"], + "featureCategories": [], + "myDataType": "", + "subjectGroups": [], + "subjectSubtypes": [], + "type": "EXISTING_CHOICE_LIST" + }, + "inputType": "LIST", + "parent": "equipment_used", + "placeholder": "", + "type": "CHOICE_LIST" + } + }, + "headers": { + "header-info": { + "label": "Important Information", + "section": "section-details", + "size": "MEDIUM" + } + }, + "order": ["section-details", "section-location"], + "sections": { + "section-details": { + "columns": 1, + "isActive": true, + "label": "Patrol Details", + "leftColumn": [ + { + "name": "patrol_leader", + "type": "field" + }, + { + "name": "patrol_size", + "type": "field" + }, + { + "name": "patrol_activity", + "type": "field" + }, + { + "name": "header-info", + "type": "header" + }, + { + "name": "patrol_notes", + "type": "field" + }, + { + "name": "patrol_date", + "type": "field" + } + ], + "rightColumn": [] + }, + "section-location": { + "columns": 2, + "isActive": true, + "label": "Location & Equipment", + "leftColumn": [ + { + "name": "patrol_location", + "type": "field" + } + ], + "rightColumn": [ + { + "name": "equipment_used", + "type": "field" + } + ] + } + } + } +} as V2Schema; + +export const expectedUISchemaForMockV2: any = { + "type": "VerticalLayout", + "elements": [ + { + "type": "VerticalLayout", + "label": "Patrol Details", + "elements": [ + { + "type": "Control", + "scope": "#/properties/patrol_leader", + "label": "Patrol Leader", + "options": { + "placeholder": "Enter patrol leader name", + "description": "Name of the patrol leader" + } + }, + { + "type": "Control", + "scope": "#/properties/patrol_size", + "label": "Patrol Size", + "options": { + "format": "number", + "placeholder": "5", + "description": "Number of people in the patrol" + } + }, + { + "type": "Control", + "scope": "#/properties/patrol_activity", + "label": "Patrol Activity", + "options": { + "format": "dropdown", + "placeholder": "Select activity type", + "description": "Type of patrol activity" + } + }, + { + "type": "Control", + "scope": "#/properties/patrol_notes", + "label": "Patrol Notes", + "options": { + "multi": true, + "placeholder": "Enter additional notes", + "description": "Additional notes about the patrol" + } + }, + { + "type": "Control", + "scope": "#/properties/patrol_date", + "label": "Patrol Date", + "options": { + "format": "date-time", + "display": "date-time", + "description": "Date and time of patrol" + } + } + ] + }, + { + "type": "HorizontalLayout", + "label": "Location & Equipment", + "elements": [ + { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/patrol_location", + "label": "Patrol Location", + "options": { + "format": "location", + "display": "map", + "description": "GPS coordinates of patrol location" + } + } + ] + }, + { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/equipment_used", + "label": "Equipment Used", + "options": { + "format": "array", + "addButtonText": "Add Equipment", + "itemIdentifier": "item_name" + } + } + ] + } + ] + } + ] +}; diff --git a/src/v2/utils.ts b/src/v2/utils.ts new file mode 100644 index 0000000..b9fc14c --- /dev/null +++ b/src/v2/utils.ts @@ -0,0 +1,302 @@ +import { + V2Schema, + V2UIField, + V2Property, + V2BaseProperty, + V2Header, + JSONFormsControl, + JSONFormsLayout, + JSONFormsLabel, + JSONFormsUIElement, + JSONFormsUISchema +} from '../common/types'; + +/** + * Determines if a field should be rendered based on deprecation status + */ +export const isFieldVisible = (property: V2Property): boolean => { + return !property.deprecated; +}; + +/** + * Creates a JSONForms control element for a field + */ +export const createControl = ( + fieldName: string, + property: V2Property, + uiField: V2UIField +): JSONFormsControl => { + const control: JSONFormsControl = { + type: 'Control', + scope: `#/properties/${fieldName}`, + label: property.title, + options: {} + }; + + // Add format based on field type + switch (uiField.type) { + case 'TEXT': + if (uiField.inputType === 'LONG_TEXT') { + control.options!.multi = true; + } + if (uiField.placeholder) { + control.options!.placeholder = uiField.placeholder; + } + break; + + case 'NUMERIC': + control.options!.format = 'number'; + if (uiField.placeholder) { + control.options!.placeholder = uiField.placeholder; + } + break; + + case 'DATE_TIME': + // Always set format and display for DATE_TIME fields + control.options!.format = property.format || 'date-time'; + control.options!.display = property.format || 'date-time'; + break; + + case 'CHOICE_LIST': + if (uiField.inputType === 'DROPDOWN') { + control.options!.format = 'dropdown'; + if (uiField.placeholder) { + control.options!.placeholder = uiField.placeholder; + } + } else if (uiField.inputType === 'LIST') { + control.options!.format = 'radio'; + } + + // Handle multiple choice (array type) + if (property.type === 'array') { + control.options!.multi = true; + } + break; + + case 'LOCATION': + control.options!.format = 'location'; + control.options!.display = 'map'; + break; + + case 'COLLECTION': + control.options!.format = 'array'; + control.options!.addButtonText = uiField.buttonText || 'Add Item'; + if (uiField.itemIdentifier) { + control.options!.itemIdentifier = uiField.itemIdentifier; + } + + // Add collection constraints + if (property.maxItems !== undefined) { + control.options!.maxItems = property.maxItems; + } + if (property.minItems !== undefined) { + control.options!.minItems = property.minItems; + } + + if (property.type === 'array' && property.items?.properties) { + control.options!.detail = generateCollectionUISchemaInternal(property, uiField); + } + break; + + case 'ATTACHMENT': + control.options!.format = 'file'; + if (uiField.allowableFileTypes) { + control.options!.accept = uiField.allowableFileTypes.join(','); + } + break; + } + + // Add description if available + if (property.description) { + control.options!.description = property.description; + } + + return control; +}; + +/** + * Creates a JSONForms label element for a header + */ +export const createHeaderLabel = ( + headerId: string, + header: V2Header +): JSONFormsLabel => { + return { + type: 'Label', + text: header.label, + options: { + size: header.size + } + }; +}; + +/** + * Creates a single-column vertical layout for a section + */ +export const createSectionLayout = ( + sectionId: string, + section: V2Schema['ui']['sections'][string], + fieldControls: JSONFormsControl[], + headers: Record +): JSONFormsLayout => { + const layout: JSONFormsLayout = { + type: 'VerticalLayout', // Always vertical for React Native + label: section.label, + elements: [] + }; + + // Order: leftColumn items first, then rightColumn items + const orderedElements: JSONFormsUIElement[] = []; + + // Add left column elements first + section.leftColumn.forEach(item => { + if (item.type === 'field') { + const element = fieldControls.find(el => el.scope === `#/properties/${item.name}`); + if (element) { + orderedElements.push(element); + } + } else if (item.type === 'header') { + const header = headers[item.name]; + if (header) { + const headerLabel = createHeaderLabel(item.name, header); + orderedElements.push(headerLabel); + } + } + }); + + // Add right column elements after left column + section.rightColumn.forEach(item => { + if (item.type === 'field') { + const element = fieldControls.find(el => el.scope === `#/properties/${item.name}`); + if (element) { + orderedElements.push(element); + } + } else if (item.type === 'header') { + const header = headers[item.name]; + if (header) { + const headerLabel = createHeaderLabel(item.name, header); + orderedElements.push(headerLabel); + } + } + }); + + layout.elements = orderedElements; + return layout; +}; + +/** + * Gets all fields that should be rendered (non-deprecated, visible) + */ +export const getVisibleFields = (schema: V2Schema): Array<{ name: string; property: V2Property; uiField: V2UIField }> => { + const visibleFields: Array<{ name: string; property: V2Property; uiField: V2UIField }> = []; + + Object.entries(schema.json.properties).forEach(([fieldName, property]) => { + const uiField = schema.ui.fields[fieldName]; + + if (isFieldVisible(property) && uiField) { + visibleFields.push({ name: fieldName, property, uiField }); + } + }); + + return visibleFields; +}; + +/** + * Groups fields by their parent section + */ +export const groupFieldsBySection = ( + fields: Array<{ name: string; property: V2Property; uiField: V2UIField }>, + sections: V2Schema['ui']['sections'] +): Record> => { + const grouped: Record> = {}; + + fields.forEach(field => { + const sectionId = field.uiField.parent; + + // Only include fields that belong to sections (not collections) + if (sections[sectionId]) { + if (!grouped[sectionId]) { + grouped[sectionId] = []; + } + grouped[sectionId].push(field); + } + }); + + return grouped; +}; + +/** + * Generate UI schema for collection items + */ +const generateCollectionUISchemaInternal = ( + collectionProperty: V2Property, + uiField?: V2UIField +): JSONFormsUISchema => { + if (collectionProperty.type !== 'array' || !collectionProperty.items?.properties) { + throw new Error('Collection property must be an array with item properties'); + } + + const itemProperties = collectionProperty.items.properties; + const itemControls: JSONFormsControl[] = []; + + // Helper function to create control for a field + const createItemControl = (fieldName: string, property: V2BaseProperty): JSONFormsControl => { + const control: JSONFormsControl = { + type: 'Control', + scope: `#/properties/${fieldName}`, + label: (property as any).title || fieldName, + options: {} + }; + + // Set basic options based on property type + switch (property.type) { + case 'string': + if (property.format === 'uri') { + control.options!.format = 'file'; + control.options!.accept = 'image/*'; + } + break; + case 'number': + control.options!.format = 'number'; + break; + } + + if (property.description) { + control.options!.description = property.description; + } + + return control; + }; + + // If leftColumn/rightColumn are specified, use that order + if (uiField && (uiField.leftColumn || uiField.rightColumn)) { + // Add left column fields first + if (uiField.leftColumn) { + uiField.leftColumn.forEach(fieldName => { + if (itemProperties[fieldName]) { + itemControls.push(createItemControl(fieldName, itemProperties[fieldName])); + } + }); + } + + // Add right column fields after (React Native single-column) + if (uiField.rightColumn) { + uiField.rightColumn.forEach(fieldName => { + if (itemProperties[fieldName]) { + itemControls.push(createItemControl(fieldName, itemProperties[fieldName])); + } + }); + } + } else { + Object.entries(itemProperties).forEach(([fieldName, property]) => { + itemControls.push(createItemControl(fieldName, property)); + }); + } + + const uiSchema: JSONFormsUISchema = { + type: 'VerticalLayout', + elements: itemControls + }; + + return uiSchema; +}; diff --git a/test/schemaUtils.test.ts b/test/schemaUtils.test.ts new file mode 100644 index 0000000..5516f4f --- /dev/null +++ b/test/schemaUtils.test.ts @@ -0,0 +1,100 @@ +import { detectSchemaVersion } from '../src/common/schemaUtils'; + +describe('Schema Version Detection', () => { + it('should detect v1 schema format', () => { + const v1Schema = JSON.stringify({ + schema: { + "$schema": "http://json-schema.org/draft-04/schema#", + title: "Test Schema", + type: "object", + properties: {} + }, + definition: [] + }); + + const result = detectSchemaVersion(v1Schema); + expect(result.version).toBe('v1'); + expect(result.data.schema).toBeDefined(); + expect(result.data.definition).toBeDefined(); + }); + + it('should detect v1 schema format with data wrapper', () => { + const wrappedV1Schema = JSON.stringify({ + data: { + schema: { + "$schema": "http://json-schema.org/draft-04/schema#", + title: "Test Schema", + type: "object", + properties: {} + }, + definition: [] + }, + status: { code: 200, message: "OK" } + }); + + const result = detectSchemaVersion(wrappedV1Schema); + expect(result.version).toBe('v1'); + expect(result.data.schema).toBeDefined(); + expect(result.data.definition).toBeDefined(); + }); + + it('should detect v2 schema format', () => { + const v2Schema = JSON.stringify({ + json: { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {} + }, + ui: { + fields: {}, + sections: {}, + order: [] + } + }); + + const result = detectSchemaVersion(v2Schema); + expect(result.version).toBe('v2'); + expect(result.data.json).toBeDefined(); + expect(result.data.ui).toBeDefined(); + }); + + it('should detect v2 schema format with data wrapper', () => { + const wrappedV2Schema = JSON.stringify({ + data: { + json: { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {} + }, + ui: { + fields: {}, + sections: {}, + order: [] + } + }, + status: { code: 200, message: "OK" } + }); + + const result = detectSchemaVersion(wrappedV2Schema); + expect(result.version).toBe('v2'); + expect(result.data.json).toBeDefined(); + expect(result.data.ui).toBeDefined(); + }); + + it('should default to v1 for ambiguous schemas', () => { + const ambiguousSchema = JSON.stringify({ + someProperty: "value" + }); + + const result = detectSchemaVersion(ambiguousSchema); + expect(result.version).toBe('v1'); + }); + + it('should throw error for invalid JSON', () => { + const invalidJson = "{ invalid json }"; + + expect(() => { + detectSchemaVersion(invalidJson); + }).toThrow('Invalid JSON schema'); + }); +}); \ No newline at end of file diff --git a/test/v2.test.ts b/test/v2.test.ts new file mode 100644 index 0000000..5f81794 --- /dev/null +++ b/test/v2.test.ts @@ -0,0 +1,318 @@ +import { generateUISchema } from '../src/v2/generateUISchema'; +import { mockV2Schema } from '../src/v2/mockData'; +import { V2Schema } from '../src/common/types'; + +describe('V2 generateUISchema', () => { + it('should generate UI schema for basic V2 schema', () => { + const result = generateUISchema(mockV2Schema); + + expect(result.type).toBe('VerticalLayout'); + expect(result.elements).toHaveLength(2); + expect(result.elements![0]).toMatchObject({ + type: 'VerticalLayout', + label: 'Patrol Details' + }); + expect(result.elements![1]).toMatchObject({ + type: 'VerticalLayout', + label: 'Location & Equipment' + }); + }); + + it('should create controls with proper scopes and labels', () => { + const result = generateUISchema(mockV2Schema); + + const firstSection = result.elements![0]; + const controls = firstSection.elements!; + + expect(controls[0]).toMatchObject({ + type: 'Control', + scope: '#/properties/patrol_leader', + label: 'Patrol Leader' + }); + + expect(controls[1]).toMatchObject({ + type: 'Control', + scope: '#/properties/patrol_size', + label: 'Patrol Size' + }); + }); + + it('should handle text field options correctly', () => { + const result = generateUISchema(mockV2Schema); + const firstSection = result.elements![0]; + const controls = firstSection.elements!; + + // Short text field + const leaderControl = controls[0]; + expect(leaderControl.options).toMatchObject({ + placeholder: 'Enter patrol leader name', + description: 'Name of the patrol leader' + }); + + // Long text field (multi-line) - now at position 4 due to header at position 3 + const notesControl = controls[4]; + expect(notesControl.options).toMatchObject({ + multi: true, + placeholder: 'Enter additional notes' + }); + }); + + it('should handle numeric field options correctly', () => { + const result = generateUISchema(mockV2Schema); + const firstSection = result.elements![0]; + const sizeControl = firstSection.elements![1]; + + expect(sizeControl.options).toMatchObject({ + format: 'number', + placeholder: '5', + description: 'Number of people in the patrol' + }); + }); + + it('should handle choice list field options correctly', () => { + const result = generateUISchema(mockV2Schema); + const firstSection = result.elements![0]; + const activityControl = firstSection.elements![2]; + + expect(activityControl.options).toMatchObject({ + format: 'dropdown', + placeholder: 'Select activity type', + description: 'Type of patrol activity' + }); + }); + + it('should handle date-time field options correctly', () => { + const result = generateUISchema(mockV2Schema); + const firstSection = result.elements![0]; + const dateControl = firstSection.elements![5]; // Date field is now at position 5 + + expect(dateControl.options).toMatchObject({ + format: 'date-time', + display: 'date-time', + description: 'Date and time of patrol' + }); + }); + + it('should handle location field options correctly', () => { + const result = generateUISchema(mockV2Schema); + const secondSection = result.elements![1]; + const locationControl = secondSection.elements![0]; + + expect(locationControl.options).toMatchObject({ + format: 'location', + display: 'map', + description: 'GPS coordinates of patrol location' + }); + }); + + it('should handle collection field options correctly', () => { + const result = generateUISchema(mockV2Schema); + const secondSection = result.elements![1]; + const collectionControl = secondSection.elements![1]; + + expect(collectionControl.options).toMatchObject({ + format: 'array', + addButtonText: 'Add Equipment', + itemIdentifier: 'item_name' + }); + + expect(collectionControl.options!.detail).toBeDefined(); + expect(collectionControl.options!.detail).toMatchObject({ + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/item_name', + label: 'Item Name' + }, + { + type: 'Control', + scope: '#/properties/item_condition', + label: 'Item Condition' + } + ] + }); + }); + + it('should exclude deprecated fields', () => { + const result = generateUISchema(mockV2Schema); + const allControls = getAllControls(result); + + // Should not include deprecated_field + const deprecatedControl = allControls.find(control => + control.scope === '#/properties/deprecated_field' + ); + expect(deprecatedControl).toBeUndefined(); + }); + + it('should handle inactive sections', () => { + const schemaWithInactiveSection: V2Schema = { + ...mockV2Schema, + ui: { + ...mockV2Schema.ui, + sections: { + ...mockV2Schema.ui.sections, + 'section-details': { + ...mockV2Schema.ui.sections['section-details'], + isActive: false + } + } + } + }; + + const result = generateUISchema(schemaWithInactiveSection); + + // Should only have one section (the active one) + expect(result.elements).toHaveLength(1); + expect(result.elements![0]).toMatchObject({ + label: 'Location & Equipment' + }); + }); + + it('should respect section order', () => { + const result = generateUISchema(mockV2Schema); + + expect(result.elements![0]).toMatchObject({ + label: 'Patrol Details' + }); + expect(result.elements![1]).toMatchObject({ + label: 'Location & Equipment' + }); + }); + + it('should handle collection constraints and column layout', () => { + const schemaWithConstraints: V2Schema = { + ...mockV2Schema, + json: { + ...mockV2Schema.json, + properties: { + ...mockV2Schema.json.properties, + test_collection: { + deprecated: false, + title: 'Test Collection', + type: 'array', + maxItems: 5, + minItems: 1, + items: { + additionalProperties: false, + type: 'object', + required: ['field1'], + properties: { + field1: { type: 'string' }, + field2: { type: 'string' }, + field3: { type: 'number' } + } + }, + unevaluatedItems: false + } + } + }, + ui: { + ...mockV2Schema.ui, + fields: { + ...mockV2Schema.ui.fields, + test_collection: { + type: 'COLLECTION', + parent: 'section-details', + buttonText: 'Add Test Item', + columns: 2, + itemIdentifier: 'field1', + leftColumn: ['field1', 'field2'], + rightColumn: ['field3'] + } + }, + sections: { + ...mockV2Schema.ui.sections, + 'section-details': { + ...mockV2Schema.ui.sections['section-details'], + leftColumn: [ + ...mockV2Schema.ui.sections['section-details'].leftColumn, + { + name: 'test_collection', + type: 'field' + } + ] + } + } + } + }; + + const result = generateUISchema(schemaWithConstraints); + const firstSection = result.elements![0]; + // Find the collection control (should be last in the section) + const collectionControl = firstSection.elements!.find((el: any) => + el.scope === '#/properties/test_collection' + ); + + expect(collectionControl).toBeDefined(); + expect(collectionControl.options).toMatchObject({ + format: 'array', + addButtonText: 'Add Test Item', + itemIdentifier: 'field1', + maxItems: 5, + minItems: 1 + }); + + // Check that the detail UI schema respects leftColumn/rightColumn order + expect(collectionControl.options.detail).toMatchObject({ + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/field1', label: 'field1' }, + { type: 'Control', scope: '#/properties/field2', label: 'field2' }, + { type: 'Control', scope: '#/properties/field3', label: 'field3' } + ] + }); + }); + + it('should handle single column sections correctly', () => { + const result = generateUISchema(mockV2Schema); + const firstSection = result.elements![0]; + + expect(firstSection.type).toBe('VerticalLayout'); + expect(firstSection.elements).toHaveLength(6); // 5 fields + 1 header in left column + }); + + it('should handle two column sections correctly (React Native single-column)', () => { + const result = generateUISchema(mockV2Schema); + const secondSection = result.elements![1]; + + // React Native: Two-column sections are converted to single-column VerticalLayout + expect(secondSection.type).toBe('VerticalLayout'); + expect(secondSection.label).toBe('Location & Equipment'); + + // Fields are combined into single column: leftColumn first, then rightColumn + expect(secondSection.elements).toHaveLength(2); // patrol_location + equipment_used + + expect(secondSection.elements![0]).toMatchObject({ + type: 'Control', + scope: '#/properties/patrol_location', + label: 'Patrol Location' + }); + + expect(secondSection.elements![1]).toMatchObject({ + type: 'Control', + scope: '#/properties/equipment_used', + label: 'Equipment Used' + }); + }); +}); + + +// Helper function to extract all controls from nested structure +function getAllControls(uiSchema: any): any[] { + const controls: any[] = []; + + function traverse(element: any) { + if (element.type === 'Control') { + controls.push(element); + } else if (element.elements) { + element.elements.forEach(traverse); + } + } + + if (uiSchema.elements) { + uiSchema.elements.forEach(traverse); + } + + return controls; +} diff --git a/tsconfig.json b/tsconfig.json index c936192..a67f8ab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "declarationMap": true, "sourceMap": true, "outDir": "./dist", - "rootDir": ".", + "rootDir": "./src", "isolatedModules": true, "allowJs": true, "moduleResolution": "node", @@ -32,8 +32,7 @@ } }, "include": [ - "src/**/*", - "test/**/*" + "src/**/*" ], "exclude": [ "node_modules", diff --git a/webpack.config.tsx b/webpack.config.tsx index 29480cd..5ab5174 100644 --- a/webpack.config.tsx +++ b/webpack.config.tsx @@ -10,7 +10,9 @@ const config = { filename: "bundle.js", path: path.resolve(__dirname, "dist"), libraryTarget: "commonjs2", - clean: true, + clean: { + keep: /\.(js|d\.ts|js\.map|d\.ts\.map)$/, + }, }, module: { rules: [