@svgrid/enterprise 2.6.0 → 2.6.2
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/ExpressionEditorHarness.svelte +47 -0
- package/dist/ExpressionEditorHarness.svelte.d.ts +15 -0
- package/dist/SvAdvancedFilter.svelte +208 -0
- package/dist/SvAdvancedFilter.svelte.d.ts +37 -0
- package/dist/SvExpressionEditor.svelte +255 -124
- package/dist/SvGridEditPanel.svelte +1157 -1012
- package/dist/SvGridEditPanel.svelte.d.ts +2 -0
- package/dist/advanced-filter/expr-columns-from-grid.d.ts +30 -0
- package/dist/advanced-filter/expr-columns-from-grid.js +44 -0
- package/dist/advanced-filter-enable.d.ts +5 -0
- package/dist/advanced-filter-enable.js +35 -0
- package/dist/cdn/svgrid-enterprise.svelte-external.js +7663 -6482
- package/dist/designer/assets/GridMenus-Bjr19Tz_.js +7 -0
- package/dist/designer/assets/{SvListBox-BVzVfCG3.js → SvListBox-BB8rt4aP.js} +1 -1
- package/dist/designer/assets/index-BfzoL904.css +1 -0
- package/dist/designer/assets/{index-BF0UH638.js → index-xjfKckuH.js} +139 -139
- package/dist/designer/assets/{js-scroller.svelte-tHanKJx0.js → js-scroller.svelte-CGkUUXwV.js} +2 -2
- package/dist/designer/assets/src-BT3LjWJL.css +1 -0
- package/dist/designer/assets/src-DDhAK0WP.js +2893 -0
- package/dist/designer/index.html +6 -6
- package/dist/expressions/builder-tree.d.ts +62 -0
- package/dist/expressions/builder-tree.js +133 -0
- package/dist/expressions/compile.d.ts +14 -0
- package/dist/expressions/compile.js +286 -0
- package/dist/expressions/expression-columns.d.ts +1 -11
- package/dist/expressions/expression-columns.js +40 -11
- package/dist/index.d.ts +7 -3
- package/dist/index.js +7 -3
- package/dist/install.js +2 -0
- package/dist/node/studio.js +743 -25
- package/dist/schema.d.ts +39 -0
- package/dist/schema.js +9 -0
- package/dist/sources/rest.js +25 -0
- package/dist/sources/supabase.js +49 -3
- package/dist/studio/emit-project.js +85 -11
- package/dist/studio/index.d.ts +2 -2
- package/dist/studio/index.js +5 -2
- package/dist/studio/project.d.ts +124 -3
- package/dist/studio/project.js +393 -4
- package/dist/studio/ui-components.generated.js +213 -0
- package/dist/sveltekit/in-memory.js +96 -3
- package/dist/sveltekit/query-plan.d.ts +32 -1
- package/dist/sveltekit/query-plan.js +88 -1
- package/dist/sveltekit/sql.d.ts +20 -0
- package/dist/sveltekit/sql.js +26 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/dist/designer/assets/GridMenus-CW-rnHS9.js +0 -7
- package/dist/designer/assets/index-oYok52zd.css +0 -1
- package/dist/designer/assets/src-BPJruwB9.js +0 -2873
- package/dist/designer/assets/src-D-O2F805.css +0 -1
package/dist/studio/project.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Studio project model - the declarative spine the visual designer edits and the
|
|
3
|
+
* codegen emits. A project is a multi-entity app: `entities` (the data model) +
|
|
4
|
+
* `screens` (one per entity by default), where a screen is an ordered list of
|
|
5
|
+
* data-bound `blocks` (grid, form, chart, dashboard, KPI, master-detail, lookup).
|
|
6
|
+
*
|
|
7
|
+
* Pure + node-safe (lives in the `./studio` subtree - `.js` imports, no Svelte,
|
|
8
|
+
* no `sources/`), mirroring the immutable-ops style of `schema-designer.ts`:
|
|
9
|
+
* every mutation returns a NEW project so Svelte reactivity + undo stay simple.
|
|
10
|
+
* Block-config unions are defined here (not imported from `sources/`) to keep the
|
|
11
|
+
* module resolvable under node16.
|
|
12
|
+
*/
|
|
13
|
+
import { schemaToFormFields } from '../schema.js';
|
|
1
14
|
import { collectColumnRefs } from '../expressions/parse.js';
|
|
2
15
|
import { uiComponentSpec } from './ui-components.js';
|
|
3
16
|
/** Block kinds allowed inside a Tabs / Accordion container: the controller-free,
|
|
@@ -401,10 +414,12 @@ export function setJob(project, id, job) {
|
|
|
401
414
|
const next = job ? [...rest, { ...job, id }] : rest;
|
|
402
415
|
return { ...project, jobs: next.length ? next : undefined };
|
|
403
416
|
}
|
|
404
|
-
/** The designer's block palette, in menu order.
|
|
405
|
-
*
|
|
417
|
+
/** The designer's block palette, in menu order. A grid owns edit-in-popup and a
|
|
418
|
+
* record panel edits the selected row, so the standalone Form block is the one
|
|
419
|
+
* that creates: a form on its own page, with no grid behind it. */
|
|
406
420
|
export const blockPalette = [
|
|
407
421
|
{ kind: 'grid', label: 'Grid' },
|
|
422
|
+
{ kind: 'form', label: 'Form' },
|
|
408
423
|
{ kind: 'chart', label: 'Chart', needs: 'measure' },
|
|
409
424
|
{ kind: 'pivot', label: 'Pivot', needs: 'measure' },
|
|
410
425
|
{ kind: 'dashboard', label: 'Dashboard' },
|
|
@@ -463,7 +478,8 @@ export function defaultBlockConfig(kind, entity) {
|
|
|
463
478
|
case 'grid':
|
|
464
479
|
return { kind, columns: gridColumns(entity), pageSize: 10, selectable: true, sortable: true, filterable: false, editing: 'form', formPresentation: 'modal', density: 'normal', striped: false, cellSelection: false, rowSummaries: false, paginated: true, paginationPosition: 'bottom', pageSizeOptions: [10, 25, 50, 100] };
|
|
465
480
|
case 'form':
|
|
466
|
-
|
|
481
|
+
// Inline: a create form belongs on the page, not floating over it.
|
|
482
|
+
return { kind, presentation: 'inline', afterSave: 'reset' };
|
|
467
483
|
case 'chart': {
|
|
468
484
|
const measure = pickMeasure(entity);
|
|
469
485
|
return { kind, dimension: pickDimension(entity), measure, reduce: measure ? 'sum' : 'count', type: 'bar' };
|
|
@@ -839,6 +855,365 @@ export function removeEntity(project, name) {
|
|
|
839
855
|
screens: project.screens.filter((s) => s.entity !== name),
|
|
840
856
|
};
|
|
841
857
|
}
|
|
858
|
+
// ---- Form layout ----------------------------------------------------------
|
|
859
|
+
//
|
|
860
|
+
// The arrangement lives on the entity (`EntitySchema.form`) rather than on the
|
|
861
|
+
// block that happens to render it, so a form built once travels with the entity:
|
|
862
|
+
// it round-trips through `studio.config.json`, generates into the app, and draws
|
|
863
|
+
// the same in the edit panel and in a server-rendered form. These are the
|
|
864
|
+
// operations a builder UI drives; keeping them here - pure, on the project -
|
|
865
|
+
// means the designer and an MCP tool cannot disagree about what "move a field"
|
|
866
|
+
// means.
|
|
867
|
+
/**
|
|
868
|
+
* The fields a form actually renders, in schema order. Arranging anything else
|
|
869
|
+
* would be a lie: a field hidden from the form cannot be put in a section and
|
|
870
|
+
* then appear there.
|
|
871
|
+
*/
|
|
872
|
+
function formFieldNames(schema) {
|
|
873
|
+
return schemaToFormFields(schema).map((f) => f.field);
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* The sections as a builder should show them, plus the fields no section claims.
|
|
877
|
+
*
|
|
878
|
+
* Sections name their fields by string, so a rename, a delete, or hiding a field
|
|
879
|
+
* from the form can leave a name behind. Those are dropped here rather than
|
|
880
|
+
* drawn as ghosts, and a field in no section comes back in `unassigned` - which
|
|
881
|
+
* is where the form itself puts it too, in a trailing untitled group.
|
|
882
|
+
*/
|
|
883
|
+
export function formPlan(schema, override) {
|
|
884
|
+
const inForm = formFieldNames(schema);
|
|
885
|
+
const known = new Set(inForm);
|
|
886
|
+
const source = override ?? schema.form?.sections ?? [];
|
|
887
|
+
const seen = new Set();
|
|
888
|
+
const sections = source.map((section) => {
|
|
889
|
+
// A field named twice belongs to the first section that claimed it: two
|
|
890
|
+
// copies of one control in a form is never what anybody meant. Claiming as
|
|
891
|
+
// we go also catches a name repeated inside a single section.
|
|
892
|
+
const fields = section.fields.filter((f) => {
|
|
893
|
+
if (!known.has(f) || seen.has(f))
|
|
894
|
+
return false;
|
|
895
|
+
seen.add(f);
|
|
896
|
+
return true;
|
|
897
|
+
});
|
|
898
|
+
return { ...section, fields };
|
|
899
|
+
});
|
|
900
|
+
return { sections, unassigned: inForm.filter((f) => !seen.has(f)) };
|
|
901
|
+
}
|
|
902
|
+
/** Replace an entity's form layout. `undefined` drops it back to the default. */
|
|
903
|
+
export function setEntityForm(project, entityName, form) {
|
|
904
|
+
return mapEntity(project, entityName, (schema) => {
|
|
905
|
+
const next = { ...schema };
|
|
906
|
+
// An empty layout is deleted rather than stored, so a config file does not
|
|
907
|
+
// carry `form: {}` around and a reader can tell "no layout" from "one column".
|
|
908
|
+
if (form && (form.columns !== undefined || form.sections?.length))
|
|
909
|
+
next.form = form;
|
|
910
|
+
else
|
|
911
|
+
delete next.form;
|
|
912
|
+
return next;
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
/** Set the form's column count, or clear it back to the default. */
|
|
916
|
+
export function setFormColumns(project, entityName, columns) {
|
|
917
|
+
const schema = entityOf(project, entityName);
|
|
918
|
+
if (!schema)
|
|
919
|
+
return project;
|
|
920
|
+
return setEntityForm(project, entityName, { ...schema.form, columns });
|
|
921
|
+
}
|
|
922
|
+
/** Append a section. New sections start empty - fields move in afterwards. */
|
|
923
|
+
export function addFormSection(project, entityName, title = 'Section') {
|
|
924
|
+
const schema = entityOf(project, entityName);
|
|
925
|
+
if (!schema)
|
|
926
|
+
return project;
|
|
927
|
+
return setEntityForm(project, entityName, { ...schema.form, sections: [...(schema.form?.sections ?? []), { title, fields: [] }] });
|
|
928
|
+
}
|
|
929
|
+
/** Patch one section. An out-of-range index is a no-op rather than an error. */
|
|
930
|
+
export function updateFormSection(project, entityName, index, patch) {
|
|
931
|
+
const schema = entityOf(project, entityName);
|
|
932
|
+
const sections = schema?.form?.sections;
|
|
933
|
+
if (!sections?.[index])
|
|
934
|
+
return project;
|
|
935
|
+
const next = sections.map((s, i) => {
|
|
936
|
+
if (i !== index)
|
|
937
|
+
return s;
|
|
938
|
+
const merged = { ...s, ...patch };
|
|
939
|
+
// A cleared title / description is dropped rather than stored blank, so the
|
|
940
|
+
// form renders an untitled group instead of an empty heading.
|
|
941
|
+
if (!merged.title)
|
|
942
|
+
delete merged.title;
|
|
943
|
+
if (!merged.description)
|
|
944
|
+
delete merged.description;
|
|
945
|
+
if (!merged.visibleWhen)
|
|
946
|
+
delete merged.visibleWhen;
|
|
947
|
+
if (!merged.columns)
|
|
948
|
+
delete merged.columns;
|
|
949
|
+
if (!merged.collapsible) {
|
|
950
|
+
delete merged.collapsible;
|
|
951
|
+
delete merged.collapsed;
|
|
952
|
+
}
|
|
953
|
+
if (!merged.collapsed)
|
|
954
|
+
delete merged.collapsed;
|
|
955
|
+
return merged;
|
|
956
|
+
});
|
|
957
|
+
return setEntityForm(project, entityName, { ...schema.form, sections: next });
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Remove a section. Its fields stay in the form - they fall back to the trailing
|
|
961
|
+
* untitled group, so deleting a heading never deletes the questions under it.
|
|
962
|
+
*/
|
|
963
|
+
export function removeFormSection(project, entityName, index) {
|
|
964
|
+
const schema = entityOf(project, entityName);
|
|
965
|
+
const sections = schema?.form?.sections;
|
|
966
|
+
if (!sections?.[index])
|
|
967
|
+
return project;
|
|
968
|
+
return setEntityForm(project, entityName, { ...schema.form, sections: sections.filter((_, i) => i !== index) });
|
|
969
|
+
}
|
|
970
|
+
/** Reorder the sections themselves. */
|
|
971
|
+
export function moveFormSection(project, entityName, from, to) {
|
|
972
|
+
const schema = entityOf(project, entityName);
|
|
973
|
+
const sections = schema?.form?.sections;
|
|
974
|
+
if (!sections?.[from] || to < 0 || to >= sections.length || from === to)
|
|
975
|
+
return project;
|
|
976
|
+
const next = [...sections];
|
|
977
|
+
next.splice(to, 0, next.splice(from, 1)[0]);
|
|
978
|
+
return setEntityForm(project, entityName, { ...schema.form, sections: next });
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Move a field into a section at a position, or out of every section when
|
|
982
|
+
* `toSection` is null. The field is pulled out of wherever it was first, so it
|
|
983
|
+
* can never end up in two sections - which would render the control twice.
|
|
984
|
+
*/
|
|
985
|
+
export function moveFormField(project, entityName, field, toSection, toIndex = Number.MAX_SAFE_INTEGER) {
|
|
986
|
+
const schema = entityOf(project, entityName);
|
|
987
|
+
// Only a field the form renders can be placed - see `formPlan`.
|
|
988
|
+
if (!schema || !formFieldNames(schema).includes(field))
|
|
989
|
+
return project;
|
|
990
|
+
const sections = schema.form?.sections ?? [];
|
|
991
|
+
if (toSection !== null && !sections[toSection])
|
|
992
|
+
return project;
|
|
993
|
+
const stripped = sections.map((s) => ({ ...s, fields: s.fields.filter((f) => f !== field) }));
|
|
994
|
+
if (toSection === null)
|
|
995
|
+
return setEntityForm(project, entityName, { ...schema.form, sections: stripped });
|
|
996
|
+
const target = stripped[toSection];
|
|
997
|
+
const at = Math.max(0, Math.min(toIndex, target.fields.length));
|
|
998
|
+
const fields = [...target.fields];
|
|
999
|
+
fields.splice(at, 0, field);
|
|
1000
|
+
return setEntityForm(project, entityName, {
|
|
1001
|
+
...schema.form,
|
|
1002
|
+
sections: stripped.map((s, i) => (i === toSection ? { ...s, fields } : s)),
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Move several fields at once, as one contiguous block in the order given.
|
|
1007
|
+
*
|
|
1008
|
+
* Not a loop of {@link moveFormField}: each single move both shifts the target
|
|
1009
|
+
* indices and re-resolves the plan, so a loop lands the group scattered or
|
|
1010
|
+
* reversed. Strip every named field first, then splice the whole group at the
|
|
1011
|
+
* index computed against the stripped target.
|
|
1012
|
+
*
|
|
1013
|
+
* Unknown and hidden-from-form names are dropped, duplicates keep their first
|
|
1014
|
+
* position, and an input that resolves to nothing returns the project
|
|
1015
|
+
* unchanged (same reference), so an undo stack never records a no-op.
|
|
1016
|
+
*/
|
|
1017
|
+
export function moveFormFields(project, entityName, fields, toSection, toIndex = Number.MAX_SAFE_INTEGER) {
|
|
1018
|
+
const schema = entityOf(project, entityName);
|
|
1019
|
+
if (!schema)
|
|
1020
|
+
return project;
|
|
1021
|
+
const movable = new Set(formFieldNames(schema));
|
|
1022
|
+
const group = [];
|
|
1023
|
+
for (const f of fields)
|
|
1024
|
+
if (movable.has(f) && !group.includes(f))
|
|
1025
|
+
group.push(f);
|
|
1026
|
+
if (!group.length)
|
|
1027
|
+
return project;
|
|
1028
|
+
const sections = schema.form?.sections ?? [];
|
|
1029
|
+
if (toSection !== null && !sections[toSection])
|
|
1030
|
+
return project;
|
|
1031
|
+
const moving = new Set(group);
|
|
1032
|
+
const stripped = sections.map((s) => ({ ...s, fields: s.fields.filter((f) => !moving.has(f)) }));
|
|
1033
|
+
if (toSection === null)
|
|
1034
|
+
return setEntityForm(project, entityName, { ...schema.form, sections: stripped });
|
|
1035
|
+
const target = stripped[toSection];
|
|
1036
|
+
const at = Math.max(0, Math.min(toIndex, target.fields.length));
|
|
1037
|
+
const next = [...target.fields];
|
|
1038
|
+
next.splice(at, 0, ...group);
|
|
1039
|
+
return setEntityForm(project, entityName, {
|
|
1040
|
+
...schema.form,
|
|
1041
|
+
sections: stripped.map((s, i) => (i === toSection ? { ...s, fields: next } : s)),
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Set (or clear) a field's value-driven conditions. Each is a `PredicateExpr`,
|
|
1046
|
+
* so it stays data all the way into the generated app - see `EntityField.when`.
|
|
1047
|
+
*/
|
|
1048
|
+
export function setFieldConditions(project, entityName, field, when) {
|
|
1049
|
+
return mapEntity(project, entityName, (schema) => ({
|
|
1050
|
+
...schema,
|
|
1051
|
+
fields: schema.fields.map((f) => {
|
|
1052
|
+
if (f.field !== field)
|
|
1053
|
+
return f;
|
|
1054
|
+
const next = { ...f };
|
|
1055
|
+
if (when?.visible || when?.disabled || when?.required) {
|
|
1056
|
+
next.when = {
|
|
1057
|
+
...(when.visible && { visible: when.visible }),
|
|
1058
|
+
...(when.disabled && { disabled: when.disabled }),
|
|
1059
|
+
...(when.required && { required: when.required }),
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
delete next.when;
|
|
1064
|
+
}
|
|
1065
|
+
return next;
|
|
1066
|
+
}),
|
|
1067
|
+
}));
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Patch one field of one entity. The form builder edits an entity that is not
|
|
1071
|
+
* necessarily the selected screen's, so it needs the project-level door rather
|
|
1072
|
+
* than the schema-level `updateField`.
|
|
1073
|
+
*
|
|
1074
|
+
* Deliberately shallow: `type` changes belong to the schema designer, which also
|
|
1075
|
+
* has to clean up `options` / `relation` when the type moves.
|
|
1076
|
+
*/
|
|
1077
|
+
export function updateEntityField(project, entityName, field, patch) {
|
|
1078
|
+
return mapEntity(project, entityName, (schema) => ({
|
|
1079
|
+
...schema,
|
|
1080
|
+
fields: schema.fields.map((f) => (f.field === field ? { ...f, ...patch } : f)),
|
|
1081
|
+
}));
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Patch a field's form presentation (`EntityField.input`). Merges rather than
|
|
1085
|
+
* replaces, and a key set to `undefined` or `''` is removed - so clearing a
|
|
1086
|
+
* placeholder leaves no empty string behind, and emptying the last key drops
|
|
1087
|
+
* `input` itself rather than persisting `input: {}`.
|
|
1088
|
+
*/
|
|
1089
|
+
export function setFieldInput(project, entityName, field, patch) {
|
|
1090
|
+
return mapEntity(project, entityName, (schema) => ({
|
|
1091
|
+
...schema,
|
|
1092
|
+
fields: schema.fields.map((f) => {
|
|
1093
|
+
if (f.field !== field)
|
|
1094
|
+
return f;
|
|
1095
|
+
const input = { ...f.input };
|
|
1096
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
1097
|
+
if (value === undefined || value === '')
|
|
1098
|
+
delete input[key];
|
|
1099
|
+
else
|
|
1100
|
+
input[key] = value;
|
|
1101
|
+
}
|
|
1102
|
+
const next = { ...f };
|
|
1103
|
+
if (Object.keys(input).length)
|
|
1104
|
+
next.input = input;
|
|
1105
|
+
else
|
|
1106
|
+
delete next.input;
|
|
1107
|
+
return next;
|
|
1108
|
+
}),
|
|
1109
|
+
}));
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Show or hide a field on one surface, keeping the other surface's answer.
|
|
1113
|
+
* `hidden: true` means both, so it is expanded before one half is changed -
|
|
1114
|
+
* otherwise hiding a field from the form would silently un-hide its column.
|
|
1115
|
+
*/
|
|
1116
|
+
export function setFieldHidden(project, entityName, field, surface, hidden) {
|
|
1117
|
+
return mapEntity(project, entityName, (schema) => ({
|
|
1118
|
+
...schema,
|
|
1119
|
+
fields: schema.fields.map((f) => {
|
|
1120
|
+
if (f.field !== field)
|
|
1121
|
+
return f;
|
|
1122
|
+
const current = f.hidden === true ? { grid: true, form: true } : { ...(f.hidden || {}) };
|
|
1123
|
+
const merged = { ...current, [surface]: hidden };
|
|
1124
|
+
const next = { ...f };
|
|
1125
|
+
if (merged.grid && merged.form)
|
|
1126
|
+
next.hidden = true;
|
|
1127
|
+
else if (merged.grid || merged.form)
|
|
1128
|
+
next.hidden = { ...(merged.grid && { grid: true }), ...(merged.form && { form: true }) };
|
|
1129
|
+
else
|
|
1130
|
+
delete next.hidden;
|
|
1131
|
+
return next;
|
|
1132
|
+
}),
|
|
1133
|
+
}));
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* The form controls worth offering for a field type. A form builder that listed
|
|
1137
|
+
* every editor for every type would be a wall of mostly-wrong choices - a date
|
|
1138
|
+
* field has no business rendering as a colour picker. The first entry is what
|
|
1139
|
+
* the field renders as when `input.editorType` is unset.
|
|
1140
|
+
*/
|
|
1141
|
+
export function formControlsFor(type) {
|
|
1142
|
+
switch (type) {
|
|
1143
|
+
case 'number': return ['number', 'slider', 'rating', 'text'];
|
|
1144
|
+
case 'boolean': return ['checkbox'];
|
|
1145
|
+
case 'date':
|
|
1146
|
+
case 'dateString': return ['date', 'date-native'];
|
|
1147
|
+
case 'datetime': return ['datetime', 'datetime-native', 'time'];
|
|
1148
|
+
case 'enum': return ['select', 'rich-select', 'list', 'chips', 'autocomplete'];
|
|
1149
|
+
case 'relation': return ['select', 'autocomplete'];
|
|
1150
|
+
case 'json': return ['textarea', 'text'];
|
|
1151
|
+
default: return ['text', 'textarea', 'password', 'phone', 'country', 'mask', 'color', 'autocomplete', 'chips'];
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
export function formControlSettings(control, type) {
|
|
1155
|
+
const kind = control ?? formControlsFor(type)[0];
|
|
1156
|
+
if (kind === 'mask')
|
|
1157
|
+
return ['mask'];
|
|
1158
|
+
if (kind === 'slider' || kind === 'rating')
|
|
1159
|
+
return ['range', 'step'];
|
|
1160
|
+
if (kind === 'number' || (type === 'number' && kind !== 'text'))
|
|
1161
|
+
return ['range', 'step', 'precision', 'affix'];
|
|
1162
|
+
return [];
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* A first pass at sectioning an unarranged form, by what the field names say
|
|
1166
|
+
* they are. Studio's promise is a working app in a few clicks, and an entity of
|
|
1167
|
+
* fifteen fields in one flat list is not that.
|
|
1168
|
+
*
|
|
1169
|
+
* A guess, offered once: it returns sections for a builder to drop in and then
|
|
1170
|
+
* edit, and it never invents a group for a form small enough not to need one.
|
|
1171
|
+
*/
|
|
1172
|
+
const SECTION_HINTS = [
|
|
1173
|
+
{ title: 'Contact', match: /^(email|phone|mobile|fax|website|url|contact)/i },
|
|
1174
|
+
{ title: 'Address', match: /(address|street|city|state|province|zip|postcode|postal|country|region)/i },
|
|
1175
|
+
{ title: 'Money', match: /(amount|price|cost|total|revenue|mrr|arr|salary|budget|value|balance|fee|tax|discount)/i },
|
|
1176
|
+
{ title: 'Dates', match: /(date|_at$|At$|deadline|due|expires|starts|ends)/i },
|
|
1177
|
+
{ title: 'Notes', match: /(note|notes|comment|comments|description|summary|remark)/i },
|
|
1178
|
+
];
|
|
1179
|
+
export function suggestFormSections(schema) {
|
|
1180
|
+
const names = formFieldNames(schema);
|
|
1181
|
+
// Below this a flat form reads fine, and a heading over two fields is noise.
|
|
1182
|
+
if (names.length < 6)
|
|
1183
|
+
return [];
|
|
1184
|
+
const buckets = new Map();
|
|
1185
|
+
const rest = [];
|
|
1186
|
+
for (const name of names) {
|
|
1187
|
+
const hint = SECTION_HINTS.find((h) => h.match.test(name));
|
|
1188
|
+
if (!hint) {
|
|
1189
|
+
rest.push(name);
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
const bucket = buckets.get(hint.title);
|
|
1193
|
+
if (bucket)
|
|
1194
|
+
bucket.push(name);
|
|
1195
|
+
else
|
|
1196
|
+
buckets.set(hint.title, [name]);
|
|
1197
|
+
}
|
|
1198
|
+
// A bucket of one is not a group; give it back to Details rather than crown it.
|
|
1199
|
+
const sections = [];
|
|
1200
|
+
for (const [title, fields] of buckets) {
|
|
1201
|
+
if (fields.length > 1)
|
|
1202
|
+
sections.push({ title, fields });
|
|
1203
|
+
else
|
|
1204
|
+
rest.push(...fields);
|
|
1205
|
+
}
|
|
1206
|
+
if (!sections.length)
|
|
1207
|
+
return [];
|
|
1208
|
+
// Whatever matched nothing leads, under the entity's own name for a heading.
|
|
1209
|
+
return rest.length ? [{ title: 'Details', fields: rest }, ...sections] : sections;
|
|
1210
|
+
}
|
|
1211
|
+
/** Apply a change to one entity's schema, leaving the project alone if it is unknown. */
|
|
1212
|
+
function mapEntity(project, entityName, fn) {
|
|
1213
|
+
if (!entityOf(project, entityName))
|
|
1214
|
+
return project;
|
|
1215
|
+
return { ...project, entities: project.entities.map((e) => (e.name === entityName ? fn(e) : e)) };
|
|
1216
|
+
}
|
|
842
1217
|
/** Append a screen, making its id AND route unique against the existing set. */
|
|
843
1218
|
function appendScreen(project, base, keyBase) {
|
|
844
1219
|
const ids = new Set(project.screens.map((s) => s.id));
|
|
@@ -1707,7 +2082,21 @@ export function validateProject(project) {
|
|
|
1707
2082
|
for (const b of flattenBlocks(s.blocks)) {
|
|
1708
2083
|
const at = (message, level = 'warning') => ({ level, message, screen: s.id, block: b.id });
|
|
1709
2084
|
const c = b.config;
|
|
1710
|
-
if (c.kind === '
|
|
2085
|
+
if (c.kind === 'form') {
|
|
2086
|
+
// A form on a screen with no entity has nothing to create.
|
|
2087
|
+
if (!s.entity)
|
|
2088
|
+
issues.push(at('A Form block needs a screen bound to an entity.', 'error'));
|
|
2089
|
+
// "Go to a screen" that names none, or names a screen that has since been
|
|
2090
|
+
// removed, leaves the user on a form that appears to do nothing on save.
|
|
2091
|
+
if (c.afterSave === 'navigate') {
|
|
2092
|
+
if (!c.navigateTo)
|
|
2093
|
+
issues.push(at('The form is set to open a screen after saving, but no screen is chosen.'));
|
|
2094
|
+
else if (!project.screens.some((x) => x.id === c.navigateTo)) {
|
|
2095
|
+
issues.push(at(`The form opens a missing screen "${c.navigateTo}" after saving.`, 'error'));
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
else if (c.kind === 'master-detail') {
|
|
1711
2100
|
if (!c.childEntity || !c.foreignKey)
|
|
1712
2101
|
issues.push(at('Master/detail needs a child entity + foreign key.'));
|
|
1713
2102
|
else if (!entityOf(project, c.childEntity))
|