@qoretechnologies/reqraft 0.10.2 → 0.10.5
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.
- package/.claude/CLAUDE.md +5 -0
- package/design/COMPACT_ENGINE_REDESIGN.md +156 -0
- package/design/FORM_ENGINE_COMPACT_UX_PLAN.md +353 -0
- package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
- package/dist/components/form/engine/CompactRow.js +158 -101
- package/dist/components/form/engine/CompactRow.js.map +1 -1
- package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
- package/dist/components/form/engine/CompactToolbar.js +122 -105
- package/dist/components/form/engine/CompactToolbar.js.map +1 -1
- package/dist/components/form/engine/FormEngine.d.ts +9 -1
- package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +272 -82
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/compactRowStyles.d.ts +6 -3
- package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
- package/dist/components/form/engine/compactRowStyles.js +76 -49
- package/dist/components/form/engine/compactRowStyles.js.map +1 -1
- package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
- package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
- package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
- package/dist/components/form/engine/readFirst.d.ts +19 -0
- package/dist/components/form/engine/readFirst.d.ts.map +1 -1
- package/dist/components/form/engine/readFirst.js +22 -1
- package/dist/components/form/engine/readFirst.js.map +1 -1
- package/dist/components/form/engine/variants/VariantCalmTable.d.ts +6 -0
- package/dist/components/form/engine/variants/VariantCalmTable.d.ts.map +1 -0
- package/dist/components/form/engine/variants/VariantCalmTable.js +94 -0
- package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -0
- package/dist/components/form/engine/variants/VariantCards.d.ts +6 -0
- package/dist/components/form/engine/variants/VariantCards.d.ts.map +1 -0
- package/dist/components/form/engine/variants/VariantCards.js +80 -0
- package/dist/components/form/engine/variants/VariantCards.js.map +1 -0
- package/dist/components/form/engine/variants/VariantFocus.d.ts +7 -0
- package/dist/components/form/engine/variants/VariantFocus.d.ts.map +1 -0
- package/dist/components/form/engine/variants/VariantFocus.js +138 -0
- package/dist/components/form/engine/variants/VariantFocus.js.map +1 -0
- package/dist/components/form/engine/variants/VariantMinimal.d.ts +6 -0
- package/dist/components/form/engine/variants/VariantMinimal.d.ts.map +1 -0
- package/dist/components/form/engine/variants/VariantMinimal.js +73 -0
- package/dist/components/form/engine/variants/VariantMinimal.js.map +1 -0
- package/dist/components/form/engine/variants/focusDemo.d.ts +13 -0
- package/dist/components/form/engine/variants/focusDemo.d.ts.map +1 -0
- package/dist/components/form/engine/variants/focusDemo.js +139 -0
- package/dist/components/form/engine/variants/focusDemo.js.map +1 -0
- package/dist/components/form/engine/variants/variantModel.d.ts +70 -0
- package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -0
- package/dist/components/form/engine/variants/variantModel.js +133 -0
- package/dist/components/form/engine/variants/variantModel.js.map +1 -0
- package/dist/components/form/engine/variants/variantParts.d.ts +79 -0
- package/dist/components/form/engine/variants/variantParts.d.ts.map +1 -0
- package/dist/components/form/engine/variants/variantParts.js +191 -0
- package/dist/components/form/engine/variants/variantParts.js.map +1 -0
- package/dist/components/form/fields/auto/AutoFormField.d.ts +3 -0
- package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
- package/dist/components/form/fields/auto/AutoFormField.js +5 -2
- package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
- package/package.json +1 -1
- package/src/components/form/engine/CompactRow.tsx +273 -258
- package/src/components/form/engine/CompactToolbar.tsx +112 -85
- package/src/components/form/engine/FormEngine.stories.tsx +239 -115
- package/src/components/form/engine/FormEngine.tsx +332 -83
- package/src/components/form/engine/compactRowStyles.ts +221 -144
- package/src/components/form/engine/compactToolbarContext.ts +1 -0
- package/src/components/form/engine/readFirst.ts +35 -0
- package/src/components/form/engine/variants/FormEngineVariants.stories.tsx +119 -0
- package/src/components/form/engine/variants/VariantCalmTable.tsx +242 -0
- package/src/components/form/engine/variants/VariantCards.tsx +212 -0
- package/src/components/form/engine/variants/VariantFocus.tsx +382 -0
- package/src/components/form/engine/variants/VariantMinimal.tsx +170 -0
- package/src/components/form/engine/variants/focusDemo.ts +145 -0
- package/src/components/form/engine/variants/variantModel.ts +216 -0
- package/src/components/form/engine/variants/variantParts.tsx +313 -0
- package/src/components/form/fields/auto/AutoFormField.stories.tsx +9 -2
- package/src/components/form/fields/auto/AutoFormField.tsx +8 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared row model for the FormEngine compact VARIANTS playground.
|
|
3
|
+
*
|
|
4
|
+
* This is a prototyping harness: every variant renders the SAME normalized rows
|
|
5
|
+
* (derived from a real options schema + values via the real `readFirst`
|
|
6
|
+
* formatters) and differs only in layout / chrome / UX. That keeps the
|
|
7
|
+
* comparison about presentation, not data. Editing is intentionally stubbed
|
|
8
|
+
* (expand shows a placeholder) — the point is to compare the read-first view
|
|
9
|
+
* that currently feels crowded, then graft the winner onto the real engine.
|
|
10
|
+
*/
|
|
11
|
+
import { IQorusFormField, IQorusFormSchema } from '@qoretechnologies/ts-toolkit';
|
|
12
|
+
import {
|
|
13
|
+
colorToCss,
|
|
14
|
+
formatBytes,
|
|
15
|
+
formatOptionValue,
|
|
16
|
+
getFileSize,
|
|
17
|
+
getOptionGroup,
|
|
18
|
+
getOptionGroupLabel,
|
|
19
|
+
getValueType,
|
|
20
|
+
isOptionValueEmpty,
|
|
21
|
+
} from '../readFirst';
|
|
22
|
+
import { isValueTemplate } from '../../../../helpers/templates';
|
|
23
|
+
|
|
24
|
+
export type TVariantStatus =
|
|
25
|
+
| 'set' // has a valid value
|
|
26
|
+
| 'unset' // empty, not required — calm, no alarm
|
|
27
|
+
| 'todo' // required (or required-group) but unset — gentle amber
|
|
28
|
+
| 'invalid'; // user-entered value fails validation — red (only when surfaced)
|
|
29
|
+
|
|
30
|
+
export type TVariantValueKind =
|
|
31
|
+
| 'text'
|
|
32
|
+
| 'number'
|
|
33
|
+
| 'bool'
|
|
34
|
+
| 'color'
|
|
35
|
+
| 'file'
|
|
36
|
+
| 'template'
|
|
37
|
+
| 'richtext'
|
|
38
|
+
| 'hash'
|
|
39
|
+
| 'empty';
|
|
40
|
+
|
|
41
|
+
export interface IVariantValue {
|
|
42
|
+
kind: TVariantValueKind;
|
|
43
|
+
/** Human-readable summary (already formatted by readFirst). */
|
|
44
|
+
display: string;
|
|
45
|
+
/** color: css color; file: size label; hash: field count; template: label */
|
|
46
|
+
extra?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface IVariantRow {
|
|
50
|
+
name: string;
|
|
51
|
+
label: string;
|
|
52
|
+
shortDesc?: string;
|
|
53
|
+
longDesc?: string;
|
|
54
|
+
required: boolean;
|
|
55
|
+
readOnly: boolean;
|
|
56
|
+
status: TVariantStatus;
|
|
57
|
+
/** Short reason for todo/invalid, e.g. "Text value is empty". */
|
|
58
|
+
reason?: string;
|
|
59
|
+
value: IVariantValue;
|
|
60
|
+
typeLabel: string;
|
|
61
|
+
/** required_groups this field belongs to (one-of constraints). */
|
|
62
|
+
requiredGroups: string[];
|
|
63
|
+
/** True when this field is a member of an unsatisfied one-of group. */
|
|
64
|
+
inUnmetGroup: boolean;
|
|
65
|
+
/** Raw schema + value object — passed to AutoFormField for REAL editing. */
|
|
66
|
+
schema: any;
|
|
67
|
+
field: any;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface IVariantGroup {
|
|
71
|
+
name: string;
|
|
72
|
+
label: string;
|
|
73
|
+
rows: IVariantRow[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One-of required group: members + whether any is filled. */
|
|
77
|
+
export interface IRequiredGroupInfo {
|
|
78
|
+
key: string;
|
|
79
|
+
members: string[];
|
|
80
|
+
memberLabels: string[];
|
|
81
|
+
satisfied: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface IVariantConfig {
|
|
85
|
+
groupLabels?: Record<string, string>;
|
|
86
|
+
/** name → reason; in the engine this comes from the validity pass. */
|
|
87
|
+
invalidReasons?: Record<string, string>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Default (basic-fixture) invalid set — overridable via config.
|
|
91
|
+
const DEFAULT_INVALID: Record<string, string> = {
|
|
92
|
+
optionWithInvalidValue: 'Text value is empty',
|
|
93
|
+
schemaOption: 'Hash arguments are invalid',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function buildValue(field: IQorusFormField | undefined, schema: any): IVariantValue {
|
|
97
|
+
if (!field || isOptionValueEmpty(field.value)) return { kind: 'empty', display: '' };
|
|
98
|
+
|
|
99
|
+
const valueType = getValueType(field, schema);
|
|
100
|
+
const formatted = formatOptionValue(field, schema);
|
|
101
|
+
|
|
102
|
+
if (valueType === 'rgbcolor') {
|
|
103
|
+
return { kind: 'color', display: formatted, extra: colorToCss(field.value) };
|
|
104
|
+
}
|
|
105
|
+
if (valueType === 'file') {
|
|
106
|
+
const size = getFileSize(field.value);
|
|
107
|
+
return {
|
|
108
|
+
kind: 'file',
|
|
109
|
+
display: formatted,
|
|
110
|
+
extra: size !== undefined ? formatBytes(size) : undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (valueType === 'bool' || valueType === 'boolean') {
|
|
114
|
+
return { kind: 'bool', display: formatted };
|
|
115
|
+
}
|
|
116
|
+
if (valueType === 'hash' || valueType === 'free-hash') {
|
|
117
|
+
return { kind: 'hash', display: formatted };
|
|
118
|
+
}
|
|
119
|
+
if (typeof field.value === 'string' && isValueTemplate(field.value)) {
|
|
120
|
+
// The real chip resolves the template's display name from the templates
|
|
121
|
+
// list; the prototype shows a friendly label of the key.
|
|
122
|
+
const label =
|
|
123
|
+
field.value.replace(/^\$[a-z]+:/i, '').replace(/[-_]/g, ' ') || field.value;
|
|
124
|
+
return { kind: 'template', display: label.replace(/\b\w/g, (c) => c.toUpperCase()), extra: 'local' };
|
|
125
|
+
}
|
|
126
|
+
if (valueType === 'richtext' && Array.isArray(field.value)) {
|
|
127
|
+
return { kind: 'richtext', display: formatted };
|
|
128
|
+
}
|
|
129
|
+
return { kind: 'text', display: formatted };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function buildVariantGroups(
|
|
133
|
+
options: IQorusFormSchema,
|
|
134
|
+
values: Record<string, IQorusFormField>,
|
|
135
|
+
config: IVariantConfig = {}
|
|
136
|
+
): { groups: IVariantGroup[]; requiredGroups: Record<string, IRequiredGroupInfo> } {
|
|
137
|
+
const invalidReasons = config.invalidReasons ?? DEFAULT_INVALID;
|
|
138
|
+
const groupMap = new Map<string, IVariantGroup>();
|
|
139
|
+
|
|
140
|
+
// Pass 1: collect one-of required-group membership + satisfaction.
|
|
141
|
+
const reqGroups: Record<string, IRequiredGroupInfo> = {};
|
|
142
|
+
Object.entries(options).forEach(([name, schema]: [string, any]) => {
|
|
143
|
+
(schema?.required_groups || []).forEach((key: string) => {
|
|
144
|
+
if (!reqGroups[key]) reqGroups[key] = { key, members: [], memberLabels: [], satisfied: false };
|
|
145
|
+
reqGroups[key].members.push(name);
|
|
146
|
+
reqGroups[key].memberLabels.push((schema?.display_name as string) || name);
|
|
147
|
+
if (!isOptionValueEmpty(values[name]?.value)) reqGroups[key].satisfied = true;
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
Object.entries(options).forEach(([name, schema]: [string, any]) => {
|
|
152
|
+
const field = values[name];
|
|
153
|
+
const empty = isOptionValueEmpty(field?.value);
|
|
154
|
+
const ownGroups: string[] = schema?.required_groups || [];
|
|
155
|
+
// Member of a one-of group that nobody has satisfied yet.
|
|
156
|
+
const inUnmetGroup = ownGroups.some((k) => reqGroups[k] && !reqGroups[k].satisfied);
|
|
157
|
+
const required = !!schema?.required;
|
|
158
|
+
|
|
159
|
+
let status: TVariantStatus;
|
|
160
|
+
let reason: string | undefined;
|
|
161
|
+
if (invalidReasons[name]) {
|
|
162
|
+
status = 'invalid';
|
|
163
|
+
reason = invalidReasons[name];
|
|
164
|
+
} else if (empty && required) {
|
|
165
|
+
status = 'todo';
|
|
166
|
+
reason = 'Needs a value';
|
|
167
|
+
} else if (empty && inUnmetGroup) {
|
|
168
|
+
status = 'todo';
|
|
169
|
+
reason = `One of ${reqGroups[ownGroups[0]].memberLabels.join(' / ')}`;
|
|
170
|
+
} else if (empty) {
|
|
171
|
+
status = 'unset';
|
|
172
|
+
} else {
|
|
173
|
+
status = 'set';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const groupName = getOptionGroup(schema);
|
|
177
|
+
const groupLabel = config.groupLabels?.[groupName] ?? getOptionGroupLabel(groupName, undefined);
|
|
178
|
+
if (!groupMap.has(groupName)) {
|
|
179
|
+
groupMap.set(groupName, { name: groupName, label: groupLabel, rows: [] });
|
|
180
|
+
}
|
|
181
|
+
groupMap.get(groupName)!.rows.push({
|
|
182
|
+
name,
|
|
183
|
+
label: (schema?.display_name as string) || name,
|
|
184
|
+
shortDesc: schema?.short_desc,
|
|
185
|
+
longDesc: schema?.desc,
|
|
186
|
+
required: required || ownGroups.length > 0,
|
|
187
|
+
readOnly: !!schema?.readonly,
|
|
188
|
+
status,
|
|
189
|
+
reason,
|
|
190
|
+
value: buildValue(field, schema),
|
|
191
|
+
typeLabel: `${(schema?.ui_type as string) || (schema?.type as string) || 'auto'}`,
|
|
192
|
+
requiredGroups: ownGroups,
|
|
193
|
+
inUnmetGroup,
|
|
194
|
+
schema,
|
|
195
|
+
field,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Stable group order: 'general' first, then the rest, 'optional' last.
|
|
200
|
+
const groups = Array.from(groupMap.values()).sort((a, b) => {
|
|
201
|
+
const rank = (g: string) => (g === 'general' ? 0 : g === 'optional' ? 2 : 1);
|
|
202
|
+
return rank(a.name) - rank(b.name);
|
|
203
|
+
});
|
|
204
|
+
return { groups, requiredGroups: reqGroups };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function summarize(groups: IVariantGroup[]) {
|
|
208
|
+
const rows = groups.flatMap((g) => g.rows);
|
|
209
|
+
const total = rows.length;
|
|
210
|
+
const set = rows.filter((r) => r.status === 'set').length;
|
|
211
|
+
const todo = rows.filter((r) => r.status === 'todo').length;
|
|
212
|
+
const invalid = rows.filter((r) => r.status === 'invalid').length;
|
|
213
|
+
const attention = todo + invalid;
|
|
214
|
+
const pct = total ? Math.round((set / total) * 100) : 0;
|
|
215
|
+
return { total, set, todo, invalid, attention, pct };
|
|
216
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared rendering parts for the FormEngine compact VARIANTS playground.
|
|
3
|
+
* Value rendering is shared so every variant shows identical data; the `tone`
|
|
4
|
+
* prop lets a variant choose a calm (desaturated) or vivid chip treatment.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
ReqoreButton,
|
|
8
|
+
ReqoreCollapsibleContent,
|
|
9
|
+
ReqoreControlGroup,
|
|
10
|
+
ReqoreIcon,
|
|
11
|
+
ReqoreInput,
|
|
12
|
+
ReqoreTag,
|
|
13
|
+
} from '@qoretechnologies/reqore';
|
|
14
|
+
import { useReqoreTheme } from '@qoretechnologies/reqore/dist/hooks/useTheme';
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import { AutoFormField } from '../../fields/auto/AutoFormField';
|
|
17
|
+
import { StructuredDataView } from '../_structuredData/StructuredDataView';
|
|
18
|
+
import {
|
|
19
|
+
buildVariantGroups,
|
|
20
|
+
summarize,
|
|
21
|
+
IVariantGroup,
|
|
22
|
+
IVariantRow,
|
|
23
|
+
IVariantValue,
|
|
24
|
+
TVariantStatus,
|
|
25
|
+
} from './variantModel';
|
|
26
|
+
|
|
27
|
+
export function useVariantColors() {
|
|
28
|
+
const theme = useReqoreTheme();
|
|
29
|
+
const intents = (theme.intents || {}) as Record<string, string>;
|
|
30
|
+
return {
|
|
31
|
+
main: theme.main as string,
|
|
32
|
+
text: (theme.text?.color as string) || '#e8e8e8',
|
|
33
|
+
muted: `${(theme.text?.color as string) || '#e8e8e8'}99`,
|
|
34
|
+
faint: `${(theme.text?.color as string) || '#e8e8e8'}55`,
|
|
35
|
+
line: `${(theme.text?.color as string) || '#ffffff'}14`,
|
|
36
|
+
hover: `${(theme.text?.color as string) || '#ffffff'}0c`,
|
|
37
|
+
surface: `${(theme.text?.color as string) || '#ffffff'}08`,
|
|
38
|
+
danger: intents.danger || '#a82a2a',
|
|
39
|
+
warning: intents.warning || '#d17c29',
|
|
40
|
+
success: intents.success || '#4a7110',
|
|
41
|
+
info: intents.info || '#3b7bbf',
|
|
42
|
+
custom1: intents.custom1 || '#762f7e',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const STATUS_COLOR = (
|
|
47
|
+
status: TVariantStatus,
|
|
48
|
+
c: ReturnType<typeof useVariantColors>
|
|
49
|
+
) =>
|
|
50
|
+
status === 'invalid' ? c.danger
|
|
51
|
+
: status === 'todo' ? c.warning
|
|
52
|
+
: status === 'set' ? c.success
|
|
53
|
+
: c.faint;
|
|
54
|
+
|
|
55
|
+
/** A single status mark — one dot, color = severity. Replaces stripe+box+icon. */
|
|
56
|
+
export const StatusDot = ({
|
|
57
|
+
status,
|
|
58
|
+
size = 7,
|
|
59
|
+
}: {
|
|
60
|
+
status: TVariantStatus;
|
|
61
|
+
size?: number;
|
|
62
|
+
}) => {
|
|
63
|
+
const c = useVariantColors();
|
|
64
|
+
if (status === 'unset') return null;
|
|
65
|
+
return (
|
|
66
|
+
<span
|
|
67
|
+
aria-hidden
|
|
68
|
+
style={{
|
|
69
|
+
width: size,
|
|
70
|
+
height: size,
|
|
71
|
+
borderRadius: '50%',
|
|
72
|
+
flex: '0 0 auto',
|
|
73
|
+
background: STATUS_COLOR(status, c),
|
|
74
|
+
boxShadow: status !== 'set' ? `0 0 0 3px ${STATUS_COLOR(status, c)}22` : undefined,
|
|
75
|
+
}}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** The value summary. `tone='calm'` desaturates template/richtext chips. */
|
|
81
|
+
export const ValueView = ({
|
|
82
|
+
value,
|
|
83
|
+
tone = 'calm',
|
|
84
|
+
}: {
|
|
85
|
+
value: IVariantValue;
|
|
86
|
+
tone?: 'calm' | 'vivid';
|
|
87
|
+
}) => {
|
|
88
|
+
const c = useVariantColors();
|
|
89
|
+
const ellipsis: React.CSSProperties = {
|
|
90
|
+
overflow: 'hidden',
|
|
91
|
+
textOverflow: 'ellipsis',
|
|
92
|
+
whiteSpace: 'nowrap',
|
|
93
|
+
minWidth: 0,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
switch (value.kind) {
|
|
97
|
+
case 'empty':
|
|
98
|
+
// A quiet dash reads as "no value" without shouting "Not set" on every row.
|
|
99
|
+
return <span style={{ color: c.faint }}>—</span>;
|
|
100
|
+
|
|
101
|
+
case 'color':
|
|
102
|
+
return (
|
|
103
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
|
|
104
|
+
<span
|
|
105
|
+
aria-hidden
|
|
106
|
+
style={{
|
|
107
|
+
width: 12,
|
|
108
|
+
height: 12,
|
|
109
|
+
borderRadius: 3,
|
|
110
|
+
flex: '0 0 auto',
|
|
111
|
+
background: value.extra,
|
|
112
|
+
border: `1px solid ${c.line}`,
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
<span style={ellipsis}>{value.display}</span>
|
|
116
|
+
</span>
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
case 'file':
|
|
120
|
+
return (
|
|
121
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
|
122
|
+
<ReqoreIcon icon='FileLine' size='13px' style={{ color: c.muted, flexShrink: 0 }} />
|
|
123
|
+
<span style={ellipsis}>{value.display}</span>
|
|
124
|
+
{value.extra ?
|
|
125
|
+
<span style={{ color: c.faint, fontSize: 11, flexShrink: 0 }}>{value.extra}</span>
|
|
126
|
+
: null}
|
|
127
|
+
</span>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
case 'bool':
|
|
131
|
+
return <span>{value.display}</span>;
|
|
132
|
+
|
|
133
|
+
case 'hash':
|
|
134
|
+
return (
|
|
135
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: c.muted }}>
|
|
136
|
+
<ReqoreIcon icon='BracesLine' size='12px' style={{ color: c.faint }} />
|
|
137
|
+
<span>{value.display}</span>
|
|
138
|
+
</span>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
case 'template':
|
|
142
|
+
return tone === 'vivid' ?
|
|
143
|
+
<ReqoreTag size='small' intent='info' icon='ExchangeDollarLine' label={value.display} />
|
|
144
|
+
: <span
|
|
145
|
+
style={{
|
|
146
|
+
display: 'inline-flex',
|
|
147
|
+
alignItems: 'center',
|
|
148
|
+
gap: 5,
|
|
149
|
+
padding: '1px 7px',
|
|
150
|
+
borderRadius: 4,
|
|
151
|
+
background: c.surface,
|
|
152
|
+
border: `1px solid ${c.line}`,
|
|
153
|
+
color: c.text,
|
|
154
|
+
fontSize: 12,
|
|
155
|
+
maxWidth: '100%',
|
|
156
|
+
...ellipsis,
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
<ReqoreIcon icon='ExchangeDollarLine' size='11px' style={{ color: c.custom1, flexShrink: 0 }} />
|
|
160
|
+
<span style={ellipsis}>{value.display}</span>
|
|
161
|
+
</span>;
|
|
162
|
+
|
|
163
|
+
case 'richtext':
|
|
164
|
+
return <span style={ellipsis}>{value.display}</span>;
|
|
165
|
+
|
|
166
|
+
default:
|
|
167
|
+
return <span style={ellipsis}>{value.display}</span>;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** A reusable description disclosure used by every variant (tap-friendly).
|
|
172
|
+
* Unifies short_desc (inline) + long desc into ONE control. */
|
|
173
|
+
export const useDisclosure = () => {
|
|
174
|
+
const [open, setOpen] = React.useState<Record<string, boolean>>({});
|
|
175
|
+
return {
|
|
176
|
+
isOpen: (id: string) => !!open[id],
|
|
177
|
+
toggle: (id: string) => setOpen((p) => ({ ...p, [id]: !p[id] })),
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Shared form state: filter (all / needs-attention), add-field, inline editing.
|
|
183
|
+
// Each variant calls useVariantForm and renders <VariantToolbar> + <InlineEdit>.
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
export type TVariantFilter = 'all' | 'attention';
|
|
186
|
+
|
|
187
|
+
export function useVariantForm(options: any, values: any, config?: any) {
|
|
188
|
+
const [filter, setFilter] = React.useState<TVariantFilter>('all');
|
|
189
|
+
const [query, setQuery] = React.useState('');
|
|
190
|
+
const [showDescriptions, setShowDescriptions] = React.useState(false);
|
|
191
|
+
const [editing, setEditing] = React.useState<string | null>(null);
|
|
192
|
+
|
|
193
|
+
const { groups, requiredGroups } = React.useMemo(
|
|
194
|
+
() => buildVariantGroups(options, values, config),
|
|
195
|
+
[options, values, config]
|
|
196
|
+
);
|
|
197
|
+
const summary = React.useMemo(() => summarize(groups), [groups]);
|
|
198
|
+
|
|
199
|
+
// Filtered view: text query (label match) + 'attention' (todo/invalid only).
|
|
200
|
+
const visibleGroups: IVariantGroup[] = React.useMemo(() => {
|
|
201
|
+
const q = query.trim().toLowerCase();
|
|
202
|
+
return groups
|
|
203
|
+
.map((g) => ({
|
|
204
|
+
...g,
|
|
205
|
+
rows: g.rows.filter(
|
|
206
|
+
(r) =>
|
|
207
|
+
(filter === 'all' || r.status === 'todo' || r.status === 'invalid') &&
|
|
208
|
+
(!q || r.label.toLowerCase().includes(q) || r.name.toLowerCase().includes(q))
|
|
209
|
+
),
|
|
210
|
+
}))
|
|
211
|
+
.filter((g) => g.rows.length);
|
|
212
|
+
}, [groups, filter, query]);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
groups,
|
|
216
|
+
visibleGroups,
|
|
217
|
+
requiredGroups,
|
|
218
|
+
summary,
|
|
219
|
+
filter,
|
|
220
|
+
setFilter,
|
|
221
|
+
toggleAttention: () => setFilter((f) => (f === 'attention' ? 'all' : 'attention')),
|
|
222
|
+
query,
|
|
223
|
+
setQuery,
|
|
224
|
+
showDescriptions,
|
|
225
|
+
toggleDescriptions: () => setShowDescriptions((v) => !v),
|
|
226
|
+
editing,
|
|
227
|
+
startEdit: (name: string) => setEditing((cur) => (cur === name ? null : name)),
|
|
228
|
+
stopEdit: () => setEditing(null),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export type TVariantForm = ReturnType<typeof useVariantForm>;
|
|
233
|
+
|
|
234
|
+
/** Compact toolbar: just a prominent filter field. The "needs attention" control
|
|
235
|
+
* lives in each variant's header (the count line is the link); all optional
|
|
236
|
+
* fields render inline in the Optional group, so there's no "add field" step. */
|
|
237
|
+
export const VariantToolbar = ({ form }: { form: TVariantForm }) => {
|
|
238
|
+
const c = useVariantColors();
|
|
239
|
+
const { summary, query, setQuery, showDescriptions, toggleDescriptions } = form;
|
|
240
|
+
return (
|
|
241
|
+
<ReqoreControlGroup verticalAlign='center' fluid>
|
|
242
|
+
<ReqoreInput
|
|
243
|
+
placeholder='Filter fields…'
|
|
244
|
+
icon='Search2Line'
|
|
245
|
+
iconColor='muted'
|
|
246
|
+
pill
|
|
247
|
+
fluid
|
|
248
|
+
value={query}
|
|
249
|
+
onChange={(e: any) => setQuery(e.currentTarget.value)}
|
|
250
|
+
onClearClick={() => setQuery('')}
|
|
251
|
+
/>
|
|
252
|
+
{/* Show short descriptions under every field — like the compact engine's
|
|
253
|
+
"Show all descriptions" toggle. Icon-only; info-intent when active. */}
|
|
254
|
+
<ReqoreButton
|
|
255
|
+
fixed
|
|
256
|
+
flat
|
|
257
|
+
minimal
|
|
258
|
+
intent={showDescriptions ? 'info' : undefined}
|
|
259
|
+
active={showDescriptions}
|
|
260
|
+
icon={showDescriptions ? 'InformationFill' : 'InformationLine'}
|
|
261
|
+
tooltip={showDescriptions ? 'Hide field descriptions' : 'Show field descriptions'}
|
|
262
|
+
onClick={toggleDescriptions}
|
|
263
|
+
/>
|
|
264
|
+
<span style={{ color: c.faint, fontSize: 12, paddingLeft: 4, whiteSpace: 'nowrap' }}>
|
|
265
|
+
{summary.set}/{summary.total} set
|
|
266
|
+
</span>
|
|
267
|
+
</ReqoreControlGroup>
|
|
268
|
+
);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** Complex value preview — the SAME nested tree + "Show more" fade the compact
|
|
272
|
+
* engine uses, so hash/list fields show their contents immediately. */
|
|
273
|
+
export const ComplexPreview = ({ value, onOpen }: { value: unknown; onOpen?: () => void }) => (
|
|
274
|
+
<ReqoreCollapsibleContent maxCollapsedHeight={120}>
|
|
275
|
+
<div onClick={(e) => e.stopPropagation()}>
|
|
276
|
+
<StructuredDataView
|
|
277
|
+
value={value}
|
|
278
|
+
collapsibleRoot={false}
|
|
279
|
+
defaultExpandDepth={2}
|
|
280
|
+
onItemClick={onOpen}
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
</ReqoreCollapsibleContent>
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
/** Inline editor: mounts the REAL field editor (AutoFormField) for the row's
|
|
287
|
+
* schema + value, so clicking a field edits it for real (string, number, bool,
|
|
288
|
+
* colour, file, richtext, list, date, …). Local state only — the prototype
|
|
289
|
+
* doesn't persist; in the engine this flows through handleValueChange. */
|
|
290
|
+
export const InlineEdit = ({ row, onDone }: { row: IVariantRow; onDone: () => void }) => {
|
|
291
|
+
const [value, setValue] = React.useState(row.field?.value);
|
|
292
|
+
return (
|
|
293
|
+
<div
|
|
294
|
+
style={{ gridColumn: '1 / -1', display: 'flex', flexFlow: 'column', gap: 8, padding: '10px 0 12px' }}
|
|
295
|
+
onClick={(e) => e.stopPropagation()}
|
|
296
|
+
>
|
|
297
|
+
<AutoFormField
|
|
298
|
+
{...(row.schema || {})}
|
|
299
|
+
name={row.name}
|
|
300
|
+
value={value}
|
|
301
|
+
default_value={row.schema?.default_value}
|
|
302
|
+
onChange={(_n: string, v: any) => setValue(v)}
|
|
303
|
+
fluid
|
|
304
|
+
size='small'
|
|
305
|
+
/>
|
|
306
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
307
|
+
<ReqoreButton size='small' intent='success' icon='CheckLine' fixed onClick={onDone}>
|
|
308
|
+
Done
|
|
309
|
+
</ReqoreButton>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
};
|
|
@@ -190,9 +190,16 @@ export const ViaFormEngine: Story = {
|
|
|
190
190
|
},
|
|
191
191
|
async play({ canvasElement }) {
|
|
192
192
|
const canvas = within(canvasElement);
|
|
193
|
-
|
|
193
|
+
// Generous timeout: findByText defaults to 1s, which flakes under CI load
|
|
194
|
+
// while the engine boots the auto field's type picker (the rest of the suite
|
|
195
|
+
// waits ~10s).
|
|
196
|
+
await expect(
|
|
197
|
+
await canvas.findByText('My Auto Field', undefined, { timeout: 10000 })
|
|
198
|
+
).toBeInTheDocument();
|
|
194
199
|
// The auto field renders its type picker inside the engine-driven form.
|
|
195
|
-
await expect(
|
|
200
|
+
await expect(
|
|
201
|
+
await canvas.findByText('Please select data type', undefined, { timeout: 10000 })
|
|
202
|
+
).toBeInTheDocument();
|
|
196
203
|
},
|
|
197
204
|
};
|
|
198
205
|
|
|
@@ -71,6 +71,9 @@ export interface IAutoFieldProps
|
|
|
71
71
|
uniqueName?: string;
|
|
72
72
|
|
|
73
73
|
arg_schema?: string | IOptionsSchema;
|
|
74
|
+
/** Render the arg_schema sub-form in compact (read-first) mode, matching the
|
|
75
|
+
* parent engine. */
|
|
76
|
+
compact?: boolean;
|
|
74
77
|
path?: string;
|
|
75
78
|
column?: boolean;
|
|
76
79
|
level?: number;
|
|
@@ -137,6 +140,7 @@ function AutoField<T = any>({
|
|
|
137
140
|
noSoft,
|
|
138
141
|
path,
|
|
139
142
|
arg_schema,
|
|
143
|
+
compact,
|
|
140
144
|
column,
|
|
141
145
|
level = 0,
|
|
142
146
|
canBeNull,
|
|
@@ -540,6 +544,10 @@ function AutoField<T = any>({
|
|
|
540
544
|
<FormEngine
|
|
541
545
|
wrapperPadding='top'
|
|
542
546
|
flat
|
|
547
|
+
compact={compact}
|
|
548
|
+
// Embedded sub-form: no scroll context of its own, so its toolbar
|
|
549
|
+
// isn't sticky and its header stays transparent (no dark backdrop).
|
|
550
|
+
compactNested
|
|
543
551
|
name={name}
|
|
544
552
|
uniqueName={uniqueName}
|
|
545
553
|
options={finalArgSchema}
|