@fieldia/viewer 0.6.0 → 0.7.0

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/src/lib/viewer.js CHANGED
@@ -4,103 +4,11 @@ exports.DEFAULT_LABELS = exports.VIEWER_LABELS = void 0;
4
4
  exports.mountViewer = mountViewer;
5
5
  const core_1 = require("@fieldia/core");
6
6
  const widgets_1 = require("@fieldia/widgets");
7
- const dialog_1 = require("./dialog");
8
- exports.VIEWER_LABELS = {
9
- en: {
10
- save: 'Save',
11
- discard: 'Discard',
12
- saving: 'Saving…',
13
- saved: 'Saved',
14
- submit: 'Submit',
15
- next: 'Next',
16
- back: 'Back',
17
- skip: 'Skip',
18
- steps: 'Steps',
19
- stepOf: 'Step {n} of {total}',
20
- submitted: 'Thank you. Your answers were sent.',
21
- submitAnother: 'Submit another response',
22
- draftFound: 'You have unsaved answers from {time}.',
23
- restore: 'Restore',
24
- discardDraft: 'Discard',
25
- ok: 'OK',
26
- cancel: 'Cancel',
27
- loading: 'Loading…',
28
- saveClose: 'Save & Close',
29
- close: 'Close',
30
- dismiss: 'Dismiss',
31
- },
32
- ar: {
33
- save: 'حفظ',
34
- discard: 'تجاهل',
35
- saving: 'جارٍ الحفظ…',
36
- saved: 'تم الحفظ',
37
- submit: 'إرسال',
38
- next: 'التالي',
39
- back: 'رجوع',
40
- skip: 'تخطٍّ',
41
- steps: 'الخطوات',
42
- stepOf: 'الخطوة {n} من {total}',
43
- submitted: 'شكرًا لك. تم إرسال إجاباتك.',
44
- submitAnother: 'إرسال إجابة أخرى',
45
- draftFound: 'لديك إجابات غير محفوظة من {time}.',
46
- restore: 'استعادة',
47
- discardDraft: 'تجاهل',
48
- ok: 'موافق',
49
- cancel: 'إلغاء',
50
- loading: 'جارٍ التحميل…',
51
- saveClose: 'حفظ وإغلاق',
52
- close: 'إغلاق',
53
- dismiss: 'إخفاء',
54
- },
55
- de: {
56
- save: 'Speichern',
57
- discard: 'Verwerfen',
58
- saving: 'Wird gespeichert…',
59
- saved: 'Gespeichert',
60
- submit: 'Absenden',
61
- next: 'Weiter',
62
- back: 'Zurück',
63
- skip: 'Überspringen',
64
- steps: 'Schritte',
65
- stepOf: 'Schritt {n} von {total}',
66
- submitted: 'Vielen Dank. Ihre Antworten wurden gesendet.',
67
- submitAnother: 'Weitere Antwort senden',
68
- draftFound: 'Sie haben ungespeicherte Antworten vom {time}.',
69
- restore: 'Wiederherstellen',
70
- discardDraft: 'Verwerfen',
71
- ok: 'OK',
72
- cancel: 'Abbrechen',
73
- loading: 'Wird geladen…',
74
- saveClose: 'Speichern und schließen',
75
- close: 'Schließen',
76
- dismiss: 'Ausblenden',
77
- },
78
- fr: {
79
- save: 'Enregistrer',
80
- discard: 'Annuler les modifications',
81
- saving: 'Enregistrement…',
82
- saved: 'Enregistré',
83
- submit: 'Envoyer',
84
- next: 'Suivant',
85
- back: 'Retour',
86
- skip: 'Passer',
87
- steps: 'Étapes',
88
- stepOf: 'Étape {n} sur {total}',
89
- submitted: 'Merci. Vos réponses ont été envoyées.',
90
- submitAnother: 'Envoyer une autre réponse',
91
- draftFound: 'Vous avez des réponses non enregistrées du {time}.',
92
- restore: 'Restaurer',
93
- discardDraft: 'Ignorer',
94
- ok: 'OK',
95
- cancel: 'Annuler',
96
- loading: 'Chargement…',
97
- saveClose: 'Enregistrer et fermer',
98
- close: 'Fermer',
99
- dismiss: 'Masquer',
100
- },
101
- };
102
- /** English, kept under its old name. */
103
- exports.DEFAULT_LABELS = exports.VIEWER_LABELS.en;
7
+ const related_1 = require("./related");
8
+ const labels_1 = require("./labels");
9
+ var labels_2 = require("./labels");
10
+ Object.defineProperty(exports, "VIEWER_LABELS", { enumerable: true, get: function () { return labels_2.VIEWER_LABELS; } });
11
+ Object.defineProperty(exports, "DEFAULT_LABELS", { enumerable: true, get: function () { return labels_2.DEFAULT_LABELS; } });
104
12
  let mounts = 0;
