@xeplr/ui-factory 1.8.4 → 1.8.6
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 +3 -1
- package/README.md +60 -8
- package/package.json +1 -1
- package/src/buttons.js +44 -2
- package/src/controls.js +46 -3
- package/src/designs/ButtonsView.jsx +31 -7
- package/src/designs/ControlView.jsx +26 -44
- package/src/designs/FileField.jsx +99 -0
- package/src/designs/PropertyPanel.jsx +53 -3
- package/src/designs/ScreenSample.jsx +1 -0
- package/src/designs/factory.css +40 -9
- package/src/model.js +4 -4
- package/src/pages.jsx +7 -0
- package/src/remote.js +20 -6
- package/src/tableSchema.js +4 -2
- package/src/useFactoryScreen.js +20 -8
- package/src/useFilePreview.js +40 -0
- package/src/validateDocument.js +52 -13
- package/src/values.js +27 -8
|
@@ -3,8 +3,8 @@ import { getAtPath } from '../propertyPath.js'
|
|
|
3
3
|
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
|
-
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'
|
|
6
|
+
import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS, FILE_SOURCES, MAX_FILES } from '../controls.js'
|
|
7
|
+
import { BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, CONDITION_OPERATORS, NOTE_INPUT, FILES_INPUT, optionValues } from '../buttons.js'
|
|
8
8
|
|
|
9
9
|
// The selected control's properties, GENERATED from its entry in controls.js:
|
|
10
10
|
// each field there names a path and an editor type, and this file owns only
|
|
@@ -941,6 +941,7 @@ function ButtonsEditor({ value, onChange, functions }) {
|
|
|
941
941
|
const fn = pick.find((f) => f.name === b.action) || null
|
|
942
942
|
const schema = (fn && fn.inputSchema) || []
|
|
943
943
|
const takesNote = schema.some((f) => f.name === NOTE_INPUT)
|
|
944
|
+
const takesFiles = schema.some((f) => f.name === FILES_INPUT)
|
|
944
945
|
return (
|
|
945
946
|
<fieldset key={i} className="xeplr-factory-rows-editor-card">
|
|
946
947
|
<legend>{b.label || `Button ${i + 1}`}</legend>
|
|
@@ -961,7 +962,7 @@ function ButtonsEditor({ value, onChange, functions }) {
|
|
|
961
962
|
</select>
|
|
962
963
|
</MiniField>
|
|
963
964
|
{fn && fn.description && <div className="xeplr-factory-prop-help">{fn.description}</div>}
|
|
964
|
-
{schema.filter((f) => f.name !== NOTE_INPUT).map((f) => (
|
|
965
|
+
{schema.filter((f) => f.name !== NOTE_INPUT && f.name !== FILES_INPUT).map((f) => (
|
|
965
966
|
<MiniField key={f.name} label={(f.label || f.name) + (f.required && f.default === undefined ? ' *' : '')} help={f.description}>
|
|
966
967
|
<ParamInput field={f} value={b.params ? b.params[f.name] : undefined} onChange={(v) => {
|
|
967
968
|
const params = { ...(b.params || {}) }
|
|
@@ -981,10 +982,14 @@ function ButtonsEditor({ value, onChange, functions }) {
|
|
|
981
982
|
{takesNote
|
|
982
983
|
? <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
984
|
: fn && <div className="xeplr-factory-prop-help">{fn.name} takes no note.</div>}
|
|
985
|
+
{takesFiles
|
|
986
|
+
? <AskFileEditor value={b.ask.file} onChange={(file) => edit(i, { ask: clean({ ...b.ask, file }) })} />
|
|
987
|
+
: b.ask.file && <div className="xeplr-factory-prop-help">{fn ? fn.name : 'This function'} takes no files — the picker will be refused when publishing.</div>}
|
|
984
988
|
</>
|
|
985
989
|
)}
|
|
986
990
|
<ConditionsEditor label="Shows when" value={b.when} onChange={(v) => edit(i, { when: v })} />
|
|
987
991
|
<ConditionsEditor label="Greyed out when" value={b.disabledWhen} onChange={(v) => edit(i, { disabledWhen: v })} />
|
|
992
|
+
<ConditionsEditor label="Shown as the current choice when" value={b.pressedWhen} onChange={(v) => edit(i, { pressedWhen: v })} />
|
|
988
993
|
<MiniField label="Look">
|
|
989
994
|
<select className="xeplr-factory-prop-input" value={b.variant || 'default'} onChange={(e) => edit(i, { variant: e.target.value === 'default' ? undefined : e.target.value })}>
|
|
990
995
|
{BUTTON_VARIANTS.map((v) => <option key={v} value={v}>{VARIANT_LABELS[v]}</option>)}
|
|
@@ -1000,6 +1005,51 @@ function ButtonsEditor({ value, onChange, functions }) {
|
|
|
1000
1005
|
|
|
1001
1006
|
const VARIANT_LABELS = { default: 'Ordinary', primary: 'Main', danger: 'Undo / remove' }
|
|
1002
1007
|
|
|
1008
|
+
/**
|
|
1009
|
+
* The file a button's press takes (`ask.file`): a file field's rules, set the
|
|
1010
|
+
* same way. Offered only when the function takes "files".
|
|
1011
|
+
*/
|
|
1012
|
+
function AskFileEditor({ value, onChange }) {
|
|
1013
|
+
const f = value && typeof value === 'object' ? value : null
|
|
1014
|
+
const set = (patch) => {
|
|
1015
|
+
const next = { ...f, ...patch }
|
|
1016
|
+
Object.keys(next).forEach((k) => { if (next[k] === undefined || next[k] === '' || next[k] === false) delete next[k] })
|
|
1017
|
+
if (!next.multiple) delete next.maxFiles
|
|
1018
|
+
onChange(next)
|
|
1019
|
+
}
|
|
1020
|
+
return (
|
|
1021
|
+
<>
|
|
1022
|
+
<label className="xeplr-factory-toggle">
|
|
1023
|
+
<input type="checkbox" checked={Boolean(f)} onChange={(e) => onChange(e.target.checked ? { label: 'Photo', accept: FILE_KINDS.image.accept } : undefined)} />
|
|
1024
|
+
<span>Asks for a file</span>
|
|
1025
|
+
</label>
|
|
1026
|
+
{f && (
|
|
1027
|
+
<>
|
|
1028
|
+
<MiniField label="File box label"><input className="xeplr-factory-prop-input" value={f.label || ''} onChange={(e) => set({ label: e.target.value })} /></MiniField>
|
|
1029
|
+
<MiniField label="Allowed types"><AcceptEditor value={f.accept} onChange={(accept) => set({ accept })} /></MiniField>
|
|
1030
|
+
<MiniField label="Take from">
|
|
1031
|
+
<select className="xeplr-factory-prop-input" value={f.source || 'any'} onChange={(e) => set({ source: e.target.value === 'any' ? undefined : e.target.value })}>
|
|
1032
|
+
{FILE_SOURCES.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
1033
|
+
</select>
|
|
1034
|
+
</MiniField>
|
|
1035
|
+
<MiniField label="Largest size (MB)"><input className="xeplr-factory-prop-input" type="number" min={1} max={200} value={f.maxSize ?? ''} onChange={(e) => set({ maxSize: e.target.value === '' ? undefined : Number(e.target.value) })} /></MiniField>
|
|
1036
|
+
<label className="xeplr-factory-toggle">
|
|
1037
|
+
<input type="checkbox" checked={Boolean(f.multiple)} onChange={(e) => set({ multiple: e.target.checked })} />
|
|
1038
|
+
<span>Several files</span>
|
|
1039
|
+
</label>
|
|
1040
|
+
{f.multiple && (
|
|
1041
|
+
<MiniField label="At most (files)"><input className="xeplr-factory-prop-input" type="number" min={1} max={MAX_FILES} value={f.maxFiles ?? ''} onChange={(e) => set({ maxFiles: e.target.value === '' ? undefined : Number(e.target.value) })} /></MiniField>
|
|
1042
|
+
)}
|
|
1043
|
+
<label className="xeplr-factory-toggle">
|
|
1044
|
+
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => set({ required: e.target.checked })} />
|
|
1045
|
+
<span>Required</span>
|
|
1046
|
+
</label>
|
|
1047
|
+
</>
|
|
1048
|
+
)}
|
|
1049
|
+
</>
|
|
1050
|
+
)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1003
1053
|
/** One parameter's value: a dropdown when the function names the values allowed. */
|
|
1004
1054
|
function ParamInput({ field, value, onChange }) {
|
|
1005
1055
|
const allowed = optionValues(field.options)
|
|
@@ -220,6 +220,7 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
220
220
|
error={node.props?.name ? ctrl.errors[node.props.name] : undefined}
|
|
221
221
|
options={CHOICE_TYPES.includes(node.type) ? ctrl.optionsFor(node) : undefined}
|
|
222
222
|
upload={node.type === 'file' ? ctrl.upload : undefined}
|
|
223
|
+
readFile={node.type === 'file' ? ctrl.readFile : undefined}
|
|
223
224
|
rows={node.type === 'table' && ctrl.rowsFor ? ctrl.rowsFor(node) : undefined}
|
|
224
225
|
stepper={node.type === 'stepper' ? {
|
|
225
226
|
active: ctrl.steps.active(node.id),
|
package/src/designs/factory.css
CHANGED
|
@@ -157,15 +157,40 @@
|
|
|
157
157
|
.xeplr-factory-choice { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); cursor: pointer; color: var(--xf-ink); }
|
|
158
158
|
.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); }
|
|
159
159
|
|
|
160
|
-
/* ── file ───────────────────────────────────────────────────────────────────
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
/* ── file ───────────────────────────────────────────────────────────────────
|
|
161
|
+
What the field holds — a row per file, a thumbnail for a picture — and,
|
|
162
|
+
while it has room, the picker: a box that looks like the other inputs, the
|
|
163
|
+
real <input type="file"> laid over it, invisible, so a tap opens the
|
|
164
|
+
device's own picker (on a phone: Photos, Camera, Files). */
|
|
165
|
+
.xeplr-factory-files { display: flex; flex-direction: column; gap: calc(6 * var(--xf-u, 1px)); min-width: 0; min-height: 0; overflow: auto; }
|
|
166
|
+
.xeplr-factory-files-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: calc(4 * var(--xf-u, 1px)); }
|
|
167
|
+
.xeplr-factory-files-item { display: flex; align-items: center; gap: calc(8 * var(--xf-u, 1px)); min-width: 0; }
|
|
168
|
+
.xeplr-factory-files-thumb {
|
|
169
|
+
flex: none; width: calc(40 * var(--xf-u, 1px)); height: calc(40 * var(--xf-u, 1px)); min-width: 28px; min-height: 28px;
|
|
170
|
+
object-fit: cover; border-radius: calc(4 * var(--xf-u, 1px)); border: 1px solid var(--xf-field-border); background: var(--xf-field-bg);
|
|
171
|
+
}
|
|
172
|
+
.xeplr-factory-files-icon {
|
|
173
|
+
flex: none; width: calc(40 * var(--xf-u, 1px)); height: calc(40 * var(--xf-u, 1px)); min-width: 28px; min-height: 28px;
|
|
174
|
+
display: inline-flex; align-items: center; justify-content: center; border-radius: calc(4 * var(--xf-u, 1px));
|
|
175
|
+
border: 1px solid var(--xf-field-border); background: var(--xf-field-bg);
|
|
176
|
+
}
|
|
177
|
+
.xeplr-factory-files-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--xf-ink); }
|
|
178
|
+
.xeplr-factory-files-remove {
|
|
164
179
|
flex: none; border: 1px solid var(--xf-field-border); background: var(--xf-field-bg); color: var(--xf-muted);
|
|
165
|
-
border-radius: calc(6 * var(--xf-u)); padding: calc(3 * var(--xf-u)) calc(8 * var(--xf-u)); cursor: pointer; font: inherit;
|
|
180
|
+
border-radius: calc(6 * var(--xf-u, 1px)); padding: calc(3 * var(--xf-u, 1px)) calc(8 * var(--xf-u, 1px)); cursor: pointer; font: inherit;
|
|
166
181
|
}
|
|
167
|
-
.xeplr-factory-
|
|
168
|
-
.xeplr-factory-
|
|
182
|
+
.xeplr-factory-files-remove:hover { color: var(--xf-ink); border-color: var(--xf-accent); }
|
|
183
|
+
.xeplr-factory-files-add {
|
|
184
|
+
position: relative; display: flex; align-items: center; justify-content: center; flex: none;
|
|
185
|
+
min-height: calc(34 * var(--xf-u, 1px)); padding: calc(5 * var(--xf-u, 1px)) calc(10 * var(--xf-u, 1px)); box-sizing: border-box;
|
|
186
|
+
border: 1px dashed var(--xf-field-border); border-radius: calc(6 * var(--xf-u, 1px)); background: var(--xf-field-bg);
|
|
187
|
+
color: var(--xf-ink); cursor: pointer;
|
|
188
|
+
}
|
|
189
|
+
.xeplr-factory-files-add:hover, .xeplr-factory-files-add:focus-within { border-color: var(--xf-accent); }
|
|
190
|
+
.xeplr-factory-files-add--off { cursor: default; opacity: .6; }
|
|
191
|
+
.xeplr-factory-files-input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: inherit; margin: 0; }
|
|
192
|
+
.xeplr-factory-files-add-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
193
|
+
.xeplr-factory-files-error { color: var(--xf-danger); font-size: .9em; white-space: normal; }
|
|
169
194
|
|
|
170
195
|
/* ── stepper ──────────────────────────────────────────────────────────────
|
|
171
196
|
The journey across the top: a circle per step, joined by a line. The line
|
|
@@ -372,6 +397,10 @@ button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:foc
|
|
|
372
397
|
.xeplr-factory-button--danger { color: var(--xf-danger); border-color: color-mix(in srgb, var(--xf-danger) 55%, var(--xf-border)); }
|
|
373
398
|
.xeplr-factory-button--danger:hover:not(:disabled) { background: color-mix(in srgb, var(--xf-danger) 10%, transparent); }
|
|
374
399
|
.xeplr-factory-buttons--design .xeplr-factory-button { cursor: inherit; }
|
|
400
|
+
/* The current choice (pressedWhen): filled, and never faded — greyed out is
|
|
401
|
+
"unavailable", which the current status is not. */
|
|
402
|
+
.xeplr-factory-button--pressed,
|
|
403
|
+
.xeplr-factory-button--pressed:disabled { color: var(--xf-accent-ink); background: var(--xf-accent); border-color: var(--xf-accent); opacity: 1; }
|
|
375
404
|
.xeplr-factory-buttons-note {
|
|
376
405
|
display: inline-flex; align-items: center; gap: 6px; font-size: .9em; padding: 4px 6px 4px 10px; border-radius: 6px;
|
|
377
406
|
color: var(--xf-good); background: color-mix(in srgb, var(--xf-good) 10%, transparent);
|
|
@@ -800,7 +829,9 @@ button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:foc
|
|
|
800
829
|
the smallest target a thumb hits reliably. */
|
|
801
830
|
.xeplr-factory-input { --xf-min-font: 16px; font-size: max(16px, 1em); flex: none; min-height: 44px; }
|
|
802
831
|
.xeplr-factory-textarea { min-height: 112px; }
|
|
803
|
-
.xeplr-factory-
|
|
832
|
+
.xeplr-factory-files { overflow: visible; }
|
|
833
|
+
.xeplr-factory-files-add { min-height: 44px; }
|
|
834
|
+
.xeplr-factory-files-thumb, .xeplr-factory-files-icon { width: 48px; height: 48px; }
|
|
804
835
|
/* In the flow now: a message may push the next field down, and wraps
|
|
805
836
|
rather than being cut off at the edge of a narrow screen. */
|
|
806
837
|
.xeplr-factory-error { position: static; white-space: normal; }
|
|
@@ -808,7 +839,7 @@ button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:foc
|
|
|
808
839
|
.xeplr-factory-check input { width: 22px; height: 22px; }
|
|
809
840
|
.xeplr-factory-choice input { width: 20px; height: 20px; }
|
|
810
841
|
.xeplr-factory-choices { overflow: visible; }
|
|
811
|
-
.xeplr-factory-
|
|
842
|
+
.xeplr-factory-files-remove { min-height: 40px; padding-inline: 14px; }
|
|
812
843
|
.xeplr-factory-text { height: auto; overflow: visible; }
|
|
813
844
|
.xeplr-factory-stepper { height: auto; }
|
|
814
845
|
.xeplr-factory-list { max-height: none; }
|
package/src/model.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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, LAYOUTS, CHOICE_TYPES, STEP_LIMIT, MULTI_SEPARATOR, FILE_KINDS, MAX_FILE_MB, acceptList, 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, IMAGE_EXTENSIONS, FILE_SOURCES, MAX_FILES, isImageFile, filePicker, 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,
|
|
@@ -16,12 +16,12 @@ export { validateDocument, assertValidDocument } from './validateDocument.js'
|
|
|
16
16
|
export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFunctions, missingFunctions, functionProblems, setEvent, functionsFor } from './events.js'
|
|
17
17
|
// Buttons and lists whose rows a function works out — names and values only.
|
|
18
18
|
export {
|
|
19
|
-
BUTTON_EVENT, LIST_ROWS_EVENT, ROW_DETAILS_EVENT, BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, NOTE_INPUT, CONDITION_OPERATORS,
|
|
20
|
-
conditionMet, buttonShown, buttonDisabled, screenButtons, buttonById, buttonInput, nodeFunctions, paramProblems, optionValues
|
|
19
|
+
BUTTON_EVENT, LIST_ROWS_EVENT, ROW_DETAILS_EVENT, BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, NOTE_INPUT, FILES_INPUT, CONDITION_OPERATORS,
|
|
20
|
+
conditionMet, buttonShown, buttonDisabled, buttonPressed, screenButtons, buttonById, buttonInput, askFile, askFileProblem, nodeFunctions, paramProblems, optionValues
|
|
21
21
|
} from './buttons.js'
|
|
22
22
|
// Which pages are drawn from documents and which are web only (ui/pages.json).
|
|
23
23
|
export { checkInventory, routesIn, pageWarnings, webOnlyMenus } from './pagesInventory.js'
|
|
24
|
-
export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
|
|
24
|
+
export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, isMultiFile, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
|
|
25
25
|
export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
|
|
26
26
|
export { scaffoldEntity } from './scaffold.js'
|
|
27
27
|
export { getAtPath, setAtPath } from './propertyPath.js'
|
package/src/pages.jsx
CHANGED
|
@@ -65,6 +65,8 @@ export function FactoryBuilder(props) {
|
|
|
65
65
|
* @prop fetchRecord async ({ screen, id }) → record — Edit loads the record fresh
|
|
66
66
|
* @prop onDelete async ({ id, source, record }) → void
|
|
67
67
|
* @prop onChange (values) → void
|
|
68
|
+
* @prop uploadFile async (file, { screen, field }) → { path } — a file field's upload
|
|
69
|
+
* @prop readFile async (path) → Blob — an attached file's bytes, for a picture's preview
|
|
68
70
|
* @prop screens { id → document } — the screens a list's Edit / New open in a popup
|
|
69
71
|
* @prop loadScreen async (id) → document — for screens not in `screens`
|
|
70
72
|
* @prop controls control registry (default: built-ins)
|
|
@@ -107,6 +109,7 @@ export function FactoryScreen(props) {
|
|
|
107
109
|
onDelete={props.onDelete}
|
|
108
110
|
uploadFile={props.uploadFile}
|
|
109
111
|
fileUrl={props.fileUrl}
|
|
112
|
+
readFile={props.readFile}
|
|
110
113
|
screens={props.screens}
|
|
111
114
|
loadScreen={props.loadScreen}
|
|
112
115
|
controls={props.controls}
|
|
@@ -138,6 +141,10 @@ export function FactoryScreen(props) {
|
|
|
138
141
|
fetchRecord={props.fetchRecord}
|
|
139
142
|
fetchRecords={props.fetchRecords}
|
|
140
143
|
onDelete={props.onDelete}
|
|
144
|
+
// A file field in the popup uploads like one on the page.
|
|
145
|
+
uploadFile={props.uploadFile}
|
|
146
|
+
fileUrl={props.fileUrl}
|
|
147
|
+
readFile={props.readFile}
|
|
141
148
|
screens={props.screens}
|
|
142
149
|
loadScreen={props.loadScreen}
|
|
143
150
|
controls={props.controls}
|
package/src/remote.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// in the app's database; this only sends and receives.
|
|
4
4
|
//
|
|
5
5
|
// import { authFetch } from '@xeplr/ui-account'
|
|
6
|
-
// const factory = createFactoryApi({ fetch: authFetch })
|
|
6
|
+
// const factory = createFactoryApi({ fetch: authFetch, fetchFile: authFetchFile })
|
|
7
7
|
// <FactoryScreen document={screen} {...factory.screenProps} />
|
|
8
8
|
// <FactoryBuilder document={screen} {...factory.builderProps} lockedNames={…} />
|
|
9
9
|
//
|
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
/**
|
|
13
13
|
* @param options.fetch a fetch that adds auth and tenant headers (e.g. @xeplr/ui-account's authFetch)
|
|
14
14
|
* @param options.base prefix before /factory, if the router is mounted elsewhere (default '')
|
|
15
|
+
* @param options.fetchFile (url) → Blob, signed like `fetch` (@xeplr/ui-account's authFetchFile):
|
|
16
|
+
* how an attached picture is read back for its preview. Without it
|
|
17
|
+
* a file field shows names only — an <img> cannot send the token.
|
|
15
18
|
*/
|
|
16
19
|
import { createChildTable } from './masters.js'
|
|
17
20
|
|
|
18
|
-
export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
21
|
+
export function createFactoryApi({ fetch: doFetch, fetchFile, base = '' } = {}) {
|
|
19
22
|
if (typeof doFetch !== 'function') throw new Error('createFactoryApi: pass { fetch } — usually authFetch from @xeplr/ui-account')
|
|
20
23
|
|
|
21
24
|
// Two kinds of fetch are accepted, because both are what apps have:
|
|
@@ -135,7 +138,16 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
135
138
|
// A button's press on one record → { button, message, output }. The
|
|
136
139
|
// server reads the button — its function and parameters — from the
|
|
137
140
|
// published screen; the request names it and carries the note.
|
|
138
|
-
pressButton: async ({ screen, button, id, note }) => one(await call('POST', `/buttons/${enc(screen)}/${enc(button)}`,
|
|
141
|
+
pressButton: async ({ screen, button, id, note, files }) => one(await call('POST', `/buttons/${enc(screen)}/${enc(button)}`, {
|
|
142
|
+
id, ...(note ? { note } : {}), ...(Array.isArray(files) && files.length ? { files } : {})
|
|
143
|
+
})),
|
|
144
|
+
// A file a button's ask.file takes, stored before the press; the press
|
|
145
|
+
// then names it by the path this answers with.
|
|
146
|
+
uploadButtonFile: async (file, { screen, button }) => {
|
|
147
|
+
const form = new FormData()
|
|
148
|
+
form.append('file', file)
|
|
149
|
+
return one(await send('POST', `/buttons/${enc(screen)}/${enc(button)}/files`, form))
|
|
150
|
+
},
|
|
139
151
|
|
|
140
152
|
// A file field's upload. The answer's `path` is what the record stores;
|
|
141
153
|
// the file itself is read back through the same route, with auth.
|
|
@@ -144,10 +156,12 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
144
156
|
form.append('file', file)
|
|
145
157
|
return one(await send('POST', `/files/${enc(screen)}/${enc(field)}`, form))
|
|
146
158
|
},
|
|
147
|
-
fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}
|
|
159
|
+
fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}`,
|
|
160
|
+
// An attached file's bytes (a picture's preview), when the host said how.
|
|
161
|
+
readFile: typeof fetchFile === 'function' ? (path) => fetchFile(api.fileUrl(path)) : undefined
|
|
148
162
|
}
|
|
149
|
-
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,
|
|
150
|
-
fetchRows: api.fetchRows, fetchRowDetails: api.fetchRowDetails, pressButton: api.pressButton }
|
|
163
|
+
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, readFile: api.readFile,
|
|
164
|
+
fetchRows: api.fetchRows, fetchRowDetails: api.fetchRowDetails, pressButton: api.pressButton, uploadButtonFile: api.uploadButtonFile }
|
|
151
165
|
// makeChildTable: a list on a form can make the table it is about to point
|
|
152
166
|
// at — rows belonging to this record — without leaving the designer. A
|
|
153
167
|
// server act, which is why the builder is given it rather than doing it.
|
package/src/tableSchema.js
CHANGED
|
@@ -88,8 +88,10 @@ export function columnForField(node) {
|
|
|
88
88
|
case 'datetime':
|
|
89
89
|
return { ...base, type: 'timestamp' }
|
|
90
90
|
case 'file':
|
|
91
|
-
// The stored file's path, as the upload answered with it
|
|
92
|
-
|
|
91
|
+
// The stored file's path, as the upload answered with it — several of
|
|
92
|
+
// them, comma separated, in a text column (varchar → text is a widening,
|
|
93
|
+
// so turning "several files" on keeps what is saved).
|
|
94
|
+
return p.multiple ? { ...base, type: 'text' } : { ...base, type: 'varchar', length: FILE_PATH_LENGTH }
|
|
93
95
|
case 'multiselect':
|
|
94
96
|
// Every chosen id in one text column, separated by commas.
|
|
95
97
|
return { ...base, type: 'text' }
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -5,7 +5,7 @@ import { validateDocument } from './validateDocument.js'
|
|
|
5
5
|
import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource, resolveBinding } from './values.js'
|
|
6
6
|
import { hookMethod } from './hooks.js'
|
|
7
7
|
import { useUrlSteps, useUrlListQuery } from './useUrlPlaces.js'
|
|
8
|
-
import { buttonShown, buttonDisabled } from './buttons.js'
|
|
8
|
+
import { buttonShown, buttonDisabled, buttonPressed } from './buttons.js'
|
|
9
9
|
|
|
10
10
|
// A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
|
|
11
11
|
//
|
|
@@ -61,7 +61,9 @@ import { buttonShown, buttonDisabled } from './buttons.js'
|
|
|
61
61
|
* @param hooks a FactoryHooks (or an object with some of its methods) — get / save / delete / actions
|
|
62
62
|
* @param fetchRows async ({ screen, node, q, filter }) → { rows, counts } — a list whose rows a function works out (rowsFrom)
|
|
63
63
|
* @param fetchRowDetails async ({ screen, node, column, id }) → [{ text, sub?, at?, open? }] — an activity cell's hover box
|
|
64
|
-
* @param pressButton async ({ screen, button, id, note }) → { message } — a button's press, run on the server
|
|
64
|
+
* @param pressButton async ({ screen, button, id, note, files }) → { message } — a button's press, run on the server
|
|
65
|
+
* @param uploadButtonFile async (file, { screen, button }) → { path } — a file a button's `ask.file` takes, stored before the press
|
|
66
|
+
* @param readFile async (path) → Blob — an attached file's bytes: a picture's preview (no preview without it)
|
|
65
67
|
* @param onButtonDone ({ button, result, record }) → void — after a press: the host reloads what it shows
|
|
66
68
|
* @param reloadKey anything — when it changes, every list reloads (the host heard something changed)
|
|
67
69
|
* @param urlKey where the open step of each stepper lives in the URL (`<urlKey>Steps`),
|
|
@@ -82,9 +84,9 @@ import { buttonShown, buttonDisabled } from './buttons.js'
|
|
|
82
84
|
const SEARCH_PAUSE_MS = 250
|
|
83
85
|
|
|
84
86
|
export function useFactoryScreen({
|
|
85
|
-
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
|
|
87
|
+
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl, readFile,
|
|
86
88
|
onOpenRecord, onDone, onDirtyChange, params, urlKey, previous,
|
|
87
|
-
fetchRows, fetchRowDetails, pressButton, onButtonDone, reloadKey,
|
|
89
|
+
fetchRows, fetchRowDetails, pressButton, uploadButtonFile, onButtonDone, reloadKey,
|
|
88
90
|
link, onReady,
|
|
89
91
|
controls = CONTROLS
|
|
90
92
|
} = {}) {
|
|
@@ -830,6 +832,7 @@ export function useFactoryScreen({
|
|
|
830
832
|
// and is greyed out is read from it; the press runs on the server, which
|
|
831
833
|
// checks the same again on the saved record.
|
|
832
834
|
const pressRef = useRef(pressButton); pressRef.current = pressButton
|
|
835
|
+
const buttonUploadRef = useRef(uploadButtonFile); buttonUploadRef.current = uploadButtonFile
|
|
833
836
|
const buttonDoneRef = useRef(onButtonDone); buttonDoneRef.current = onButtonDone
|
|
834
837
|
const [pressing, setPressing] = useState(null) // button id on the wire
|
|
835
838
|
const [buttonNote, setButtonNote] = useState(null) // { kind: 'done' | 'error', text, button }
|
|
@@ -838,16 +841,24 @@ export function useFactoryScreen({
|
|
|
838
841
|
record: buttonRecord,
|
|
839
842
|
buttons: ((node.props && node.props.buttons) || [])
|
|
840
843
|
.filter((b) => buttonShown(b, buttonRecord))
|
|
841
|
-
.map((b) => ({ ...b, disabled: buttonDisabled(b, buttonRecord) || recordId === null || recordId === undefined })),
|
|
844
|
+
.map((b) => ({ ...b, pressed: buttonPressed(b, buttonRecord), disabled: buttonDisabled(b, buttonRecord) || recordId === null || recordId === undefined })),
|
|
842
845
|
pressing,
|
|
843
846
|
note: buttonNote,
|
|
844
847
|
clearNote: () => setButtonNote(null),
|
|
845
|
-
|
|
848
|
+
readFile: readFile || null,
|
|
849
|
+
// A file the button's ask.file takes: stored first, its path sent with the press.
|
|
850
|
+
upload: async (file, b) => {
|
|
851
|
+
if (!buttonUploadRef.current) throw new Error('No uploadButtonFile was provided, so this button cannot take a file')
|
|
852
|
+
const out = await buttonUploadRef.current(file, { screen: doc.id, button: b.id })
|
|
853
|
+
if (!out || !out.path) throw new Error('The upload did not answer with the stored file')
|
|
854
|
+
return out
|
|
855
|
+
},
|
|
856
|
+
press: async (b, typed, files) => {
|
|
846
857
|
if (!pressRef.current) { setButtonNote({ kind: 'error', text: 'No pressButton was provided', button: b.id }); return false }
|
|
847
858
|
setPressing(b.id)
|
|
848
859
|
setButtonNote(null)
|
|
849
860
|
try {
|
|
850
|
-
const result = await pressRef.current({ screen: doc.id, button: b.id, id: recordId, note: typed })
|
|
861
|
+
const result = await pressRef.current({ screen: doc.id, button: b.id, id: recordId, note: typed, files })
|
|
851
862
|
setButtonNote({ kind: 'done', text: (result && result.message) || `${b.label} — done`, button: b.id })
|
|
852
863
|
setListVersion((v) => v + 1)
|
|
853
864
|
if (buttonDoneRef.current) buttonDoneRef.current({ button: b, result, record: buttonRecord })
|
|
@@ -859,7 +870,7 @@ export function useFactoryScreen({
|
|
|
859
870
|
setPressing(null)
|
|
860
871
|
}
|
|
861
872
|
}
|
|
862
|
-
}), [buttonRecord, pressing, buttonNote, doc, recordId])
|
|
873
|
+
}), [buttonRecord, pressing, buttonNote, doc, recordId, readFile])
|
|
863
874
|
|
|
864
875
|
return {
|
|
865
876
|
document: doc,
|
|
@@ -889,6 +900,7 @@ export function useFactoryScreen({
|
|
|
889
900
|
visibleNodes,
|
|
890
901
|
upload,
|
|
891
902
|
fileUrl,
|
|
903
|
+
readFile: readFile || null,
|
|
892
904
|
flush,
|
|
893
905
|
save: runSave,
|
|
894
906
|
saveNow: runSave,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { isImageFile } from './controls.js'
|
|
3
|
+
|
|
4
|
+
// A STORED PICTURE, SHOWN. Attachments come back only through the factory's
|
|
5
|
+
// signed route, which an <img src> cannot call — so the bytes are fetched
|
|
6
|
+
// (`readFile`, the host's signed fetch) and shown from a local object URL,
|
|
7
|
+
// released when the path changes or the field goes away. No JSX.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param path a stored path, as the upload answered with it
|
|
11
|
+
* @param readFile async (path) → Blob, or nothing (then: no preview)
|
|
12
|
+
* @returns {{ url, failed }} — url is null until loaded, for a non-picture,
|
|
13
|
+
* or without readFile
|
|
14
|
+
*/
|
|
15
|
+
export function useFilePreview(path, readFile) {
|
|
16
|
+
const [state, setState] = useState({ url: null, failed: false })
|
|
17
|
+
const wanted = Boolean(path && readFile && isImageFile(path))
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!wanted) { setState({ url: null, failed: false }); return undefined }
|
|
21
|
+
let gone = false
|
|
22
|
+
let url = null
|
|
23
|
+
Promise.resolve()
|
|
24
|
+
.then(() => readFile(path))
|
|
25
|
+
.then((blob) => {
|
|
26
|
+
if (gone) return
|
|
27
|
+
url = URL.createObjectURL(blob)
|
|
28
|
+
setState({ url, failed: false })
|
|
29
|
+
})
|
|
30
|
+
.catch(() => { if (!gone) setState({ url: null, failed: true }) })
|
|
31
|
+
return () => {
|
|
32
|
+
gone = true
|
|
33
|
+
if (url) URL.revokeObjectURL(url)
|
|
34
|
+
}
|
|
35
|
+
// readFile is the host's function; a new one each render must not refetch.
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
|
+
}, [path, wanted])
|
|
38
|
+
|
|
39
|
+
return state
|
|
40
|
+
}
|
package/src/validateDocument.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// fields writing the same key — looks like a data problem to whoever uses it.
|
|
12
12
|
|
|
13
13
|
import { SCREEN_EVENT_KEYS, FUNCTION_NAME } from './events.js'
|
|
14
|
-
import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, LAYOUTS, MULTI_SEPARATOR, MAX_FILE_MB, STEP_LIMIT, acceptList } from './controls.js'
|
|
14
|
+
import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, LAYOUTS, MULTI_SEPARATOR, MAX_FILE_MB, STEP_LIMIT, acceptList, FILE_SOURCES, IMAGE_EXTENSIONS, MAX_FILES } from './controls.js'
|
|
15
15
|
import { DOCUMENT_KIND, DOCUMENT_VERSION } from './document.js'
|
|
16
16
|
import { RESERVED_COLUMNS, MAX_IDENTIFIER } from './tableSchema.js'
|
|
17
17
|
import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
|
|
@@ -281,19 +281,44 @@ function checkStep(node, doc, at, err) {
|
|
|
281
281
|
|
|
282
282
|
/** A file field: which extensions it takes, and how big a file may be. */
|
|
283
283
|
function checkFile(props, at, err) {
|
|
284
|
-
|
|
285
|
-
|
|
284
|
+
checkFileRules(props, `${at}.props`, err)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A file's rules — a file field's props, or a button's `ask.file`: what it
|
|
289
|
+
* takes, how large, how many, and from where. `at` is where they sit.
|
|
290
|
+
*/
|
|
291
|
+
function checkFileRules(rules, at, err) {
|
|
292
|
+
if (rules.accept !== undefined) {
|
|
293
|
+
if (typeof rules.accept !== 'string') err(`${at}.accept`, 'must be a string of extensions, e.g. ".pdf,.docx"')
|
|
286
294
|
else {
|
|
287
|
-
const list = acceptList(
|
|
288
|
-
if (!list.length) err(`${at}.
|
|
295
|
+
const list = acceptList(rules.accept)
|
|
296
|
+
if (!list.length) err(`${at}.accept`, 'names no extension — ".pdf,.docx", or leave it out for any file')
|
|
289
297
|
list.forEach((x) => {
|
|
290
|
-
if (!EXTENSION.test(x)) err(`${at}.
|
|
298
|
+
if (!EXTENSION.test(x)) err(`${at}.accept`, `"${x}" is not an extension — letters and digits after a dot, e.g. ".xlsx"`)
|
|
291
299
|
})
|
|
292
300
|
}
|
|
293
301
|
}
|
|
294
|
-
if (
|
|
295
|
-
if (!(Number.isFinite(
|
|
296
|
-
err(`${at}.
|
|
302
|
+
if (rules.maxSize !== undefined) {
|
|
303
|
+
if (!(Number.isFinite(rules.maxSize) && rules.maxSize > 0 && rules.maxSize <= MAX_FILE_MB)) {
|
|
304
|
+
err(`${at}.maxSize`, `must be a number of megabytes from 1 to ${MAX_FILE_MB}`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (rules.multiple !== undefined && typeof rules.multiple !== 'boolean') err(`${at}.multiple`, 'must be true or false')
|
|
308
|
+
if (rules.maxFiles !== undefined) {
|
|
309
|
+
if (!(Number.isInteger(rules.maxFiles) && rules.maxFiles >= 1 && rules.maxFiles <= MAX_FILES)) err(`${at}.maxFiles`, `must be a whole number of files from 1 to ${MAX_FILES}`)
|
|
310
|
+
else if (!rules.multiple) err(`${at}.maxFiles`, 'is for several files — turn on "multiple", or remove it')
|
|
311
|
+
}
|
|
312
|
+
if (rules.source !== undefined) {
|
|
313
|
+
const sources = FILE_SOURCES.map((s) => s.value)
|
|
314
|
+
if (!sources.includes(rules.source)) err(`${at}.source`, `must be one of: ${sources.join(', ')}`)
|
|
315
|
+
else if (rules.source === 'camera') {
|
|
316
|
+
// A camera takes pictures: a field that also takes PDFs could never be
|
|
317
|
+
// filled the way it says, and one that takes no images at all is wrong.
|
|
318
|
+
const list = typeof rules.accept === 'string' ? acceptList(rules.accept) : []
|
|
319
|
+
if (!list.length || list.some((x) => !IMAGE_EXTENSIONS.includes(x))) {
|
|
320
|
+
err(`${at}.accept`, `a camera takes photos — with "source": "camera", the allowed types must be images only (${IMAGE_EXTENSIONS.join(',')})`)
|
|
321
|
+
}
|
|
297
322
|
}
|
|
298
323
|
}
|
|
299
324
|
}
|
|
@@ -522,14 +547,27 @@ function checkFedColumn(c, p, err) {
|
|
|
522
547
|
}
|
|
523
548
|
}
|
|
524
549
|
|
|
525
|
-
const BUTTON_KEYS = ['id', 'label', 'action', 'params', 'ask', 'when', 'disabledWhen', 'variant']
|
|
550
|
+
const BUTTON_KEYS = ['id', 'label', 'action', 'params', 'ask', 'when', 'disabledWhen', 'pressedWhen', 'variant']
|
|
526
551
|
const ASK_KEYS = ['title', 'message', 'note']
|
|
552
|
+
// A button's `ask.file`: the file a press takes — a file field's rules, plus its label and required.
|
|
553
|
+
const ASK_FILE_KEYS = ['label', 'required', 'accept', 'maxSize', 'multiple', 'maxFiles', 'source']
|
|
527
554
|
|
|
528
555
|
/**
|
|
529
556
|
* A panel of buttons. Each names a function and the values it is given; the
|
|
530
557
|
* server checks the names against what is registered when the screen is
|
|
531
558
|
* published (functionProblems). Here: the shape.
|
|
532
559
|
*/
|
|
560
|
+
function checkAskFile(f, at, err) {
|
|
561
|
+
if (!f || typeof f !== 'object' || Array.isArray(f)) { err(at, 'must be { label?, accept, maxSize?, multiple?, maxFiles?, source?, required? } — the file a press takes'); return }
|
|
562
|
+
const bad = Object.keys(f).filter((k) => !ASK_FILE_KEYS.includes(k))
|
|
563
|
+
if (bad.length) err(at, `only ${ASK_FILE_KEYS.map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
|
|
564
|
+
if (f.label !== undefined && typeof f.label !== 'string') err(`${at}.label`, 'must be a string')
|
|
565
|
+
if (f.required !== undefined && typeof f.required !== 'boolean') err(`${at}.required`, 'must be true or false')
|
|
566
|
+
// The server checks an upload against these, so there must be something to check.
|
|
567
|
+
if (f.accept === undefined) err(`${at}.accept`, 'is required — the extensions it takes, e.g. ".png,.jpg,.jpeg" (the server refuses anything else)')
|
|
568
|
+
checkFileRules(f, at, err)
|
|
569
|
+
}
|
|
570
|
+
|
|
533
571
|
function checkButtons(props, at, err, ids) {
|
|
534
572
|
if (props.title !== undefined && typeof props.title !== 'string') err(`${at}.props.title`, 'must be a string')
|
|
535
573
|
if (!Array.isArray(props.buttons)) { err(`${at}.props.buttons`, 'must be an array of buttons — { id, label, action, params? }'); return }
|
|
@@ -553,13 +591,14 @@ function checkButtons(props, at, err, ids) {
|
|
|
553
591
|
if (b.ask !== undefined) {
|
|
554
592
|
if (!b.ask || typeof b.ask !== 'object' || Array.isArray(b.ask)) err(`${p}.ask`, 'must be { title?, message?, note? } — what the person is asked before it runs')
|
|
555
593
|
else {
|
|
556
|
-
const bad = Object.keys(b.ask).filter((k) => !ASK_KEYS.includes(k))
|
|
557
|
-
if (bad.length) err(`${p}.ask`, `only ${ASK_KEYS.map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
|
|
594
|
+
const bad = Object.keys(b.ask).filter((k) => !ASK_KEYS.includes(k) && k !== 'file')
|
|
595
|
+
if (bad.length) err(`${p}.ask`, `only ${[...ASK_KEYS, 'file'].map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
|
|
558
596
|
ASK_KEYS.forEach((k) => { if (b.ask[k] !== undefined && typeof b.ask[k] !== 'string') err(`${p}.ask.${k}`, 'must be a string') })
|
|
597
|
+
if (b.ask.file !== undefined) checkAskFile(b.ask.file, `${p}.ask.file`, err)
|
|
559
598
|
}
|
|
560
599
|
}
|
|
561
600
|
if (b.variant !== undefined && !BUTTON_VARIANTS.includes(b.variant)) err(`${p}.variant`, `must be one of: ${BUTTON_VARIANTS.join(', ')}`)
|
|
562
|
-
;['when', 'disabledWhen'].forEach((k) => { if (b[k] !== undefined) checkRules(b[k], `${p}.${k}`, err) })
|
|
601
|
+
;['when', 'disabledWhen', 'pressedWhen'].forEach((k) => { if (b[k] !== undefined) checkRules(b[k], `${p}.${k}`, err) })
|
|
563
602
|
})
|
|
564
603
|
}
|
|
565
604
|
|