@kubex/zinc 1.1.62 → 1.1.64
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/dist/custom-elements.json +3141 -1885
- package/dist/vscode.html-custom-data.json +102 -3
- package/dist/web-types.json +224 -6
- package/dist/zn.d.ts +235 -0
- package/dist/zn.min.js +795 -679
- package/docs/data/preview-frame-payload-error.json +4 -0
- package/docs/data/preview-frame-payload.json +6 -0
- package/docs/pages/components/form-group.md +12 -0
- package/docs/pages/components/preview-frame-demo.njk +138 -0
- package/docs/pages/components/preview-frame.md +105 -0
- package/docs/pages/components/remarkd-editor.md +103 -0
- package/docs/pages/components/toggle.md +2 -2
- package/package.json +3 -2
- package/src/components/file/file.component.ts +16 -1
- package/src/components/form-group/form-group.component.ts +24 -12
- package/src/components/form-group/form-group.scss +10 -0
- package/src/components/form-group/form-group.test.ts +19 -0
- package/src/components/preview-frame/index.ts +12 -0
- package/src/components/preview-frame/preview-frame.component.ts +246 -0
- package/src/components/preview-frame/preview-frame.scss +27 -0
- package/src/components/preview-frame/preview-frame.test.ts +268 -0
- package/src/components/query-builder/query-builder.component.ts +14 -6
- package/src/components/remarkd-editor/index.ts +12 -0
- package/src/components/remarkd-editor/remarkd-editor.component.ts +752 -0
- package/src/components/remarkd-editor/remarkd-editor.scss +241 -0
- package/src/components/remarkd-editor/remarkd-editor.test.ts +147 -0
- package/src/components/select/select.component.ts +4 -0
- package/src/components/select/select.test.ts +29 -0
- package/src/components/sp/sp.scss +5 -1
- package/src/components/tabs/tabs.component.ts +9 -4
- package/src/components/tabs/tabs.test.ts +42 -1
- package/src/components/tile/tile.scss +5 -1
- package/src/components/toggle/toggle.component.ts +3 -12
- package/src/components/toggle/toggle.test.ts +29 -0
- package/src/events/zn-error.ts +1 -1
- package/src/zinc.ts +2 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import {type CSSResultGroup, html, unsafeCSS} from 'lit';
|
|
2
|
+
import {MutationController} from '@lit-labs/observers/mutation-controller.js';
|
|
3
|
+
import {property, query, state} from 'lit/decorators.js';
|
|
4
|
+
import {styleMap} from 'lit/directives/style-map.js';
|
|
5
|
+
import ZincElement from '../../internal/zinc-element';
|
|
6
|
+
|
|
7
|
+
import styles from './preview-frame.scss';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @summary Embeds a live preview iframe and drives the hp-preview postMessage
|
|
11
|
+
* protocol: answers the frame's ready handshake with a config payload fetched
|
|
12
|
+
* from data-uri, auto-saves watched forms on change, and refreshes the
|
|
13
|
+
* preview after each save.
|
|
14
|
+
* @documentation https://zinc.style/components/preview-frame
|
|
15
|
+
* @status experimental
|
|
16
|
+
* @since 1.0
|
|
17
|
+
*
|
|
18
|
+
* @event zn-error - Emitted when the preview reports a render error or a save fails.
|
|
19
|
+
*
|
|
20
|
+
* @csspart base - The component's base wrapper.
|
|
21
|
+
* @csspart iframe - The preview iframe.
|
|
22
|
+
* @csspart error - The error overlay.
|
|
23
|
+
*/
|
|
24
|
+
export default class ZnPreviewFrame extends ZincElement {
|
|
25
|
+
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
26
|
+
|
|
27
|
+
/** URL of the preview shell page (tokened embed URL). */
|
|
28
|
+
@property() src = '';
|
|
29
|
+
|
|
30
|
+
/** Expected origin of the iframe; all postMessage traffic is checked against it. */
|
|
31
|
+
@property({attribute: 'frame-origin'}) frameOrigin = '';
|
|
32
|
+
|
|
33
|
+
/** Endpoint returning the hp-preview:config payload JSON. The console proxy rewrites this attribute to an app-prefixed path for proper fetch resolution. */
|
|
34
|
+
@property({attribute: 'data-uri'}) dataUri = '';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Selector (resolved against the component's root node) for the forms to watch.
|
|
38
|
+
* Defaults to only forms explicitly opted in via a `data-auto-save` attribute —
|
|
39
|
+
* unmarked forms keep normal submit behavior and are never intercepted,
|
|
40
|
+
* auto-saved, or used to trigger a preview refresh. Override to widen the scope.
|
|
41
|
+
*/
|
|
42
|
+
@property() watch = 'form[data-auto-save]';
|
|
43
|
+
|
|
44
|
+
/** Debounce in ms between a form change and its auto-save. */
|
|
45
|
+
@property({type: Number}) debounce = 400;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Zooms the previewed page out (0–1]. The frame always fills the panel;
|
|
49
|
+
* zoom shrinks the content browser-style, so 0.4 shows the page at 40%
|
|
50
|
+
* size with correspondingly more of it visible. 1 = natural size.
|
|
51
|
+
*/
|
|
52
|
+
@property({type: Number}) zoom = 1;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The visible height (in CSS pixels) of the preview panel. Fixed rather
|
|
56
|
+
* than measured, because a measured height would feed back into the
|
|
57
|
+
* scaled iframe's layout box and grow unbounded.
|
|
58
|
+
*/
|
|
59
|
+
@property({type: Number, attribute: 'min-height'}) minHeight = 480;
|
|
60
|
+
|
|
61
|
+
@query('iframe') frame: HTMLIFrameElement;
|
|
62
|
+
|
|
63
|
+
@state() private error = '';
|
|
64
|
+
|
|
65
|
+
private _generation = 0;
|
|
66
|
+
|
|
67
|
+
private readonly _watchedForms = new Set<HTMLFormElement>();
|
|
68
|
+
private readonly _debounceTimers = new Map<HTMLFormElement, number>();
|
|
69
|
+
|
|
70
|
+
// Forms are siblings in light DOM and get replaced when other content
|
|
71
|
+
// re-renders; re-resolve them whenever the surrounding DOM changes.
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
73
|
+
private readonly _formObserver = new MutationController(this, {
|
|
74
|
+
target: null,
|
|
75
|
+
config: {subtree: true, childList: true},
|
|
76
|
+
callback: () => this._attachForms(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
connectedCallback() {
|
|
80
|
+
super.connectedCallback();
|
|
81
|
+
window.addEventListener('message', this._onMessage);
|
|
82
|
+
this._attachForms();
|
|
83
|
+
const root = this.getRootNode();
|
|
84
|
+
if (root instanceof Document) {
|
|
85
|
+
this._formObserver.observe(root.body);
|
|
86
|
+
} else if (root instanceof ShadowRoot) {
|
|
87
|
+
// MutationController's type only accepts Element, but MutationObserver.observe()
|
|
88
|
+
// accepts any Node at runtime, including a ShadowRoot.
|
|
89
|
+
this._formObserver.observe(root as unknown as Element);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
disconnectedCallback() {
|
|
94
|
+
super.disconnectedCallback();
|
|
95
|
+
window.removeEventListener('message', this._onMessage);
|
|
96
|
+
this._watchedForms.forEach(form => this._detachForm(form));
|
|
97
|
+
this._watchedForms.clear();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private readonly _onMessage = (e: MessageEvent) => {
|
|
101
|
+
// Fail closed: with frame-origin unset, every message is rejected.
|
|
102
|
+
if (e.origin !== this.frameOrigin) return;
|
|
103
|
+
if (!this.frame || e.source !== this.frame.contentWindow) return;
|
|
104
|
+
|
|
105
|
+
const data = e.data as {type?: string; message?: string} | undefined;
|
|
106
|
+
switch (data?.type) {
|
|
107
|
+
case 'hp-preview:ready':
|
|
108
|
+
void this._sendConfig();
|
|
109
|
+
break;
|
|
110
|
+
case 'hp-preview:rendered':
|
|
111
|
+
this.error = '';
|
|
112
|
+
break;
|
|
113
|
+
case 'hp-preview:error':
|
|
114
|
+
this._fail(String(data.message ?? 'Preview failed to render'));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Re-fetches the payload and pushes a fresh config to the preview. */
|
|
120
|
+
refresh() {
|
|
121
|
+
return this._sendConfig();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async _sendConfig() {
|
|
125
|
+
const generation = ++this._generation;
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(this.dataUri, {
|
|
128
|
+
credentials: 'same-origin',
|
|
129
|
+
// The console proxy pagelet-wraps app responses; 'download' streams
|
|
130
|
+
// the endpoint's raw JSON through verbatim.
|
|
131
|
+
headers: {'x-kx-fetch-style': 'download'},
|
|
132
|
+
});
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(await response.text() || response.statusText);
|
|
135
|
+
}
|
|
136
|
+
const payload = await response.json() as Record<string, unknown>;
|
|
137
|
+
if (generation !== this._generation) return; // a newer refresh is in flight
|
|
138
|
+
this.frame?.contentWindow?.postMessage(
|
|
139
|
+
{type: 'hp-preview:config', ...payload},
|
|
140
|
+
this.frameOrigin
|
|
141
|
+
);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (generation === this._generation) {
|
|
144
|
+
this._fail(err instanceof Error ? err.message : String(err));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private _attachForms() {
|
|
150
|
+
const root = this.getRootNode() as Document | ShadowRoot;
|
|
151
|
+
const matched = new Set<HTMLFormElement>();
|
|
152
|
+
root.querySelectorAll(this.watch).forEach(node => {
|
|
153
|
+
if (node instanceof HTMLFormElement) matched.add(node);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this._watchedForms.forEach(form => {
|
|
157
|
+
if (!matched.has(form)) {
|
|
158
|
+
this._detachForm(form);
|
|
159
|
+
this._watchedForms.delete(form);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
matched.forEach(form => {
|
|
164
|
+
if (this._watchedForms.has(form)) return;
|
|
165
|
+
this._watchedForms.add(form);
|
|
166
|
+
form.addEventListener('submit', this._onSubmit, {capture: true});
|
|
167
|
+
form.addEventListener('zn-change', this._onChange);
|
|
168
|
+
form.addEventListener('zn-input', this._onChange);
|
|
169
|
+
form.addEventListener('change', this._onChange);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private _detachForm(form: HTMLFormElement) {
|
|
174
|
+
form.removeEventListener('submit', this._onSubmit, {capture: true});
|
|
175
|
+
form.removeEventListener('zn-change', this._onChange);
|
|
176
|
+
form.removeEventListener('zn-input', this._onChange);
|
|
177
|
+
form.removeEventListener('change', this._onChange);
|
|
178
|
+
const timer = this._debounceTimers.get(form);
|
|
179
|
+
if (timer) window.clearTimeout(timer);
|
|
180
|
+
this._debounceTimers.delete(form);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private readonly _onChange = (e: Event) => {
|
|
184
|
+
const form = e.currentTarget as HTMLFormElement;
|
|
185
|
+
const existing = this._debounceTimers.get(form);
|
|
186
|
+
if (existing) window.clearTimeout(existing);
|
|
187
|
+
this._debounceTimers.set(form, window.setTimeout(() => {
|
|
188
|
+
this._debounceTimers.delete(form);
|
|
189
|
+
if (!form.isConnected) return;
|
|
190
|
+
form.requestSubmit();
|
|
191
|
+
}, this.debounce));
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
private readonly _onSubmit = (e: SubmitEvent) => {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
e.stopImmediatePropagation();
|
|
197
|
+
void this._save(e.currentTarget as HTMLFormElement);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
private async _save(form: HTMLFormElement) {
|
|
201
|
+
try {
|
|
202
|
+
const response = await fetch(form.getAttribute('action') || '', {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
credentials: 'same-origin',
|
|
205
|
+
body: new FormData(form),
|
|
206
|
+
});
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
throw new Error(await response.text() || response.statusText);
|
|
209
|
+
}
|
|
210
|
+
await this._sendConfig();
|
|
211
|
+
} catch (err) {
|
|
212
|
+
this._fail(err instanceof Error ? err.message : String(err));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private _fail(message: string) {
|
|
217
|
+
this.error = message;
|
|
218
|
+
this.emit('zn-error', {detail: {message}});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
render() {
|
|
222
|
+
const zoom = this.zoom > 0 && this.zoom <= 1 ? this.zoom : 1;
|
|
223
|
+
// Browser-style zoom-out: the iframe lays out oversized (1/zoom) and is
|
|
224
|
+
// transformed back down, so the frame fills the panel while the content
|
|
225
|
+
// renders smaller and more of the page is visible. Percentage width means
|
|
226
|
+
// nothing is measured — no layout feedback loop.
|
|
227
|
+
const iframeStyles = {
|
|
228
|
+
width: `${100 / zoom}%`,
|
|
229
|
+
height: `${this.minHeight / zoom}px`,
|
|
230
|
+
transform: `scale(${zoom})`,
|
|
231
|
+
transformOrigin: '0 0',
|
|
232
|
+
};
|
|
233
|
+
const containerStyles = {height: `${this.minHeight}px`};
|
|
234
|
+
|
|
235
|
+
return html`
|
|
236
|
+
<div part="base" class="preview" style="${styleMap(containerStyles)}">
|
|
237
|
+
<iframe part="iframe"
|
|
238
|
+
src="${this.src}"
|
|
239
|
+
title="Payment form preview"
|
|
240
|
+
allow="local-network-access"
|
|
241
|
+
style="${styleMap(iframeStyles)}"></iframe>
|
|
242
|
+
${this.error ? html`
|
|
243
|
+
<div part="error" class="preview__error">${this.error}</div>` : ''}
|
|
244
|
+
</div>`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.preview {
|
|
6
|
+
position: relative;
|
|
7
|
+
width: 100%;
|
|
8
|
+
overflow: hidden;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
iframe {
|
|
12
|
+
display: block;
|
|
13
|
+
width: 100%;
|
|
14
|
+
border: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.preview__error {
|
|
18
|
+
position: absolute;
|
|
19
|
+
inset: 0;
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
justify-content: center;
|
|
23
|
+
padding: 1rem;
|
|
24
|
+
text-align: center;
|
|
25
|
+
background: rgba(0, 0, 0, 0.65);
|
|
26
|
+
color: #fff;
|
|
27
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import '../../../dist/zn.min.js';
|
|
2
|
+
import {expect, fixture, html, waitUntil} from '@open-wc/testing';
|
|
3
|
+
|
|
4
|
+
// The container's ResizeObserver can emit a benign "loop completed with undelivered
|
|
5
|
+
// notifications" warning when the panel resizes during layout-measuring tests. It's
|
|
6
|
+
// not a real error — ignore it so the test runner doesn't treat it as an uncaught
|
|
7
|
+
// exception (capture phase runs before the runner's).
|
|
8
|
+
window.addEventListener('error', (e: ErrorEvent) => {
|
|
9
|
+
if (typeof e.message === 'string' && e.message.includes('ResizeObserver loop')) {
|
|
10
|
+
e.stopImmediatePropagation();
|
|
11
|
+
e.preventDefault();
|
|
12
|
+
}
|
|
13
|
+
}, true);
|
|
14
|
+
|
|
15
|
+
describe('<zn-preview-frame>', () => {
|
|
16
|
+
it('renders an iframe pointing at src', async () => {
|
|
17
|
+
const el = await fixture(html`
|
|
18
|
+
<zn-preview-frame
|
|
19
|
+
src="https://site.example/embed/preview?t=x"
|
|
20
|
+
frame-origin="https://site.example"
|
|
21
|
+
data-uri="/payload"></zn-preview-frame>`);
|
|
22
|
+
|
|
23
|
+
const iframe = el.shadowRoot!.querySelector('iframe');
|
|
24
|
+
expect(iframe).to.exist;
|
|
25
|
+
expect(iframe!.getAttribute('src')).to.equal('https://site.example/embed/preview?t=x');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const FIXTURE = html`
|
|
29
|
+
<zn-preview-frame
|
|
30
|
+
src="about:blank"
|
|
31
|
+
frame-origin="https://site.example"
|
|
32
|
+
data-uri="/payload"></zn-preview-frame>`;
|
|
33
|
+
|
|
34
|
+
const PAYLOAD = {pageType: 'payment.subscribe', page: {Name: 'Page'}, config: {BaseProduct: 'prod'}};
|
|
35
|
+
|
|
36
|
+
let fetchCalls: {uri: string; init?: RequestInit}[];
|
|
37
|
+
const realFetch = window.fetch;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
fetchCalls = [];
|
|
41
|
+
window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
|
|
42
|
+
fetchCalls.push({uri: String(uri), init});
|
|
43
|
+
return Promise.resolve(new Response(JSON.stringify(PAYLOAD), {
|
|
44
|
+
status: 200,
|
|
45
|
+
headers: {'Content-Type': 'application/json'}
|
|
46
|
+
}));
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
window.fetch = realFetch;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function ready(el: Element, origin = 'https://site.example') {
|
|
55
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
56
|
+
window.dispatchEvent(new MessageEvent('message', {
|
|
57
|
+
data: {type: 'hp-preview:ready'},
|
|
58
|
+
origin,
|
|
59
|
+
source: iframe.contentWindow
|
|
60
|
+
}));
|
|
61
|
+
return iframe;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
it('answers hp-preview:ready by fetching the payload and posting hp-preview:config', async () => {
|
|
65
|
+
const el = await fixture(FIXTURE);
|
|
66
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
67
|
+
const posted: {msg: Record<string, unknown>; origin: string}[] = [];
|
|
68
|
+
(iframe.contentWindow as {postMessage: (msg: Record<string, unknown>, origin: string) => void}).postMessage =
|
|
69
|
+
(msg: Record<string, unknown>, origin: string) => posted.push({msg, origin});
|
|
70
|
+
|
|
71
|
+
ready(el);
|
|
72
|
+
|
|
73
|
+
await waitUntil(() => posted.length === 1);
|
|
74
|
+
expect(fetchCalls[0].uri).to.equal('/payload');
|
|
75
|
+
expect((fetchCalls[0].init?.headers as Record<string, string>)['x-kx-fetch-style']).to.equal('download');
|
|
76
|
+
expect(posted[0].origin).to.equal('https://site.example');
|
|
77
|
+
expect(posted[0].msg['type']).to.equal('hp-preview:config');
|
|
78
|
+
expect(posted[0].msg['pageType']).to.equal('payment.subscribe');
|
|
79
|
+
expect(posted[0].msg['config']).to.deep.equal({BaseProduct: 'prod'});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('refresh() re-fetches the payload and posts a fresh config', async () => {
|
|
83
|
+
const el = await fixture(FIXTURE);
|
|
84
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
85
|
+
const posted: Record<string, unknown>[] = [];
|
|
86
|
+
(iframe.contentWindow as {postMessage: (msg: Record<string, unknown>) => void}).postMessage = (msg: Record<string, unknown>) => posted.push(msg);
|
|
87
|
+
|
|
88
|
+
await (el as HTMLElement & {refresh: () => Promise<void>}).refresh();
|
|
89
|
+
|
|
90
|
+
expect(fetchCalls[0].uri).to.equal('/payload');
|
|
91
|
+
expect(posted[0]['type']).to.equal('hp-preview:config');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('ignores messages from other origins', async () => {
|
|
95
|
+
const el = await fixture(FIXTURE);
|
|
96
|
+
ready(el, 'https://evil.example');
|
|
97
|
+
|
|
98
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
99
|
+
expect(fetchCalls.length).to.equal(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects all messages when frame-origin is unset', async () => {
|
|
103
|
+
const el = await fixture(html`
|
|
104
|
+
<zn-preview-frame src="about:blank" data-uri="/payload"></zn-preview-frame>`);
|
|
105
|
+
ready(el, 'https://site.example');
|
|
106
|
+
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
108
|
+
expect(fetchCalls.length).to.equal(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('auto-saves a watched form on change, then refreshes the preview', async () => {
|
|
112
|
+
const wrapper = await fixture(html`
|
|
113
|
+
<div>
|
|
114
|
+
<form action="/save" method="post" data-auto-save>
|
|
115
|
+
<input name="caption" value="hello">
|
|
116
|
+
</form>
|
|
117
|
+
<zn-preview-frame
|
|
118
|
+
src="about:blank"
|
|
119
|
+
frame-origin="https://site.example"
|
|
120
|
+
data-uri="/payload"
|
|
121
|
+
debounce="10"></zn-preview-frame>
|
|
122
|
+
</div>`);
|
|
123
|
+
|
|
124
|
+
const el = wrapper.querySelector('zn-preview-frame')!;
|
|
125
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
126
|
+
const posted: Record<string, unknown>[] = [];
|
|
127
|
+
(iframe.contentWindow as {postMessage: (msg: Record<string, unknown>) => void}).postMessage = (msg: Record<string, unknown>) => posted.push(msg);
|
|
128
|
+
|
|
129
|
+
const form = wrapper.querySelector('form')!;
|
|
130
|
+
form.querySelector('input')!.dispatchEvent(
|
|
131
|
+
new Event('change', {bubbles: true}));
|
|
132
|
+
|
|
133
|
+
await waitUntil(() => fetchCalls.length === 2);
|
|
134
|
+
expect(fetchCalls[0].uri).to.contain('/save');
|
|
135
|
+
expect(fetchCalls[0].init?.method).to.equal('POST');
|
|
136
|
+
expect(fetchCalls[0].init?.body).to.be.instanceOf(FormData);
|
|
137
|
+
expect((fetchCalls[0].init!.body as FormData).get('caption')).to.equal('hello');
|
|
138
|
+
expect(fetchCalls[1].uri).to.equal('/payload');
|
|
139
|
+
await waitUntil(() => posted.length === 1);
|
|
140
|
+
expect(posted[0]['type']).to.equal('hp-preview:config');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('intercepts watched form submits so they never bubble to the shell', async () => {
|
|
144
|
+
const wrapper = await fixture(html`
|
|
145
|
+
<div>
|
|
146
|
+
<form action="/save" method="post" data-auto-save><input name="a" value="1"></form>
|
|
147
|
+
<zn-preview-frame
|
|
148
|
+
src="about:blank"
|
|
149
|
+
frame-origin="https://site.example"
|
|
150
|
+
data-uri="/payload"></zn-preview-frame>
|
|
151
|
+
</div>`);
|
|
152
|
+
|
|
153
|
+
let bubbled = false;
|
|
154
|
+
wrapper.addEventListener('submit', () => { bubbled = true; });
|
|
155
|
+
|
|
156
|
+
wrapper.querySelector('form')!.requestSubmit();
|
|
157
|
+
|
|
158
|
+
await waitUntil(() => fetchCalls.length >= 1);
|
|
159
|
+
expect(bubbled).to.equal(false);
|
|
160
|
+
expect(fetchCalls[0].uri).to.contain('/save');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('does not save a form removed from the DOM mid-debounce', async () => {
|
|
164
|
+
const wrapper = await fixture(html`
|
|
165
|
+
<div>
|
|
166
|
+
<form action="/save" method="post" data-auto-save><input name="a" value="1"></form>
|
|
167
|
+
<zn-preview-frame
|
|
168
|
+
src="about:blank"
|
|
169
|
+
frame-origin="https://site.example"
|
|
170
|
+
data-uri="/payload"
|
|
171
|
+
debounce="10"></zn-preview-frame>
|
|
172
|
+
</div>`);
|
|
173
|
+
|
|
174
|
+
const form = wrapper.querySelector('form')!;
|
|
175
|
+
form.querySelector('input')!.dispatchEvent(new Event('change', {bubbles: true}));
|
|
176
|
+
form.remove();
|
|
177
|
+
|
|
178
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
179
|
+
expect(fetchCalls.length).to.equal(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('leaves forms without data-auto-save alone', async () => {
|
|
183
|
+
const wrapper = await fixture(html`
|
|
184
|
+
<div>
|
|
185
|
+
<form action="/save" method="post"><input name="a" value="1"></form>
|
|
186
|
+
<zn-preview-frame
|
|
187
|
+
src="about:blank"
|
|
188
|
+
frame-origin="https://site.example"
|
|
189
|
+
data-uri="/payload"
|
|
190
|
+
debounce="10"></zn-preview-frame>
|
|
191
|
+
</div>`);
|
|
192
|
+
|
|
193
|
+
let bubbled = false;
|
|
194
|
+
wrapper.addEventListener('submit', e => { bubbled = true; e.preventDefault(); });
|
|
195
|
+
|
|
196
|
+
const form = wrapper.querySelector('form')!;
|
|
197
|
+
form.querySelector('input')!.dispatchEvent(new Event('change', {bubbles: true}));
|
|
198
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
199
|
+
expect(fetchCalls.length).to.equal(0);
|
|
200
|
+
|
|
201
|
+
form.requestSubmit();
|
|
202
|
+
expect(bubbled).to.equal(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('shows hp-preview:error messages in the overlay and clears on rendered', async () => {
|
|
206
|
+
const el = await fixture(FIXTURE);
|
|
207
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
208
|
+
let znError: CustomEvent<{message?: string}> | null = null;
|
|
209
|
+
el.addEventListener('zn-error', (e: Event) => { znError = e as CustomEvent<{message?: string}>; });
|
|
210
|
+
|
|
211
|
+
window.dispatchEvent(new MessageEvent('message', {
|
|
212
|
+
data: {type: 'hp-preview:error', message: 'bad config'},
|
|
213
|
+
origin: 'https://site.example',
|
|
214
|
+
source: iframe.contentWindow
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
await waitUntil(() => el.shadowRoot!.querySelector('[part="error"]'));
|
|
218
|
+
expect(el.shadowRoot!.querySelector('[part="error"]')!.textContent).to.contain('bad config');
|
|
219
|
+
expect(znError).to.exist;
|
|
220
|
+
expect(znError!.detail.message).to.equal('bad config');
|
|
221
|
+
|
|
222
|
+
window.dispatchEvent(new MessageEvent('message', {
|
|
223
|
+
data: {type: 'hp-preview:rendered'},
|
|
224
|
+
origin: 'https://site.example',
|
|
225
|
+
source: iframe.contentWindow
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
await waitUntil(() => !el.shadowRoot!.querySelector('[part="error"]'));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('shows a payload fetch failure in the overlay', async () => {
|
|
232
|
+
window.fetch = () => Promise.resolve(new Response('payload exploded', {status: 500}));
|
|
233
|
+
const el = await fixture(FIXTURE);
|
|
234
|
+
ready(el);
|
|
235
|
+
|
|
236
|
+
await waitUntil(() => el.shadowRoot!.querySelector('[part="error"]'));
|
|
237
|
+
expect(el.shadowRoot!.querySelector('[part="error"]')!.textContent).to.contain('payload exploded');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('zooms the content out: oversized layout scaled back into the frame', async () => {
|
|
241
|
+
const el = await fixture(html`
|
|
242
|
+
<zn-preview-frame
|
|
243
|
+
src="about:blank"
|
|
244
|
+
frame-origin="https://site.example"
|
|
245
|
+
data-uri="/payload"
|
|
246
|
+
zoom="0.4"
|
|
247
|
+
min-height="600"></zn-preview-frame>`);
|
|
248
|
+
|
|
249
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
250
|
+
await waitUntil(() => iframe.style.transform === 'scale(0.4)');
|
|
251
|
+
// 1/zoom oversize, transformed back down — the frame itself fills the
|
|
252
|
+
// panel while the page renders at 40%.
|
|
253
|
+
expect(iframe.style.width).to.equal('250%');
|
|
254
|
+
expect(iframe.style.height).to.equal('1500px');
|
|
255
|
+
const container = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
|
|
256
|
+
expect(container.style.height).to.equal('600px');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('renders at natural size by default', async () => {
|
|
260
|
+
const el = await fixture(FIXTURE);
|
|
261
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
262
|
+
await waitUntil(() => iframe.style.transform === 'scale(1)');
|
|
263
|
+
expect(iframe.style.width).to.equal('100%');
|
|
264
|
+
expect(iframe.style.height).to.equal('480px');
|
|
265
|
+
const container = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
|
|
266
|
+
expect(container.style.height).to.equal('480px');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -167,6 +167,10 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
|
|
|
167
167
|
}) showValues: string[] = [];
|
|
168
168
|
|
|
169
169
|
|
|
170
|
+
private get _usedFilterIds(): Set<string> {
|
|
171
|
+
return new Set([...this._selectedRules.values()].map(rule => rule.id));
|
|
172
|
+
}
|
|
173
|
+
|
|
170
174
|
get validationMessage(): string {
|
|
171
175
|
return '';
|
|
172
176
|
}
|
|
@@ -196,7 +200,7 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
|
|
|
196
200
|
size="medium"
|
|
197
201
|
placeholder="Select Filter"
|
|
198
202
|
@zn-change="${this._addRule}">
|
|
199
|
-
${this.filters && this.filters.map(item => html`
|
|
203
|
+
${this.filters && this.filters.filter(item => !this._usedFilterIds.has(item.id)).map(item => html`
|
|
200
204
|
<zn-option value="${item.id}">
|
|
201
205
|
${item.name.charAt(0).toUpperCase() + item.name.slice(1)}
|
|
202
206
|
</zn-option>`)}
|
|
@@ -227,6 +231,7 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
|
|
|
227
231
|
|
|
228
232
|
const filter: QueryBuilderItem | undefined = this.filters.find(item => item.id === id);
|
|
229
233
|
if (filter === undefined) return;
|
|
234
|
+
if (this._usedFilterIds.has(filter.id)) return;
|
|
230
235
|
|
|
231
236
|
const uniqueId = Math.random().toString(36).substring(7);
|
|
232
237
|
|
|
@@ -237,11 +242,12 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
|
|
|
237
242
|
value: ''
|
|
238
243
|
});
|
|
239
244
|
|
|
245
|
+
const availableFilters = this.filters.filter(item => item.id === filter.id || !this._usedFilterIds.has(item.id));
|
|
240
246
|
const select = html`
|
|
241
247
|
<zn-select class="query-builder__key"
|
|
242
248
|
@zn-change="${(e: ZnChangeEvent) => this._changeRule(uniqueId, e)}"
|
|
243
249
|
value="${filter?.id}">
|
|
244
|
-
${
|
|
250
|
+
${availableFilters.map((item: QueryBuilderItem) => {
|
|
245
251
|
return html`
|
|
246
252
|
<zn-option value="${item.id}">
|
|
247
253
|
${item.name.charAt(0).toUpperCase() + item.name.slice(1)}
|
|
@@ -338,7 +344,7 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
|
|
|
338
344
|
break;
|
|
339
345
|
}
|
|
340
346
|
case 'dateTime': {
|
|
341
|
-
input = this._createDateInput(uniqueId, true
|
|
347
|
+
input = this._createDateInput(uniqueId, true, filter.dateSubmitFormat);
|
|
342
348
|
break;
|
|
343
349
|
}
|
|
344
350
|
default: {
|
|
@@ -401,9 +407,9 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
|
|
|
401
407
|
?time-picker="${hasTime}"
|
|
402
408
|
class="query-builder__value"
|
|
403
409
|
@zn-change="${(e: ZnChangeEvent) => this._updateDateValue(uniqueId, e, submitFormat)}"
|
|
404
|
-
|
|
410
|
+
>
|
|
405
411
|
</zn-datepicker>
|
|
406
|
-
|
|
412
|
+
`;
|
|
407
413
|
return litToHTML<ZnDatepicker>(input);
|
|
408
414
|
}
|
|
409
415
|
|
|
@@ -451,7 +457,9 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
|
|
|
451
457
|
this._handleChange();
|
|
452
458
|
}
|
|
453
459
|
|
|
454
|
-
private _updateDateValue(id: string, event: Event | {
|
|
460
|
+
private _updateDateValue(id: string, event: Event | {
|
|
461
|
+
target: ZnDatepicker | HTMLDivElement
|
|
462
|
+
}, submitFormat: QueryBuilderDateSubmitFormat = 'legacy') {
|
|
455
463
|
const filter = this._selectedRules.get(id);
|
|
456
464
|
if (!filter) return;
|
|
457
465
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ZnRemarkdEditor from './remarkd-editor.component';
|
|
2
|
+
|
|
3
|
+
export * from './remarkd-editor.component';
|
|
4
|
+
export default ZnRemarkdEditor;
|
|
5
|
+
|
|
6
|
+
ZnRemarkdEditor.define('zn-remarkd-editor');
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
interface HTMLElementTagNameMap {
|
|
10
|
+
'zn-remarkd-editor': ZnRemarkdEditor;
|
|
11
|
+
}
|
|
12
|
+
}
|