105
13
  /**
106
14
  * Render a page into `host` and keep it in step with its form. Plain DOM: the
@@ -109,17 +17,18 @@ let mounts = 0;
109
17
  */
110
18
  function mountViewer(host, options) {
111
19
  const doc = host.ownerDocument;
112
- const checked = (0, core_1.validatePage)(options.page);
20
+ // The quick check of names and conditions: the full one, with its validation library, belongs where pages are made.
21
+ const checked = (0, core_1.checkPage)(options.page);
113
22
  if (!checked.ok) {
114
23
  throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
115
24
  }
116
- const page = checked.page;
25
+ const page = options.translate ? (0, core_1.translatePage)(checked.page, options.translate) : checked.page;
117
26
  const locale = options.locale ?? 'en';
118
27
  const preferences = options.preferences ?? (0, widgets_1.browserPreferences)();
119
- const dialogs = pageDialogs(options);
28
+ const dialogs = (0, related_1.pageDialogs)(options);
120
29
  const ownsForm = !options.form;
121
30
  const form = options.form ?? (0, core_1.createForm)({ ...options, page, messages: options.messages ?? core_1.MESSAGES[locale] });
122
- const labels = { ...exports.VIEWER_LABELS[locale], ...options.labels };
31
+ const labels = { ...labels_1.VIEWER_LABELS[locale], ...options.labels };
123
32
  const widgetLabels = widgets_1.WIDGET_LABELS[locale];
124
33
  const dir = options.dir ?? (locale === 'ar' ? 'rtl' : undefined);
125
34
  const prefix = `fd${++mounts}`;
@@ -151,6 +60,12 @@ function mountViewer(host, options) {
151
60
  if (node.colspan)
152
61
  wrapper.style.setProperty('--fd-span', String(node.colspan));
153
62
  const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, node.label ?? def.label);
63
+ // The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
64
+ const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? (0, widgets_1.drawIcon)(doc, 'check') : null;
65
+ if (mark) {
66
+ mark.classList.add('fd-valid-mark');
67
+ label.append(mark);
68
+ }
154
69
  const widget = (0, widgets_1.createWidget)({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
155
70
  if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
156
71
  // `for` stays: a custom field that puts the id on its own input is
@@ -162,7 +77,8 @@ function mountViewer(host, options) {
162
77
  }
163
78
  const helpText = node.help ?? def.help;
164
79
  const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
165
- const error = el('div', { class: 'fd-error', id: `${id}-error`, role: 'alert', hidden: '' });
80
+ // Not an alert of its own: a refused save is announced once, naming every field to look at.
81
+ const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
166
82
  // A warning from the data source's onchange, beside the field whose change brought it.
167
83
  const warning = el('div', { class: 'fd-warning', role: 'status', hidden: '' });
168
84
  wrapper.append(label, widget.element, ...(help ? [help] : []), error, warning);
@@ -178,10 +94,17 @@ function mountViewer(host, options) {
178
94
  const warned = state.warning && state.warningField === node.field ? state.warning : '';
179
95
  warning.hidden = !warned;
180
96
  warning.textContent = warned;
97
+ if (mark) {
98
+ const value = state.values[node.field];
99
+ const filled = value !== null && value !== undefined && value !== '' && !(Array.isArray(value) && !value.length);
100
+ const valid = !shown.readonly && !message && filled && state.dirty.includes(node.field) && form.problem(node.field) === null;
101
+ wrapper.classList.toggle('fd-valid', valid);
102
+ mark.toggleAttribute('hidden', !valid);
103
+ }
181
104
  widget.update({
182
105
  value: state.values[node.field],
183
106
  values: state.values,
184
- readonly: shown.readonly,
107
+ readonly: shown.readonly || locked,
185
108
  required: shown.required,
186
109
  invalid: !!message,
187
110
  describedBy: [help?.id, message ? error.id : undefined].filter(Boolean).join(' ') || undefined,
@@ -334,12 +257,101 @@ function mountViewer(host, options) {
334
257
  }
335
258
  }
336
259
  // ---- shared chrome ----------------------------------------------------------
337
- const status = el('div', { class: 'fd-status', role: 'status', 'aria-live': 'polite' });
260
+ /** The whole form locked: every field read-only, no Save; buttons still run. */
261
+ let locked = options.readonly === true;
262
+ // Edit unlocks; Done saves what changed first, and locks only once that is saved.
263
+ const editSwitch = options.editSwitch ? el('button', { type: 'button', class: 'fd-button fd-edit-switch' }) : null;
264
+ editSwitch?.addEventListener('click', async () => {
265
+ if (!locked) {
266
+ if (form.getState().dirty.length) {
267
+ const saved = await form.save();
268
+ if (!saved) {
269
+ if (!form.getState().saveProblem)
270
+ announce(checkText(form.getState()));
271
+ focusFirstProblem();
272
+ return;
273
+ }
274
+ }
275
+ locked = true;
276
+ }
277
+ else
278
+ locked = false;
279
+ root.toggleAttribute('data-readonly', locked);
280
+ render(form.getState());
281
+ });
282
+ root.toggleAttribute('data-readonly', locked);
283
+ updaters.push(() => {
284
+ if (!editSwitch)
285
+ return;
286
+ editSwitch.textContent = locked ? labels.edit : labels.done;
287
+ editSwitch.classList.toggle('fd-button-primary', locked);
288
+ });
289
+ // The status says its words alone, so a screen reader hears them; Retry sits beside it.
290
+ const statusText = el('div', { class: 'fd-status', role: 'status', 'aria-live': 'polite' });
291
+ const retry = el('button', { type: 'button', class: 'fd-button fd-button-link fd-retry', hidden: '' }, labels.retry);
292
+ retry.addEventListener('click', () => void submitOrSave());
293
+ const status = el('div', { class: 'fd-status-box' }, statusText, retry);
294
+ const notDone = () => (page.data.kind === 'responses' ? labels.notSent : labels.notSaved);
295
+ /** "Not saved. Check: Email, Phone": the fields with a problem, in the order they show. */
296
+ function checkText(state) {
297
+ const names = [];
298
+ for (const key of Object.keys(state.errors)) {
299
+ const field = key.split('.')[0];
300
+ const shown = root.querySelector(`.fd-field[data-field="${field}"]:not([hidden]) > .fd-label`);
301
+ const name = shown?.firstChild?.textContent ?? page.fields[field]?.label ?? field;
302
+ if (!names.includes(name))
303
+ names.push(name);
304
+ }
305
+ const inOrder = [...root.querySelectorAll('.fd-field[data-field] > .fd-label')].map((label) => label.firstChild?.textContent ?? '');
306
+ names.sort((a, b) => (inOrder.indexOf(a) + 1 || Infinity) - (inOrder.indexOf(b) + 1 || Infinity));
307
+ return names.length ? fill(labels.checkFields, { what: notDone(), fields: names.join(', ') }) : notDone();
308
+ }
309
+ let quietTimer;
338
310
  updaters.push((state) => {
339
- const text = state.status === 'saving' ? labels.saving : state.status === 'saved' ? labels.saved : state.status === 'loading' ? labels.loading : state.status === 'error' ? state.error ?? '' : '';
340
- status.textContent = text;
341
- status.classList.toggle('fd-status-error', state.status === 'error');
342
- status.classList.toggle('fd-status-saved', state.status === 'saved');
311
+ const problem = state.saveProblem;
312
+ const text = state.status === 'saving' ? labels.saving
313
+ : state.status === 'saved' ? labels.saved
314
+ : state.status === 'loading' ? labels.loading
315
+ : state.status === 'error' && problem ? (problem.kind === 'fields' ? checkText(state) : problem.kind === 'other' ? problem.message : notDone())
316
+ : state.status === 'error' ? state.error ?? ''
317
+ : '';
318
+ statusText.textContent = text;
319
+ retry.hidden = !(state.status === 'error' && problem?.kind === 'other');
320
+ statusText.classList.toggle('fd-status-error', state.status === 'error');
321
+ statusText.classList.toggle('fd-status-saved', state.status === 'saved');
322
+ // A toast shows while there is something to say, and lets "Saved" go after a moment.
323
+ if (options.saveStatus === 'toast') {
324
+ clearTimeout(quietTimer);
325
+ status.hidden = !text;
326
+ if (state.status === 'saved')
327
+ quietTimer = setTimeout(() => (status.hidden = true), 2500);
328
+ }
329
+ });
330
+ // A save the network failed: a banner over the page, with Retry.
331
+ const bannerRetry = el('button', { type: 'button', class: 'fd-button fd-retry' }, labels.retry);
332
+ bannerRetry.addEventListener('click', () => void submitOrSave());
333
+ const banner = el('div', { class: 'fd-banner fd-tone-warning', role: 'alert', hidden: '' }, el('span', { class: 'fd-banner-text' }, labels.offline), bannerRetry);
334
+ /** Said at once to a screen reader: why a save or send did not go through. */
335
+ const announcer = el('div', { class: 'fd-announce', role: 'alert', 'aria-live': 'assertive' });
336
+ function announce(text) {
337
+ announcer.textContent = '';
338
+ setTimeout(() => (announcer.textContent = text), 0);
339
+ }
340
+ // Each new reason a save was refused, from Save or from autosave, is shown where it belongs.
341
+ let seenProblem = null;
342
+ updaters.push((state) => {
343
+ if (state.saveProblem === seenProblem)
344
+ return;
345
+ seenProblem = state.saveProblem;
346
+ banner.hidden = seenProblem?.kind !== 'network';
347
+ if (!seenProblem)
348
+ return;
349
+ if (seenProblem.kind === 'rule')
350
+ void dialogConfirm(seenProblem.message, false);
351
+ else if (seenProblem.kind === 'fields')
352
+ announce(checkText(state));
353
+ else if (seenProblem.kind === 'other')
354
+ announce(seenProblem.message);
343
355
  });
344
356
  const draft = el('div', { class: 'fd-draft', hidden: '' });
345
357
  const draftText = el('span');
@@ -373,6 +385,9 @@ function mountViewer(host, options) {
373
385
  async function submitOrSave() {
374
386
  const saved = await form.save();
375
387
  if (!saved) {
388
+ // Stopped by the page's own checks: say which fields; a refused save says so above.
389
+ if (!form.getState().saveProblem)
390
+ announce(checkText(form.getState()));
376
391
  focusFirstProblem();
377
392
  return;
378
393
  }
@@ -407,11 +422,15 @@ function mountViewer(host, options) {
407
422
  discard.addEventListener('click', () => form.reset());
408
423
  actions.insertBefore(discard, action);
409
424
  updaters.push((state) => {
410
- const dirty = state.dirty.length > 0 && state.status !== 'saving';
425
+ const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
411
426
  action.hidden = !dirty;
412
427
  discard.hidden = !dirty;
413
428
  });
414
429
  }
430
+ else
431
+ updaters.push(() => (action.hidden = locked));
432
+ if (editSwitch)
433
+ actions.prepend(editSwitch);
415
434
  if (options.showActions === false)
416
435
  return el('div', {}, box);
417
436
  if (page.actionsPosition === 'top') {
@@ -520,8 +539,11 @@ function mountViewer(host, options) {
520
539
  }
521
540
  else
522
541
  actions.append(save, discard, ...(node.buttons ?? []).map(buttonItem), status);
542
+ // The Edit switch leads the header bar, before Save and the record's own buttons.
543
+ if (editSwitch && options.showActions !== false)
544
+ actions.prepend(editSwitch);
523
545
  updaters.push((state) => {
524
- const dirty = state.dirty.length > 0 && state.status !== 'saving';
546
+ const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
525
547
  save.hidden = !dirty;
526
548
  discard.hidden = !dirty;
527
549
  });
@@ -607,7 +629,7 @@ function mountViewer(host, options) {
607
629
  if (atFoot && options.showActions !== false) {
608
630
  card.append(foot);
609
631
  updaters.push((state) => {
610
- foot.hidden = state.dirty.length === 0 && !status.textContent;
632
+ foot.hidden = state.dirty.length === 0 && !statusText.textContent;
611
633
  });
612
634
  }
613
635
  const layout = el('div', { class: 'fd-sheet-layout' }, card);
@@ -626,7 +648,7 @@ function mountViewer(host, options) {
626
648
  barOptions['visibleStates'] = config.visibleStates;
627
649
  const node = { type: 'field', id: '#statusbar', field: config.field, widget: 'statusbar', options: barOptions };
628
650
  const widget = (0, widgets_1.createWidget)({ form, name: config.field, field: def, node, id: uid('statusbar'), document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
629
- updaters.push((state) => widget.update({ value: state.values[config.field], values: state.values, readonly: form.node('#statusbar').readonly, required: false, invalid: false }));
651
+ updaters.push((state) => widget.update({ value: state.values[config.field], values: state.values, readonly: form.node('#statusbar').readonly || locked, required: false, invalid: false }));
630
652
  return widget.element;
631
653
  }
632
654
  let wizardForward = null;
@@ -641,8 +663,17 @@ function mountViewer(host, options) {
641
663
  else
642
664
  body = sectionsLayout(layout);
643
665
  const head = layout.type === 'sheet' ? null : pageHead();
644
- content.append(...(head ? [head] : []), draft, body);
645
- root.append(content, done);
666
+ content.append(...(head ? [head] : []), banner, draft, body);
667
+ root.append(content, done, announcer);
668
+ // The status leaves its place beside Save when the page wants it in a corner or across the top.
669
+ if (options.saveStatus === 'toast') {
670
+ status.classList.add('fd-toast');
671
+ root.append(status);
672
+ }
673
+ else if (options.saveStatus === 'bar') {
674
+ status.classList.add('fd-status-bar');
675
+ content.prepend(status);
676
+ }
646
677
  root.addEventListener('submit', (event) => {
647
678
  event.preventDefault();
648
679
  if (wizardForward)
@@ -650,13 +681,65 @@ function mountViewer(host, options) {
650
681
  else
651
682
  void submitOrSave();
652
683
  });
684
+ // ---- keys -------------------------------------------------------------------
685
+ /** Text typed and not yet taken, such as a tag without its Enter, is taken now. */
686
+ function commitTyping() {
687
+ const active = doc.activeElement;
688
+ if (active && root.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
689
+ active.dispatchEvent(new Event('change', { bubbles: true }));
690
+ }
691
+ }
692
+ /** The fields Enter can move between: on this page, shown, with a box to type or choose in, outside tables of lines. */
693
+ function nextField(from) {
694
+ const fields = [...root.querySelectorAll('.fd-field')].filter((field) => !field.closest('[hidden], [data-type="one2many"] .fd-field, .fd-form-dialog') && !field.parentElement?.closest('[data-type="one2many"]'));
695
+ const here = from.closest('.fd-field');
696
+ const after = fields.slice(fields.indexOf(here) + 1);
697
+ for (const field of after) {
698
+ const control = field.querySelector('input:not([type="hidden"]):not([readonly]):not([disabled]), select:not([disabled]), textarea:not([readonly]), [contenteditable="true"]');
699
+ if (control)
700
+ return control;
701
+ }
702
+ return null;
703
+ }
704
+ root.addEventListener('keydown', (event) => {
705
+ if (event.isComposing || event.key !== 'Enter')
706
+ return;
707
+ const keys = options.keys ?? {};
708
+ if (event.ctrlKey || event.metaKey) {
709
+ // Saves even after a field used the Enter (a tag box adding its tag); the grid keeps its own keys.
710
+ if (keys.saveWithCtrlEnter === false || options.showActions === false)
711
+ return;
712
+ if (event.target.closest('.ag-root-wrapper'))
713
+ return;
714
+ event.preventDefault();
715
+ commitTyping();
716
+ if (wizardForward)
717
+ void wizardForward();
718
+ else
719
+ void submitOrSave();
720
+ return;
721
+ }
722
+ // A plain Enter a field used itself, such as picking from an open list, is the field's.
723
+ if (event.defaultPrevented || !keys.enterMovesToNext || event.shiftKey || event.altKey)
724
+ return;
725
+ const from = event.target;
726
+ if (from.tagName !== 'INPUT' && from.tagName !== 'SELECT')
727
+ return;
728
+ if (from.closest('[data-type="one2many"]'))
729
+ return;
730
+ if (['button', 'submit', 'reset', 'file'].includes(from.type))
731
+ return;
732
+ event.preventDefault();
733
+ nextField(from)?.focus();
734
+ });
653
735
  // ---- confirmation dialog -----------------------------------------------------
654
- function dialogConfirm(message) {
736
+ /** A question with Cancel and OK, or with `withCancel` false a notice with OK alone. */
737
+ function dialogConfirm(message, withCancel = true) {
655
738
  return new Promise((resolve) => {
656
739
  const text = el('p', { id: `${prefix}-confirm` }, message);
657
740
  const cancel = el('button', { type: 'button', class: 'fd-button' }, labels.cancel);
658
741
  const ok = el('button', { type: 'button', class: 'fd-button fd-button-primary' }, labels.ok);
659
- const dialog = el('div', { class: 'fd-dialog', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': text.id }, text, el('div', { class: 'fd-actions fd-actions-end' }, cancel, ok));
742
+ const dialog = el('div', { class: 'fd-dialog', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': text.id }, text, el('div', { class: 'fd-actions fd-actions-end' }, ...(withCancel ? [cancel, ok] : [ok])));
660
743
  const backdrop = el('div', { class: 'fd-dialog-backdrop' }, dialog);
661
744
  const close = (answer) => {
662
745
  backdrop.remove();
@@ -700,6 +783,12 @@ function mountViewer(host, options) {
700
783
  focusFirstProblem();
701
784
  return valid;
702
785
  },
786
+ setReadonly(readonly) {
787
+ locked = readonly;
788
+ root.toggleAttribute('data-readonly', locked);
789
+ render(form.getState());
790
+ },
791
+ isReadonly: () => locked,
703
792
  destroy() {
704
793
  unsubscribe();
705
794
  for (const cleanup of cleanups)
@@ -710,85 +799,6 @@ function mountViewer(host, options) {
710
799
  },
711
800
  };
712
801
  }
713
- /** The field a page's records are named by: its title, or "name", or its first line of text. */
714
- function nameFieldOf(page) {
715
- if (page.layout.type === 'sheet' && page.layout.title?.field)
716
- return page.layout.title.field;
717
- if (page.fields['name'])
718
- return 'name';
719
- return Object.entries(page.fields).find(([, def]) => def.type === 'char')?.[0] ?? null;
720
- }
721
- /** A form made of fields, for values edited in a dialog (a line, for one). */
722
- function valuesPage(fields, title, readonly) {
723
- const shown = Object.fromEntries(Object.entries(fields).map(([name, def]) => [name, readonly ? { ...def, readonly: true } : def]));
724
- return {
725
- fieldia: '0.1',
726
- id: 'values',
727
- title,
728
- data: { kind: 'record', model: 'values' },
729
- fields: shown,
730
- layout: {
731
- type: 'sections',
732
- id: 'values',
733
- children: [{ type: 'section', id: 'values-section', columns: 2, children: Object.keys(shown).map((name) => ({ type: 'field', id: `values-${name}`, field: name })) }],
734
- },
735
- };
736
- }
737
- /** Only finding and making linked records: values edited in a dialog are no record of the source's to load, save or recalculate. */
738
- function lookupsOf(source) {
739
- if (!source)
740
- return undefined;
741
- return {
742
- ...(source.search ? { search: (request) => source.search(request) } : {}),
743
- ...(source.create ? { create: (request) => source.create(request) } : {}),
744
- };
745
- }
746
- /** The dialogs a page's widgets may open, made from the viewer's own options. */
747
- function pageDialogs(options) {
748
- const pageFor = (model) => (typeof options.relatedPages === 'function' ? options.relatedPages(model) : options.relatedPages?.[model]) ?? null;
749
- // A dialog's page looks and reads like the page that opened it, and can open dialogs of its own.
750
- const shared = {
751
- locale: options.locale,
752
- skin: options.skin,
753
- dir: options.dir,
754
- widgets: options.widgets,
755
- preferences: options.preferences,
756
- relatedPages: options.relatedPages,
757
- };
758
- return {
759
- canOpen: (model) => pageFor(model) !== null,
760
- async openRecord(model, request) {
761
- const related = pageFor(model);
762
- if (!related)
763
- return null;
764
- const nameField = nameFieldOf(related);
765
- const result = await (0, dialog_1.openFormDialog)({
766
- ...shared,
767
- page: related,
768
- dataSource: options.dataSource,
769
- recordId: request.recordId ?? null,
770
- values: request.name && nameField ? { [nameField]: request.name } : undefined,
771
- title: request.title,
772
- size: 'large',
773
- });
774
- if (!result.saved || result.recordId === null)
775
- return null;
776
- const name = nameField ? result.values[nameField] : null;
777
- return { id: result.recordId, label: typeof name === 'string' && name ? name : request.name ?? request.title };
778
- },
779
- searchMore: (request) => (0, dialog_1.openSearchDialog)({ title: request.title, search: request.search, locale: options.locale, skin: options.skin, dir: options.dir }),
780
- async editValues(request) {
781
- const result = await (0, dialog_1.openFormDialog)({
782
- ...shared,
783
- page: valuesPage(request.fields, request.title, request.readonly === true),
784
- dataSource: lookupsOf(options.dataSource),
785
- values: request.values,
786
- title: request.title,
787
- mode: 'values',
788
- ...(request.recompute ? { recompute: request.recompute } : {}),
789
- });
790
- return result.saved ? result.values : null;
791
- },
792
- };
793
- }
802
+ /** Fields whose ✓ would say nothing: a yes/no box is never wrong, a table or a file has its own look. */
803
+ const NO_VALID_MARK = new Set(['boolean', 'one2many', 'binary', 'image', 'html', 'json', 'properties']);
794
804
  //# sourceMappingURL=viewer.js.map