@kubex/zinc 1.1.143 → 1.1.145

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.143",
3
+ "version": "1.1.145",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -9,6 +9,17 @@ import ZnDialog from "../dialog";
9
9
 
10
10
  import styles from './confirm.scss';
11
11
 
12
+ interface PageletAlert {
13
+ action?: string;
14
+ style?: string;
15
+ title?: string;
16
+ }
17
+
18
+ interface PageletResponse {
19
+ status?: number;
20
+ actions?: PageletAlert[];
21
+ }
22
+
12
23
  /**
13
24
  * @summary Short summary of the component's intended use.
14
25
  * @documentation https://zinc.style/components/confirm-modal
@@ -83,6 +94,12 @@ export default class ZnConfirm extends ZincElement {
83
94
  /** Internal loading state used when showLoading is enabled */
84
95
  @state() private loading: boolean = false;
85
96
 
97
+ @state() private failed: boolean = false;
98
+
99
+ @state() private failure: string = '';
100
+
101
+ private submitted: HTMLFormElement | null = null;
102
+
86
103
  protected firstUpdated(_changedProperties: PropertyValues) {
87
104
  super.firstUpdated(_changedProperties);
88
105
  if (this.open) {
@@ -116,15 +133,66 @@ export default class ZnConfirm extends ZincElement {
116
133
  show = (event: Event | undefined = undefined) => {
117
134
  const trigger = event?.target as HTMLButtonElement
118
135
  if (trigger?.disabled) return;
119
- this.loading = false;
136
+ this.reset();
120
137
  this.dialog.show();
121
138
  }
122
139
 
123
140
  hide() {
124
- this.loading = false;
141
+ this.reset();
125
142
  this.dialog.hide();
126
143
  }
127
144
 
145
+ disconnectedCallback() {
146
+ super.disconnectedCallback();
147
+ this.stopWatching();
148
+ }
149
+
150
+ private reset() {
151
+ this.stopWatching();
152
+ this.loading = false;
153
+ this.failed = false;
154
+ this.failure = '';
155
+ if (this.dialog?.closer) {
156
+ this.dialog.closer.disabled = false;
157
+ }
158
+ }
159
+
160
+ private stopWatching() {
161
+ this.submitted?.removeEventListener('complete', this.settle);
162
+ this.submitted?.removeEventListener('error', this.settle);
163
+ this.submitted = null;
164
+ }
165
+
166
+ /**
167
+ * The console dispatches the pagelet lifecycle on the form it submitted. Without it the loading
168
+ * state only ends when a response happens to reload the page, so every failure hangs the dialog.
169
+ */
170
+ private settle = (event: Event) => {
171
+ const response = (event as CustomEvent<{ response?: PageletResponse }>).detail?.response;
172
+ this.stopWatching();
173
+ if (event.type === 'error' || this.responseFailed(response)) {
174
+ this.loading = false;
175
+ this.failed = true;
176
+ this.failure = this.alertTitle(response);
177
+ if (this.dialog?.closer) {
178
+ this.dialog.closer.disabled = false;
179
+ }
180
+ return;
181
+ }
182
+ this.hide();
183
+ }
184
+
185
+ private responseFailed(response?: PageletResponse): boolean {
186
+ if (!response) return true;
187
+ if ((response.status ?? 0) >= 400) return true;
188
+ return (response.actions ?? []).some(action => action.action === 'alert' && action.style === 'error');
189
+ }
190
+
191
+ private alertTitle(response?: PageletResponse): string {
192
+ const alert = (response?.actions ?? []).find(action => action.action === 'alert' && action.style === 'error');
193
+ return alert?.title ?? '';
194
+ }
195
+
128
196
 
129
197
  render() {
130
198
  const src = {
@@ -153,10 +221,11 @@ export default class ZnConfirm extends ZincElement {
153
221
  : ''}
154
222
 
155
223
  <div class="confirm-dialog__content">
156
- ${!this.loading ? html`
224
+ ${this.loading ? html`Loading...` : this.failed ? html`
225
+ <strong>Failed</strong>
226
+ ${this.failure ? html`<p>${this.failure}</p>` : ''}` : html`
157
227
  ${this.content ? html`${this.content}` : ''}
158
- <slot></slot>` : html`
159
- Loading...`}
228
+ <slot></slot>`}
160
229
  </div>
161
230
 
162
231
  <zn-button outline color="${this.type}" slot="footer" dialog-closer disabled=${this.loading || nothing}>
@@ -196,12 +265,18 @@ export default class ZnConfirm extends ZincElement {
196
265
  document.dispatchEvent(new CustomEvent('zn-register-element', {
197
266
  detail: {element: form}
198
267
  }))
268
+ if (this.showLoading) {
269
+ this.failed = false;
270
+ this.failure = '';
271
+ this.loading = true;
272
+ this.dialog.closer.disabled = true;
273
+ this.submitted = form;
274
+ form.addEventListener('complete', this.settle);
275
+ form.addEventListener('error', this.settle);
276
+ }
199
277
  form.requestSubmit();
200
278
  if (!this.showLoading) {
201
279
  this.hide();
202
- } else {
203
- this.loading = true;
204
- this.dialog.closer.disabled = true;
205
280
  }
206
281
  }
207
282
  }
@@ -1,6 +1,38 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import { expect, fixture, html } from '@open-wc/testing';
3
3
 
4
+ const click = (el: Element) => el.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
5
+
6
+ const settle = (form: HTMLFormElement, type: string, detail: unknown) =>
7
+ form.dispatchEvent(new CustomEvent(type, {detail}));
8
+
9
+ async function submittedConfirm() {
10
+ const el = await fixture<HTMLElement>(html`
11
+ <div>
12
+ <zn-confirm caption="Deploy" show-loading>
13
+ <form action="/deploy" method="post"></form>
14
+ </zn-confirm>
15
+ </div>
16
+ `);
17
+ const confirm = el.querySelector('zn-confirm')!;
18
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
19
+
20
+ const form = confirm.querySelector('form')!;
21
+ form.addEventListener('submit', e => e.preventDefault());
22
+
23
+ (confirm as never as {show: () => void}).show();
24
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
25
+
26
+ const dialog = confirm.shadowRoot!.querySelector('zn-dialog')!;
27
+ const buttons = confirm.shadowRoot!.querySelectorAll('zn-button[slot="footer"]');
28
+ click(buttons[1]);
29
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
30
+
31
+ return {confirm, form, dialog, cancel: buttons[0] as HTMLElement & {disabled: boolean}};
32
+ }
33
+
34
+ const closer = (dialog: Element) => dialog.shadowRoot!.querySelector('.dialog__close')!;
35
+
4
36
  describe('<zn-confirm-modal>', () => {
5
37
  it('should render a component', async () => {
6
38
  const el = await fixture(html` <zn-confirm-modal></zn-confirm-modal> `);
@@ -22,4 +54,56 @@ describe('<zn-confirm-modal>', () => {
22
54
  // An unescaped `#id` selector throws a SyntaxError here, leaving the component unrendered.
23
55
  expect(confirm.shadowRoot!.querySelector('zn-dialog')).to.exist;
24
56
  });
57
+
58
+ it('shows loading until the submitted request settles', async () => {
59
+ const {confirm} = await submittedConfirm();
60
+
61
+ expect(confirm.shadowRoot!.textContent).to.contain('Loading');
62
+ });
63
+
64
+ it('reports failure when the response carries a danger alert', async () => {
65
+ const {confirm, form, dialog, cancel} = await submittedConfirm();
66
+
67
+ settle(form, 'complete', {
68
+ response: {status: 200, actions: [{action: 'alert', style: 'error', title: 'Deployment stopped'}]}
69
+ });
70
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
71
+
72
+ expect(confirm.shadowRoot!.textContent).to.contain('Failed');
73
+ expect(confirm.shadowRoot!.textContent).to.contain('Deployment stopped');
74
+ expect(confirm.shadowRoot!.textContent).to.not.contain('Loading');
75
+ expect(cancel.disabled).to.be.false;
76
+ expect(closer(dialog).disabled).to.be.false;
77
+ });
78
+
79
+ it('reports failure when the request errors', async () => {
80
+ const {confirm, form, dialog} = await submittedConfirm();
81
+
82
+ settle(form, 'error', {});
83
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
84
+
85
+ expect(confirm.shadowRoot!.textContent).to.contain('Failed');
86
+ expect(closer(dialog).disabled).to.be.false;
87
+ });
88
+
89
+ it('closes when the submitted request succeeds', async () => {
90
+ const {confirm, form, dialog} = await submittedConfirm();
91
+
92
+ settle(form, 'complete', {response: {status: 200, actions: [{action: 'alert', style: 'success', title: 'Deployed'}]}});
93
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
94
+
95
+ expect((dialog as HTMLElement & {open: boolean}).open).to.be.false;
96
+ expect(closer(dialog).disabled).to.be.false;
97
+ });
98
+
99
+ it('clears a previous failure when reopened', async () => {
100
+ const {confirm, form} = await submittedConfirm();
101
+ settle(form, 'error', {});
102
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
103
+
104
+ (confirm as never as {show: () => void}).show();
105
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
106
+
107
+ expect(confirm.shadowRoot!.textContent).to.not.contain('Failed');
108
+ });
25
109
  });
@@ -102,7 +102,9 @@ $control-positions: (
102
102
  flex-direction: column;
103
103
  flex-wrap: nowrap;
104
104
  align-items: stretch;
105
- justify-content: center;
105
+ /* Grid rows stretch cards to one height; centring would float a short card's
106
+ image off the row's shared line, so pin to the image's named edge. */
107
+ justify-content: flex-start;
106
108
 
107
109
  .selection-card__image-container {
108
110
  align-self: center;
@@ -124,6 +126,10 @@ $control-positions: (
124
126
  }
125
127
  }
126
128
 
129
+ &.selection-card--image-bottom {
130
+ justify-content: flex-end;
131
+ }
132
+
127
133
  /* Positioned controls */
128
134
  @each $name, $position in $control-positions {
129
135
  $block: map.get($position, block);