@xelqun/forms 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -2
  3. package/package.json +56 -5
  4. package/resources/css/xelqun.css +162 -0
  5. package/resources/js/components/XqFieldWrapper.vue +59 -0
  6. package/resources/js/components/XqForm.vue +128 -0
  7. package/resources/js/components/XqNode.vue +22 -0
  8. package/resources/js/components/XqUnknownNode.vue +15 -0
  9. package/resources/js/components/defaults.ts +44 -0
  10. package/resources/js/components/fields/XqCheckboxField.vue +48 -0
  11. package/resources/js/components/fields/XqDatePickerField.vue +96 -0
  12. package/resources/js/components/fields/XqFileUploadField.vue +247 -0
  13. package/resources/js/components/fields/XqRadioField.vue +45 -0
  14. package/resources/js/components/fields/XqRepeaterField.vue +166 -0
  15. package/resources/js/components/fields/XqSelectField.vue +151 -0
  16. package/resources/js/components/fields/XqTextInputField.vue +100 -0
  17. package/resources/js/components/fields/XqTextareaField.vue +28 -0
  18. package/resources/js/components/fields/XqToggleField.vue +47 -0
  19. package/resources/js/components/layouts/XqFieldset.vue +16 -0
  20. package/resources/js/components/layouts/XqGrid.vue +13 -0
  21. package/resources/js/components/layouts/XqSection.vue +62 -0
  22. package/resources/js/components/layouts/XqTabsLayout.vue +64 -0
  23. package/resources/js/composables/context.ts +22 -0
  24. package/resources/js/composables/layout.ts +55 -0
  25. package/resources/js/composables/registry.ts +25 -0
  26. package/resources/js/composables/useField.ts +41 -0
  27. package/resources/js/composables/useXqForm.ts +16 -0
  28. package/resources/js/core/conditions.ts +52 -0
  29. package/resources/js/core/expressions.ts +131 -0
  30. package/resources/js/core/http.ts +101 -0
  31. package/resources/js/core/log.ts +93 -0
  32. package/resources/js/core/patch.ts +65 -0
  33. package/resources/js/core/paths.ts +122 -0
  34. package/resources/js/core/queue.ts +190 -0
  35. package/resources/js/core/rows.ts +26 -0
  36. package/resources/js/core/store.ts +560 -0
  37. package/resources/js/core/values.ts +161 -0
  38. package/resources/js/index.ts +24 -0
  39. package/resources/js/lib/utils.ts +18 -0
  40. package/resources/js/plugin.ts +17 -0
  41. package/resources/js/schema/types.ts +99 -0
  42. package/resources/js/ui/XqBadge.vue +33 -0
  43. package/resources/js/ui/XqButton.vue +59 -0
  44. package/resources/js/ui/XqCalendar.vue +90 -0
  45. package/resources/js/ui/XqCard.vue +20 -0
  46. package/resources/js/ui/XqCheckbox.vue +28 -0
  47. package/resources/js/ui/XqCombobox.vue +184 -0
  48. package/resources/js/ui/XqInput.vue +16 -0
  49. package/resources/js/ui/XqLabel.vue +17 -0
  50. package/resources/js/ui/XqRadioGroup.vue +24 -0
  51. package/resources/js/ui/XqRadioGroupItem.vue +31 -0
  52. package/resources/js/ui/XqSelect.vue +79 -0
  53. package/resources/js/ui/XqSwitch.vue +27 -0
  54. package/resources/js/ui/XqTabs.vue +19 -0
  55. package/resources/js/ui/XqTabsContent.vue +17 -0
  56. package/resources/js/ui/XqTabsList.vue +21 -0
  57. package/resources/js/ui/XqTabsTrigger.vue +27 -0
  58. package/resources/js/ui/XqTextarea.vue +16 -0
  59. package/resources/js/ui/classes.ts +12 -0
  60. package/resources/js/ui/index.ts +17 -0
