Skip to content

Commit 174af59

Browse files
committed
feat: "form" becomes "engine", with pure form controller, no browser required
1 parent 3b3bff9 commit 174af59

38 files changed

+3724
-1550
lines changed
File renamed without changes.

packages/form/README.md renamed to packages/engine/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
# JSON Schema Form Element — ***Barebone*** edition
1+
# JSON Schema Form Engine
22

33
```sh
4-
npm install @jsfe/form
4+
npm install @jsfe/engine
55
```
66

77
- Consult the [documentation](../../README.md).
88
- Open the [playground](https://jsfe.js.org).
99
- Try the [examples](https://github.com/json-schema-form-element/examples#readme).
1010

11-
---
11+
<!-- ---
1212
1313
# `packages/form/src/index.ts`:
1414
@@ -84,4 +84,4 @@ npm install @jsfe/form
8484
| Kind | Name | Declaration | Module | Package |
8585
| ---- | ----- | ----------- | ------------------------------------- | ------- |
8686
| `js` | `Jsf` | Jsf | packages/form/src/json-schema-form.ts | |
87-
87+
-->

packages/form/package.json renamed to packages/engine/package.json

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "@jsfe/form",
2+
"name": "@jsfe/engine",
33
"version": "0.4.0",
44
"description": "Effortless forms, with standards. Base form element for all implementations.",
55
"keywords": [
@@ -31,15 +31,21 @@
3131
"./scss/*": "./src/widgets/*.scss",
3232
"./css": "./dist/esm/styles.css",
3333
"./jss": "./dist/esm/styles.js",
34-
"./min": "./dist/esm-min"
34+
"./min": "./dist/esm-min",
35+
"./experimental": "./dist/esm/experimental.js",
36+
"./logger": {
37+
"development": "./dist/esm/utils/logger.dev.js",
38+
"default": "./dist/esm/utils/logger.prod.js"
39+
}
3540
},
3641
"files": [
3742
"./dist/esm",
3843
"./dist/esm-min",
3944
"./src/**/*.scss",
4045
"./vscode.html-custom-data.json",
4146
"./vscode.css-custom-data.json",
42-
"./custom-elements.json"
47+
"./custom-elements.json",
48+
"!/dist/**/*.test.*"
4349
],
4450
"scripts": {
4551
"// build": "pnpm clean ; pnpm ts:build ; pnpm css:build ; pnpm css:to-js",
@@ -53,22 +59,25 @@
5359
"clean": "rm -rf ./dist",
5460
"dev": "pnpm ts:dev",
5561
"ts:build": "pnpm tsc",
56-
"ts:dev": "pnpm tsc --watch"
62+
"ts:dev": "pnpm tsc --watch",
63+
"test": "pnpm tsc",
64+
"test:unit": "node --test --test-reporter=spec --experimental-test-coverage --enable-source-maps 'dist/**/*.test.js'",
65+
"test:unit:dev": "node --test --enable-source-maps --watch 'dist/**/*.test.js'",
66+
"lint": "eslint --cache \"src/**/*.{ts,tsx,js}\"",
67+
"lint:fix": "eslint --fix --cache \"src/**/*.{ts,tsx,js}\"",
68+
"format": "prettier --cache --write \"src/**/*.{ts,tsx,js,json,md,yml}\"",
69+
"format:check": "prettier --cache --check \"src/**/*.{ts,tsx,js,json,md,yml}\""
5770
},
5871
"dependencies": {
59-
"@jsfe/types": "workspace:*",
60-
"lodash-es": "^4.17.21"
72+
"@standard-schema/spec": "^1.0.0",
73+
"@types/json-schema": "^7.0.15",
74+
"signal-polyfill": "^0.2.2"
6175
},
6276
"devDependencies": {
63-
"@types/lodash-es": "^4.17.12",
64-
"sass": "^1.69.5",
65-
"typescript": "^5.3.2"
66-
},
67-
"peerDependencies": {
68-
"lit": "^3.1.0"
77+
"sinon": "19.0.2",
78+
"typescript": "^5.8.3"
6979
},
7080
"publishConfig": {
7181
"access": "public"
72-
},
73-
"customElements": "custom-elements.json"
82+
}
7483
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type {
2+
ArrayPrimitiveWidgetOptions,
3+
PrimitiveArray,
4+
WidgetTypeBaseParameters,
5+
} from './types/form.js';
6+
7+
import { makeIdFromPath } from './utils/object-paths.js';
8+
import { getPrimitiveArray } from './utils/utilities.js';
9+
10+
/**
11+
* Generates widget options for an array of primitives field based on the
12+
* provided schema, data, path, and UI state.
13+
*
14+
* @returns The options for rendering the array of primitives field widget.
15+
*/
16+
export function widgetArrayPrimitive({
17+
data,
18+
form,
19+
level = 0,
20+
path,
21+
required,
22+
schema,
23+
uiSchema,
24+
}: WidgetTypeBaseParameters): ArrayPrimitiveWidgetOptions {
25+
const value: PrimitiveArray =
26+
getPrimitiveArray(data) ??
27+
getPrimitiveArray(schema.default) ??
28+
([] as PrimitiveArray);
29+
30+
const pathAsString = path.join('.');
31+
32+
const helpText = schema.description ?? uiSchema['ui:help'] ?? '';
33+
34+
const items = schema.items;
35+
if (typeof items !== 'object' || Array.isArray(items)) {
36+
const error = 'Wrong object field';
37+
// TODO: Make an error type.
38+
return {
39+
classes: {},
40+
form,
41+
helpText: error,
42+
html: {
43+
disabled: false,
44+
45+
element: 'select',
46+
id: '',
47+
name: '',
48+
readonly: false,
49+
required: false,
50+
},
51+
label: error,
52+
level,
53+
path: [],
54+
pathAsString: pathAsString,
55+
schema,
56+
value,
57+
widget: 'Error',
58+
};
59+
}
60+
61+
const disabled = uiSchema['ui:disabled'] ?? false;
62+
const label = uiSchema['ui:title'] ?? schema.title;
63+
const enumm =
64+
'type' in items && items.enum
65+
? // FIXME: Real assertion of what's inside, remove the casting.
66+
(items.enum as number[] | string[])
67+
: undefined;
68+
69+
const options: ArrayPrimitiveWidgetOptions = {
70+
classes: {},
71+
enum: enumm,
72+
form,
73+
helpText,
74+
html: {
75+
disabled,
76+
element: 'select',
77+
id: makeIdFromPath(pathAsString),
78+
name: pathAsString,
79+
required,
80+
},
81+
label,
82+
level,
83+
path,
84+
pathAsString,
85+
schema,
86+
value,
87+
88+
widget: 'CheckboxGroup',
89+
};
90+
91+
if (uiSchema['ui:widget'] === 'SelectMultiple')
92+
options.widget = 'SelectMultiple';
93+
94+
return options;
95+
}

packages/engine/src/array.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import type { JSONSchema7 } from 'json-schema';
2+
3+
import { Logger } from '@jsfe/engine/logger';
4+
5+
import type {
6+
ArrayChildWidgetOptions,
7+
ArrayWidgetOptions,
8+
WidgetTypeBaseParameters,
9+
} from './types/form.js';
10+
11+
import { getChildUiSchema, makeIdFromPath } from './utils/object-paths.js';
12+
13+
const log = new Logger();
14+
15+
/**
16+
* Generates widget options for an array field based on the provided schema,
17+
* data, path, and UI state.
18+
*
19+
* @returns The options for rendering the array field widget.
20+
*/
21+
export function widgetArray({
22+
data = [],
23+
form,
24+
level = 0,
25+
path,
26+
required = false,
27+
schema,
28+
schemaPath,
29+
uiSchema,
30+
}: WidgetTypeBaseParameters): ArrayWidgetOptions {
31+
if (!Array.isArray(data)) throw new Error('Incorrect data');
32+
33+
const addItemClick = (_event: Event) => {
34+
log.trace({ _event });
35+
36+
if (!Array.isArray(data)) return;
37+
38+
if (typeof schema.items !== 'object' || !('type' in schema.items)) return;
39+
40+
if (schema.items.type === 'string') {
41+
data.push(schema.items.default ?? '');
42+
} else if (schema.items.properties) {
43+
data.push(schema.items.default ?? {});
44+
} else if (schema.items.type === 'array') {
45+
data.push(schema.items.default ?? []);
46+
}
47+
48+
const schemaPathAugmented = [...schemaPath];
49+
schemaPathAugmented.push('items');
50+
form.handleChange(
51+
[...path, data.length - 1],
52+
data.at(-1),
53+
schemaPathAugmented,
54+
);
55+
};
56+
57+
const pathAsString = path.join('.');
58+
const parentSelector = `[name="${pathAsString}"]`;
59+
60+
const children: ArrayChildWidgetOptions[] = [];
61+
for (const [index] of data.entries()) {
62+
if (
63+
typeof schema.items !== 'object' ||
64+
Array.isArray(schema.items) ||
65+
!Array.isArray(data)
66+
)
67+
continue;
68+
69+
const schemaPathAugmented = [...schemaPath];
70+
schemaPathAugmented.push('items');
71+
72+
const widget = form.traverse({
73+
data: data[index],
74+
form,
75+
level: level + 1,
76+
path: [...path, index],
77+
pathAsString: '_TODO_',
78+
required: required,
79+
schema: schema.items as JSONSchema7,
80+
schemaPath: schemaPathAugmented,
81+
uiSchema: getChildUiSchema(uiSchema, index),
82+
});
83+
84+
const move = (direction: number) => (_event: Event) => {
85+
if (!Array.isArray(data)) return;
86+
const hold = data[index] as unknown;
87+
88+
data[index] = data[index + direction] as unknown;
89+
90+
data[index + direction] = hold;
91+
form.handleChange([...path], data, schemaPathAugmented);
92+
};
93+
94+
const controls = {
95+
delete: {
96+
click: (_event: Event) => {
97+
const newValue = data.filter((_, index_) => index_ !== index);
98+
99+
form.handleChange([...path], newValue, schemaPathAugmented);
100+
},
101+
},
102+
103+
down: {
104+
click: move(1),
105+
disabled: data[index + 1] === undefined,
106+
},
107+
108+
handle: {
109+
dragstart: (event: DragEvent) => {
110+
log.trace(event);
111+
if (!event.dataTransfer) return;
112+
event.dataTransfer.setData('integer', String(index));
113+
},
114+
mousedown: (_event: MouseEvent) => {
115+
// FIXME:
116+
// event.target!.style.cursor = 'grab';
117+
},
118+
},
119+
120+
up: {
121+
click: move(-1),
122+
disabled: data[index - 1] === undefined,
123+
},
124+
wrapper: {
125+
dragenter: (event: DragEvent) => {
126+
const arrayElement = (event.target as HTMLElement).closest(
127+
parentSelector,
128+
);
129+
log.trace(arrayElement);
130+
event.stopPropagation();
131+
arrayElement?.setAttribute('data-dropzone', '');
132+
},
133+
dragleave: (event: DragEvent) => {
134+
const arrayElement = (event.target as HTMLElement).closest(
135+
parentSelector,
136+
);
137+
event.stopPropagation();
138+
arrayElement
139+
?.closest(parentSelector)
140+
?.removeAttribute('data-dropzone');
141+
},
142+
dragover: (event: DragEvent) => {
143+
event.preventDefault();
144+
event.stopPropagation();
145+
const dataTransfer = event.dataTransfer;
146+
if (dataTransfer) dataTransfer.dropEffect = 'move';
147+
},
148+
drop: (event: DragEvent) => {
149+
event.stopPropagation();
150+
const index_ = event.dataTransfer?.getData('integer');
151+
if (!index_) return;
152+
const originIndex = Number.parseInt(index_, 10);
153+
154+
if (!Array.isArray(data)) return;
155+
const hold = data[index] as unknown;
156+
157+
data[index] = data[originIndex] as unknown;
158+
159+
data[originIndex] = hold;
160+
form.handleChange([...path], data, schemaPathAugmented);
161+
162+
(event.target as HTMLElement)
163+
.closest(parentSelector)
164+
?.removeAttribute('data-dropzone');
165+
},
166+
},
167+
};
168+
169+
children.push({ ...widget, controls });
170+
}
171+
const arrayLabel = schema.title ?? uiSchema['ui:title'];
172+
173+
// eslint-disable-next-line sonarjs/no-nested-template-literals
174+
let itemLabel = `List item ${arrayLabel ? `(${arrayLabel})` : ''}`;
175+
176+
if (
177+
typeof schema.items === 'object' &&
178+
!Array.isArray(schema.items) &&
179+
'title' in schema.items &&
180+
schema.items.title
181+
) {
182+
itemLabel = schema.items.title;
183+
}
184+
185+
const options: ArrayWidgetOptions = {
186+
children,
187+
classes: {},
188+
controls: { add: { click: addItemClick } },
189+
form,
190+
html: {
191+
element: 'fieldset',
192+
id: makeIdFromPath(pathAsString),
193+
name: pathAsString,
194+
},
195+
itemLabel,
196+
label: arrayLabel,
197+
level,
198+
path,
199+
pathAsString,
200+
schema,
201+
value: data,
202+
widget: 'Array',
203+
};
204+
205+
return options;
206+
}

0 commit comments

Comments
 (0)