@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.
@@ -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
- {field.type !== 'toggle' && field.type !== 'toggleValue' && field.type !== 'columns' && field.type !== 'actions' && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
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
- style={{ width: width ? `min(${width + 48}px, 96vw)` : undefined }}
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="xeplr-factory-page"
185
- style={{ height: `${pageHeight}cqw` }}
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
- left: `${node.x * 100}%`,
196
- top: `${node.y * aspect * 100}cqw`,
197
- width: `${node.w * 100}%`,
198
- height: `${node.h * aspect * 100}cqw`
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
  ))}