@dile/crud 2.9.5 → 2.10.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.
@@ -0,0 +1,3 @@
1
+ export { DileStepsForm } from './src/DileStepsForm.js';
2
+ export { DileStepForm } from './src/DileStepForm.js';
3
+ export { DileStepNextPrev } from './src/DileStepNextPrev.js';
@@ -0,0 +1,202 @@
1
+ import { LitElement, html, css } from "lit";
2
+ import "../../ajax/ajax.js";
3
+ import { ResponseApiAdapter } from "../../../lib/ResponseApiAdapter.js";
4
+ import "../step-next-prev.js";
5
+
6
+ export class DileStepForm extends LitElement {
7
+ static styles = [
8
+ css`
9
+ :host {
10
+ display: block;
11
+ }
12
+ :host(:not([active])) {
13
+ display: none;
14
+ }
15
+ `,
16
+ ];
17
+
18
+ static get properties() {
19
+ return {
20
+ /** Label of the step, shown in the steps indicator */
21
+ label: { type: String },
22
+ /** The step is the visible one. Managed by dile-steps-form */
23
+ active: { type: Boolean, reflect: true },
24
+ /** Endpoint where the step data is saved */
25
+ endpoint: { type: String },
26
+ /** HTTP method used to save the step */
27
+ method: { type: String },
28
+ /** Zero-based index of the step, set by dile-steps-form */
29
+ step: { type: Number },
30
+ /** Labels of all the steps, set by dile-steps-form */
31
+ steps: { type: Array },
32
+ /** Shows the alternative exit link in this step */
33
+ alternativeExit: { type: Boolean },
34
+ alternativeExitLabel: { type: String },
35
+ /** Name of the field added to the payload when the alternative exit is used. Empty to not send it */
36
+ alternativeExitField: { type: String },
37
+ prevLabel: { type: String },
38
+ nextLabel: { type: String },
39
+ endLabel: { type: String },
40
+ /** Default labels, set by dile-steps-form: { prev, next, end } */
41
+ defaultLabels: { type: Object },
42
+ /** The form of this step receives the data returned when another step is saved */
43
+ refreshOnSave: { type: Boolean },
44
+ sendDataAsFormData: { type: Boolean },
45
+ responseAdapter: { type: Object },
46
+ language: { type: String },
47
+ loading: { type: Boolean },
48
+ };
49
+ }
50
+
51
+ constructor() {
52
+ super();
53
+ this.active = false;
54
+ this.method = "patch";
55
+ this.steps = [];
56
+ this.alternativeExit = false;
57
+ this.alternativeExitField = "alternativeExit";
58
+ this.defaultLabels = {};
59
+ this.refreshOnSave = false;
60
+ this.sendDataAsFormData = false;
61
+ this.language = "en";
62
+ this.loading = false;
63
+ this.alternativeExitRequested = false;
64
+ this.defaultResponseAdapter = new ResponseApiAdapter();
65
+ }
66
+
67
+ get elajax() {
68
+ return this.shadowRoot.getElementById("ajaxsave");
69
+ }
70
+
71
+ get form() {
72
+ return this.querySelector('[slot="form"]');
73
+ }
74
+
75
+ get adapter() {
76
+ return this.responseAdapter ?? this.defaultResponseAdapter;
77
+ }
78
+
79
+ updated(changedProperties) {
80
+ if (
81
+ changedProperties.has("label") &&
82
+ changedProperties.get("label") !== undefined
83
+ ) {
84
+ this.dispatchEvent(
85
+ new CustomEvent("dile-step-form-label-change", {
86
+ bubbles: true,
87
+ detail: { label: this.label },
88
+ }),
89
+ );
90
+ }
91
+ }
92
+
93
+ render() {
94
+ return html`
95
+ <dile-ajax
96
+ id="ajaxsave"
97
+ method="${this.method}"
98
+ url="${this.endpoint}"
99
+ language="${this.language}"
100
+ ?sendDataAsFormData=${this.sendDataAsFormData}
101
+ @ajax-success="${this.doSuccessSave}"
102
+ @ajax-error="${this.doErrorSave}"
103
+ ></dile-ajax>
104
+ <slot name="form"></slot>
105
+ <dile-step-next-prev
106
+ .current=${this.step}
107
+ .steps=${this.steps}
108
+ ?alternativeExit=${this.alternativeExit}
109
+ .alternativeExitLabel=${this.alternativeExitLabel}
110
+ .prevLabel=${this.prevLabel || this.defaultLabels?.prev}
111
+ .nextLabel=${this.nextLabel || this.defaultLabels?.next}
112
+ .endLabel=${this.endLabel || this.defaultLabels?.end}
113
+ language="${this.language}"
114
+ ?loading=${this.loading}
115
+ @dile-step-next-click=${this.next}
116
+ @dile-step-alternative-exit=${this.alternativeNext}
117
+ @dile-step-prev-click=${this.navigatePrev}
118
+ ></dile-step-next-prev>
119
+ `;
120
+ }
121
+
122
+ next() {
123
+ this.submit(false);
124
+ }
125
+
126
+ alternativeNext() {
127
+ this.submit(true);
128
+ }
129
+
130
+ submit(alternativeExit) {
131
+ const form = this.form;
132
+ if (!form) {
133
+ throw new Error(
134
+ `[dile-step-form] No element with slot="form" was found in step ${this.step + 1}. The form element must have the slot="form" attribute.`,
135
+ );
136
+ }
137
+ const data = form.getData();
138
+ if (alternativeExit && this.alternativeExitField) {
139
+ data[this.alternativeExitField] = true;
140
+ }
141
+ this.alternativeExitRequested = alternativeExit;
142
+ this.setLoading(true);
143
+ this.elajax.data = data;
144
+ this.elajax.generateRequest();
145
+ }
146
+
147
+ setLoading(loading) {
148
+ this.loading = loading;
149
+ this.dispatchEvent(
150
+ new CustomEvent("dile-step-form-loading", {
151
+ bubbles: true,
152
+ composed: true,
153
+ detail: { loading },
154
+ }),
155
+ );
156
+ }
157
+
158
+ doSuccessSave(e) {
159
+ const alternativeExit = this.alternativeExitRequested;
160
+ this.alternativeExitRequested = false;
161
+ this.setLoading(false);
162
+ this.adapter.setResponse(e.detail);
163
+ const data = this.adapter.getData();
164
+ this.dispatchEvent(
165
+ new CustomEvent("dile-step-form-success", {
166
+ bubbles: true,
167
+ composed: true,
168
+ detail: { message: this.adapter.getMessage(), data },
169
+ }),
170
+ );
171
+ this.dispatchEvent(
172
+ new CustomEvent("dile-step-next", {
173
+ bubbles: true,
174
+ detail: { data, alternativeExit },
175
+ }),
176
+ );
177
+ }
178
+
179
+ doErrorSave(e) {
180
+ this.alternativeExitRequested = false;
181
+ this.setLoading(false);
182
+ this.adapter.setResponse(e.detail);
183
+ const errors = this.adapter.getValidationErrors();
184
+ this.dispatchEvent(
185
+ new CustomEvent("dile-step-form-error", {
186
+ bubbles: true,
187
+ composed: true,
188
+ detail: { message: e.detail.message, errors, status: e.detail.status },
189
+ }),
190
+ );
191
+ this.form?.showErrors(errors);
192
+ }
193
+
194
+ navigatePrev() {
195
+ this.dispatchEvent(
196
+ new CustomEvent("dile-step-prev", {
197
+ bubbles: true,
198
+ detail: { index: this.step - 1 },
199
+ }),
200
+ );
201
+ }
202
+ }
@@ -0,0 +1,134 @@
1
+ import { LitElement, html, css } from "lit";
2
+ import "@dile/ui/components/button/button.js";
3
+ import { DileI18nMixin } from "../../../lib/DileI18nMixin.js";
4
+
5
+ export class DileStepNextPrev extends DileI18nMixin(LitElement) {
6
+ static styles = [
7
+ css`
8
+ :host {
9
+ display: block;
10
+ container-type: inline-size;
11
+ margin-top: var(--dile-step-next-prev-margin-top, 1.5rem);
12
+ }
13
+ section {
14
+ display: flex;
15
+ flex-direction: column;
16
+ justify-content: space-between;
17
+ gap: 0.25rem;
18
+ align-items: center;
19
+ }
20
+ @container (min-width: 420px) {
21
+ section {
22
+ flex-direction: row;
23
+ gap: 1rem;
24
+ }
25
+ }
26
+ .alternative-exit {
27
+ flex-grow: 1;
28
+ display: flex;
29
+ justify-content: flex-end;
30
+ }
31
+ .alternative-exit button {
32
+ color: var(--dile-step-alternative-exit-color, var(--dile-link-color));
33
+ background: none;
34
+ border: none;
35
+ padding: 0;
36
+ font: inherit;
37
+ cursor: pointer;
38
+ text-decoration: none;
39
+ }
40
+ .alternative-exit button:disabled {
41
+ cursor: default;
42
+ opacity: 0.5;
43
+ }
44
+ `,
45
+ ];
46
+
47
+ static get properties() {
48
+ return {
49
+ current: { type: Number },
50
+ steps: { type: Array },
51
+ prevLabel: { type: String },
52
+ nextLabel: { type: String },
53
+ endLabel: { type: String },
54
+ alternativeExit: { type: Boolean },
55
+ alternativeExitLabel: { type: String },
56
+ loading: { type: Boolean },
57
+ };
58
+ }
59
+
60
+ constructor() {
61
+ super();
62
+ this.current = 0;
63
+ this.steps = [];
64
+ this.alternativeExit = false;
65
+ this.loading = false;
66
+ }
67
+
68
+ get isLast() {
69
+ return this.current >= this.steps.length - 1;
70
+ }
71
+
72
+ get computedPrevLabel() {
73
+ return this.prevLabel || this.translations?.step_prev_label || "Previous";
74
+ }
75
+
76
+ get computedNextLabel() {
77
+ return this.nextLabel || this.translations?.step_next_label || "Next";
78
+ }
79
+
80
+ get computedEndLabel() {
81
+ return this.endLabel || this.translations?.step_end_label || "Finish";
82
+ }
83
+
84
+ render() {
85
+ return html`
86
+ <section>
87
+ <div class="prev">
88
+ ${this.current > 0
89
+ ? html`<dile-button ?disabled=${this.loading} @click=${this.prev}
90
+ >${this.computedPrevLabel}</dile-button
91
+ >`
92
+ : ""}
93
+ </div>
94
+ ${this.alternativeExit && this.alternativeExitLabel
95
+ ? html`<div class="alternative-exit">
96
+ <button
97
+ type="button"
98
+ ?disabled=${this.loading}
99
+ @click=${this.alternativeExitClick}
100
+ >
101
+ ${this.alternativeExitLabel}
102
+ </button>
103
+ </div>`
104
+ : ""}
105
+ <div class="next">
106
+ <dile-button ?loading=${this.loading} @click=${this.next}>
107
+ ${this.isLast ? this.computedEndLabel : this.computedNextLabel}
108
+ </dile-button>
109
+ </div>
110
+ </section>
111
+ `;
112
+ }
113
+
114
+ dispatchNavigationEvent(eventName) {
115
+ this.dispatchEvent(
116
+ new CustomEvent(eventName, {
117
+ detail: {
118
+ current: this.current,
119
+ },
120
+ }),
121
+ );
122
+ }
123
+
124
+ next() {
125
+ this.dispatchNavigationEvent("dile-step-next-click");
126
+ }
127
+ prev() {
128
+ this.dispatchNavigationEvent("dile-step-prev-click");
129
+ }
130
+
131
+ alternativeExitClick() {
132
+ this.dispatchNavigationEvent("dile-step-alternative-exit");
133
+ }
134
+ }
@@ -0,0 +1,347 @@
1
+ import { LitElement, html, css } from "lit";
2
+ import "@dile/ui/components/steps/steps.js";
3
+ import "../../ajax/ajax.js";
4
+ import "@dile/ui/components/inline-feedback/inline-feedback.js";
5
+ import { ResponseApiAdapter } from "../../../lib/ResponseApiAdapter.js";
6
+ import { DileI18nMixin } from "../../../lib/DileI18nMixin.js";
7
+
8
+ export class DileStepsForm extends DileI18nMixin(LitElement) {
9
+ static styles = [
10
+ css`
11
+ :host {
12
+ display: block;
13
+ }
14
+ dile-steps {
15
+ margin-bottom: 1rem;
16
+ }
17
+ .header {
18
+ margin-bottom: 1.5rem;
19
+ font-size: var(--dile-steps-form-header-font-size, 1rem);
20
+ color: var(--dile-steps-form-header-color, var(--dile-on-background-color, #303030));
21
+ }
22
+ .title {
23
+ font-weight: var(--dile-steps-form-title-font-weight, 600);
24
+ color: var(--dile-on-background-color, #303030);
25
+ }
26
+ .separator {
27
+ margin: 0 0.4rem;
28
+ opacity: 0.5;
29
+ }
30
+ .step-label {
31
+ opacity: 0.75;
32
+ color: var(--dile-on-background-color, #303030);
33
+ }
34
+ .header:focus-visible {
35
+ outline: 2px solid var(--dile-step-active-border-color, var(--dile-primary-dark-color, #0039cb));
36
+ outline-offset: 2px;
37
+ }
38
+ dile-inline-feedback {
39
+ margin-top: var(--dile-steps-form-feedback-margin-top, 1rem);
40
+ }
41
+ :host([hidefeedback]) dile-inline-feedback {
42
+ display: none;
43
+ }
44
+ `,
45
+ ];
46
+
47
+ static get properties() {
48
+ return {
49
+ /** Labels of the steps. If empty, the label attribute of each dile-step-form is used */
50
+ steps: { type: Array },
51
+ title: { type: String },
52
+ /** Endpoint to load the data of every step form */
53
+ endpoint: { type: String },
54
+ current: { type: Number },
55
+ reached: { type: Number },
56
+ freeNavigation: { type: Boolean },
57
+ vertical: { type: Boolean },
58
+ /** Default labels for the navigation buttons of every step */
59
+ prevLabel: { type: String },
60
+ nextLabel: { type: String },
61
+ endLabel: { type: String },
62
+ /** Response adapter for the initial load and, if set, for every step */
63
+ responseAdapter: { type: Object },
64
+ /** Hides the inline feedback messages */
65
+ hideFeedback: { type: Boolean, reflect: true },
66
+ };
67
+ }
68
+
69
+ constructor() {
70
+ super();
71
+ this.steps = [];
72
+ this.current = 0;
73
+ this.reached = 0;
74
+ this.freeNavigation = false;
75
+ this.vertical = false;
76
+ this.hideFeedback = false;
77
+ this.defaultResponseAdapter = new ResponseApiAdapter();
78
+ }
79
+
80
+ get adapter() {
81
+ return this.responseAdapter ?? this.defaultResponseAdapter;
82
+ }
83
+
84
+ connectedCallback() {
85
+ super.connectedCallback();
86
+ this.addEventListener("dile-step-next", this.onStepNext);
87
+ this.addEventListener("dile-step-prev", this.onStepPrev);
88
+ this.addEventListener("dile-step-form-loading", this.onStepLoading);
89
+ this.addEventListener("dile-step-form-success", this.onStepSuccess);
90
+ this.addEventListener("dile-step-form-error", this.onStepError);
91
+ this.addEventListener("dile-step-form-label-change", this.syncStepForms);
92
+ }
93
+
94
+ disconnectedCallback() {
95
+ super.disconnectedCallback();
96
+ this.removeEventListener("dile-step-next", this.onStepNext);
97
+ this.removeEventListener("dile-step-prev", this.onStepPrev);
98
+ this.removeEventListener("dile-step-form-loading", this.onStepLoading);
99
+ this.removeEventListener("dile-step-form-success", this.onStepSuccess);
100
+ this.removeEventListener("dile-step-form-error", this.onStepError);
101
+ this.removeEventListener("dile-step-form-label-change", this.syncStepForms);
102
+ }
103
+
104
+ updated(changedProperties) {
105
+ if (
106
+ ["steps", "current", "prevLabel", "nextLabel", "endLabel", "responseAdapter", "language"].some(
107
+ (property) => changedProperties.has(property),
108
+ )
109
+ ) {
110
+ this.syncStepForms();
111
+ }
112
+ if (changedProperties.has("endpoint") && this.endpoint) {
113
+ this.fetchData();
114
+ }
115
+ if (
116
+ changedProperties.has("current") &&
117
+ changedProperties.get("current") !== undefined
118
+ ) {
119
+ this.focusHeader();
120
+ }
121
+ }
122
+
123
+ render() {
124
+ return html`
125
+ ${this.endpoint
126
+ ? html`
127
+ <dile-ajax
128
+ id="ajaxget"
129
+ method="get"
130
+ url="${this.endpoint}"
131
+ language="${this.language}"
132
+ @ajax-success="${this.onFetchSuccess}"
133
+ @ajax-error="${this.onFetchError}"
134
+ ></dile-ajax>
135
+ `
136
+ : ""}
137
+ ${this.headerTemplate}
138
+ <dile-steps
139
+ .steps=${this.stepLabels}
140
+ current="${this.current}"
141
+ reached="${this.reached}"
142
+ ?freeNavigation=${this.freeNavigation}
143
+ ?vertical=${this.vertical}
144
+ @dile-steps-select=${this.onStepSelect}
145
+ ></dile-steps>
146
+ <slot @slotchange=${this.syncStepForms}></slot>
147
+ <dile-inline-feedback id="feedback"></dile-inline-feedback>
148
+ `;
149
+ }
150
+
151
+ get feedback() {
152
+ return this.shadowRoot.getElementById("feedback");
153
+ }
154
+
155
+ get stepForms() {
156
+ return [...this.querySelectorAll(":scope > dile-step-form")];
157
+ }
158
+
159
+ get stepLabels() {
160
+ if (this.steps.length) {
161
+ return this.steps;
162
+ }
163
+ return this.stepForms.map((stepForm) => stepForm.label ?? "");
164
+ }
165
+
166
+ get formElements() {
167
+ return this.stepForms
168
+ .map((stepForm) => stepForm.form)
169
+ .filter((form) => form);
170
+ }
171
+
172
+ get currentStepLabel() {
173
+ return this.stepLabels[this.current] ?? "";
174
+ }
175
+
176
+ get headerTemplate() {
177
+ if (!this.title && !this.currentStepLabel) return "";
178
+ return html`
179
+ <div class="header" tabindex="-1" role="status">
180
+ ${this.title
181
+ ? html`<span class="title">${this.title}</span
182
+ ><span class="separator">›</span
183
+ ><span class="step-label">${this.currentStepLabel}</span>`
184
+ : html`<span class="step-label">${this.currentStepLabel}</span>`}
185
+ </div>
186
+ `;
187
+ }
188
+
189
+ focusHeader() {
190
+ this.shadowRoot.querySelector(".header")?.focus();
191
+ }
192
+
193
+ syncStepForms() {
194
+ const forms = this.stepForms;
195
+ const labels = this.stepLabels;
196
+ forms.forEach((form, index) => {
197
+ form.step = index;
198
+ form.steps = labels;
199
+ form.active = index === this.current;
200
+ form.language = this.language;
201
+ form.defaultLabels = {
202
+ prev: this.prevLabel,
203
+ next: this.nextLabel,
204
+ end: this.endLabel,
205
+ };
206
+ if (this.responseAdapter) {
207
+ form.responseAdapter = this.responseAdapter;
208
+ }
209
+ });
210
+ if (this.steps.length && forms.length && forms.length !== this.steps.length) {
211
+ console.warn(
212
+ `[dile-steps-form] The number of <dile-step-form> elements (${forms.length}) does not match steps.length (${this.steps.length}).`,
213
+ );
214
+ }
215
+ this.requestUpdate();
216
+ }
217
+
218
+ dispatchLoading(loading) {
219
+ this.dispatchEvent(
220
+ new CustomEvent("dile-steps-form-loading", {
221
+ bubbles: true,
222
+ composed: true,
223
+ detail: { loading },
224
+ }),
225
+ );
226
+ }
227
+
228
+ fetchData() {
229
+ this.dispatchLoading(true);
230
+ this.shadowRoot.getElementById("ajaxget")?.generateRequest();
231
+ }
232
+
233
+ async onFetchSuccess(e) {
234
+ this.adapter.setResponse(e.detail);
235
+ const data = this.adapter.getData();
236
+ await Promise.all(this.formElements.map((form) => form.updateComplete));
237
+ this.setData(data);
238
+ this.dispatchLoading(false);
239
+ this.dispatchEvent(
240
+ new CustomEvent("dile-steps-form-loaded", {
241
+ bubbles: true,
242
+ composed: true,
243
+ detail: { data },
244
+ }),
245
+ );
246
+ }
247
+
248
+ onFetchError(e) {
249
+ this.dispatchLoading(false);
250
+ this.dispatchEvent(
251
+ new CustomEvent("dile-steps-form-error", {
252
+ bubbles: true,
253
+ composed: true,
254
+ detail: {
255
+ message: e.detail.message,
256
+ data: e.detail.data,
257
+ errors: e.detail.errors,
258
+ status: e.detail.status,
259
+ },
260
+ }),
261
+ );
262
+ }
263
+
264
+ /** Loads again the data of every step form from the endpoint */
265
+ reload() {
266
+ if (this.endpoint) {
267
+ this.fetchData();
268
+ }
269
+ }
270
+
271
+ /** Sets the same data in the form of every step */
272
+ setData(data) {
273
+ this.formElements.forEach((form) => form.setData(data));
274
+ }
275
+
276
+ onStepLoading(e) {
277
+ if (e.detail.loading) {
278
+ this.feedback?.clear();
279
+ }
280
+ }
281
+
282
+ onStepSuccess(e) {
283
+ this.stepForms
284
+ .filter((stepForm) => stepForm.refreshOnSave && !e.composedPath().includes(stepForm))
285
+ .forEach((stepForm) => stepForm.form?.setData(e.detail.data));
286
+ this.feedback?.positiveFeedbackWithDelay(
287
+ e.detail.message || this.translations?.step_success || "Data saved",
288
+ 5000,
289
+ );
290
+ }
291
+
292
+ onStepError(e) {
293
+ this.feedback?.negativeFeedbackWithDelay(
294
+ e.detail.message || this.translations?.step_error || "Data could not be saved",
295
+ 5000,
296
+ );
297
+ }
298
+
299
+ onStepNext(e) {
300
+ e.stopPropagation();
301
+ const next = this.current + 1;
302
+ if (next < this.stepForms.length && !e.detail.alternativeExit) {
303
+ this.navigateTo(next);
304
+ } else {
305
+ this.dispatchEvent(
306
+ new CustomEvent("dile-steps-form-complete", {
307
+ bubbles: true,
308
+ composed: true,
309
+ detail: {
310
+ data: e.detail.data,
311
+ alternativeExit: Boolean(e.detail.alternativeExit),
312
+ },
313
+ }),
314
+ );
315
+ }
316
+ }
317
+
318
+ onStepPrev(e) {
319
+ e.stopPropagation();
320
+ this.feedback?.clear();
321
+ this.navigateTo(this.current - 1);
322
+ }
323
+
324
+ onStepSelect(e) {
325
+ e.stopPropagation();
326
+ this.feedback?.clear();
327
+ this.navigateTo(e.detail.index);
328
+ }
329
+
330
+ /** Shows the step at the given zero-based index */
331
+ navigateTo(index) {
332
+ if (index < 0 || index >= this.stepForms.length) return;
333
+ this.current = index;
334
+ if (index > this.reached) this.reached = index;
335
+ this.stepForms.forEach((stepForm, i) => {
336
+ stepForm.active = i === index;
337
+ });
338
+ }
339
+
340
+ next() {
341
+ this.navigateTo(this.current + 1);
342
+ }
343
+
344
+ prev() {
345
+ this.navigateTo(this.current - 1);
346
+ }
347
+ }
@@ -0,0 +1,5 @@
1
+ import { DileStepForm } from './src/DileStepForm.js';
2
+
3
+ if (!customElements.get('dile-step-form')) {
4
+ customElements.define('dile-step-form', DileStepForm);
5
+ }
@@ -0,0 +1,5 @@
1
+ import { DileStepNextPrev } from './src/DileStepNextPrev.js';
2
+
3
+ if (!customElements.get('dile-step-next-prev')) {
4
+ customElements.define('dile-step-next-prev', DileStepNextPrev);
5
+ }
@@ -0,0 +1,5 @@
1
+ import { DileStepsForm } from './src/DileStepsForm.js';
2
+
3
+ if (!customElements.get('dile-steps-form')) {
4
+ customElements.define('dile-steps-form', DileStepsForm);
5
+ }
@@ -0,0 +1,230 @@
1
+ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
2
+ import './steps-form.js';
3
+ import './step-form.js';
4
+
5
+ class TestStepForm extends HTMLElement {
6
+ constructor() {
7
+ super();
8
+ this.value = '';
9
+ this.receivedData = [];
10
+ this.errors = null;
11
+ }
12
+ getData() {
13
+ return { name: this.value };
14
+ }
15
+ setData(data) {
16
+ this.receivedData.push(data);
17
+ }
18
+ showErrors(errors) {
19
+ this.errors = errors;
20
+ }
21
+ }
22
+ if (!customElements.get('test-step-form')) {
23
+ customElements.define('test-step-form', TestStepForm);
24
+ }
25
+
26
+ const loadedResource = { id: 1, name: 'Loaded' };
27
+
28
+ function okResponse(data, message = 'Saved') {
29
+ return Promise.resolve({ status: 200, data: { message, data } });
30
+ }
31
+
32
+ function validationError(errors) {
33
+ return Promise.reject({
34
+ response: { status: 422, data: { message: 'Invalid data', errors } },
35
+ });
36
+ }
37
+
38
+ const flush = () => new Promise(resolve => setTimeout(resolve, 0));
39
+
40
+ describe('dile-steps-form', () => {
41
+ let get;
42
+ let patch;
43
+
44
+ beforeEach(() => {
45
+ get = vi.fn(() => okResponse(loadedResource));
46
+ patch = vi.fn((url, data) => okResponse({ ...loadedResource, ...data, url }));
47
+ window.axiosInstance = { get, patch };
48
+ });
49
+
50
+ afterEach(() => {
51
+ document.body.innerHTML = '';
52
+ delete window.axiosInstance;
53
+ });
54
+
55
+ async function renderStepsForm(attributes = '', thirdStepAttributes = '') {
56
+ document.body.innerHTML = `
57
+ <dile-steps-form endpoint="/api/resource" ${attributes}>
58
+ <dile-step-form label="First" endpoint="/api/resource/first">
59
+ <test-step-form slot="form"></test-step-form>
60
+ </dile-step-form>
61
+ <dile-step-form label="Second" endpoint="/api/resource/second" alternativeExit alternativeExitLabel="Save as draft">
62
+ <test-step-form slot="form"></test-step-form>
63
+ </dile-step-form>
64
+ <dile-step-form label="Third" endpoint="/api/resource/third" ${thirdStepAttributes}>
65
+ <test-step-form slot="form"></test-step-form>
66
+ </dile-step-form>
67
+ </dile-steps-form>
68
+ `;
69
+ const el = document.body.querySelector('dile-steps-form');
70
+ await el.updateComplete;
71
+ await flush();
72
+ await el.updateComplete;
73
+ return el;
74
+ }
75
+
76
+ function stepForms(el) {
77
+ return [...el.querySelectorAll('dile-step-form')];
78
+ }
79
+
80
+ function forms(el) {
81
+ return [...el.querySelectorAll('test-step-form')];
82
+ }
83
+
84
+ it('takes the step labels from the label attribute of each step', async () => {
85
+ const el = await renderStepsForm();
86
+
87
+ expect(el.shadowRoot.querySelector('dile-steps').steps).toEqual(['First', 'Second', 'Third']);
88
+ expect(el.shadowRoot.querySelector('.step-label').textContent).toBe('First');
89
+ });
90
+
91
+ it('only shows the current step', async () => {
92
+ const el = await renderStepsForm();
93
+
94
+ expect(stepForms(el).map(step => step.active)).toEqual([true, false, false]);
95
+ expect(stepForms(el).map(step => getComputedStyle(step).display)).toEqual(['block', 'none', 'none']);
96
+ });
97
+
98
+ it('loads the endpoint and sets the data in every form', async () => {
99
+ const el = await renderStepsForm();
100
+ let loadedDetail;
101
+ el.addEventListener('dile-steps-form-loaded', e => { loadedDetail = e.detail; });
102
+
103
+ el.reload();
104
+ await flush();
105
+
106
+ expect(get).toHaveBeenCalledWith('/api/resource', expect.anything());
107
+ expect(loadedDetail).toEqual({ data: loadedResource });
108
+ forms(el).forEach(form => expect(form.receivedData.at(-1)).toEqual(loadedResource));
109
+ });
110
+
111
+ it('saves the step with PATCH and moves to the next step', async () => {
112
+ const el = await renderStepsForm();
113
+ forms(el)[0].value = 'Acme';
114
+ let successDetail;
115
+ el.addEventListener('dile-step-form-success', e => { successDetail = e.detail; });
116
+
117
+ stepForms(el)[0].next();
118
+ await flush();
119
+ await el.updateComplete;
120
+
121
+ expect(patch).toHaveBeenCalledWith('/api/resource/first', { name: 'Acme' }, undefined);
122
+ expect(successDetail.message).toBe('Saved');
123
+ expect(el.current).toBe(1);
124
+ expect(stepForms(el).map(step => step.active)).toEqual([false, true, false]);
125
+ });
126
+
127
+ it('dispatches dile-steps-form-complete after saving the last step', async () => {
128
+ const el = await renderStepsForm();
129
+ let completeDetail;
130
+ el.addEventListener('dile-steps-form-complete', e => { completeDetail = e.detail; });
131
+
132
+ el.navigateTo(2);
133
+ stepForms(el)[2].next();
134
+ await flush();
135
+
136
+ expect(completeDetail.alternativeExit).toBe(false);
137
+ expect(completeDetail.data.url).toBe('/api/resource/third');
138
+ });
139
+
140
+ it('completes with the alternative exit and sends the alternativeExit field', async () => {
141
+ const el = await renderStepsForm();
142
+ let completeDetail;
143
+ el.addEventListener('dile-steps-form-complete', e => { completeDetail = e.detail; });
144
+
145
+ el.navigateTo(1);
146
+ stepForms(el)[1].alternativeNext();
147
+ await flush();
148
+
149
+ expect(patch).toHaveBeenCalledWith('/api/resource/second', { name: '', alternativeExit: true }, undefined);
150
+ expect(completeDetail.alternativeExit).toBe(true);
151
+ });
152
+
153
+ it('does not keep the alternative exit after a failed save', async () => {
154
+ const el = await renderStepsForm();
155
+ let completed = false;
156
+ el.addEventListener('dile-steps-form-complete', () => { completed = true; });
157
+ el.navigateTo(1);
158
+
159
+ patch.mockImplementationOnce(() => validationError({ name: ['Required'] }));
160
+ stepForms(el)[1].alternativeNext();
161
+ await flush();
162
+ expect(forms(el)[1].errors).toEqual({ name: ['Required'] });
163
+
164
+ stepForms(el)[1].next();
165
+ await flush();
166
+
167
+ expect(completed).toBe(false);
168
+ expect(el.current).toBe(2);
169
+ });
170
+
171
+ it('dispatches dile-step-form-error with the validation errors and status', async () => {
172
+ const el = await renderStepsForm();
173
+ let errorDetail;
174
+ el.addEventListener('dile-step-form-error', e => { errorDetail = e.detail; });
175
+ patch.mockImplementationOnce(() => validationError({ name: ['Required'] }));
176
+
177
+ stepForms(el)[0].next();
178
+ await flush();
179
+
180
+ expect(errorDetail).toEqual({ message: 'Invalid data', errors: { name: ['Required'] }, status: 422 });
181
+ expect(el.current).toBe(0);
182
+ });
183
+
184
+ it('sends the saved data to the steps with refreshOnSave', async () => {
185
+ const el = await renderStepsForm('', 'refreshOnSave');
186
+ forms(el).forEach(form => { form.receivedData = []; });
187
+
188
+ stepForms(el)[0].next();
189
+ await flush();
190
+
191
+ expect(forms(el)[2].receivedData).toHaveLength(1);
192
+ expect(forms(el)[2].receivedData[0].url).toBe('/api/resource/first');
193
+ expect(forms(el)[1].receivedData).toHaveLength(0);
194
+ });
195
+
196
+ it('dispatches dile-steps-form-error with the status when the load fails', async () => {
197
+ get.mockImplementation(() => Promise.reject({
198
+ response: { status: 404, data: { message: 'Not found' } },
199
+ }));
200
+ let errorDetail;
201
+ document.body.addEventListener('dile-steps-form-error', e => { errorDetail = e.detail; });
202
+
203
+ await renderStepsForm();
204
+ await flush();
205
+
206
+ expect(errorDetail.status).toBe(404);
207
+ expect(errorDetail.message).toBe('Not found');
208
+ });
209
+
210
+ it('translates the navigation labels with the language property', async () => {
211
+ const el = await renderStepsForm('language="es"');
212
+ const nextPrev = stepForms(el)[0].shadowRoot.querySelector('dile-step-next-prev');
213
+
214
+ await vi.waitFor(() => {
215
+ expect(nextPrev.shadowRoot.querySelector('.next dile-button').textContent.trim()).toBe('Siguiente');
216
+ });
217
+ });
218
+
219
+ it('uses the labels defined in the step before the translations', async () => {
220
+ const el = await renderStepsForm('', 'endLabel="Create invoice"');
221
+ el.navigateTo(2);
222
+ await el.updateComplete;
223
+ await stepForms(el)[2].updateComplete;
224
+ const nextPrev = stepForms(el)[2].shadowRoot.querySelector('dile-step-next-prev');
225
+ await nextPrev.updateComplete;
226
+
227
+ expect(nextPrev.shadowRoot.querySelector('.next dile-button').textContent.trim()).toBe('Create invoice');
228
+ expect(nextPrev.shadowRoot.querySelector('.prev dile-button').textContent.trim()).toBe('Previous');
229
+ });
230
+ });
package/lib/i18n/en.js CHANGED
@@ -77,4 +77,9 @@ export const translations = {
77
77
  retry_label: "Retry",
78
78
  list_view_label: "List view",
79
79
  grid_view_label: "Grid view",
80
- };
80
+ step_prev_label: "Previous",
81
+ step_next_label: "Next",
82
+ step_end_label: "Finish",
83
+ step_success: "Data saved",
84
+ step_error: "Data could not be saved",
85
+ };
package/lib/i18n/es.js CHANGED
@@ -77,4 +77,9 @@ export const translations = {
77
77
  retry_label: "Reintentar",
78
78
  list_view_label: "Lista",
79
79
  grid_view_label: "Rejilla",
80
- };
80
+ step_prev_label: "Anterior",
81
+ step_next_label: "Siguiente",
82
+ step_end_label: "Finalizar",
83
+ step_success: "Datos guardados",
84
+ step_error: "No se han podido guardar los datos",
85
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/crud",
3
- "version": "2.9.5",
3
+ "version": "2.10.0",
4
4
  "description": "Components to create a generic crud system based on Web Components and Lit",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -25,12 +25,12 @@
25
25
  },
26
26
  "homepage": "https://dile-components.com/",
27
27
  "dependencies": {
28
- "@dile/ui": "^3.17.1",
28
+ "@dile/ui": "^3.18.0",
29
29
  "axios": "^1.19.0",
30
30
  "lit": "^2.7.0 || ^3.0.0"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
34
34
  },
35
- "gitHead": "388965a96f6e0d8da0fdadb2729eaa4b04b3bba1"
35
+ "gitHead": "1b065736666cc26f8afc1106ec7ffc98feaec60e"
36
36
  }