@qoretechnologies/reqraft 0.10.36 → 0.10.37-pr.95.g4e65c57
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/dist/components/codeSize/index.d.ts +37 -0
- package/dist/components/codeSize/index.d.ts.map +1 -0
- package/dist/components/codeSize/index.js +87 -0
- package/dist/components/codeSize/index.js.map +1 -0
- package/dist/components/form/engine/CompactRow.d.ts +6 -1
- package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
- package/dist/components/form/engine/CompactRow.js +259 -19
- package/dist/components/form/engine/CompactRow.js.map +1 -1
- package/dist/components/form/engine/FormEngine.d.ts +20 -0
- package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +137 -24
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts +37 -0
- package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -0
- package/dist/components/form/engine/_structuredData/SchemaDataView.js +307 -0
- package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -0
- package/dist/components/form/engine/compactRowStyles.d.ts +13 -0
- package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
- package/dist/components/form/engine/compactRowStyles.js +30 -5
- package/dist/components/form/engine/compactRowStyles.js.map +1 -1
- package/dist/components/form/engine/readFirst.d.ts +66 -1
- package/dist/components/form/engine/readFirst.d.ts.map +1 -1
- package/dist/components/form/engine/readFirst.js +175 -5
- package/dist/components/form/engine/readFirst.js.map +1 -1
- package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
- package/dist/components/form/fields/array/ArrayAuto.js +51 -21
- package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
- package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
- package/dist/components/form/fields/array/ArrayAutoField.js +42 -25
- package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
- package/dist/components/form/fields/auto/AutoFormField.d.ts +4 -0
- package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
- package/dist/components/form/fields/auto/AutoFormField.js +8 -1
- package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
- package/dist/helpers/validations.d.ts +14 -0
- package/dist/helpers/validations.d.ts.map +1 -1
- package/dist/helpers/validations.js +58 -3
- package/dist/helpers/validations.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/codeSize/index.tsx +79 -0
- package/src/components/form/engine/CompactRow.tsx +313 -24
- package/src/components/form/engine/FormEngine.stories.tsx +487 -14
- package/src/components/form/engine/FormEngine.tsx +142 -8
- package/src/components/form/engine/_structuredData/SchemaDataView.stories.tsx +129 -0
- package/src/components/form/engine/_structuredData/SchemaDataView.tsx +594 -0
- package/src/components/form/engine/compactRowStyles.ts +44 -2
- package/src/components/form/engine/readFirst.ts +201 -5
- package/src/components/form/fields/array/ArrayAuto.tsx +35 -4
- package/src/components/form/fields/array/ArrayAutoField.stories.tsx +53 -0
- package/src/components/form/fields/array/ArrayAutoField.tsx +22 -4
- package/src/components/form/fields/auto/AutoFormField.tsx +11 -0
- package/src/helpers/validations.ts +59 -2
- package/src/index.tsx +1 -0
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
IQorusFormField,
|
|
3
|
+
IQorusFormSchema,
|
|
4
|
+
TQorusFormFieldSchema,
|
|
5
|
+
TQorusType,
|
|
6
|
+
} from '@qoretechnologies/ts-toolkit';
|
|
2
7
|
import { isRendererOnlyUiType } from './rendererTypes';
|
|
3
8
|
import { isUiEncodedValue } from './_structuredData/structuredData';
|
|
4
9
|
import { renderExpressionToText } from '../expressions/renderExpressionToText';
|
|
@@ -40,6 +45,57 @@ const summarizeListItem = (item: unknown): string | number | undefined => {
|
|
|
40
45
|
return undefined;
|
|
41
46
|
};
|
|
42
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Label ONE list item from the sub-schema that describes it.
|
|
50
|
+
*
|
|
51
|
+
* `summarizeListItem` can only read an item that names ITSELF — one carrying a
|
|
52
|
+
* `name` or `display_name` key. A hash whose fields are all domain fields names
|
|
53
|
+
* itself nowhere: an auth profile's scheme has `type`, `cookie_name`,
|
|
54
|
+
* `redirect_url`, and the row fell back to "2 items" — true, and silent about
|
|
55
|
+
* which two.
|
|
56
|
+
*
|
|
57
|
+
* The `arg_schema` is the same description the sub-form was built from, so the
|
|
58
|
+
* row can read an item the way the form asked for it: the FIRST declared field
|
|
59
|
+
* the item actually has, resolved through that field's allowed values. The
|
|
60
|
+
* scheme list then reads "Default RBAC, Cookie" — the words the author picked —
|
|
61
|
+
* instead of a count.
|
|
62
|
+
*
|
|
63
|
+
* Schema order, not value order: the first declared field is the one the form
|
|
64
|
+
* puts at the top of the item, which is the one that identifies it to its author.
|
|
65
|
+
* Value order is insertion order and would vary between two equal items.
|
|
66
|
+
*/
|
|
67
|
+
const summarizeHashItemFromSchema = (
|
|
68
|
+
item: unknown,
|
|
69
|
+
argSchema: Record<string, TQorusFormFieldSchema> | undefined
|
|
70
|
+
): string | number | undefined => {
|
|
71
|
+
if (!argSchema) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const record = isTypedEnvelope(item) ? item.value : item;
|
|
75
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const fields = record as Record<string, unknown>;
|
|
79
|
+
const key = Object.keys(argSchema).find((name) => {
|
|
80
|
+
const raw = fields[name];
|
|
81
|
+
const value = isTypedEnvelope(raw) ? raw.value : raw;
|
|
82
|
+
return value !== undefined && value !== null && value !== '';
|
|
83
|
+
});
|
|
84
|
+
if (!key) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const raw = fields[key];
|
|
88
|
+
const value = isTypedEnvelope(raw) ? raw.value : raw;
|
|
89
|
+
const label = getAllowedValueLabel(value, argSchema[key]);
|
|
90
|
+
if (label) {
|
|
91
|
+
return label;
|
|
92
|
+
}
|
|
93
|
+
// No allowed values (a free-text field): the stored value IS what was typed,
|
|
94
|
+
// so it reads correctly as-is. An object at this position has no short form,
|
|
95
|
+
// and a count beats printing its shape.
|
|
96
|
+
return typeof value === 'string' || typeof value === 'number' ? value : undefined;
|
|
97
|
+
};
|
|
98
|
+
|
|
43
99
|
/** Summarise a list value: join item labels, or fall back to an "N items" count
|
|
44
100
|
* (e.g. a list of anonymous hashes). Never prints raw objects. */
|
|
45
101
|
/**
|
|
@@ -52,8 +108,15 @@ const summarizeListItem = (item: unknown): string | number | undefined => {
|
|
|
52
108
|
* does, so it can read the same way instead of printing the wire form back.
|
|
53
109
|
*/
|
|
54
110
|
const formatList = (items: unknown[], schema?: TQorusFormFieldSchema): string => {
|
|
111
|
+
const argSchema = (schema as { arg_schema?: Record<string, TQorusFormFieldSchema> } | undefined)
|
|
112
|
+
?.arg_schema;
|
|
55
113
|
const parts = items
|
|
56
|
-
.map(
|
|
114
|
+
.map(
|
|
115
|
+
(item) =>
|
|
116
|
+
getAllowedValueLabel(item, schema) ??
|
|
117
|
+
summarizeListItem(item) ??
|
|
118
|
+
summarizeHashItemFromSchema(item, argSchema)
|
|
119
|
+
)
|
|
57
120
|
.filter((part) => part !== '' && part !== undefined && part !== null);
|
|
58
121
|
|
|
59
122
|
return parts.length ? parts.join(', ') : pluralize(items.length, 'item');
|
|
@@ -133,9 +196,25 @@ export const shouldAutoCollapseCompactOption = (
|
|
|
133
196
|
!isOptionValueEmpty(value) &&
|
|
134
197
|
(isCompactBooleanOption(schema) || isFixedCompactAllowedValueOption(schema));
|
|
135
198
|
|
|
136
|
-
/**
|
|
137
|
-
*
|
|
138
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Prefer the matching allowed_values entry's display_name (fallback `name`)
|
|
201
|
+
* over the raw stored value.
|
|
202
|
+
*
|
|
203
|
+
* Exported (as `findAllowedValueOption`) because the form engine's value
|
|
204
|
+
* validation has to answer the SAME question — "is this stored value one of the
|
|
205
|
+
* declared choices?" — and answering it differently loses data. An
|
|
206
|
+
* `allowed_values` entry is written three ways: an envelope (`{value: {type,
|
|
207
|
+
* value}}`), a bare value (`{value: 'default'}`) or a named entry
|
|
208
|
+
* (`{name: 'default'}`). This has always accepted all three; the engine's
|
|
209
|
+
* clearing guard accepted only the first and the third, so a schema using the
|
|
210
|
+
* bare form had its value ERASED on load while this function went on rendering
|
|
211
|
+
* the display name for it — the row read "Default RBAC" collapsed and "—" when
|
|
212
|
+
* opened, and the value was gone from the submitted data.
|
|
213
|
+
*/
|
|
214
|
+
export const findAllowedValueOption = (
|
|
215
|
+
value: unknown,
|
|
216
|
+
schema?: TQorusFormFieldSchema
|
|
217
|
+
): any | undefined => {
|
|
139
218
|
const s = schema as { allowed_values?: any[]; items?: any[] } | undefined;
|
|
140
219
|
const options = s?.allowed_values?.length ? s.allowed_values : s?.items;
|
|
141
220
|
if (!options?.length) {
|
|
@@ -154,6 +233,9 @@ const getAllowedValueLabel = (
|
|
|
154
233
|
return match ? match.display_name || match.title || match.name || undefined : undefined;
|
|
155
234
|
};
|
|
156
235
|
|
|
236
|
+
/** Local alias for the module's own callers. */
|
|
237
|
+
const findAllowedOption = findAllowedValueOption;
|
|
238
|
+
|
|
157
239
|
/** True when the field's selectable options carry images (logos). Such a choice
|
|
158
240
|
* renders too tall/rich for an inline row, so compact opens it in the card. */
|
|
159
241
|
export const optionHasImages = (schema?: TQorusFormFieldSchema): boolean => {
|
|
@@ -716,3 +798,117 @@ export const getFirstAttentionOptionName = (
|
|
|
716
798
|
|
|
717
799
|
return undefined;
|
|
718
800
|
};
|
|
801
|
+
|
|
802
|
+
/* ------------------------------------------------------------------------- */
|
|
803
|
+
/* Record identity — which field says WHICH item this is */
|
|
804
|
+
/* */
|
|
805
|
+
/* Lives here, beside the formatters it uses, rather than in the view that */
|
|
806
|
+
/* first needed it: it is data logic, not rendering, and BOTH list renderers */
|
|
807
|
+
/* plus their tests import it. Keeping it in `SchemaDataView` dragged React, */
|
|
808
|
+
/* styled-components and every Reqore component it renders into any module */
|
|
809
|
+
/* that only wanted to know an item's name — which broke `arrayAutoField`'s */
|
|
810
|
+
/* mocked-Reqore test the moment the editable list started using it. */
|
|
811
|
+
/* ------------------------------------------------------------------------- */
|
|
812
|
+
|
|
813
|
+
/** A UI-encoded value carries its type alongside it; the record's own value is inside. */
|
|
814
|
+
const unwrap = (value: unknown): unknown =>
|
|
815
|
+
isUiEncodedValue(value) ? (value as { value: unknown }).value : value;
|
|
816
|
+
|
|
817
|
+
/** Whether a value is worth a row of its own. `false` and `0` are values. */
|
|
818
|
+
export const isSet = (value: unknown): boolean =>
|
|
819
|
+
value !== undefined && value !== null && value !== '' && !(Array.isArray(value) && !value.length);
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The keys to render, in the order to render them: the schema's own order first
|
|
823
|
+
* (that is the order the form puts the fields in), then anything stored that the
|
|
824
|
+
* schema does not mention, so undescribed data is shown rather than dropped.
|
|
825
|
+
*/
|
|
826
|
+
export const orderedKeys = (record: Record<string, unknown>, schema: IQorusFormSchema): string[] => {
|
|
827
|
+
const described = Object.keys(schema).filter((key) => isSet(unwrap(record[key])));
|
|
828
|
+
const extra = Object.keys(record).filter((key) => !(key in schema) && isSet(unwrap(record[key])));
|
|
829
|
+
return [...described, ...extra];
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
/** A field whose content is source code. `ui_type` is what the form renders by,
|
|
833
|
+
* so it is what the preview reads by too — the storage type is just `string`. */
|
|
834
|
+
export const isCodeField = (fieldSchema: TQorusFormFieldSchema | undefined): boolean => {
|
|
835
|
+
const uiType = (fieldSchema as { ui_type?: string } | undefined)?.ui_type;
|
|
836
|
+
return uiType === 'code-editor';
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* The field whose value heads the item — the first DECLARED one holding a plain
|
|
841
|
+
* scalar.
|
|
842
|
+
*
|
|
843
|
+
* Schema order, not value order: the first declared field is the one the form
|
|
844
|
+
* puts at the top of an item, which is the one that says which item it is. A
|
|
845
|
+
* code body or a nested level is skipped, not because it is unimportant but
|
|
846
|
+
* because it has no one-line form — it belongs in the rows below where it can
|
|
847
|
+
* actually be read.
|
|
848
|
+
*
|
|
849
|
+
* One definition, used both to RENDER the heading and to omit that field from
|
|
850
|
+
* the rows. Computing it twice is how the heading and the rows start disagreeing
|
|
851
|
+
* about which field was promoted, and the item shows its name twice or not at
|
|
852
|
+
* all.
|
|
853
|
+
*/
|
|
854
|
+
export const titleKeyFor = (
|
|
855
|
+
record: Record<string, unknown>,
|
|
856
|
+
schema: IQorusFormSchema
|
|
857
|
+
): string | undefined =>
|
|
858
|
+
orderedKeys(record, schema).find((key) => {
|
|
859
|
+
const fieldSchema = schema[key] as TQorusFormFieldSchema | undefined;
|
|
860
|
+
if (isCodeField(fieldSchema)) {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
const raw = unwrap(record[key]);
|
|
864
|
+
return typeof raw === 'string' || typeof raw === 'number';
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
/** What identifies one record: which field was promoted, and how to draw it. */
|
|
868
|
+
export interface IRecordIdentity {
|
|
869
|
+
/** The promoted field's key, so a caller can omit it from the rows below. */
|
|
870
|
+
key: string;
|
|
871
|
+
/** The value, formatted the way its own row would format it. */
|
|
872
|
+
text: string;
|
|
873
|
+
/** The promoted field's label — a caption for the heading, not a second line. */
|
|
874
|
+
label: string;
|
|
875
|
+
/** A literal keeps its mono face; a chosen label is prose. */
|
|
876
|
+
mono: boolean;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* The identity of one record, for any surface that heads a list item with it.
|
|
881
|
+
*
|
|
882
|
+
* Exported because the preview is not the only place a list of records is shown:
|
|
883
|
+
* the editable list (`ArrayAuto`) heads the same records, and it must promote the
|
|
884
|
+
* same field and format it the same way. Two implementations would drift the
|
|
885
|
+
* moment one of them learned about a new field type, and the reader would meet
|
|
886
|
+
* an item called `init` in the preview and `#1` in the editor.
|
|
887
|
+
*/
|
|
888
|
+
export const recordIdentity = (
|
|
889
|
+
record: Record<string, unknown>,
|
|
890
|
+
schema: IQorusFormSchema | undefined
|
|
891
|
+
): IRecordIdentity | undefined => {
|
|
892
|
+
if (!schema) {
|
|
893
|
+
return undefined;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const key = titleKeyFor(record, schema);
|
|
897
|
+
|
|
898
|
+
if (!key) {
|
|
899
|
+
return undefined;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const fieldSchema = schema[key] as TQorusFormFieldSchema | undefined;
|
|
903
|
+
const raw = unwrap(record[key]);
|
|
904
|
+
|
|
905
|
+
return {
|
|
906
|
+
key,
|
|
907
|
+
text: formatOptionValue({ type: fieldSchema?.type, value: raw } as IQorusFormField, fieldSchema),
|
|
908
|
+
label: fieldLabel(key, fieldSchema),
|
|
909
|
+
mono: !findAllowedValueOption(raw, fieldSchema),
|
|
910
|
+
};
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
export const fieldLabel = (key: string, fieldSchema: TQorusFormFieldSchema | undefined): string =>
|
|
914
|
+
(fieldSchema as { display_name?: string } | undefined)?.display_name || key;
|
|
@@ -24,6 +24,7 @@ import { validateFieldWithResult } from '../../../../helpers/validations';
|
|
|
24
24
|
import { useWhyDidYouUpdate } from '../../../../hooks/useWhyDidYouUpdate';
|
|
25
25
|
// Direct imports — the cycle (ArrayAuto → TemplateField/auto → ArrayAuto) is
|
|
26
26
|
// safe the same way Field → AutoFormField → Field is.
|
|
27
|
+
import { recordIdentity } from '../../engine/readFirst';
|
|
27
28
|
import { AutoFormField } from '../auto/AutoFormField';
|
|
28
29
|
import { TemplateField } from '../template/TemplateField';
|
|
29
30
|
|
|
@@ -64,6 +65,12 @@ export const ArrayAuto = ({
|
|
|
64
65
|
}: IArrayAutoProps): any => {
|
|
65
66
|
const confirmAction = useReqoreProperty('confirmAction');
|
|
66
67
|
const [localValue, setLocalValue] = useState(value);
|
|
68
|
+
// The row this component just created, so its sub-form can open the field it
|
|
69
|
+
// cannot be saved without. Only a row added HERE gets it: the rows already in
|
|
70
|
+
// the value were added by an earlier session (or arrived from the server), and
|
|
71
|
+
// opening one of those on mount would be this component reopening a decision
|
|
72
|
+
// the author already made.
|
|
73
|
+
const [justAddedIndex, setJustAddedIndex] = useState<number | undefined>(undefined);
|
|
67
74
|
|
|
68
75
|
useWhyDidYouUpdate(`Array Auto ${name}`, {
|
|
69
76
|
name,
|
|
@@ -87,6 +94,7 @@ export const ArrayAuto = ({
|
|
|
87
94
|
);
|
|
88
95
|
|
|
89
96
|
const addValue: () => void = () => {
|
|
97
|
+
setJustAddedIndex(localValue.length);
|
|
90
98
|
setLocalValue([...localValue, defaultValueByType[rest.type]]);
|
|
91
99
|
};
|
|
92
100
|
|
|
@@ -126,12 +134,29 @@ export const ArrayAuto = ({
|
|
|
126
134
|
// Render list of auto fields
|
|
127
135
|
return (
|
|
128
136
|
<ReqoreControlGroup vertical fluid>
|
|
129
|
-
{map(localValue, (val: string | number, idx: string) =>
|
|
137
|
+
{map(localValue, (val: string | number, idx: string) => {
|
|
138
|
+
// The same field the collapsed preview promotes, resolved by the same
|
|
139
|
+
// definition — see `recordIdentity`. A list of seven methods headed
|
|
140
|
+
// `#1 … #7` makes the reader open each one to find out which is which,
|
|
141
|
+
// and heading them differently in the preview and the editor would make
|
|
142
|
+
// one item answer to two names.
|
|
143
|
+
const identity =
|
|
144
|
+
val && typeof val === 'object' && !Array.isArray(val)
|
|
145
|
+
? recordIdentity(val as Record<string, unknown>, rest.arg_schema)
|
|
146
|
+
: undefined;
|
|
147
|
+
|
|
148
|
+
return (
|
|
130
149
|
<ReqorePanel
|
|
131
150
|
key={idx}
|
|
132
|
-
|
|
151
|
+
// `#N` stays as the fallback: a row whose identifying field is still
|
|
152
|
+
// empty (one just added) has nothing to be called yet, and a blank
|
|
153
|
+
// heading would be worse than a number.
|
|
154
|
+
label={identity?.text || `#${idx + 1}`}
|
|
155
|
+
tooltip={identity?.label}
|
|
133
156
|
unMountContentOnCollapse={false}
|
|
134
|
-
badge
|
|
157
|
+
// The position moves to the badge once the name owns the heading, so
|
|
158
|
+
// "which of these is third" stays answerable.
|
|
159
|
+
badge={identity ? `#${idx + 1}` : display_name || name}
|
|
135
160
|
collapsible
|
|
136
161
|
responsiveActions={false}
|
|
137
162
|
responsiveTitle={false}
|
|
@@ -171,12 +196,18 @@ export const ArrayAuto = ({
|
|
|
171
196
|
allowTemplates
|
|
172
197
|
defaultType={rest.type}
|
|
173
198
|
name={`${name}-${idx}`}
|
|
199
|
+
// AFTER `{...rest}` on purpose: this is the one prop that differs
|
|
200
|
+
// per row, and spreading rest over it would give every row the same
|
|
201
|
+
// answer. Rides TemplateField's `rest` into the row's AutoFormField,
|
|
202
|
+
// which hands it to the row's nested FormEngine (case 'hash').
|
|
203
|
+
expandFirstRequired={Number(idx) === justAddedIndex}
|
|
174
204
|
value={val}
|
|
175
205
|
onChange={(_name, value) => handleChange(idx, value)}
|
|
176
206
|
/>
|
|
177
207
|
{showValidationMessage(val)}
|
|
178
208
|
</ReqorePanel>
|
|
179
|
-
|
|
209
|
+
);
|
|
210
|
+
})}
|
|
180
211
|
<ReqoreControlGroup fluid>
|
|
181
212
|
<ReqoreButton
|
|
182
213
|
onClick={addValue}
|
|
@@ -339,3 +339,56 @@ export const ComplexHashItems: Story = {
|
|
|
339
339
|
await expect(document.querySelectorAll('.array-auto-compact-tag').length).toBe(0);
|
|
340
340
|
},
|
|
341
341
|
};
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The editable list, headed by what identifies each record.
|
|
345
|
+
*
|
|
346
|
+
* The rows used to read `#1 #2 #3`, so finding one method meant opening each in
|
|
347
|
+
* turn — and the collapsed preview beside it already headed the same records
|
|
348
|
+
* `init` / `onOrderStatus`, which made one item answer to two names. Both now
|
|
349
|
+
* resolve the heading through `recordIdentity`: the first field the SCHEMA
|
|
350
|
+
* declares that holds a scalar, skipping any it leaves unset. The position keeps
|
|
351
|
+
* a badge, so "the third one" is still answerable.
|
|
352
|
+
*/
|
|
353
|
+
export const HashItemsHeadedByIdentity: Story = {
|
|
354
|
+
args: {
|
|
355
|
+
type: 'hash',
|
|
356
|
+
display_name: 'Service Methods',
|
|
357
|
+
arg_schema: {
|
|
358
|
+
name: { type: 'string', ui_type: 'string', display_name: 'Method Name', required: true },
|
|
359
|
+
description: { type: 'string', ui_type: 'string', display_name: 'Description' },
|
|
360
|
+
},
|
|
361
|
+
value: [
|
|
362
|
+
{ name: 'init', description: 'The init method' },
|
|
363
|
+
{ name: 'onOrderStatus', description: 'Handles order status callbacks' },
|
|
364
|
+
{ description: 'Added but not yet named' },
|
|
365
|
+
{},
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
parameters: {
|
|
369
|
+
docs: {
|
|
370
|
+
description: {
|
|
371
|
+
story:
|
|
372
|
+
'Renders four hash items headed by their identifying value: two named methods read as "init" and "onOrderStatus", one with no name falls through to its description, and an entirely empty one keeps its position number.',
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
async play() {
|
|
377
|
+
await waitFor(() => expect(document.querySelectorAll('.array-auto-item').length).toBe(4), {
|
|
378
|
+
timeout: 5000,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const headings = [...document.querySelectorAll('.array-auto-item')].map(
|
|
382
|
+
(item) => item.textContent ?? ''
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
await expect(headings[0]).toContain('init');
|
|
386
|
+
await expect(headings[1]).toContain('onOrderStatus');
|
|
387
|
+
// An unset field is skipped rather than promoted blank, so this one is
|
|
388
|
+
// headed by the next field that IS set.
|
|
389
|
+
await expect(headings[2]).toContain('Added but not yet named');
|
|
390
|
+
// And a record with nothing set at all keeps its position — there is
|
|
391
|
+
// genuinely nothing to call it.
|
|
392
|
+
await expect(headings[3]).toContain('#4');
|
|
393
|
+
},
|
|
394
|
+
};
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from '@qoretechnologies/reqore';
|
|
10
10
|
import { IQorusFormSchema } from '@qoretechnologies/ts-toolkit';
|
|
11
11
|
import { map, size } from 'lodash';
|
|
12
|
+
import { recordIdentity } from '../../engine/readFirst';
|
|
12
13
|
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
|
13
14
|
import { useDebounce } from 'react-use';
|
|
14
15
|
import { validateFieldWithResult } from '../../../../helpers/validations';
|
|
@@ -279,11 +280,27 @@ export const ArrayAutoField = memo(
|
|
|
279
280
|
|
|
280
281
|
return (
|
|
281
282
|
<ReqoreControlGroup vertical fluid>
|
|
282
|
-
{map(localValue, (val, idx) =>
|
|
283
|
+
{map(localValue, (val, idx) => {
|
|
284
|
+
// The same field the collapsed preview promotes, resolved by the same
|
|
285
|
+
// definition — see `recordIdentity`. Both editable list renderers head
|
|
286
|
+
// their records with it, so an item cannot be called `init` in one
|
|
287
|
+
// place and `#1` in another.
|
|
288
|
+
const identity =
|
|
289
|
+
val && typeof val === 'object' && !Array.isArray(val)
|
|
290
|
+
? recordIdentity(val as Record<string, unknown>, arg_schema)
|
|
291
|
+
: undefined;
|
|
292
|
+
|
|
293
|
+
return (
|
|
283
294
|
<ReqorePanel
|
|
284
295
|
key={idx}
|
|
285
|
-
|
|
286
|
-
|
|
296
|
+
// `#N` stays as the fallback: a record whose identifying field is
|
|
297
|
+
// still empty has nothing to be called yet, and a blank heading
|
|
298
|
+
// would be worse than a number.
|
|
299
|
+
label={identity?.text || `#${idx + 1}`}
|
|
300
|
+
tooltip={identity?.label}
|
|
301
|
+
// The position moves to the badge once the name owns the heading,
|
|
302
|
+
// so "which of these is third" stays answerable.
|
|
303
|
+
badge={identity ? `#${idx + 1}` : display_name || name}
|
|
287
304
|
collapsible
|
|
288
305
|
unMountContentOnCollapse={false}
|
|
289
306
|
responsiveActions={false}
|
|
@@ -321,7 +338,8 @@ export const ArrayAutoField = memo(
|
|
|
321
338
|
})}
|
|
322
339
|
{renderValidationMessage(val)}
|
|
323
340
|
</ReqorePanel>
|
|
324
|
-
|
|
341
|
+
);
|
|
342
|
+
})}
|
|
325
343
|
|
|
326
344
|
<ReqoreButton
|
|
327
345
|
onClick={addItem}
|
|
@@ -75,6 +75,10 @@ export interface IAutoFieldProps
|
|
|
75
75
|
/** Render the arg_schema sub-form in compact (read-first) mode, matching the
|
|
76
76
|
* parent engine. */
|
|
77
77
|
compact?: boolean;
|
|
78
|
+
/** Open the arg_schema sub-form's first attention row on mount, without taking
|
|
79
|
+
* focus — see FormEngine's `expandFirstRequired`. Set by ArrayAuto on a
|
|
80
|
+
* just-added row so its required choice is visible without a second click. */
|
|
81
|
+
expandFirstRequired?: boolean;
|
|
78
82
|
path?: string;
|
|
79
83
|
column?: boolean;
|
|
80
84
|
level?: number;
|
|
@@ -161,6 +165,10 @@ function AutoField<T = any>({
|
|
|
161
165
|
// Only the nested arg_schema mount sites re-forward this into their
|
|
162
166
|
// sub-forms explicitly.
|
|
163
167
|
inheritedFromParent,
|
|
168
|
+
// Destructured for the same reason as `inheritedFromParent`: only the nested
|
|
169
|
+
// sub-form mount site consumes it, and leaving it in `...rest` would spread an
|
|
170
|
+
// unknown attribute onto the primitive field renderers' DOM nodes.
|
|
171
|
+
expandFirstRequired,
|
|
164
172
|
...rest
|
|
165
173
|
}: IAutoFieldProps & T) {
|
|
166
174
|
const [currentType, setType] = useState<IQorusType>(defaultInternalType || null);
|
|
@@ -559,6 +567,9 @@ function AutoField<T = any>({
|
|
|
559
567
|
wrapperPadding='top'
|
|
560
568
|
flat
|
|
561
569
|
compact={compact}
|
|
570
|
+
// A row added just now: open the field it cannot be saved
|
|
571
|
+
// without, instead of making the author find it and click it.
|
|
572
|
+
expandFirstRequired={expandFirstRequired}
|
|
562
573
|
// Embedded sub-form: no scroll context of its own, so its toolbar
|
|
563
574
|
// isn't sticky and its header stays transparent (no dark backdrop).
|
|
564
575
|
compactNested
|
|
@@ -14,6 +14,51 @@ import { getAddress, getProtocol, splitByteSize } from './common';
|
|
|
14
14
|
import { getOptionsFromRequiredGroups } from './options';
|
|
15
15
|
import { getTemplateKey, getTemplateValue, isValueTemplate } from './templates';
|
|
16
16
|
|
|
17
|
+
/** The five cron fields, in order, as a schedule hash may name them. */
|
|
18
|
+
const CRON_FIELDS: [string, string][] = [
|
|
19
|
+
['minutes', 'minute'],
|
|
20
|
+
['hours', 'hour'],
|
|
21
|
+
['days', 'day'],
|
|
22
|
+
['months', 'month'],
|
|
23
|
+
['dow', 'weekday'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A five-field cron expression, from either representation a caller may hold.
|
|
28
|
+
*
|
|
29
|
+
* A schedule reaches the form in one of two shapes, and both are legitimate: a
|
|
30
|
+
* joined string (`"0 0 1 1 *"`), or the hash a Qorus job carries
|
|
31
|
+
* (`{minutes, hours, days, months, dow}` — also accepted under the singular
|
|
32
|
+
* `minute`/`hour`/`day`/`month`/`weekday` spellings). The renderer already
|
|
33
|
+
* accepted both; validation did not, and fed the hash straight to
|
|
34
|
+
* `isValidCron`, which calls `.trim()` on it.
|
|
35
|
+
*
|
|
36
|
+
* Returns `undefined` for anything that is neither, so the caller reports an
|
|
37
|
+
* invalid value rather than throwing.
|
|
38
|
+
*/
|
|
39
|
+
export const cronExpressionFromValue = (value: unknown): string | undefined => {
|
|
40
|
+
if (typeof value === 'string') {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hash = value as Record<string, unknown>;
|
|
49
|
+
const parts = CRON_FIELDS.map(([plural, singular]) => {
|
|
50
|
+
const field = hash[plural] ?? hash[singular];
|
|
51
|
+
return field === undefined || field === null || field === '' ? '*' : String(field);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// An object with none of the five keys is not a schedule — every part
|
|
55
|
+
// defaulting to `*` would otherwise turn any object at all into "* * * * *"
|
|
56
|
+
return parts.every((part) => part === '*') &&
|
|
57
|
+
!CRON_FIELDS.some(([plural, singular]) => plural in hash || singular in hash)
|
|
58
|
+
? undefined
|
|
59
|
+
: parts.join(' ');
|
|
60
|
+
};
|
|
61
|
+
|
|
17
62
|
export interface IValidationResult {
|
|
18
63
|
isValid: boolean;
|
|
19
64
|
reason?: string;
|
|
@@ -322,11 +367,23 @@ export const _validateField = (
|
|
|
322
367
|
case 'array':
|
|
323
368
|
case 'file-tree':
|
|
324
369
|
return resultFromBoolean(value && value.length > 0, 'At least one value is required');
|
|
325
|
-
case 'cron':
|
|
370
|
+
case 'cron': {
|
|
326
371
|
if (!value) {
|
|
327
372
|
return invalidResult('Cron value is required');
|
|
328
373
|
}
|
|
329
|
-
|
|
374
|
+
const expression = cronExpressionFromValue(value);
|
|
375
|
+
// A value that is neither a cron string nor a schedule hash is INVALID,
|
|
376
|
+
// not a crash: `isValidCron` calls `.trim()` on whatever it is given, so
|
|
377
|
+
// passing it a hash threw a TypeError out of validation and took the
|
|
378
|
+
// whole form down through the host's error boundary.
|
|
379
|
+
if (expression === undefined) {
|
|
380
|
+
return invalidResult('Cron expression is invalid');
|
|
381
|
+
}
|
|
382
|
+
return resultFromBoolean(
|
|
383
|
+
isValidCron(expression, { alias: true }),
|
|
384
|
+
'Cron expression is invalid'
|
|
385
|
+
);
|
|
386
|
+
}
|
|
330
387
|
case 'date':
|
|
331
388
|
return resultFromBoolean(
|
|
332
389
|
value !== undefined &&
|
package/src/index.tsx
CHANGED