@xeplr/ui-factory 1.0.6 → 1.0.8
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/AUTHORING.md +16 -5
- package/README.md +49 -3
- package/package.json +1 -1
- package/src/controls.js +191 -0
- package/src/designs/BuilderSample.jsx +38 -4
- package/src/designs/ControlView.jsx +202 -2
- package/src/designs/PropertyPanel.jsx +71 -1
- package/src/designs/ScreenSample.jsx +34 -3
- package/src/designs/factory.css +65 -0
- package/src/document.js +67 -3
- package/src/generate.js +83 -19
- package/src/model.js +4 -3
- package/src/remote.js +32 -2
- package/src/tableSchema.js +16 -2
- package/src/useFactoryBuilder.js +40 -3
- package/src/useFactoryScreen.js +61 -5
- package/src/validateDocument.js +89 -6
- package/src/values.js +89 -10
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getAtPath } from '../propertyPath.js'
|
|
2
2
|
import { slugify, inputNodes } from '../document.js'
|
|
3
|
-
import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS } from '../controls.js'
|
|
3
|
+
import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS } from '../controls.js'
|
|
4
4
|
|
|
5
5
|
// The selected control's properties, GENERATED from its entry in controls.js:
|
|
6
6
|
// each field there names a path and an editor type, and this file owns only
|
|
@@ -152,6 +152,39 @@ function DateEditor({ id, value, onChange }) {
|
|
|
152
152
|
return <input id={id} className="xeplr-factory-prop-input" type="date" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
function DatetimeEditor({ id, value, onChange }) {
|
|
156
|
+
return <input id={id} className="xeplr-factory-prop-input" type="datetime-local" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Which files a file field takes: one of the everyday sets, or a list typed
|
|
161
|
+
* out. The list is what is saved either way — the sets are a way in, not a
|
|
162
|
+
* second setting to keep in step.
|
|
163
|
+
*/
|
|
164
|
+
function AcceptEditor({ id, value, onChange }) {
|
|
165
|
+
const kind = Object.keys(FILE_KINDS).find((k) => FILE_KINDS[k].accept === value)
|
|
166
|
+
return (
|
|
167
|
+
<div className="xeplr-factory-prop-stack">
|
|
168
|
+
<select
|
|
169
|
+
id={id}
|
|
170
|
+
className="xeplr-factory-prop-input"
|
|
171
|
+
value={kind || '__custom'}
|
|
172
|
+
onChange={(e) => { const k = e.target.value; if (k !== '__custom') onChange(FILE_KINDS[k].accept) }}
|
|
173
|
+
>
|
|
174
|
+
{Object.entries(FILE_KINDS).map(([k, f]) => <option key={k} value={k}>{f.label}</option>)}
|
|
175
|
+
<option value="__custom">Just these…</option>
|
|
176
|
+
</select>
|
|
177
|
+
<input
|
|
178
|
+
className="xeplr-factory-prop-input"
|
|
179
|
+
type="text"
|
|
180
|
+
value={value ?? ''}
|
|
181
|
+
placeholder=".pdf,.docx"
|
|
182
|
+
onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
155
188
|
/** A font family: the common ones, or anything typed. */
|
|
156
189
|
function FontEditor({ id, value, onChange }) {
|
|
157
190
|
const known = FONT_FAMILIES.some((f) => f.value === value)
|
|
@@ -384,11 +417,48 @@ function StaticOptions({ data, onChange, errors }) {
|
|
|
384
417
|
)
|
|
385
418
|
}
|
|
386
419
|
|
|
420
|
+
/**
|
|
421
|
+
* The steps, as one line: "Connect, Transform, Review". A step KEEPS ITS KEY
|
|
422
|
+
* while it is renamed — the controls on it point at the key — so editing the
|
|
423
|
+
* line matches by position, and only a step that is added or removed changes
|
|
424
|
+
* which controls are shown.
|
|
425
|
+
*/
|
|
426
|
+
function StepsEditor({ id, value, onChange }) {
|
|
427
|
+
const steps = Array.isArray(value) ? value : []
|
|
428
|
+
return (
|
|
429
|
+
<input
|
|
430
|
+
id={id}
|
|
431
|
+
className="xeplr-factory-prop-input"
|
|
432
|
+
type="text"
|
|
433
|
+
value={steps.map((s) => s.label).join(', ')}
|
|
434
|
+
placeholder="Connect, Transform, Review"
|
|
435
|
+
onChange={(e) => {
|
|
436
|
+
const labels = e.target.value.split(',').map((t) => t.trim()).filter(Boolean)
|
|
437
|
+
onChange(labels.map((label, i) => ({
|
|
438
|
+
key: steps[i] ? steps[i].key : uniqueStepKey(steps, slugify(label, '_') || `step_${i + 1}`),
|
|
439
|
+
label
|
|
440
|
+
})))
|
|
441
|
+
}}
|
|
442
|
+
/>
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function uniqueStepKey(steps, base) {
|
|
447
|
+
const taken = new Set(steps.map((s) => s.key))
|
|
448
|
+
if (!taken.has(base)) return base
|
|
449
|
+
let n = 2
|
|
450
|
+
while (taken.has(`${base}_${n}`)) n++
|
|
451
|
+
return `${base}_${n}`
|
|
452
|
+
}
|
|
453
|
+
|
|
387
454
|
export const EDITORS = {
|
|
388
455
|
text: TextEditor,
|
|
389
456
|
textarea: TextareaEditor,
|
|
390
457
|
number: NumberEditor,
|
|
391
458
|
date: DateEditor,
|
|
459
|
+
datetime: DatetimeEditor,
|
|
460
|
+
accept: AcceptEditor,
|
|
461
|
+
steps: StepsEditor,
|
|
392
462
|
toggle: ToggleEditor,
|
|
393
463
|
toggleValue: ToggleValueEditor,
|
|
394
464
|
select: SelectEditor,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import ControlView from './ControlView.jsx'
|
|
2
2
|
import { contentBottom, inputNodes, MARGIN } from '../document.js'
|
|
3
3
|
import { screenStyle } from './styles.js'
|
|
4
|
+
import { CHOICE_TYPES } from '../controls.js'
|
|
4
5
|
|
|
5
6
|
// A saved screen, drawn. Presentation only — everything comes from
|
|
6
7
|
// useFactoryScreen.
|
|
@@ -21,6 +22,28 @@ const STATUS = {
|
|
|
21
22
|
invalid: 'Fix the highlighted fields to save'
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Back and Next, under the page. Next holds at a step whose own fields are not
|
|
27
|
+
* filled in — and shows why, rather than moving on and leaving a problem
|
|
28
|
+
* behind on a step nobody is looking at.
|
|
29
|
+
*/
|
|
30
|
+
function StepNav({ ctrl, node }) {
|
|
31
|
+
const at = ctrl.steps.active(node.id)
|
|
32
|
+
const last = ctrl.steps.count(node.id) - 1
|
|
33
|
+
const unfinished = ctrl.steps.fieldsOn(node.id, at).filter((n) => ctrl.allErrors[n.props.name])
|
|
34
|
+
const go = (to) => {
|
|
35
|
+
if (to > at && unfinished.length) { unfinished.forEach((n) => ctrl.touch(n)); return }
|
|
36
|
+
ctrl.steps.go(node.id, Math.min(Math.max(to, 0), last))
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
<div className="xeplr-factory-stepnav">
|
|
40
|
+
<button type="button" className="xeplr-factory-secondary" onClick={() => go(at - 1)} disabled={at === 0}>Back</button>
|
|
41
|
+
<span className="xeplr-factory-stepnav-where">Step {at + 1} of {last + 1}</span>
|
|
42
|
+
<button type="button" className="xeplr-factory-primary" onClick={() => go(at + 1)} disabled={at === last}>Next</button>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
25
48
|
const { document: doc } = ctrl
|
|
26
49
|
|
|
@@ -36,7 +59,10 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
const aspect = doc.aspect
|
|
39
|
-
|
|
62
|
+
// Only what the open steps show — the page is as tall as the step being
|
|
63
|
+
// filled in, not as tall as every step stacked up.
|
|
64
|
+
const shown = ctrl.visibleNodes && ctrl.visibleNodes.length ? ctrl.visibleNodes : doc.nodes
|
|
65
|
+
const pageHeight = (contentBottom(doc, shown) + MARGIN) * aspect * 100
|
|
40
66
|
const fieldNodes = inputNodes(doc)
|
|
41
67
|
const statusText = ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : STATUS[ctrl.status]
|
|
42
68
|
// A screen with no fields of its own (a list screen) has nothing to save.
|
|
@@ -61,7 +87,7 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
61
87
|
noValidate
|
|
62
88
|
aria-label={doc.name}
|
|
63
89
|
>
|
|
64
|
-
{[...
|
|
90
|
+
{[...shown].sort((a, b) => (a.z || 0) - (b.z || 0)).map((node) => (
|
|
65
91
|
<div
|
|
66
92
|
key={node.id}
|
|
67
93
|
className={`xeplr-factory-node xeplr-factory-node--${node.type}`}
|
|
@@ -77,7 +103,9 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
77
103
|
mode="live"
|
|
78
104
|
value={node.props?.name ? ctrl.values[node.props.name] : undefined}
|
|
79
105
|
error={node.props?.name ? ctrl.errors[node.props.name] : undefined}
|
|
80
|
-
options={node.type
|
|
106
|
+
options={CHOICE_TYPES.includes(node.type) ? ctrl.optionsFor(node) : undefined}
|
|
107
|
+
upload={node.type === 'file' ? ctrl.upload : undefined}
|
|
108
|
+
stepper={node.type === 'stepper' ? { active: ctrl.steps.active(node.id), onStep: (i) => ctrl.steps.go(node.id, i) } : undefined}
|
|
81
109
|
onChange={(raw) => ctrl.setValue(node, raw)}
|
|
82
110
|
onBlur={() => ctrl.touch(node)}
|
|
83
111
|
list={node.type === 'list' ? {
|
|
@@ -100,6 +128,9 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
100
128
|
</div>
|
|
101
129
|
))}
|
|
102
130
|
</form>
|
|
131
|
+
{ctrl.steps.nodes.map((node) => (
|
|
132
|
+
<StepNav key={node.id} ctrl={ctrl} node={node} />
|
|
133
|
+
))}
|
|
103
134
|
{ctrl.popup && renderPopup && renderPopup(ctrl.popup, ctrl)}
|
|
104
135
|
</div>
|
|
105
136
|
)
|
package/src/designs/factory.css
CHANGED
|
@@ -71,6 +71,64 @@
|
|
|
71
71
|
.xeplr-factory-check { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); cursor: pointer; font-weight: 500; }
|
|
72
72
|
.xeplr-factory-check input { width: calc(18 * var(--xf-u)); height: calc(18 * var(--xf-u)); min-width: 12px; min-height: 12px; margin: 0; accent-color: var(--xf-accent); }
|
|
73
73
|
|
|
74
|
+
/* ── option groups (radio, multi-select) ───────────────────────────────────
|
|
75
|
+
The group scrolls rather than spilling over the control below it, so a long
|
|
76
|
+
list of options cannot cover the next field. */
|
|
77
|
+
.xeplr-factory-choices { display: flex; gap: calc(6 * var(--xf-u)) calc(16 * var(--xf-u)); overflow: auto; min-height: 0; }
|
|
78
|
+
.xeplr-factory-choices--vertical { flex-direction: column; }
|
|
79
|
+
.xeplr-factory-choices--horizontal { flex-direction: row; flex-wrap: wrap; align-items: center; }
|
|
80
|
+
.xeplr-factory-choice { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); cursor: pointer; color: var(--xf-ink); }
|
|
81
|
+
.xeplr-factory-choice input { width: calc(16 * var(--xf-u)); height: calc(16 * var(--xf-u)); min-width: 11px; min-height: 11px; margin: 0; accent-color: var(--xf-accent); }
|
|
82
|
+
|
|
83
|
+
/* ── file ─────────────────────────────────────────────────────────────────── */
|
|
84
|
+
.xeplr-factory-file { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); min-width: 0; }
|
|
85
|
+
.xeplr-factory-file-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--xf-ink); }
|
|
86
|
+
.xeplr-factory-file-clear {
|
|
87
|
+
flex: none; border: 1px solid var(--xf-border); background: var(--xf-surface); color: var(--xf-muted);
|
|
88
|
+
border-radius: calc(6 * var(--xf-u)); padding: calc(3 * var(--xf-u)) calc(8 * var(--xf-u)); cursor: pointer; font: inherit;
|
|
89
|
+
}
|
|
90
|
+
.xeplr-factory-file-clear:hover { color: var(--xf-ink); border-color: var(--xf-accent); }
|
|
91
|
+
.xeplr-factory-file-input { padding: calc(5 * var(--xf-u)); }
|
|
92
|
+
|
|
93
|
+
/* ── stepper ──────────────────────────────────────────────────────────────
|
|
94
|
+
The journey across the top: a circle per step, joined by a line. The line is
|
|
95
|
+
drawn behind the circles, from the middle of one to the middle of the next. */
|
|
96
|
+
.xeplr-factory-stepper { display: flex; align-items: center; width: 100%; height: 100%; overflow: hidden; }
|
|
97
|
+
.xeplr-factory-steps { display: flex; align-items: flex-start; width: 100%; margin: 0; padding: 0; list-style: none; }
|
|
98
|
+
.xeplr-factory-step { flex: 1; position: relative; min-width: 0; text-align: center; }
|
|
99
|
+
.xeplr-factory-step + .xeplr-factory-step::before {
|
|
100
|
+
content: ''; position: absolute; top: calc(13 * var(--xf-u)); right: 50%; left: -50%; height: 2px;
|
|
101
|
+
background: var(--xf-border);
|
|
102
|
+
}
|
|
103
|
+
.xeplr-factory-step.is-active::before, .xeplr-factory-step.is-done::before { background: var(--xf-accent); }
|
|
104
|
+
.xeplr-factory-step-button {
|
|
105
|
+
position: relative; display: flex; flex-direction: column; align-items: center; gap: calc(4 * var(--xf-u));
|
|
106
|
+
width: 100%; padding: 0; border: none; background: none; color: var(--xf-muted); font: inherit; cursor: pointer;
|
|
107
|
+
}
|
|
108
|
+
.xeplr-factory-step-mark {
|
|
109
|
+
display: flex; align-items: center; justify-content: center;
|
|
110
|
+
width: calc(26 * var(--xf-u)); height: calc(26 * var(--xf-u)); min-width: 18px; min-height: 18px;
|
|
111
|
+
border-radius: 50%; background: var(--xf-border); color: var(--xf-ink); font-weight: 600;
|
|
112
|
+
}
|
|
113
|
+
.xeplr-factory-step-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
|
114
|
+
.xeplr-factory-step.is-active .xeplr-factory-step-mark,
|
|
115
|
+
.xeplr-factory-step.is-done .xeplr-factory-step-mark { background: var(--xf-accent); color: #fff; }
|
|
116
|
+
.xeplr-factory-step.is-active .xeplr-factory-step-button { color: var(--xf-accent); font-weight: 600; }
|
|
117
|
+
|
|
118
|
+
/* Back / Next, under the page. */
|
|
119
|
+
.xeplr-factory-stepnav { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
|
|
120
|
+
.xeplr-factory-stepnav-where { flex: 1; text-align: center; color: var(--xf-muted); font-size: 12px; }
|
|
121
|
+
|
|
122
|
+
/* Which step the builder is on. */
|
|
123
|
+
.xeplr-factory-building { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0 auto 8px; padding: 6px 0; }
|
|
124
|
+
.xeplr-factory-building-what { color: var(--xf-muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
125
|
+
.xeplr-factory-building-step {
|
|
126
|
+
border: 1px solid var(--xf-border); background: var(--xf-surface); color: var(--xf-muted);
|
|
127
|
+
border-radius: 999px; padding: 3px 10px; font: inherit; font-size: 12px; cursor: pointer;
|
|
128
|
+
}
|
|
129
|
+
.xeplr-factory-building-step.is-active { border-color: var(--xf-accent); color: var(--xf-accent); font-weight: 600; }
|
|
130
|
+
.xeplr-factory-building-every { display: flex; align-items: center; gap: 6px; margin-left: auto; color: var(--xf-muted); font-size: 12px; }
|
|
131
|
+
|
|
74
132
|
/* A label's size and weight are inline — its preset, overridden by its style. */
|
|
75
133
|
.xeplr-factory-text { margin: 0; color: var(--xf-ink); height: 100%; overflow: hidden; line-height: 1.25; box-sizing: border-box; }
|
|
76
134
|
.xeplr-factory-text--text { color: var(--xf-muted); }
|
|
@@ -144,6 +202,11 @@
|
|
|
144
202
|
.xeplr-factory-palette-item:hover { border-color: var(--xf-accent); }
|
|
145
203
|
.xeplr-factory-palette-icon { width: 14px; height: 10px; border: 1.5px solid var(--xf-muted); border-radius: 2px; flex: 0 0 auto; }
|
|
146
204
|
.xeplr-factory-palette-icon--checkbox { width: 10px; }
|
|
205
|
+
.xeplr-factory-palette-icon--radio { border-radius: 50%; width: 11px; height: 11px; }
|
|
206
|
+
.xeplr-factory-palette-icon--multiselect { height: 11px; background: repeating-linear-gradient(to bottom, var(--xf-muted) 0 2px, transparent 2px 5px); border: none; }
|
|
207
|
+
.xeplr-factory-palette-icon--datetime { border-top-width: 3px; }
|
|
208
|
+
.xeplr-factory-palette-icon--file { border-radius: 2px; border-top: 3px solid var(--xf-muted); height: 12px; width: 10px; }
|
|
209
|
+
.xeplr-factory-palette-icon--stepper { height: 8px; border-radius: 999px; background: linear-gradient(to right, var(--xf-muted) 0 30%, transparent 30%); border: none; }
|
|
147
210
|
.xeplr-factory-palette-icon--label { border: none; border-top: 2px solid var(--xf-muted); border-bottom: 2px solid var(--xf-muted); height: 6px; }
|
|
148
211
|
.xeplr-factory-palette-icon--list { height: 12px; background: repeating-linear-gradient(to bottom, var(--xf-muted) 0 1.5px, transparent 1.5px 4px); border: none; }
|
|
149
212
|
.xeplr-factory-palette-icon--textarea { height: 14px; }
|
|
@@ -184,6 +247,8 @@
|
|
|
184
247
|
border: 1px solid var(--xf-border); border-radius: 5px; background: #fff; color: var(--xf-ink, #1a1a19);
|
|
185
248
|
}
|
|
186
249
|
.xeplr-factory-prop.has-error .xeplr-factory-prop-input { border-color: var(--xf-danger); }
|
|
250
|
+
/* An editor made of two inputs — a set to pick from, and the list it fills in. */
|
|
251
|
+
.xeplr-factory-prop-stack { display: flex; flex-direction: column; gap: 5px; }
|
|
187
252
|
.xeplr-factory-prop-help { margin-top: 3px; color: var(--xf-muted); font-size: 11px; line-height: 1.4; }
|
|
188
253
|
.xeplr-factory-prop-error { margin-top: 3px; color: var(--xf-danger); font-size: 11px; }
|
|
189
254
|
.xeplr-factory-panel-errors { margin-top: 8px; padding: 8px; border-radius: 6px; color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 7%, transparent); font-size: 11px; }
|
package/src/document.js
CHANGED
|
@@ -68,7 +68,7 @@ export function setScreenProperty(doc, path, value) {
|
|
|
68
68
|
* control's top-left) or, without one, below everything already on the screen.
|
|
69
69
|
* Returns { document, node }.
|
|
70
70
|
*/
|
|
71
|
-
export function addControl(doc, type, { at, props, size } = {}, controls = CONTROLS) {
|
|
71
|
+
export function addControl(doc, type, { at, props, size, step } = {}, controls = CONTROLS) {
|
|
72
72
|
const def = controls[type]
|
|
73
73
|
if (!def) throw new Error(`Unknown control type "${type}"`)
|
|
74
74
|
const dims = { ...def.defaultSize, ...size }
|
|
@@ -88,9 +88,73 @@ export function addControl(doc, type, { at, props, size } = {}, controls = CONTR
|
|
|
88
88
|
z: nextZ(doc),
|
|
89
89
|
props: merged
|
|
90
90
|
}
|
|
91
|
+
// Dropped while a step is open: the control belongs to that step. A stepper
|
|
92
|
+
// never belongs to itself.
|
|
93
|
+
if (step && type !== 'stepper') node.step = { of: step.of, index: step.index }
|
|
91
94
|
return { document: { ...doc, nodes: [...doc.nodes, node] }, node }
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
// ── steps ────────────────────────────────────────────────────────────────
|
|
98
|
+
// A stepper shows one step at a time, and every other control says which step
|
|
99
|
+
// it belongs to. The document stays FLAT — nothing is nested — so a control is
|
|
100
|
+
// still moved, styled and checked exactly as it was; `step` only decides when
|
|
101
|
+
// it is on screen.
|
|
102
|
+
|
|
103
|
+
/** Every stepper on the screen, in reading order. */
|
|
104
|
+
export function steppers(doc, controls = CONTROLS) {
|
|
105
|
+
return (doc.nodes || []).filter((n) => n.type === 'stepper' && controls[n.type])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A stepper's steps, always as [{ key, label }]. */
|
|
109
|
+
export function stepsOf(node) {
|
|
110
|
+
return (node && node.props && Array.isArray(node.props.steps) ? node.props.steps : []).map((s, i) => ({
|
|
111
|
+
key: (s && s.key) || `step_${i + 1}`,
|
|
112
|
+
label: (s && s.label) || `Step ${i + 1}`
|
|
113
|
+
}))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Which step a node is on, or null for "every step". */
|
|
117
|
+
export function stepOf(node) {
|
|
118
|
+
const step = node && node.step
|
|
119
|
+
return step && typeof step.of === 'string' && Number.isInteger(step.index) ? step : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Puts a node on a step — or on none, with `null`, which shows it whatever
|
|
124
|
+
* step is open.
|
|
125
|
+
*/
|
|
126
|
+
export function setNodeStep(doc, id, step) {
|
|
127
|
+
return mapNode(doc, id, (n) => {
|
|
128
|
+
const next = { ...n }
|
|
129
|
+
if (step === null || step === undefined) delete next.step
|
|
130
|
+
else next.step = { of: step.of, index: step.index }
|
|
131
|
+
return next
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The nodes to show, given which step each stepper is open at.
|
|
137
|
+
* @param active { [stepper node id]: index } — missing means its first step
|
|
138
|
+
*/
|
|
139
|
+
export function nodesForSteps(doc, active = {}, controls = CONTROLS) {
|
|
140
|
+
const open = (id) => (Number.isInteger(active[id]) ? active[id] : 0)
|
|
141
|
+
const byId = new Map((doc.nodes || []).map((n) => [n.id, n]))
|
|
142
|
+
const shown = (node, seen) => {
|
|
143
|
+
const step = stepOf(node)
|
|
144
|
+
if (!step) return true
|
|
145
|
+
const owner = byId.get(step.of)
|
|
146
|
+
// A step of a stepper that is gone shows nothing: the control is orphaned,
|
|
147
|
+
// and the checker says so rather than the screen quietly losing it.
|
|
148
|
+
if (!owner || owner.type !== 'stepper') return false
|
|
149
|
+
if (step.index !== open(owner.id)) return false
|
|
150
|
+
// A stepper inside another stepper's step is only open when that one is.
|
|
151
|
+
if (seen.has(owner.id)) return false
|
|
152
|
+
seen.add(owner.id)
|
|
153
|
+
return shown(owner, seen)
|
|
154
|
+
}
|
|
155
|
+
return (doc.nodes || []).filter((n) => shown(n, new Set()))
|
|
156
|
+
}
|
|
157
|
+
|
|
94
158
|
/** Merges a geometry patch — what the canvas reports on drop. */
|
|
95
159
|
export function moveNode(doc, id, patch) {
|
|
96
160
|
const geometry = {}
|
|
@@ -160,8 +224,8 @@ export function readingOrder(nodes) {
|
|
|
160
224
|
}
|
|
161
225
|
|
|
162
226
|
/** Where the lowest control ends, as a page fraction. */
|
|
163
|
-
export function contentBottom(doc) {
|
|
164
|
-
return doc.nodes.reduce((max, n) => Math.max(max, (n.y || 0) + (n.h || 0)), 0)
|
|
227
|
+
export function contentBottom(doc, nodes) {
|
|
228
|
+
return (nodes || doc.nodes).reduce((max, n) => Math.max(max, (n.y || 0) + (n.h || 0)), 0)
|
|
165
229
|
}
|
|
166
230
|
|
|
167
231
|
// ── naming ───────────────────────────────────────────────────────────────
|
package/src/generate.js
CHANGED
|
@@ -23,14 +23,17 @@
|
|
|
23
23
|
// The result is an ordinary document, checked before it is returned; from
|
|
24
24
|
// there it is edited like any other.
|
|
25
25
|
|
|
26
|
-
import { CONTROLS } from './controls.js'
|
|
27
|
-
import { createScreen, addControl, MARGIN, GAP, slugify, camelName } from './document.js'
|
|
26
|
+
import { CONTROLS, CHOICE_TYPES } from './controls.js'
|
|
27
|
+
import { createScreen, addControl, MARGIN, GAP, slugify, camelName, stepsOf } from './document.js'
|
|
28
28
|
import { assertValidDocument } from './validateDocument.js'
|
|
29
29
|
|
|
30
30
|
/** Heights by control, as page fractions (a page is as tall as it is wide). */
|
|
31
31
|
export const SPEC_HEIGHTS = {
|
|
32
|
-
text: 0.08, number: 0.08, date: 0.08, dropdown: 0.08,
|
|
32
|
+
text: 0.08, number: 0.08, date: 0.08, datetime: 0.08, dropdown: 0.08,
|
|
33
|
+
file: 0.1, stepper: 0.09,
|
|
33
34
|
textarea: 0.18,
|
|
35
|
+
// A group is as tall as its options make it — see heightOf().
|
|
36
|
+
radio: 0.14, multiselect: 0.18,
|
|
34
37
|
checkbox: 0.05,
|
|
35
38
|
list: 0.42,
|
|
36
39
|
label: { heading: 0.07, subheading: 0.055, text: 0.045 }
|
|
@@ -42,7 +45,7 @@ export const SPEC_HEIGHTS = {
|
|
|
42
45
|
*/
|
|
43
46
|
export const ROW_GAP = 0.04
|
|
44
47
|
|
|
45
|
-
const FIELD_KEYS = ['type', 'label', 'name', 'required', 'placeholder', 'default', 'validation', 'data', 'options', 'table', 'width', 'text', 'variant', 'style']
|
|
48
|
+
const FIELD_KEYS = ['type', 'label', 'name', 'required', 'placeholder', 'default', 'validation', 'data', 'options', 'table', 'layout', 'accept', 'maxSize', 'step', 'steps', 'showNumbers', 'width', 'text', 'variant', 'style']
|
|
46
49
|
const SPEC_KEYS = ['name', 'id', 'source', 'columns', 'heading', 'fields', 'list', 'aspect', 'width', 'style']
|
|
47
50
|
const LIST_KEYS = ['title', 'source', 'columns', 'pageSize', 'actions', 'style']
|
|
48
51
|
|
|
@@ -79,20 +82,48 @@ export function screenFromSpec(spec, controls = CONTROLS) {
|
|
|
79
82
|
let col = 0
|
|
80
83
|
let rowH = 0
|
|
81
84
|
|
|
85
|
+
// A stepper on the screen, and where each of its steps starts: every step is
|
|
86
|
+
// laid out from just under the bar, so they sit on top of one another rather
|
|
87
|
+
// than running down a page as long as all of them put together.
|
|
88
|
+
let stepper = null
|
|
89
|
+
let stepTop = 0
|
|
90
|
+
let step = null
|
|
91
|
+
|
|
82
92
|
const place = (type, props, width) => {
|
|
83
93
|
const h = heightOf(type, props)
|
|
84
|
-
const isFull = width === 'full' || columns === 1 || type === 'textarea' || type === 'label' || type === 'list'
|
|
94
|
+
const isFull = width === 'full' || columns === 1 || type === 'textarea' || type === 'label' || type === 'list' || type === 'stepper'
|
|
95
|
+
let node
|
|
85
96
|
if (isFull) {
|
|
86
97
|
if (col > 0) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
|
|
87
|
-
;({ document: doc } = addControl(doc, type, { at: { x: MARGIN, y: round(y) }, size: { w: round(full), h }, props }, controls))
|
|
98
|
+
;({ document: doc, node } = addControl(doc, type, { at: { x: MARGIN, y: round(y) }, size: { w: round(full), h }, props, step }, controls))
|
|
88
99
|
y += h + ROW_GAP
|
|
89
|
-
|
|
100
|
+
} else {
|
|
101
|
+
const x = MARGIN + col * (colW + GAP)
|
|
102
|
+
;({ document: doc, node } = addControl(doc, type, { at: { x: round(x), y: round(y) }, size: { w: colW, h }, props, step }, controls))
|
|
103
|
+
rowH = Math.max(rowH, h)
|
|
104
|
+
col++
|
|
105
|
+
if (col === columns) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
|
|
106
|
+
}
|
|
107
|
+
if (type === 'stepper') {
|
|
108
|
+
stepper = node
|
|
109
|
+
stepTop = y
|
|
110
|
+
step = { of: node.id, index: 0 }
|
|
90
111
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
112
|
+
return node
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Move the layout to a step: back to the top, under the bar. */
|
|
116
|
+
const openStep = (which) => {
|
|
117
|
+
const steps = stepsOf(stepper)
|
|
118
|
+
const index = typeof which === 'number' ? which - 1 : steps.findIndex((s) => s.key === which || s.label === which)
|
|
119
|
+
if (index < 0 || index >= steps.length) {
|
|
120
|
+
throw new Error(`screenFromSpec: step ${JSON.stringify(which)} is not one of ${steps.map((s) => s.label).join(', ')}`)
|
|
121
|
+
}
|
|
122
|
+
if (step && step.index === index) return
|
|
123
|
+
step = { of: stepper.id, index }
|
|
124
|
+
y = stepTop
|
|
125
|
+
col = 0
|
|
126
|
+
rowH = 0
|
|
96
127
|
}
|
|
97
128
|
|
|
98
129
|
if (spec.heading !== false) {
|
|
@@ -106,6 +137,10 @@ export function screenFromSpec(spec, controls = CONTROLS) {
|
|
|
106
137
|
const type = field.type || 'text'
|
|
107
138
|
if (!controls[type]) throw new Error(`screenFromSpec: ${at}.type "${type}" is not a control — one of: ${Object.keys(controls).join(', ')}`)
|
|
108
139
|
if (field.width !== undefined && field.width !== 'half' && field.width !== 'full') throw new Error(`screenFromSpec: ${at}.width must be "half" or "full"`)
|
|
140
|
+
if (field.step !== undefined) {
|
|
141
|
+
if (!stepper) throw new Error(`screenFromSpec: ${at}.step needs a stepper before it — { "type": "stepper", "steps": [...] }`)
|
|
142
|
+
openStep(field.step)
|
|
143
|
+
}
|
|
109
144
|
place(type, propsFromField(type, field, at), field.width)
|
|
110
145
|
})
|
|
111
146
|
|
|
@@ -123,6 +158,18 @@ export function screenFromSpec(spec, controls = CONTROLS) {
|
|
|
123
158
|
}
|
|
124
159
|
|
|
125
160
|
function propsFromField(type, field, at) {
|
|
161
|
+
if (type === 'stepper') {
|
|
162
|
+
const steps = field.steps
|
|
163
|
+
if (!Array.isArray(steps) || steps.length < 2) throw new Error(`screenFromSpec: ${at} is a stepper — give it steps: ["Connect", "Transform", …]`)
|
|
164
|
+
const props = {
|
|
165
|
+
steps: steps.map((s, i) => (typeof s === 'string'
|
|
166
|
+
? { key: slugify(s, '_') || `step_${i + 1}`, label: s }
|
|
167
|
+
: s))
|
|
168
|
+
}
|
|
169
|
+
if (field.showNumbers !== undefined) props.showNumbers = field.showNumbers
|
|
170
|
+
if (field.style) props.style = field.style
|
|
171
|
+
return props
|
|
172
|
+
}
|
|
126
173
|
if (type === 'label') {
|
|
127
174
|
const props = { text: field.text ?? field.label ?? '', variant: field.variant || 'subheading' }
|
|
128
175
|
if (field.style) props.style = field.style
|
|
@@ -134,17 +181,23 @@ function propsFromField(type, field, at) {
|
|
|
134
181
|
if (field[k] !== undefined) props[k] = field[k]
|
|
135
182
|
})
|
|
136
183
|
if (type === 'dropdown' && props.placeholder === undefined) props.placeholder = 'Select…'
|
|
137
|
-
if (type
|
|
138
|
-
props.data = dataFromField(field, at)
|
|
139
|
-
|
|
184
|
+
if (CHOICE_TYPES.includes(type)) {
|
|
185
|
+
props.data = dataFromField(field, at, type)
|
|
186
|
+
if (field.layout !== undefined && type !== 'dropdown') props.layout = field.layout
|
|
187
|
+
// A field reading another table stores that row's id: name the column
|
|
140
188
|
// for what it holds — "Department" → departmentId, a foreign key.
|
|
141
|
-
if (props.data.source === 'table' && props.name === undefined) props.name = camelName(field.label) + 'Id'
|
|
189
|
+
if (props.data.source === 'table' && props.name === undefined && type !== 'multiselect') props.name = camelName(field.label) + 'Id'
|
|
190
|
+
}
|
|
191
|
+
else if (field.options || field.table || field.data) throw new Error(`screenFromSpec: ${at} — options/table/data only apply to ${CHOICE_TYPES.join(', ')}`)
|
|
192
|
+
if (type === 'file') {
|
|
193
|
+
;['accept', 'maxSize'].forEach((k) => { if (field[k] !== undefined) props[k] = field[k] })
|
|
194
|
+
} else if (field.accept !== undefined || field.maxSize !== undefined) {
|
|
195
|
+
throw new Error(`screenFromSpec: ${at} — accept/maxSize only apply to a file`)
|
|
142
196
|
}
|
|
143
|
-
else if (field.options || field.table || field.data) throw new Error(`screenFromSpec: ${at} — options/table/data only apply to a dropdown`)
|
|
144
197
|
return props
|
|
145
198
|
}
|
|
146
199
|
|
|
147
|
-
function dataFromField(field, at) {
|
|
200
|
+
function dataFromField(field, at, type) {
|
|
148
201
|
if (field.data) return field.data
|
|
149
202
|
if (field.table) return { source: 'table', table: field.table }
|
|
150
203
|
if (Array.isArray(field.options)) {
|
|
@@ -155,15 +208,26 @@ function dataFromField(field, at) {
|
|
|
155
208
|
: o))
|
|
156
209
|
}
|
|
157
210
|
}
|
|
158
|
-
throw new Error(`screenFromSpec: ${at} is a
|
|
211
|
+
throw new Error(`screenFromSpec: ${at} is a ${type} — give it options: [...] or table: "<name>"`)
|
|
159
212
|
}
|
|
160
213
|
|
|
161
214
|
function heightOf(type, props) {
|
|
162
215
|
const h = SPEC_HEIGHTS[type]
|
|
163
216
|
if (type === 'label') return h[props.variant] || h.text
|
|
217
|
+
// A group of options grows with them: the label, then a line per option
|
|
218
|
+
// (or one line for all of them, side by side).
|
|
219
|
+
if (type === 'radio' || type === 'multiselect') {
|
|
220
|
+
const n = props.data?.source === 'static' ? (props.data.options || []).length : 4
|
|
221
|
+
const lines = props.layout === 'horizontal' ? 1 : Math.max(n, 1)
|
|
222
|
+
return round(LABEL_H + lines * OPTION_H)
|
|
223
|
+
}
|
|
164
224
|
return h || 0.08
|
|
165
225
|
}
|
|
166
226
|
|
|
227
|
+
/** A field's label, and one option, as page fractions. */
|
|
228
|
+
const LABEL_H = 0.035
|
|
229
|
+
const OPTION_H = 0.03
|
|
230
|
+
|
|
167
231
|
function unknownKeys(obj, allowed, at) {
|
|
168
232
|
const extra = Object.keys(obj).filter((k) => !allowed.includes(k))
|
|
169
233
|
if (extra.length) throw new Error(`screenFromSpec: ${at} has unknown key(s) ${extra.join(', ')} — allowed: ${allowed.join(', ')}`)
|
package/src/model.js
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
//
|
|
4
4
|
// import { screenFromSpec, validateDocument, formSchema } from '@xeplr/ui-factory/model'
|
|
5
5
|
|
|
6
|
-
export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, LABEL_PRESETS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, FONT_FAMILIES, controlGroups, withControls } from './controls.js'
|
|
6
|
+
export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, LABEL_PRESETS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, FONT_FAMILIES, LAYOUTS, CHOICE_TYPES, STEP_LIMIT, MULTI_SEPARATOR, FILE_KINDS, MAX_FILE_MB, acceptList, controlGroups, withControls } from './controls.js'
|
|
7
7
|
export {
|
|
8
8
|
DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, DEFAULT_WIDTH, MARGIN, GAP,
|
|
9
9
|
createScreen, renameScreen, setScreenProperty, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
|
|
10
|
-
inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition
|
|
10
|
+
inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition,
|
|
11
|
+
steppers, stepsOf, stepOf, setNodeStep, nodesForSteps
|
|
11
12
|
} from './document.js'
|
|
12
13
|
export { validateDocument, assertValidDocument } from './validateDocument.js'
|
|
13
|
-
export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, saveState, recordValues, displayValue, listColumns, listSource } from './values.js'
|
|
14
|
+
export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
|
|
14
15
|
export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
|
|
15
16
|
export { scaffoldEntity } from './scaffold.js'
|
|
16
17
|
export { getAtPath, setAtPath } from './propertyPath.js'
|
package/src/remote.js
CHANGED
|
@@ -43,6 +43,27 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
43
43
|
return res && Array.isArray(res.dataArray) ? res.dataArray : []
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The same call, with a body the browser must describe itself (a FormData's
|
|
48
|
+
* multipart boundary). Nothing sets Content-Type here — see authFetch.
|
|
49
|
+
*/
|
|
50
|
+
async function send(method, path, body) {
|
|
51
|
+
let res
|
|
52
|
+
try {
|
|
53
|
+
res = await doFetch(`${base}/factory${path}`, { method, body })
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err && err.body !== undefined && err.status) throw failure(method, path, err.status, err.body, err.message)
|
|
56
|
+
throw err
|
|
57
|
+
}
|
|
58
|
+
if (res && typeof res.json === 'function' && typeof res.ok === 'boolean') {
|
|
59
|
+
let json = null
|
|
60
|
+
try { json = await res.json() } catch (_) { /* not JSON — reported below */ }
|
|
61
|
+
if (!res.ok) throw failure(method, path, res.status, json)
|
|
62
|
+
return json ? json.dataArray : []
|
|
63
|
+
}
|
|
64
|
+
return res && Array.isArray(res.dataArray) ? res.dataArray : []
|
|
65
|
+
}
|
|
66
|
+
|
|
46
67
|
function failure(method, path, status, json, fallback) {
|
|
47
68
|
const err = new Error((json && json.message) || fallback || `${method} ${path} failed (${status})`)
|
|
48
69
|
err.status = status
|
|
@@ -77,9 +98,18 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
77
98
|
fetchRecord: async ({ screen, id }) => one(await call('GET', `/records/${enc(screen)}/${enc(id)}`)),
|
|
78
99
|
onSave: async (values, { id, screen }) => one(await call('POST', `/records/${enc(screen)}/save`, { id, values })),
|
|
79
100
|
onDelete: ({ id, screen }) => call('POST', `/records/${enc(screen)}/delete`, { id }),
|
|
80
|
-
fetchOptions: ({ table }) => call('GET', `/options/${enc(table)}`)
|
|
101
|
+
fetchOptions: ({ table }) => call('GET', `/options/${enc(table)}`),
|
|
102
|
+
|
|
103
|
+
// A file field's upload. The answer's `path` is what the record stores;
|
|
104
|
+
// the file itself is read back through the same route, with auth.
|
|
105
|
+
uploadFile: async (file, { screen, field }) => {
|
|
106
|
+
const form = new FormData()
|
|
107
|
+
form.append('file', file)
|
|
108
|
+
return one(await send('POST', `/files/${enc(screen)}/${enc(field)}`, form))
|
|
109
|
+
},
|
|
110
|
+
fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}`
|
|
81
111
|
}
|
|
82
|
-
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions }
|
|
112
|
+
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl }
|
|
83
113
|
api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords }
|
|
84
114
|
return api
|
|
85
115
|
}
|
package/src/tableSchema.js
CHANGED
|
@@ -51,10 +51,11 @@ export const MAX_IDENTIFIER = 63
|
|
|
51
51
|
|
|
52
52
|
const TEXT_DEFAULT_LENGTH = 255
|
|
53
53
|
const OPTION_MIN_LENGTH = 50
|
|
54
|
+
const FILE_PATH_LENGTH = 255
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
57
|
* The column a field is stored in.
|
|
57
|
-
* @returns {{ name, type: 'varchar'|'text'|'integer'|'numeric'|'date'|'boolean', length?, references?, notNull?, default? }}
|
|
58
|
+
* @returns {{ name, type: 'varchar'|'text'|'integer'|'numeric'|'date'|'timestamp'|'boolean', length?, references?, notNull?, default? }}
|
|
58
59
|
*/
|
|
59
60
|
export function columnForField(node) {
|
|
60
61
|
const p = node.props || {}
|
|
@@ -69,9 +70,18 @@ export function columnForField(node) {
|
|
|
69
70
|
return { ...base, type: v.integer ? 'integer' : 'numeric' }
|
|
70
71
|
case 'date':
|
|
71
72
|
return { ...base, type: 'date' }
|
|
73
|
+
case 'datetime':
|
|
74
|
+
return { ...base, type: 'timestamp' }
|
|
75
|
+
case 'file':
|
|
76
|
+
// The stored file's path, as the upload answered with it.
|
|
77
|
+
return { ...base, type: 'varchar', length: FILE_PATH_LENGTH }
|
|
78
|
+
case 'multiselect':
|
|
79
|
+
// Every chosen id in one text column, separated by commas.
|
|
80
|
+
return { ...base, type: 'text' }
|
|
72
81
|
case 'checkbox':
|
|
73
82
|
return { ...base, type: 'boolean', notNull: true, default: 'false' }
|
|
74
|
-
case 'dropdown':
|
|
83
|
+
case 'dropdown':
|
|
84
|
+
case 'radio': {
|
|
75
85
|
const data = p.data || {}
|
|
76
86
|
if (data.source === 'table') {
|
|
77
87
|
// A real foreign key to the other entity's id.
|
|
@@ -118,6 +128,8 @@ export function widening(from, to) {
|
|
|
118
128
|
}
|
|
119
129
|
if (from.type === 'varchar' && to.type === 'text') return { ok: true }
|
|
120
130
|
if (from.type === 'integer' && to.type === 'numeric') return { ok: true }
|
|
131
|
+
// A date becomes that day at midnight; the other way round would lose the time.
|
|
132
|
+
if (from.type === 'date' && to.type === 'timestamp') return { ok: true }
|
|
121
133
|
return { ok: false, reason: `${describe(from)} → ${describe(to)} is a different kind of value` }
|
|
122
134
|
}
|
|
123
135
|
|
|
@@ -250,6 +262,8 @@ export function columnFromDatabase(c) {
|
|
|
250
262
|
case 'int4': return { ...base, type: 'integer' }
|
|
251
263
|
case 'numeric': return { ...base, type: 'numeric' }
|
|
252
264
|
case 'date': return { ...base, type: 'date' }
|
|
265
|
+
case 'timestamp':
|
|
266
|
+
case 'timestamptz': return { ...base, type: 'timestamp' }
|
|
253
267
|
case 'bool': return { ...base, type: 'boolean' }
|
|
254
268
|
// Anything else (a timestamp, a bigint someone chose) is not a type a field
|
|
255
269
|
// makes — so any change to it is refused rather than guessed at.
|