@xeplr/ui-factory 1.8.2 → 1.8.4
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 +21 -1
- package/README.md +190 -5
- package/bin/xeplr-factory.js +27 -1
- package/package.json +7 -3
- package/src/buttons.js +142 -0
- package/src/controls.js +48 -1
- package/src/designs/BuilderSample.jsx +1 -0
- package/src/designs/ButtonsView.jsx +107 -0
- package/src/designs/ControlView.jsx +4 -1
- package/src/designs/FedList.jsx +248 -0
- package/src/designs/ListView.jsx +6 -1
- package/src/designs/PropertyPanel.jsx +336 -4
- package/src/designs/ScreenModal.jsx +2 -1
- package/src/designs/ScreenSample.jsx +28 -7
- package/src/designs/factory.css +199 -2
- package/src/designs/styles.js +4 -1
- package/src/events.js +21 -0
- package/src/model.js +8 -1
- package/src/pagesInventory.js +91 -0
- package/src/remote.js +17 -2
- package/src/urlState.js +2 -2
- package/src/useFactoryBuilder.js +9 -1
- package/src/useFactoryScreen.js +103 -8
- package/src/useUrlPlaces.js +13 -0
- package/src/validateDocument.js +147 -3
|
@@ -4,6 +4,7 @@ import { FIELD_PRESETS, presetFor } from '../presets.js'
|
|
|
4
4
|
import { slugify, inputNodes, paramsText, screenParams } from '../document.js'
|
|
5
5
|
import { isBinding, previousBinding } from '../values.js'
|
|
6
6
|
import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS } from '../controls.js'
|
|
7
|
+
import { BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, CONDITION_OPERATORS, NOTE_INPUT, optionValues } from '../buttons.js'
|
|
7
8
|
|
|
8
9
|
// The selected control's properties, GENERATED from its entry in controls.js:
|
|
9
10
|
// each field there names a path and an editor type, and this file owns only
|
|
@@ -59,7 +60,7 @@ function FieldType({ node, controls, hint, onConvert, onKeepPlain }) {
|
|
|
59
60
|
)
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
export default function PropertyPanel({ doc, node, control, controls, errors, tables, screens, forms, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, onMakeChildTable, selectionCount }) {
|
|
63
|
+
export default function PropertyPanel({ doc, node, control, controls, errors, tables, functions, screens, forms, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, onMakeChildTable, selectionCount }) {
|
|
63
64
|
if (selectionCount > 1) {
|
|
64
65
|
return (
|
|
65
66
|
<aside className="xeplr-factory-panel">
|
|
@@ -103,13 +104,14 @@ export default function PropertyPanel({ doc, node, control, controls, errors, ta
|
|
|
103
104
|
const value = field.path === 'props.default' && field.type !== 'fillFrom' && isBinding(raw) ? undefined : raw
|
|
104
105
|
return (
|
|
105
106
|
<div key={field.path + ':' + field.type} className={`xeplr-factory-prop${errs.length ? ' has-error' : ''}`}>
|
|
106
|
-
{
|
|
107
|
+
{!SELF_LABELLED.includes(field.type) && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
|
|
107
108
|
<Editor
|
|
108
109
|
id={id}
|
|
109
110
|
field={field}
|
|
110
111
|
value={value}
|
|
111
112
|
onChange={(v) => onChange(field.path, v)}
|
|
112
113
|
tables={tables}
|
|
114
|
+
functions={functions}
|
|
113
115
|
screens={screens}
|
|
114
116
|
forms={forms}
|
|
115
117
|
node={node}
|
|
@@ -494,7 +496,10 @@ function TableEditor({ id, value, onChange, tables }) {
|
|
|
494
496
|
* are this screen's own — or, for a list screen, those of the screen it edits
|
|
495
497
|
* in. Nothing ticked means all of them.
|
|
496
498
|
*/
|
|
497
|
-
function ColumnsEditor({ value, onChange, doc, node, screens }) {
|
|
499
|
+
function ColumnsEditor({ value, onChange, doc, node, screens, functions }) {
|
|
500
|
+
// A list fed by a function has no form describing its rows: its columns
|
|
501
|
+
// are written out, each saying what it shows and how.
|
|
502
|
+
if (node && node.props && node.props.rowsFrom) return <FedColumnsEditor value={value} onChange={onChange} functions={functions} />
|
|
498
503
|
const editDoc = node && node.props.editScreen ? (screens || []).find((s) => s.id === node.props.editScreen)?.document : null
|
|
499
504
|
|
|
500
505
|
// A LIST OF ANOTHER TABLE IS NOT DESCRIBED BY THIS SCREEN. Pointing a list
|
|
@@ -549,6 +554,9 @@ function ScreenEditor({ id, value, onChange, screens, doc }) {
|
|
|
549
554
|
return (
|
|
550
555
|
<select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value || undefined)}>
|
|
551
556
|
<option value="">— open rows in this screen's fields</option>
|
|
557
|
+
{/* A screen not in the list (not loaded, another app's) is still the
|
|
558
|
+
setting — shown by its id, not as if it had been cleared. */}
|
|
559
|
+
{value && !others.some((s) => s.id === value) && <option value={value}>{value}</option>}
|
|
552
560
|
{others.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
553
561
|
</select>
|
|
554
562
|
)
|
|
@@ -778,6 +786,327 @@ function uniqueStepKey(steps, base) {
|
|
|
778
786
|
return `${base}_${n}`
|
|
779
787
|
}
|
|
780
788
|
|
|
789
|
+
/** Editors that draw their own label (a toggle's is beside it; a list of rows has a heading per row). */
|
|
790
|
+
const SELF_LABELLED = ['toggle', 'toggleValue', 'columns', 'actions', 'filters', 'buttons']
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* One of the app's registered functions, for one purpose (`field.event`):
|
|
794
|
+
* picked from those registered for it. A name that is no longer registered
|
|
795
|
+
* stays shown, marked — not silently blanked.
|
|
796
|
+
*/
|
|
797
|
+
function FunctionEditor({ id, field, value, onChange, functions }) {
|
|
798
|
+
const pick = functions && functions.forEvent ? functions.forEvent(field.event) : []
|
|
799
|
+
if (!functions || (!pick.length && !functions.loading)) {
|
|
800
|
+
return (
|
|
801
|
+
<>
|
|
802
|
+
<input id={id} className="xeplr-factory-prop-input" value={value || ''} placeholder="functionName" onChange={(e) => onChange(e.target.value.trim() || undefined)} />
|
|
803
|
+
{functions && !functions.loading && <div className="xeplr-factory-prop-help">No function is registered for “{field.event}” — the app registers one with @xeplr/actions.</div>}
|
|
804
|
+
</>
|
|
805
|
+
)
|
|
806
|
+
}
|
|
807
|
+
const known = !value || pick.some((f) => f.name === value)
|
|
808
|
+
const chosen = pick.find((f) => f.name === value)
|
|
809
|
+
return (
|
|
810
|
+
<>
|
|
811
|
+
<select id={id} className="xeplr-factory-prop-input" value={value || ''} onChange={(e) => onChange(e.target.value || undefined)}>
|
|
812
|
+
<option value="">— none —</option>
|
|
813
|
+
{!known && <option value={value}>{value} (not registered)</option>}
|
|
814
|
+
{pick.map((f) => <option key={f.name} value={f.name}>{f.name}</option>)}
|
|
815
|
+
</select>
|
|
816
|
+
{chosen && chosen.description && <div className="xeplr-factory-prop-help">{chosen.description}</div>}
|
|
817
|
+
</>
|
|
818
|
+
)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** A list's filter chips: key (what the function is asked for) and the chip's text. */
|
|
822
|
+
function FiltersEditor({ value, onChange }) {
|
|
823
|
+
const chips = Array.isArray(value) ? value : []
|
|
824
|
+
const set = (next) => onChange(next.length ? next : undefined)
|
|
825
|
+
const edit = (i, patch) => set(chips.map((c, j) => (j === i ? { ...c, ...patch } : c)))
|
|
826
|
+
return (
|
|
827
|
+
<div className="xeplr-factory-rows-editor">
|
|
828
|
+
<div className="xeplr-factory-prop-label">Filter chips</div>
|
|
829
|
+
{chips.map((c, i) => (
|
|
830
|
+
<div key={i} className="xeplr-factory-rows-editor-row">
|
|
831
|
+
<input className="xeplr-factory-prop-input" aria-label={`Chip ${i + 1} text`} placeholder="Packed" value={c.label || ''}
|
|
832
|
+
onChange={(e) => edit(i, { label: e.target.value, ...(c.key && c.key !== slugify(c.label || '', '-') ? {} : { key: slugify(e.target.value, '-') }) })} />
|
|
833
|
+
<input className="xeplr-factory-prop-input xeplr-factory-prop-input--key" aria-label={`Chip ${i + 1} key`} placeholder="packed" value={c.key || ''}
|
|
834
|
+
onChange={(e) => edit(i, { key: e.target.value.trim() })} />
|
|
835
|
+
<RowTools i={i} count={chips.length} onMove={(to) => set(move(chips, i, to))} onRemove={() => set(chips.filter((_, j) => j !== i))} />
|
|
836
|
+
</div>
|
|
837
|
+
))}
|
|
838
|
+
<button type="button" className="xeplr-factory-secondary" onClick={() => set([...chips, { key: chips.length ? `chip-${chips.length + 1}` : 'all', label: chips.length ? '' : 'All' }])}>+ Chip</button>
|
|
839
|
+
</div>
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Each column of a list fed by a function: what it sorts by, what it shows, how. */
|
|
844
|
+
function FedColumnsEditor({ value, onChange, functions }) {
|
|
845
|
+
const cols = Array.isArray(value) ? value : []
|
|
846
|
+
const set = (next) => onChange(next.length ? next : undefined)
|
|
847
|
+
const edit = (i, patch) => set(cols.map((c, j) => {
|
|
848
|
+
if (j !== i) return c
|
|
849
|
+
const next = { ...c, ...patch }
|
|
850
|
+
Object.keys(next).forEach((k) => { if (next[k] === undefined || next[k] === '') delete next[k] })
|
|
851
|
+
return next
|
|
852
|
+
}))
|
|
853
|
+
const details = functions && functions.forEvent ? functions.forEvent('rowDetails') : []
|
|
854
|
+
return (
|
|
855
|
+
<div className="xeplr-factory-rows-editor">
|
|
856
|
+
<div className="xeplr-factory-prop-label">Columns</div>
|
|
857
|
+
{cols.map((c, i) => (
|
|
858
|
+
<fieldset key={i} className="xeplr-factory-rows-editor-card">
|
|
859
|
+
<legend>{c.label || `Column ${i + 1}`}</legend>
|
|
860
|
+
<RowTools i={i} count={cols.length} onMove={(to) => set(move(cols, i, to))} onRemove={() => set(cols.filter((_, j) => j !== i))} />
|
|
861
|
+
<MiniField label="Heading"><input className="xeplr-factory-prop-input" value={c.label || ''} onChange={(e) => edit(i, { label: e.target.value })} /></MiniField>
|
|
862
|
+
<MiniField label="Sorts by (field)"><input className="xeplr-factory-prop-input" value={c.field || ''} placeholder="placedAt" onChange={(e) => edit(i, { field: e.target.value.trim() })} /></MiniField>
|
|
863
|
+
<MiniField label="Shows (field)" help="Blank → the value itself"><input className="xeplr-factory-prop-input" value={c.text || ''} placeholder="placedText" onChange={(e) => edit(i, { text: e.target.value.trim() })} /></MiniField>
|
|
864
|
+
<MiniField label="Second line (field)"><input className="xeplr-factory-prop-input" value={c.sub || ''} onChange={(e) => edit(i, { sub: e.target.value.trim() })} /></MiniField>
|
|
865
|
+
<MiniField label="Drawn as">
|
|
866
|
+
<select className="xeplr-factory-prop-input" value={c.display || 'text'} onChange={(e) => edit(i, { display: e.target.value === 'text' ? undefined : e.target.value, ...(e.target.value !== 'images' ? { more: undefined } : {}), ...(e.target.value !== 'activity' ? { details: undefined } : {}) })}>
|
|
867
|
+
{COLUMN_DISPLAYS.map((d) => <option key={d} value={d}>{DISPLAY_LABELS[d]}</option>)}
|
|
868
|
+
</select>
|
|
869
|
+
</MiniField>
|
|
870
|
+
{c.display === 'images' && <MiniField label="How many more (field)"><input className="xeplr-factory-prop-input" value={c.more || ''} onChange={(e) => edit(i, { more: e.target.value.trim() })} /></MiniField>}
|
|
871
|
+
{c.display === 'activity' && (
|
|
872
|
+
<MiniField label="Hover box from">
|
|
873
|
+
<select className="xeplr-factory-prop-input" value={c.details || ''} onChange={(e) => edit(i, { details: e.target.value || undefined })}>
|
|
874
|
+
<option value="">— none —</option>
|
|
875
|
+
{c.details && !details.some((f) => f.name === c.details) && <option value={c.details}>{c.details} (not registered)</option>}
|
|
876
|
+
{details.map((f) => <option key={f.name} value={f.name}>{f.name}</option>)}
|
|
877
|
+
</select>
|
|
878
|
+
</MiniField>
|
|
879
|
+
)}
|
|
880
|
+
<MiniField label="Badges" help="field:Text:tone, … — shown where the field is true. Tones: accent, success, warning, danger, muted">
|
|
881
|
+
<input className="xeplr-factory-prop-input" value={badgesText(c.badges)} placeholder="isNew:New, cancelled:Cancelled:danger"
|
|
882
|
+
onChange={(e) => edit(i, { badges: parseBadges(e.target.value) })} />
|
|
883
|
+
</MiniField>
|
|
884
|
+
<label className="xeplr-factory-toggle">
|
|
885
|
+
<input type="checkbox" checked={c.sortable !== false} onChange={(e) => edit(i, { sortable: e.target.checked ? undefined : false })} />
|
|
886
|
+
<span>Sortable</span>
|
|
887
|
+
</label>
|
|
888
|
+
</fieldset>
|
|
889
|
+
))}
|
|
890
|
+
<button type="button" className="xeplr-factory-secondary" onClick={() => set([...cols, { field: `column${cols.length + 1}`, label: `Column ${cols.length + 1}` }])}>+ Column</button>
|
|
891
|
+
</div>
|
|
892
|
+
)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const DISPLAY_LABELS = { text: 'Text', images: 'Pictures', activity: 'Latest activity (with hover box)' }
|
|
896
|
+
|
|
897
|
+
function badgesText(badges) {
|
|
898
|
+
return (Array.isArray(badges) ? badges : []).map((b) => [b.field, b.label, b.tone].filter(Boolean).join(':')).join(', ')
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function parseBadges(text) {
|
|
902
|
+
const list = String(text || '').split(',').map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
903
|
+
const [field, label, tone] = part.split(':').map((x) => x.trim())
|
|
904
|
+
const b = { field, label: label || field }
|
|
905
|
+
if (tone && BADGE_TONES.includes(tone)) b.tone = tone
|
|
906
|
+
return b
|
|
907
|
+
})
|
|
908
|
+
return list.length ? list : undefined
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* A PANEL'S BUTTONS. Each is a function of the app and the values it is given:
|
|
913
|
+
* picking the function lists the parameters it takes — a dropdown where it
|
|
914
|
+
* names the values allowed. Then what to ask first, and when it shows or is
|
|
915
|
+
* greyed out.
|
|
916
|
+
*/
|
|
917
|
+
function ButtonsEditor({ value, onChange, functions }) {
|
|
918
|
+
const buttons = Array.isArray(value) ? value : []
|
|
919
|
+
const set = (next) => onChange(next)
|
|
920
|
+
const edit = (i, patch) => set(buttons.map((b, j) => {
|
|
921
|
+
if (j !== i) return b
|
|
922
|
+
const next = { ...b, ...patch }
|
|
923
|
+
Object.keys(next).forEach((k) => { if (next[k] === undefined) delete next[k] })
|
|
924
|
+
return next
|
|
925
|
+
}))
|
|
926
|
+
const pick = functions && functions.forEvent ? functions.forEvent('button') : []
|
|
927
|
+
const takenIds = (except) => new Set(buttons.filter((_, j) => j !== except).map((b) => b.id))
|
|
928
|
+
const add = () => {
|
|
929
|
+
const ids = takenIds(-1)
|
|
930
|
+
let n = buttons.length + 1
|
|
931
|
+
while (ids.has(`button-${n}`)) n += 1
|
|
932
|
+
set([...buttons, { id: `button-${n}`, label: `Button ${n}`, action: pick[0] ? pick[0].name : '' }])
|
|
933
|
+
}
|
|
934
|
+
return (
|
|
935
|
+
<div className="xeplr-factory-rows-editor">
|
|
936
|
+
<div className="xeplr-factory-prop-label">Buttons</div>
|
|
937
|
+
{!pick.length && functions && !functions.loading && (
|
|
938
|
+
<div className="xeplr-factory-prop-help">No function is registered for buttons — the app registers one with @xeplr/actions and event “button”.</div>
|
|
939
|
+
)}
|
|
940
|
+
{buttons.map((b, i) => {
|
|
941
|
+
const fn = pick.find((f) => f.name === b.action) || null
|
|
942
|
+
const schema = (fn && fn.inputSchema) || []
|
|
943
|
+
const takesNote = schema.some((f) => f.name === NOTE_INPUT)
|
|
944
|
+
return (
|
|
945
|
+
<fieldset key={i} className="xeplr-factory-rows-editor-card">
|
|
946
|
+
<legend>{b.label || `Button ${i + 1}`}</legend>
|
|
947
|
+
<RowTools i={i} count={buttons.length} onMove={(to) => set(move(buttons, i, to))} onRemove={() => set(buttons.filter((_, j) => j !== i))} />
|
|
948
|
+
<MiniField label="Label">
|
|
949
|
+
<input className="xeplr-factory-prop-input" value={b.label || ''} onChange={(e) => {
|
|
950
|
+
const label = e.target.value
|
|
951
|
+
const auto = !b.id || /^button-\d+$/.test(b.id) || b.id === slugify(b.label || '', '-')
|
|
952
|
+
const id = auto ? uniqueId(slugify(label, '-') || `button-${i + 1}`, takenIds(i)) : b.id
|
|
953
|
+
edit(i, { label, id })
|
|
954
|
+
}} />
|
|
955
|
+
</MiniField>
|
|
956
|
+
<MiniField label="Runs">
|
|
957
|
+
<select className="xeplr-factory-prop-input" value={b.action || ''} onChange={(e) => edit(i, { action: e.target.value, params: undefined })}>
|
|
958
|
+
<option value="">— choose a function —</option>
|
|
959
|
+
{b.action && !fn && <option value={b.action}>{b.action} (not registered)</option>}
|
|
960
|
+
{pick.map((f) => <option key={f.name} value={f.name}>{f.name}</option>)}
|
|
961
|
+
</select>
|
|
962
|
+
</MiniField>
|
|
963
|
+
{fn && fn.description && <div className="xeplr-factory-prop-help">{fn.description}</div>}
|
|
964
|
+
{schema.filter((f) => f.name !== NOTE_INPUT).map((f) => (
|
|
965
|
+
<MiniField key={f.name} label={(f.label || f.name) + (f.required && f.default === undefined ? ' *' : '')} help={f.description}>
|
|
966
|
+
<ParamInput field={f} value={b.params ? b.params[f.name] : undefined} onChange={(v) => {
|
|
967
|
+
const params = { ...(b.params || {}) }
|
|
968
|
+
if (v === undefined || v === '') delete params[f.name]; else params[f.name] = v
|
|
969
|
+
edit(i, { params: Object.keys(params).length ? params : undefined })
|
|
970
|
+
}} />
|
|
971
|
+
</MiniField>
|
|
972
|
+
))}
|
|
973
|
+
<label className="xeplr-factory-toggle">
|
|
974
|
+
<input type="checkbox" checked={Boolean(b.ask)} onChange={(e) => edit(i, { ask: e.target.checked ? { title: `${b.label || 'Continue'}?` } : undefined })} />
|
|
975
|
+
<span>Ask first</span>
|
|
976
|
+
</label>
|
|
977
|
+
{b.ask && (
|
|
978
|
+
<>
|
|
979
|
+
<MiniField label="Question"><input className="xeplr-factory-prop-input" value={b.ask.title || ''} onChange={(e) => edit(i, { ask: clean({ ...b.ask, title: e.target.value }) })} /></MiniField>
|
|
980
|
+
<MiniField label="Explanation"><input className="xeplr-factory-prop-input" value={b.ask.message || ''} onChange={(e) => edit(i, { ask: clean({ ...b.ask, message: e.target.value }) })} /></MiniField>
|
|
981
|
+
{takesNote
|
|
982
|
+
? <MiniField label="Note box label" help="Blank → no note box"><input className="xeplr-factory-prop-input" value={b.ask.note || ''} placeholder="Reason (optional)" onChange={(e) => edit(i, { ask: clean({ ...b.ask, note: e.target.value }) })} /></MiniField>
|
|
983
|
+
: fn && <div className="xeplr-factory-prop-help">{fn.name} takes no note.</div>}
|
|
984
|
+
</>
|
|
985
|
+
)}
|
|
986
|
+
<ConditionsEditor label="Shows when" value={b.when} onChange={(v) => edit(i, { when: v })} />
|
|
987
|
+
<ConditionsEditor label="Greyed out when" value={b.disabledWhen} onChange={(v) => edit(i, { disabledWhen: v })} />
|
|
988
|
+
<MiniField label="Look">
|
|
989
|
+
<select className="xeplr-factory-prop-input" value={b.variant || 'default'} onChange={(e) => edit(i, { variant: e.target.value === 'default' ? undefined : e.target.value })}>
|
|
990
|
+
{BUTTON_VARIANTS.map((v) => <option key={v} value={v}>{VARIANT_LABELS[v]}</option>)}
|
|
991
|
+
</select>
|
|
992
|
+
</MiniField>
|
|
993
|
+
</fieldset>
|
|
994
|
+
)
|
|
995
|
+
})}
|
|
996
|
+
<button type="button" className="xeplr-factory-secondary" onClick={add}>+ Button</button>
|
|
997
|
+
</div>
|
|
998
|
+
)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const VARIANT_LABELS = { default: 'Ordinary', primary: 'Main', danger: 'Undo / remove' }
|
|
1002
|
+
|
|
1003
|
+
/** One parameter's value: a dropdown when the function names the values allowed. */
|
|
1004
|
+
function ParamInput({ field, value, onChange }) {
|
|
1005
|
+
const allowed = optionValues(field.options)
|
|
1006
|
+
if (allowed.length) {
|
|
1007
|
+
const labelOf = (v) => {
|
|
1008
|
+
const o = (field.options || []).find((x) => x && typeof x === 'object' && x.value === v)
|
|
1009
|
+
return o && o.label ? o.label : String(v)
|
|
1010
|
+
}
|
|
1011
|
+
return (
|
|
1012
|
+
<select className="xeplr-factory-prop-input" value={value === undefined ? '' : String(value)} onChange={(e) => {
|
|
1013
|
+
const hit = allowed.find((v) => String(v) === e.target.value)
|
|
1014
|
+
onChange(hit === undefined ? undefined : hit)
|
|
1015
|
+
}}>
|
|
1016
|
+
<option value="">—</option>
|
|
1017
|
+
{allowed.map((v) => <option key={String(v)} value={String(v)}>{labelOf(v)}</option>)}
|
|
1018
|
+
</select>
|
|
1019
|
+
)
|
|
1020
|
+
}
|
|
1021
|
+
if (field.type === 'boolean') {
|
|
1022
|
+
return (
|
|
1023
|
+
<select className="xeplr-factory-prop-input" value={value === undefined ? '' : String(value)} onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value === 'true')}>
|
|
1024
|
+
<option value="">{field.default === undefined ? '—' : `Default (${field.default ? 'yes' : 'no'})`}</option>
|
|
1025
|
+
<option value="true">Yes</option>
|
|
1026
|
+
<option value="false">No</option>
|
|
1027
|
+
</select>
|
|
1028
|
+
)
|
|
1029
|
+
}
|
|
1030
|
+
if (field.type === 'number') {
|
|
1031
|
+
return <input type="number" className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
|
|
1032
|
+
}
|
|
1033
|
+
return <input className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** Conditions on the record: every one must match. */
|
|
1037
|
+
function ConditionsEditor({ label, value, onChange }) {
|
|
1038
|
+
const rules = Array.isArray(value) ? value : []
|
|
1039
|
+
const set = (next) => onChange(next.length ? next : undefined)
|
|
1040
|
+
const edit = (i, patch) => set(rules.map((r, j) => {
|
|
1041
|
+
if (j !== i) return r
|
|
1042
|
+
const next = { ...r, ...patch }
|
|
1043
|
+
const op = CONDITION_OPERATORS.find((o) => o.key === next.op)
|
|
1044
|
+
if (op && op.noValue) { delete next.value; delete next.value2 }
|
|
1045
|
+
if (op && !op.second) delete next.value2
|
|
1046
|
+
return next
|
|
1047
|
+
}))
|
|
1048
|
+
return (
|
|
1049
|
+
<div className="xeplr-factory-conditions">
|
|
1050
|
+
<div className="xeplr-factory-prop-label">{label}{rules.length > 1 ? ' — all of these' : ''}</div>
|
|
1051
|
+
{rules.map((r, i) => {
|
|
1052
|
+
const op = CONDITION_OPERATORS.find((o) => o.key === r.op)
|
|
1053
|
+
return (
|
|
1054
|
+
<div key={i} className="xeplr-factory-rows-editor-row">
|
|
1055
|
+
<input className="xeplr-factory-prop-input" aria-label="Field" placeholder="customStatus" value={r.field || ''} onChange={(e) => edit(i, { field: e.target.value.trim() })} />
|
|
1056
|
+
<select className="xeplr-factory-prop-input" aria-label="Comparison" value={r.op || 'eq'} onChange={(e) => edit(i, { op: e.target.value })}>
|
|
1057
|
+
{CONDITION_OPERATORS.map((o) => <option key={o.key} value={o.key}>{o.label}</option>)}
|
|
1058
|
+
</select>
|
|
1059
|
+
{!(op && op.noValue) && <input className="xeplr-factory-prop-input" aria-label="Value" value={r.value ?? ''} onChange={(e) => edit(i, { value: e.target.value })} />}
|
|
1060
|
+
{op && op.second && <input className="xeplr-factory-prop-input" aria-label="And" value={r.value2 ?? ''} onChange={(e) => edit(i, { value2: e.target.value })} />}
|
|
1061
|
+
<button type="button" className="xeplr-factory-link-danger" aria-label="Remove condition" onClick={() => set(rules.filter((_, j) => j !== i))}>×</button>
|
|
1062
|
+
</div>
|
|
1063
|
+
)
|
|
1064
|
+
})}
|
|
1065
|
+
<button type="button" className="xeplr-factory-link" onClick={() => set([...rules, { field: '', op: 'eq', value: '' }])}>+ Condition</button>
|
|
1066
|
+
</div>
|
|
1067
|
+
)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function MiniField({ label, help, children }) {
|
|
1071
|
+
return (
|
|
1072
|
+
<label className="xeplr-factory-mini">
|
|
1073
|
+
<span className="xeplr-factory-mini-label">{label}</span>
|
|
1074
|
+
{children}
|
|
1075
|
+
{help && <span className="xeplr-factory-prop-help">{help}</span>}
|
|
1076
|
+
</label>
|
|
1077
|
+
)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function RowTools({ i, count, onMove, onRemove }) {
|
|
1081
|
+
return (
|
|
1082
|
+
<span className="xeplr-factory-rows-editor-tools">
|
|
1083
|
+
<button type="button" className="xeplr-factory-link" aria-label="Move up" disabled={i === 0} onClick={() => onMove(i - 1)}>↑</button>
|
|
1084
|
+
<button type="button" className="xeplr-factory-link" aria-label="Move down" disabled={i === count - 1} onClick={() => onMove(i + 1)}>↓</button>
|
|
1085
|
+
<button type="button" className="xeplr-factory-link-danger" aria-label="Remove" onClick={onRemove}>×</button>
|
|
1086
|
+
</span>
|
|
1087
|
+
)
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function move(list, from, to) {
|
|
1091
|
+
const next = list.slice()
|
|
1092
|
+
const [item] = next.splice(from, 1)
|
|
1093
|
+
next.splice(to, 0, item)
|
|
1094
|
+
return next
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function uniqueId(base, taken) {
|
|
1098
|
+
if (!taken.has(base)) return base
|
|
1099
|
+
let n = 2
|
|
1100
|
+
while (taken.has(`${base}-${n}`)) n += 1
|
|
1101
|
+
return `${base}-${n}`
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function clean(obj) {
|
|
1105
|
+
const out = {}
|
|
1106
|
+
Object.keys(obj).forEach((k) => { if (obj[k] !== undefined && obj[k] !== '') out[k] = obj[k] })
|
|
1107
|
+
return out
|
|
1108
|
+
}
|
|
1109
|
+
|
|
781
1110
|
export const EDITORS = {
|
|
782
1111
|
text: TextEditor,
|
|
783
1112
|
textarea: TextareaEditor,
|
|
@@ -798,5 +1127,8 @@ export const EDITORS = {
|
|
|
798
1127
|
columns: ColumnsEditor,
|
|
799
1128
|
listTable: ListTableEditor,
|
|
800
1129
|
actions: ActionsEditor,
|
|
801
|
-
dataSource: DataSourceEditor
|
|
1130
|
+
dataSource: DataSourceEditor,
|
|
1131
|
+
function: FunctionEditor,
|
|
1132
|
+
filters: FiltersEditor,
|
|
1133
|
+
buttons: ButtonsEditor
|
|
802
1134
|
}
|
|
@@ -29,7 +29,8 @@ export default function ScreenModal({ title, width, onClose, children }) {
|
|
|
29
29
|
aria-modal="true"
|
|
30
30
|
aria-label={title}
|
|
31
31
|
tabIndex={-1}
|
|
32
|
-
|
|
32
|
+
// A variable, not a width: on a phone factory.css makes it the full screen.
|
|
33
|
+
style={width ? { '--xf-modal-w': `min(${width + 48}px, 96vw)` } : undefined}
|
|
33
34
|
>
|
|
34
35
|
<header className="xeplr-factory-modal-head">
|
|
35
36
|
<span className="xeplr-factory-modal-title">{title}</span>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import ControlView from './ControlView.jsx'
|
|
2
|
-
import { contentBottom, inputNodes, MARGIN } from '../document.js'
|
|
2
|
+
import { contentBottom, inputNodes, readingOrder, MARGIN } from '../document.js'
|
|
3
3
|
import { screenStyle } from './styles.js'
|
|
4
4
|
import { CHOICE_TYPES } from '../controls.js'
|
|
5
5
|
|
|
@@ -11,6 +11,12 @@ import { CHOICE_TYPES } from '../controls.js'
|
|
|
11
11
|
// The screen is never wider than the width it was designed at, so what was
|
|
12
12
|
// designed at 800px is shown at 800px, and only a narrower screen scales it.
|
|
13
13
|
//
|
|
14
|
+
// ON A PHONE IT STACKS. Positions are handed to CSS as variables (--xf-x, …),
|
|
15
|
+
// not as left/top, so factory.css can drop them below 640px and lay the fields
|
|
16
|
+
// out one per row in reading order (--xf-order) — full width, at real sizes.
|
|
17
|
+
// Scaled down instead, an 800px design on a 360px phone is text in boxes too
|
|
18
|
+
// small to tap. The desktop layout does not change.
|
|
19
|
+
//
|
|
14
20
|
// SAVE IS A BUTTON, AND AN AJAX CALL — never a form submit. The <form> below
|
|
15
21
|
// exists for semantics only: its submit is swallowed, so Enter in a field
|
|
16
22
|
// posts nothing and saves nothing. Only the Save button saves.
|
|
@@ -164,6 +170,9 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
164
170
|
// filled in, not as tall as every step stacked up.
|
|
165
171
|
const shown = ctrl.visibleNodes && ctrl.visibleNodes.length ? ctrl.visibleNodes : doc.nodes
|
|
166
172
|
const pageHeight = (contentBottom(doc, shown) + MARGIN) * aspect * 100
|
|
173
|
+
// Where each control comes when the screen stacks (phones): read top to
|
|
174
|
+
// bottom, left to right across a row — the order the design is read in.
|
|
175
|
+
const order = new Map(readingOrder(shown).map((n, i) => [n.id, i]))
|
|
167
176
|
const fieldNodes = inputNodes(doc)
|
|
168
177
|
// A screen with no fields of its own (a list screen) has nothing to save —
|
|
169
178
|
// and no sheet: its list is the card (factory.css, --bare).
|
|
@@ -174,6 +183,11 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
174
183
|
// Nor a <form> of its own — one form inside another is not valid HTML.
|
|
175
184
|
const Page = ctrl.embedded ? 'div' : 'form'
|
|
176
185
|
const steppers = ctrl.steps.nodes
|
|
186
|
+
// A list that grows with its rows, or a panel of buttons (wrapping on a
|
|
187
|
+
// narrow screen) — its screen's one control: the page is as tall as it is,
|
|
188
|
+
// rather than it scrolling or spilling out of a fixed box.
|
|
189
|
+
const only = shown.length === 1 ? shown[0] : null
|
|
190
|
+
const grows = Boolean(only && ((only.type === 'list' && only.props && only.props.grow) || only.type === 'buttons'))
|
|
177
191
|
|
|
178
192
|
return (
|
|
179
193
|
<div
|
|
@@ -181,8 +195,8 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
181
195
|
style={{ maxWidth: doc.width, '--xf-margin': `${MARGIN * 100}%`, ...screenStyle(doc), ...style }}
|
|
182
196
|
>
|
|
183
197
|
<Page
|
|
184
|
-
className=
|
|
185
|
-
style={{
|
|
198
|
+
className={'xeplr-factory-page' + (grows ? ' xeplr-factory-page--grows' : '')}
|
|
199
|
+
style={{ '--xf-page-h': `${pageHeight}cqw` }}
|
|
186
200
|
// Never a submit: Enter posts nothing and saves nothing. Save is a button.
|
|
187
201
|
{...(ctrl.embedded ? { role: 'group' } : { onSubmit: (e) => e.preventDefault(), noValidate: true })}
|
|
188
202
|
aria-label={doc.name}
|
|
@@ -192,10 +206,11 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
192
206
|
key={node.id}
|
|
193
207
|
className={`xeplr-factory-node xeplr-factory-node--${node.type}`}
|
|
194
208
|
style={{
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
209
|
+
'--xf-x': `${node.x * 100}%`,
|
|
210
|
+
'--xf-y': `${node.y * aspect * 100}cqw`,
|
|
211
|
+
'--xf-w': `${node.w * 100}%`,
|
|
212
|
+
'--xf-h': `${node.h * aspect * 100}cqw`,
|
|
213
|
+
'--xf-order': order.get(node.id)
|
|
199
214
|
}}
|
|
200
215
|
>
|
|
201
216
|
<ControlView
|
|
@@ -222,6 +237,11 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
222
237
|
recordKey: ctrl.recordKey,
|
|
223
238
|
canDelete: ctrl.canDelete,
|
|
224
239
|
extraActions: ctrl.actionsFor ? ctrl.actionsFor(node) : [],
|
|
240
|
+
// Rows a function works out: what they are narrowed to, a
|
|
241
|
+
// cell's hover box, and a row opened at one part of it.
|
|
242
|
+
query: ctrl.listQuery,
|
|
243
|
+
rowDetails: ctrl.rowDetails,
|
|
244
|
+
onOpen: (rec, where) => ctrl.openEditor(node, rec, where),
|
|
225
245
|
// A list with an edit screen opens it in a popup; one on a form
|
|
226
246
|
// opens the row in the form's own fields.
|
|
227
247
|
// A list on the form itself swaps the record in the fields — so
|
|
@@ -233,6 +253,7 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
233
253
|
} : undefined}
|
|
234
254
|
// A form inside this one: the page draws it (renderSubform).
|
|
235
255
|
subform={node.type === 'subform' ? { sub: ctrl.subformFor(node), render: renderSubform } : undefined}
|
|
256
|
+
buttons={node.type === 'buttons' && ctrl.buttonsFor ? ctrl.buttonsFor(node) : undefined}
|
|
236
257
|
/>
|
|
237
258
|
</div>
|
|
238
259
|
))}
|