@kubex/zinc 1.1.95 → 1.1.96

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.
@@ -8,25 +8,10 @@ export interface PageSection {
8
8
  label?: string;
9
9
  /** Section content, keyed by field name (the inspector's `name` attributes). */
10
10
  data: Record<string, unknown>;
11
- /** Container instances only: the editor-chosen layout. */
12
- layout?: PageContainerLayout;
13
- /** Container instances only: one ordered stack per cell, row-major. */
14
- cells?: PageSection[][];
15
- /**
16
- * @deprecated The pre-cells fixed-slot shape. Read on load and migrated to
17
- * `cells`; never written back.
18
- */
11
+ /** Slot contents for container sections, sized to the type's `slots`. Empty slots are null. */
19
12
  children?: (PageSection | null)[];
20
13
  }
21
14
 
22
- /** A container instance's editor-chosen layout. */
23
- export interface PageContainerLayout {
24
- /** One weight per column; the array length IS the column count. */
25
- widths: number[];
26
- /** When true the builder offers a trailing empty row instead of a pinned cell count. */
27
- grow: boolean;
28
- }
29
-
30
15
  /** The complete serialisable state of a page. Order = render order. */
31
16
  export interface PageState {
32
17
  sections: PageSection[];
@@ -53,65 +38,18 @@ export interface PageSectionType {
53
38
  configTemplate?: HTMLTemplateElement;
54
39
  /** Programmatic inspector body — takes precedence over `configTemplate`. */
55
40
  renderConfig?: (section: PageSection, update: (data: Record<string, unknown>) => void) => TemplateResult;
56
- /** Marks this type a container: its card renders a grid of cells on the canvas. */
57
- container?: boolean;
58
41
  /**
59
- * Column weights a new instance of this container starts with. Stored as parsed, not
60
- * guaranteed sanitised route through `sanitiseWidths`/`defaultLayout` before use.
61
- */
62
- defaultWidths?: number[];
63
- /** Whether a new instance of this container starts growable. */
64
- defaultGrow?: boolean;
65
- /**
66
- * @deprecated Use `container` with `columns`/`widths`. Read as a container of
67
- * `DEFAULT_WIDTHS` columns with the cell count pinned to this number.
42
+ * Number of child slots this section offers on the canvas (a container tile);
43
+ * rendered as a 3-column grid. Containers cannot be placed inside other containers.
68
44
  */
69
45
  slots?: number;
70
- /**
71
- * Section type keys allowed in this container's cells. On a `container` type,
72
- * omitting it allows any type, subject to the nesting cap — that is how nesting
73
- * is reachable without enumerating types. The deprecated `slots=` alias keeps the
74
- * old rule instead: omitting it there allows any non-container type.
75
- */
46
+ /** Section type keys allowed in this container's slots. Omit to allow any non-container type. */
76
47
  accepts?: string[];
77
48
  }
78
49
 
79
- /** Beyond 6 columns a card is under ~170px on the 1024px canvas — unreadable. */
80
- export const MAX_COLUMNS = 6;
81
- /** A single weight beyond 12 makes the other columns unusably thin. */
82
- export const MAX_WIDTH = 12;
83
- /** Container nesting levels: a top-level container is 1, one inside a cell is 2. */
84
- export const MAX_CONTAINER_LEVELS = 2;
85
- /** Global section budget; also the sanity clamp on an incoming `cells` length. */
86
- export const MAX_SECTIONS = 500;
87
- /** What a bare `container` declaration seeds. */
88
- export const DEFAULT_WIDTHS: readonly number[] = [1, 1, 1];
89
-
90
- /** Container-ness is a property of the registered type, never of the instance. */
91
- export function isContainer(type: PageSectionType | undefined): boolean {
92
- return Boolean(type?.container ?? (type?.slots !== undefined && type.slots > 0));
93
- }
94
-
95
- /**
96
- * Coerces a weights list into a usable one: each entry a whole number from 1 to
97
- * MAX_WIDTH (anything unusable becomes 1), at most MAX_COLUMNS entries, falling
98
- * back to DEFAULT_WIDTHS only when nothing usable remains.
99
- */
100
- export function sanitiseWidths(raw: unknown): number[] {
101
- const list = Array.isArray(raw) ? raw.slice(0, MAX_COLUMNS) : [];
102
- const clean = list.map(entry => {
103
- const n = typeof entry === 'number' && Number.isFinite(entry) ? Math.floor(entry) : 1;
104
- return Math.min(Math.max(n, 1), MAX_WIDTH);
105
- });
106
- return clean.length ? clean : [...DEFAULT_WIDTHS];
107
- }
108
-
109
- /** The layout a newly placed instance of a container type starts with. */
110
- export function defaultLayout(type: PageSectionType | undefined): PageContainerLayout {
111
- return {
112
- widths: sanitiseWidths(type?.defaultWidths ?? [...DEFAULT_WIDTHS]),
113
- grow: Boolean(type?.defaultGrow),
114
- };
50
+ /** A container section's slot contents, padded/truncated to the type's slot count. */
51
+ export function sectionChildren(section: PageSection, type: PageSectionType): (PageSection | null)[] {
52
+ return Array.from({length: type.slots ?? 0}, (_, i) => section.children?.[i] ?? null);
115
53
  }
116
54
 
117
55
  /** Drag-and-drop MIME carrying a section type id from the palette to the canvas. */
@@ -147,12 +85,6 @@ function optionLabel(type: PageSectionType | undefined, name: string, value: str
147
85
  * so a tile reads as its linked item however its other fields were filled in.
148
86
  */
149
87
  export function sectionSummary(section: PageSection, type?: PageSectionType): string {
150
- if (isContainer(type)) {
151
- const columns = sanitiseWidths(section.layout?.widths ?? type?.defaultWidths).length;
152
- const count = (section.cells ?? []).reduce((n, cell) => n + cell.length, 0);
153
- const col = `${columns} column${columns === 1 ? '' : 's'}`;
154
- return `${col} · ${count} section${count === 1 ? '' : 's'}`;
155
- }
156
88
  const strings = Object.entries(section.data)
157
89
  .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].trim() !== '');
158
90
  for (const [name, value] of strings) {
@@ -4,12 +4,10 @@ import {defaultValue} from "../../internal/default-value";
4
4
  import {FormControlController} from "../../internal/form";
5
5
  import {property, query, state} from 'lit/decorators.js';
6
6
  import {parse as remarkdParse} from "remarkd-js";
7
- import {SlashMenuController} from "../slash-menu/slash-menu-controller";
8
7
  import {unsafeHTML} from "lit/directives/unsafe-html.js";
9
8
  import {watch} from "../../internal/watch";
10
9
  import ZincElement from '../../internal/zinc-element';
11
- import ZnSlashMenu from "../slash-menu";
12
- import type {SlashMenuItem} from "../slash-menu/slash-menu-items";
10
+ import ZnSlashMenu, {SlashMenuController, type SlashMenuItem} from "../slash-menu";
13
11
  import type {ZincFormControl} from '../../internal/zinc-element';
14
12
  import type ZnFile from "../file";
15
13
 
@@ -159,6 +157,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
159
157
  @state() private editShell = '';
160
158
  @state() private rawMode = false;
161
159
  @state() private includeOptions: IncludeOption[] | null = null;
160
+ @state() private includeLoadFailed = false;
162
161
  @state() private includePickerIndex: number | null = null;
163
162
  @state() private includeQuery = '';
164
163
 
@@ -274,6 +273,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
274
273
  handleIncludeUrlChange() {
275
274
  this.includeRequest = null;
276
275
  this.includeOptions = null;
276
+ this.includeLoadFailed = false;
277
277
  if (this.hasIncludeBlock()) void this.loadIncludeOptions();
278
278
  }
279
279
 
@@ -827,6 +827,20 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
827
827
  return data.uploadPath;
828
828
  }
829
829
 
830
+ /**
831
+ * Resolves an app-relative path the way the console's pagelet handler does:
832
+ * an app fragment's URLs sit under the app base, and the console puts the app's
833
+ * `gaid` on the host element. Links rendered here live in the shadow root,
834
+ * where a click retargets to the host, so that handler never sees them — the
835
+ * href has to carry the base itself.
836
+ */
837
+ private appPath(path: string): string {
838
+ const gaid = this.getAttribute('gaid') ?? '';
839
+ const trimmed = path.replace(/^\/+/, '');
840
+ if (!gaid || trimmed.startsWith(gaid)) return path;
841
+ return `/${gaid}/${trimmed}`;
842
+ }
843
+
830
844
  private hasIncludeBlock(): boolean {
831
845
  return this.blocks.some(block => this.parseIncludeBlock(block) !== null);
832
846
  }
@@ -836,15 +850,25 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
836
850
  if (!this.includeUrl) return Promise.resolve([]);
837
851
  if (!this.includeRequest) {
838
852
  this.includeRequest = fetch(this.includeUrl, {headers: {Accept: 'application/json'}})
839
- .then(res => res.ok ? res.json() as Promise<{items?: IncludeOption[]}> : {items: []})
840
- .then(data => data.items ?? [])
841
- .catch(error => {
842
- console.error('[zn-remarkd-editor] include list failed', error);
843
- return [] as IncludeOption[];
853
+ .then(res => {
854
+ if (!res.ok) throw new Error(`include list request failed: ${res.status}`);
855
+ return res.json() as Promise<{items?: IncludeOption[]}>;
844
856
  })
845
- .then(items => {
857
+ .then(data => {
858
+ const items = data.items ?? [];
846
859
  this.includeOptions = items;
860
+ this.includeLoadFailed = false;
847
861
  return items;
862
+ })
863
+ .catch(error => {
864
+ // A list that never arrived is not evidence an embed is broken: leave
865
+ // the options unresolved so chips keep their marker labels, and let
866
+ // the next picker open try again.
867
+ console.error('[zn-remarkd-editor] include list failed', error);
868
+ this.includeOptions = null;
869
+ this.includeLoadFailed = true;
870
+ this.includeRequest = null;
871
+ return [] as IncludeOption[];
848
872
  });
849
873
  }
850
874
  return this.includeRequest;
@@ -1009,7 +1033,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1009
1033
  ? `Include not found · ${data.target}`
1010
1034
  : !option && filePath ? 'File include' : 'Included content'}</span>
1011
1035
  ${option?.url ? html`
1012
- <a class="remarkd-editor__include-link" href=${option.url} target="_blank" rel="noreferrer"
1036
+ <a class="remarkd-editor__include-link" href=${this.appPath(option.url)}
1037
+ target="_blank" rel="noreferrer"
1013
1038
  @click=${(e: MouseEvent) => e.stopPropagation()}>Open</a>` : ''}
1014
1039
  </div>`;
1015
1040
  }
@@ -1158,7 +1183,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1158
1183
  tooltip="Cancel" @click=${this.closeIncludePicker}></zn-button>
1159
1184
  </div>
1160
1185
  ${this.includeOptions === null
1161
- ? html`<div class="remarkd-editor__include-picker-empty">Loading…</div>`
1186
+ ? html`<div class="remarkd-editor__include-picker-empty">${
1187
+ this.includeLoadFailed ? 'Could not load Includes' : 'Loading…'}</div>`
1162
1188
  : items.length
1163
1189
  ? items.map(item => html`
1164
1190
  <button type="button" class="remarkd-editor__include-option"
@@ -461,4 +461,83 @@ include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
461
461
  .map(button => button.getAttribute('tooltip'));
462
462
  expect(tooltips).to.not.contain('Include');
463
463
  });
464
+ // A list that never arrived is not evidence an embed is broken: flagging every
465
+ // chip "not found" on a failed request sends people hunting for the wrong bug.
466
+ it('should not flag includes when the list request fails', async () => {
467
+ const original = window.fetch;
468
+ window.fetch = () => Promise.resolve(new Response('nope', {status: 404}));
469
+ try {
470
+ const el = await fixture<ZnRemarkdEditor>(html`
471
+ <zn-remarkd-editor include-url="/options"
472
+ value="include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
473
+ await el.updateComplete;
474
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include'),
475
+ 'the chip never rendered');
476
+
477
+ const chip = el.shadowRoot!.querySelector('.remarkd-editor__include')!;
478
+ expect(chip.classList.contains('remarkd-editor__include--missing')).to.be.false;
479
+ expect(chip.querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Payment Terms');
480
+ } finally {
481
+ window.fetch = original;
482
+ }
483
+ });
484
+
485
+ it('should say so in the picker when the list request fails', async () => {
486
+ const original = window.fetch;
487
+ window.fetch = () => Promise.resolve(new Response('nope', {status: 500}));
488
+ try {
489
+ const el = await fixture<ZnRemarkdEditor>(html`
490
+ <zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
491
+ const buttons = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__toolbar zn-button');
492
+ buttons[buttons.length - 1].dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
493
+
494
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-picker-empty')?.textContent
495
+ ?.includes('Could not load'), 'the picker never reported the failure');
496
+ } finally {
497
+ window.fetch = original;
498
+ }
499
+ });
500
+ // An app fragment's URLs are relative to its app base, and the console puts the
501
+ // app's gaid on the host element. A link built client-side from JSON is inside
502
+ // the shadow root, where a click retargets to the host, so the pagelet handler
503
+ // never sees it — the href has to carry the base itself.
504
+ it('should resolve an include link against the app base from gaid', async () => {
505
+ stubIncludeList([{id: 'inc-1', title: 'Payment Terms', scope: 'Global',
506
+ languages: 'English', url: '/global/includes/inc-1'}]);
507
+ const el = await fixture<ZnRemarkdEditor>(html`
508
+ <zn-remarkd-editor include-url="/ch/kb/kb/kb1/documents/doc1/includes/options"
509
+ gaid="ch/kb"
510
+ value="include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
511
+
512
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'),
513
+ 'the include list never resolved');
514
+ expect(el.shadowRoot!.querySelector<HTMLAnchorElement>('.remarkd-editor__include-link')!
515
+ .getAttribute('href')).to.equal('/ch/kb/global/includes/inc-1');
516
+ });
517
+
518
+ it('should leave an include link alone when it already carries the app base', async () => {
519
+ stubIncludeList([{id: 'inc-1', title: 'Payment Terms', languages: 'English',
520
+ url: '/ch/kb/global/includes/inc-1'}]);
521
+ const el = await fixture<ZnRemarkdEditor>(html`
522
+ <zn-remarkd-editor include-url="/options" gaid="ch/kb"
523
+ value="include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
524
+
525
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'),
526
+ 'the include list never resolved');
527
+ expect(el.shadowRoot!.querySelector<HTMLAnchorElement>('.remarkd-editor__include-link')!
528
+ .getAttribute('href')).to.equal('/ch/kb/global/includes/inc-1');
529
+ });
530
+
531
+ it('should leave an include link alone without a gaid', async () => {
532
+ stubIncludeList([{id: 'inc-1', title: 'Payment Terms', languages: 'English',
533
+ url: '/global/includes/inc-1'}]);
534
+ const el = await fixture<ZnRemarkdEditor>(html`
535
+ <zn-remarkd-editor include-url="/options"
536
+ value="include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
537
+
538
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'),
539
+ 'the include list never resolved');
540
+ expect(el.shadowRoot!.querySelector<HTMLAnchorElement>('.remarkd-editor__include-link')!
541
+ .getAttribute('href')).to.equal('/global/includes/inc-1');
542
+ });
464
543
  });