@@ -0,0 +1,52 @@
1
+ import type { ConditionNode } from '../schema/types';
2
+ import { compare, equals, inList, isFilled } from './values';
3
+
4
+ export type Getter = (path: string) => unknown;
5
+
6
+ /**
7
+ * Evaluate a declarative condition tree in the browser.
8
+ * Mirrors Xelqun\Forms\Conditions\Condition::evaluateTree().
9
+ */
10
+ export function evaluateCondition(node: ConditionNode, get: Getter): boolean {
11
+ switch (node.op) {
12
+ case 'and':
13
+ return node.args.every((child) => evaluateCondition(child, get));
14
+ case 'or':
15
+ return node.args.some((child) => evaluateCondition(child, get));
16
+ case 'not':
17
+ return !evaluateCondition(node.args[0], get);
18
+ }
19
+
20
+ const actual = get(node.path);
21
+
22
+ switch (node.op) {
23
+ case 'filled':
24
+ return isFilled(actual);
25
+ case 'blank':
26
+ return !isFilled(actual);
27
+ }
28
+
29
+ const expected = node.value;
30
+ const list = Array.isArray(expected) ? expected : [expected];
31
+
32
+ switch (node.op) {
33
+ case 'eq':
34
+ return equals(actual, expected);
35
+ case 'neq':
36
+ return !equals(actual, expected);
37
+ case 'in':
38
+ return inList(actual, list);
39
+ case 'notIn':
40
+ return !inList(actual, list);
41
+ case 'gt':
42
+ return compare(actual, expected) === 1;
43
+ case 'gte':
44
+ return [0, 1].includes(compare(actual, expected) as number);
45
+ case 'lt':
46
+ return compare(actual, expected) === -1;
47
+ case 'lte':
48
+ return [-1, 0].includes(compare(actual, expected) as number);
49
+ }
50
+
51
+ return false;
52
+ }
@@ -0,0 +1,131 @@
1
+ import type { ExpressionNode } from '../schema/types';
2
+ import type { Getter } from './conditions';
3
+ import { compare, equals, isPlainObject, isTruthy, toNumber } from './values';
4
+
5
+ /**
6
+ * Evaluate an expression AST produced by the PHP parser. There is no parser
7
+ * in the browser, so nothing user-typed is ever executed.
8
+ * Mirrors Xelqun\Forms\Expressions\ExpressionEvaluator.
9
+ */
10
+ export function evaluateExpression(node: ExpressionNode, get: Getter): unknown {
11
+ if ('lit' in node) {
12
+ return node.lit;
13
+ }
14
+
15
+ if ('ref' in node) {
16
+ return get(node.ref);
17
+ }
18
+
19
+ if ('fn' in node) {
20
+ return call(node.fn, node.args, get);
21
+ }
22
+
23
+ const [first, second] = node.args;
24
+
25
+ switch (node.op) {
26
+ case '&&':
27
+ return (
28
+ isTruthy(evaluateExpression(first, get)) &&
29
+ isTruthy(evaluateExpression(second, get))
30
+ );
31
+ case '||':
32
+ return (
33
+ isTruthy(evaluateExpression(first, get)) ||
34
+ isTruthy(evaluateExpression(second, get))
35
+ );
36
+ case '!':
37
+ return !isTruthy(evaluateExpression(first, get));
38
+ case 'neg':
39
+ return -toNumber(evaluateExpression(first, get)) || 0;
40
+ }
41
+
42
+ const left = evaluateExpression(first, get);
43
+ const right = evaluateExpression(second, get);
44
+
45
+ switch (node.op) {
46
+ case '==':
47
+ return equals(left, right);
48
+ case '!=':
49
+ return !equals(left, right);
50
+ case '<':
51
+ return compare(left, right) === -1;
52
+ case '<=':
53
+ return [-1, 0].includes(compare(left, right) as number);
54
+ case '>':
55
+ return compare(left, right) === 1;
56
+ case '>=':
57
+ return [0, 1].includes(compare(left, right) as number);
58
+ }
59
+
60
+ return arithmetic(node.op, toNumber(left), toNumber(right));
61
+ }
62
+
63
+ function arithmetic(operator: string, left: number, right: number): number {
64
+ switch (operator) {
65
+ case '+':
66
+ return left + right;
67
+ case '-':
68
+ return left - right;
69
+ case '*':
70
+ return left * right;
71
+ case '/':
72
+ return right === 0 ? 0 : left / right;
73
+ case '%':
74
+ return right === 0 ? 0 : left % right;
75
+ }
76
+
77
+ throw new Error(`Unknown operator [${operator}].`);
78
+ }
79
+
80
+ function call(name: string, args: ExpressionNode[], get: Getter): unknown {
81
+ if (name === 'if') {
82
+ const condition = isTruthy(evaluateExpression(args[0], get));
83
+ const branch = args[condition ? 1 : 2];
84
+
85
+ return branch ? evaluateExpression(branch, get) : null;
86
+ }
87
+
88
+ const values = args.map((arg) => evaluateExpression(arg, get));
89
+
90
+ if (name === 'round') {
91
+ const precision = Math.trunc(toNumber(values[1] ?? 0));
92
+ const number = toNumber(values[0] ?? 0);
93
+ const factor = 10 ** precision;
94
+ const sign = number < 0 ? -1 : 1;
95
+
96
+ return (sign * Math.floor(Math.abs(number) * factor + 0.5)) / factor;
97
+ }
98
+
99
+ if (name === 'abs') {
100
+ return Math.abs(toNumber(values[0] ?? 0));
101
+ }
102
+
103
+ const items = values.flatMap((value) => {
104
+ if (Array.isArray(value)) {
105
+ return value;
106
+ }
107
+
108
+ return isPlainObject(value) ? Object.values(value) : [value];
109
+ });
110
+
111
+ if (name === 'count') {
112
+ return items.length;
113
+ }
114
+
115
+ const numbers = items.map(toNumber);
116
+
117
+ switch (name) {
118
+ case 'sum':
119
+ return numbers.reduce((total, number) => total + number, 0);
120
+ case 'avg':
121
+ return numbers.length === 0
122
+ ? 0
123
+ : numbers.reduce((total, number) => total + number, 0) / numbers.length;
124
+ case 'min':
125
+ return numbers.length === 0 ? 0 : Math.min(...numbers);
126
+ case 'max':
127
+ return numbers.length === 0 ? 0 : Math.max(...numbers);
128
+ }
129
+
130
+ throw new Error(`Unknown function [${name}].`);
131
+ }
@@ -0,0 +1,101 @@
1
+ import type { UploadResult } from '../schema/types';
2
+
3
+ export class HttpError extends Error {
4
+ constructor(
5
+ public readonly status: number,
6
+ public readonly body: any,
7
+ ) {
8
+ super(body?.message ?? `Request failed with status ${status}.`);
9
+ }
10
+
11
+ get validationErrors(): Record<string, string[]> {
12
+ return this.body?.errors ?? {};
13
+ }
14
+ }
15
+
16
+ function xsrfToken(): string | null {
17
+ if (typeof document === 'undefined') {
18
+ return null;
19
+ }
20
+
21
+ const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
22
+
23
+ return match ? decodeURIComponent(match[1]) : null;
24
+ }
25
+
26
+ function headers(json: boolean): Record<string, string> {
27
+ const result: Record<string, string> = {
28
+ Accept: 'application/json',
29
+ 'X-Requested-With': 'XMLHttpRequest',
30
+ };
31
+ const token = xsrfToken();
32
+
33
+ if (token) {
34
+ result['X-XSRF-TOKEN'] = token;
35
+ }
36
+
37
+ if (json) {
38
+ result['Content-Type'] = 'application/json';
39
+ }
40
+
41
+ return result;
42
+ }
43
+
44
+ export async function postJson<T>(url: string, body: unknown, signal?: AbortSignal): Promise<T> {
45
+ const response = await fetch(url, {
46
+ method: 'POST',
47
+ headers: headers(true),
48
+ body: JSON.stringify(body),
49
+ credentials: 'same-origin',
50
+ signal,
51
+ });
52
+ const payload = await response.json().catch(() => null);
53
+
54
+ if (!response.ok) {
55
+ throw new HttpError(response.status, payload);
56
+ }
57
+
58
+ return payload as T;
59
+ }
60
+
61
+ /**
62
+ * Upload with progress events (fetch has no upload progress).
63
+ */
64
+ export function uploadFile(
65
+ url: string,
66
+ fields: Record<string, string | Blob>,
67
+ onProgress: (percent: number) => void,
68
+ signal?: AbortSignal,
69
+ ): Promise<UploadResult> {
70
+ return new Promise((resolve, reject) => {
71
+ const request = new XMLHttpRequest();
72
+ const body = new FormData();
73
+
74
+ Object.entries(fields).forEach(([key, value]) => body.append(key, value));
75
+
76
+ request.open('POST', url);
77
+ request.withCredentials = true;
78
+ Object.entries(headers(false)).forEach(([key, value]) =>
79
+ request.setRequestHeader(key, value),
80
+ );
81
+ request.responseType = 'json';
82
+ request.upload.addEventListener('progress', (event) => {
83
+ if (event.lengthComputable) {
84
+ onProgress(Math.round((event.loaded / event.total) * 100));
85
+ }
86
+ });
87
+ request.addEventListener('load', () => {
88
+ if (request.status >= 200 && request.status < 300) {
89
+ resolve(request.response as UploadResult);
90
+ } else {
91
+ reject(new HttpError(request.status, request.response));
92
+ }
93
+ });
94
+ request.addEventListener('error', () =>
95
+ reject(new HttpError(0, { message: 'Network error.' })),
96
+ );
97
+ request.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
98
+ signal?.addEventListener('abort', () => request.abort());
99
+ request.send(body);
100
+ });
101
+ }
@@ -0,0 +1,93 @@
1
+ import { reactive } from 'vue';
2
+ import type { UpdatePatch } from '../schema/types';
3
+
4
+ export type RequestKind = 'update' | 'search' | 'upload';
5
+
6
+ export interface RequestLogEntry {
7
+ id: number;
8
+ kind: RequestKind;
9
+ /** Sequence number of a live update; null for searches and uploads. */
10
+ seq: number | null;
11
+ changed: string[];
12
+ /** Short extra context, e.g. the search term or the uploaded file name. */
13
+ detail: string | null;
14
+ status: 'pending' | 'ok' | 'stale' | 'aborted' | 'error';
15
+ startedAt: number;
16
+ ms: number | null;
17
+ patch: UpdatePatch | null;
18
+ response: unknown;
19
+ error: string | null;
20
+ }
21
+
22
+ /**
23
+ * A short, newest-first history of every request a form makes to the
24
+ * server: live updates, searches and uploads. Useful for debugging tools.
25
+ */
26
+ export class RequestLog {
27
+ readonly entries = reactive<RequestLogEntry[]>([]);
28
+
29
+ private nextId = 0;
30
+
31
+ constructor(private readonly limit = 25) {}
32
+
33
+ start(
34
+ kind: RequestKind,
35
+ changed: string[],
36
+ detail: string | null = null,
37
+ seq: number | null = null,
38
+ ): RequestLogEntry {
39
+ this.entries.unshift({
40
+ id: ++this.nextId,
41
+ kind,
42
+ seq,
43
+ changed,
44
+ detail,
45
+ status: 'pending',
46
+ startedAt: Date.now(),
47
+ ms: null,
48
+ patch: null,
49
+ response: null,
50
+ error: null,
51
+ });
52
+ this.entries.splice(this.limit);
53
+
54
+ return this.entries[0];
55
+ }
56
+
57
+ /**
58
+ * Log a request from start to finish and pass its result through.
59
+ */
60
+ async track<T>(
61
+ kind: RequestKind,
62
+ changed: string[],
63
+ detail: string | null,
64
+ request: Promise<T>,
65
+ signal?: AbortSignal,
66
+ ): Promise<T> {
67
+ const entry = this.start(kind, changed, detail);
68
+
69
+ try {
70
+ const response = await request;
71
+
72
+ entry.ms = Date.now() - entry.startedAt;
73
+ entry.response = response;
74
+ entry.status = 'ok';
75
+
76
+ return response;
77
+ } catch (error) {
78
+ entry.ms = Date.now() - entry.startedAt;
79
+ entry.status = signal?.aborted || isAbortError(error) ? 'aborted' : 'error';
80
+ entry.error = entry.status === 'error' ? errorMessage(error) : null;
81
+
82
+ throw error;
83
+ }
84
+ }
85
+ }
86
+
87
+ function isAbortError(error: unknown): boolean {
88
+ return error instanceof DOMException && error.name === 'AbortError';
89
+ }
90
+
91
+ function errorMessage(error: unknown): string {
92
+ return error instanceof Error ? error.message : String(error);
93
+ }
@@ -0,0 +1,65 @@
1
+ import type { SchemaNode } from '../schema/types';
2
+ import { isPlainObject } from './values';
3
+
4
+ function isNodeList(value: unknown): value is SchemaNode[] {
5
+ return (
6
+ Array.isArray(value) &&
7
+ value.length > 0 &&
8
+ value.every((item) => isPlainObject(item) && typeof item.key === 'string')
9
+ );
10
+ }
11
+
12
+ /**
13
+ * Merge a partial node into an existing reactive node in place.
14
+ *
15
+ * Objects are merged key by key and node lists are merged by node key, so
16
+ * existing node objects (and the components rendering them) are reused:
17
+ * nothing re-mounts and focus and cursor position are kept.
18
+ */
19
+ export function mergeNode(target: Record<string, any>, patch: Record<string, any>): void {
20
+ for (const [key, value] of Object.entries(patch)) {
21
+ const current = target[key];
22
+
23
+ if (isNodeList(value) && Array.isArray(current)) {
24
+ target[key] = mergeNodeList(current, value);
25
+ } else if (isPlainObject(value) && isPlainObject(current) && !Array.isArray(value)) {
26
+ mergeNode(current, value);
27
+ } else {
28
+ target[key] = value;
29
+ }
30
+ }
31
+ }
32
+
33
+ function mergeNodeList(current: SchemaNode[], incoming: SchemaNode[]): SchemaNode[] {
34
+ const existing = new Map(current.map((node) => [node.key, node]));
35
+
36
+ return incoming.map((node) => {
37
+ const match = existing.get(node.key);
38
+
39
+ if (!match) {
40
+ return node;
41
+ }
42
+
43
+ mergeNode(match, node);
44
+
45
+ return match;
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Visit a node and every node below it, including repeater rows (but not templates).
51
+ */
52
+ export function eachNode(nodes: SchemaNode[], callback: (node: SchemaNode) => void): void {
53
+ for (const node of nodes) {
54
+ callback(node);
55
+ eachNode(node.children ?? [], callback);
56
+
57
+ if (node.type === 'repeater' && isPlainObject(node.props?.rows)) {
58
+ for (const rowNodes of Object.values(
59
+ node.props!.rows as Record<string, SchemaNode[]>,
60
+ )) {
61
+ eachNode(rowNodes, callback);
62
+ }
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,122 @@
1
+ import { isPlainObject } from './values';
2
+
3
+ export const ROOT = 'data';
4
+ export const ROW_PLACEHOLDER = '{row}';
5
+
6
+ /**
7
+ * Read a value by dot path. Paths containing "*" return a flat list,
8
+ * matching Xelqun\Forms\Support\StatePath::get().
9
+ */
10
+ export function getPath(state: unknown, path: string): any {
11
+ const segments = path.split('.');
12
+
13
+ if (!segments.includes('*')) {
14
+ let current: any = state;
15
+
16
+ for (const segment of segments) {
17
+ if (current === null || typeof current !== 'object') {
18
+ return undefined;
19
+ }
20
+
21
+ current = current[segment];
22
+ }
23
+
24
+ return current;
25
+ }
26
+
27
+ return collect(state, segments);
28
+ }
29
+
30
+ function collect(value: unknown, segments: string[]): unknown[] {
31
+ if (segments.length === 0) {
32
+ return [value];
33
+ }
34
+
35
+ const [segment, ...rest] = segments;
36
+
37
+ if (value === null || typeof value !== 'object') {
38
+ return [];
39
+ }
40
+
41
+ if (segment === '*') {
42
+ return Object.values(value as object).flatMap((item) => collect(item, rest));
43
+ }
44
+
45
+ if (!(segment in (value as object))) {
46
+ return [];
47
+ }
48
+
49
+ return collect((value as Record<string, unknown>)[segment], rest);
50
+ }
51
+
52
+ /**
53
+ * Write a value by dot path, creating objects along the way.
54
+ */
55
+ export function setPath(state: Record<string, any>, path: string, value: unknown): void {
56
+ const segments = path.split('.');
57
+ const last = segments.pop()!;
58
+ let current: any = state;
59
+
60
+ for (const segment of segments) {
61
+ if (current[segment] === null || typeof current[segment] !== 'object') {
62
+ current[segment] = {};
63
+ }
64
+
65
+ current = current[segment];
66
+ }
67
+
68
+ current[last] = value;
69
+ }
70
+
71
+ export function deletePath(state: Record<string, any>, path: string): void {
72
+ const segments = path.split('.');
73
+ const last = segments.pop()!;
74
+ const parent = getPath(state, segments.join('.'));
75
+
76
+ if (parent !== null && typeof parent === 'object') {
77
+ delete parent[last];
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Whether two paths touch the same state ("*" and "{row}" match any segment).
83
+ */
84
+ export function overlaps(first: string, second: string): boolean {
85
+ const a = first.split('.');
86
+ const b = second.split('.');
87
+ const length = Math.min(a.length, b.length);
88
+
89
+ for (let index = 0; index < length; index++) {
90
+ const left = a[index];
91
+ const right = b[index];
92
+
93
+ if (
94
+ left !== right &&
95
+ left !== '*' &&
96
+ right !== '*' &&
97
+ left !== ROW_PLACEHOLDER &&
98
+ right !== ROW_PLACEHOLDER
99
+ ) {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ return true;
105
+ }
106
+
107
+ export function startsWithPath(path: string, prefix: string): boolean {
108
+ return path === prefix || path.startsWith(`${prefix}.`);
109
+ }
110
+
111
+ /**
112
+ * Deep-copy a value and replace every "{row}" placeholder in its strings.
113
+ */
114
+ export function replaceRow<T>(value: T, rowKey: string): T {
115
+ return JSON.parse(JSON.stringify(value).split(ROW_PLACEHOLDER).join(rowKey)) as T;
116
+ }
117
+
118
+ export function clone<T>(value: T): T {
119
+ return value === undefined ? value : JSON.parse(JSON.stringify(value));
120
+ }
121
+
122
+ export { isPlainObject };