@dile/crud 2.6.4 → 2.7.2

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.
Files changed (22) hide show
  1. package/components/crud/crud.spec.js +142 -0
  2. package/components/crud/src/DileCrud.js +23 -3
  3. package/components/list/__screenshots__/crud-data-grid.spec.js/dile-crud-data-grid-always-forces-sort-mode-to-external-regardless-of-config-1.png +0 -0
  4. package/components/list/__screenshots__/crud-data-grid.spec.js/dile-crud-data-grid-forwards-stickyFirstColumn--striped--responsiveMode-and-emptyMessage-with-sensible-defaults-1.png +0 -0
  5. package/components/list/__screenshots__/crud-data-grid.spec.js/dile-crud-data-grid-leaves-computeRowId-unset-when-config-computeItemId-is-not-a-function-1.png +0 -0
  6. package/components/list/crud-data-grid.js +5 -0
  7. package/components/list/crud-data-grid.spec.js +207 -0
  8. package/components/list/crud-item-actions.js +5 -0
  9. package/components/list/crud-item-actions.spec.js +122 -0
  10. package/components/list/crud-list.spec.js +319 -0
  11. package/components/list/index.js +4 -2
  12. package/components/list/src/DileCrudDataGrid.js +93 -0
  13. package/components/list/src/DileCrudItemActions.js +191 -0
  14. package/components/list/src/DileCrudList.js +62 -11
  15. package/components/list/src/DileCrudListItem.js +78 -124
  16. package/components/ui/__screenshots__/crud-sort-form.spec.js/dile-crud-sort-form-syncSort-suppresses-exactly-one-radio-group-echo--preserving-the-synced-direction-instead-of-falling-back-to-the-option-static-default-1.png +0 -0
  17. package/components/ui/crud-sort-form.spec.js +132 -0
  18. package/components/ui/src/DileCrudSortForm.js +24 -5
  19. package/lib/CrudConfigBuilder.spec.js +32 -0
  20. package/lib/deepMerge.spec.js +45 -0
  21. package/lib/defaultConfig.js +4 -1
  22. package/package.json +3 -3
@@ -7,6 +7,7 @@ import '../../list/crud-list-pagination-links.js';
7
7
  import '../crud-list-item.js';
8
8
  import '../crud-select-all.js';
9
9
  import '../crud-list-service.js';
10
+ import '../crud-data-grid.js';
10
11
  import { DileI18nMixin } from '../../../lib/DileI18nMixin.js';
11
12
 
12
13
  export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
@@ -67,9 +68,14 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
67
68
  }
68
69
  .elements-container {
69
70
  display: grid;
70
- grid-template-columns: var(--dile-crud-list-elements-container-template-columns, minmax(0, 1fr));
71
+ grid-template-columns: var(--dile-crud-list-elements-container-template-columns, minmax(0, 1fr));
71
72
  gap: var(--dile-crud-list-elements-container-gap, 0);
72
73
  }
74
+ .grid-container {
75
+ display: block;
76
+ width: 100%;
77
+ margin: var(--dile-crud-list-grid-container-margin, 0 0 1rem 0);
78
+ }
73
79
  `
74
80
  ];
75
81
 
@@ -94,7 +100,7 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
94
100
 
95
101
  constructor() {
96
102
  super();
97
- this.paginationData = {}
103
+ this.paginationData = {};
98
104
  this.elements = [];
99
105
  this.pageSize = 10;
100
106
  this.keyword = '';
@@ -207,6 +213,7 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
207
213
  ></dile-ajax>
208
214
  `;
209
215
  }
216
+
210
217
  get emptyTemplate() {
211
218
  return html`
212
219
  <div class="empty">
@@ -243,6 +250,33 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
243
250
  }
244
251
 
245
252
  get elementsTemplate() {
253
+ if (this.config?.templates?.grid && typeof this.config.templates.grid === 'function') {
254
+ return html`
255
+ <div
256
+ class="grid-container"
257
+ @item-checkbox-changed=${this.onItemsCheckboxChanged}
258
+ @dile-data-grid-sort=${this.onGridSort}
259
+ >
260
+ ${this.config.templates.grid(this.elements, this.actionIds, this.config, this.sort)}
261
+ </div>
262
+ `;
263
+ }
264
+ if (this.config?.grid?.columns && Array.isArray(this.config.grid.columns)) {
265
+ return html`
266
+ <div
267
+ class="grid-container"
268
+ @item-checkbox-changed=${this.onItemsCheckboxChanged}
269
+ @dile-data-grid-sort=${this.onGridSort}
270
+ >
271
+ <dile-crud-data-grid
272
+ .items=${this.elements}
273
+ .selectedIds=${this.actionIds}
274
+ .config=${this.config}
275
+ .sort=${this.sort}
276
+ ></dile-crud-data-grid>
277
+ </div>
278
+ `;
279
+ }
246
280
  return html`
247
281
  <div class="elements-container">
248
282
  ${this.elements.map(element => html`
@@ -252,7 +286,7 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
252
286
  .actionIds="${this.actionIds}"
253
287
  ?disableEdit=${!this.isItemEditable(element)}
254
288
  ?disableDelete=${!this.isItemDeletable(element)}
255
- ?disableRestore=${this.config?.customization?.disableRestore}
289
+ ?disableRestore=${this.config?.customization?.disableRestore || this.config?.customization?.disableListActions}
256
290
  ?hideCheckboxSelection="${this.config?.customization?.hideCheckboxSelection}"
257
291
  @item-checkbox-changed=${this.onItemsCheckboxChanged}
258
292
  ?isDeleted=${element.deleted_at}
@@ -276,13 +310,13 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
276
310
  }
277
311
 
278
312
  isItemEditable(element) {
279
- const isGloballyDisabled = this.config?.customization?.disableEdit;
313
+ const isGloballyDisabled = this.config?.customization?.disableListActions || this.config?.customization?.disableEdit;
280
314
  const isItemEditable = this.config?.isItemEditable(element) ?? true;
281
315
  return !isGloballyDisabled && isItemEditable;
282
316
  }
283
317
 
284
318
  isItemDeletable(element) {
285
- const isGloballyDisabled = this.config?.customization?.disableDelete;
319
+ const isGloballyDisabled = this.config?.customization?.disableListActions || this.config?.customization?.disableDelete;
286
320
  const isItemDeletable = this.config?.isItemDeletable(element) ?? true;
287
321
  return !isGloballyDisabled && isItemDeletable;
288
322
  }
@@ -314,11 +348,11 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
314
348
  }
315
349
 
316
350
  goNext() {
317
- this.elservice.goNext()
351
+ this.elservice.goNext();
318
352
  }
319
353
 
320
354
  goPrev() {
321
- this.elservice.goPrev()
355
+ this.elservice.goPrev();
322
356
  }
323
357
 
324
358
  refresh() {
@@ -332,7 +366,6 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
332
366
  this.shadowRoot.querySelector('dile-crud-select-all').reset();
333
367
  }
334
368
  this.elservice.refresh();
335
-
336
369
  }
337
370
 
338
371
  setKeyword(keyword) {
@@ -345,7 +378,26 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
345
378
  this.loading = true;
346
379
  this.sort = sortObject;
347
380
  this.elservice.setSort(sortObject);
348
- }
381
+ this.dispatchEvent(new CustomEvent('crud-list-sort-changed', {
382
+ bubbles: true,
383
+ composed: true,
384
+ detail: sortObject,
385
+ }));
386
+ }
387
+
388
+ onGridSort(e) {
389
+ if (e.detail?.field) {
390
+ this.setSort({
391
+ sortField: e.detail.field,
392
+ sortDirection: e.detail.direction || 'asc',
393
+ });
394
+ } else {
395
+ this.setSort({
396
+ sortField: null,
397
+ sortDirection: null,
398
+ });
399
+ }
400
+ }
349
401
 
350
402
  setPageSize(size) {
351
403
  this.loading = true;
@@ -389,7 +441,7 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
389
441
  if(this.config.customization?.disablePagination) {
390
442
  let ids = this.elements.map(item => this.config.computeItemId(item));
391
443
  this.dispactSelectAll(ids);
392
- } else{
444
+ } else {
393
445
  let data = {
394
446
  keyword: this.keyword,
395
447
  filters: this.filters,
@@ -413,7 +465,6 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
413
465
  if (!e.detail.checked && this.isSelectAllActive) {
414
466
  this.shadowRoot.querySelector('dile-crud-select-all').resetWithoutDispatch();
415
467
  }
416
-
417
468
  }
418
469
 
419
470
  onListError(e) {
@@ -1,67 +1,42 @@
1
- import { LitElement, html, css } from 'lit';
1
+ import { LitElement, html, css, nothing } from 'lit';
2
2
  import '@dile/ui/components/checkbox/checkbox.js';
3
- import '@dile/ui/components/button/button.js';
4
- import '@dile/iconlib/material-icons/edit.js';
5
- import '@dile/iconlib/material-icons/delete.js';
6
- import '@dile/iconlib/material-icons/restore-from-trash.js';
3
+ import '../crud-item-actions.js';
7
4
 
8
5
  export class DileCrudListItem extends LitElement {
9
6
  static styles = [
10
7
  css`
11
- :host {
12
- display: var(--dile-crud-list-item-display, block);
13
- max-width: var(--dile-crud-list-item-max-width, 100%);
14
- width: var(--dile-crud-list-item-width, 100%);
15
- }
8
+ :host {
9
+ display: var(--dile-crud-list-item-display, block);
10
+ max-width: var(--dile-crud-list-item-max-width, 100%);
11
+ width: var(--dile-crud-list-item-width, 100%);
12
+ }
13
+ section {
14
+ max-width: 100%;
15
+ overflow: hidden;
16
+ display: flex;
17
+ align-items: center;
18
+ padding: var(--dile-crud-list-item-padding, 0.5rem 0.5rem);
19
+ border-bottom: var(--dile-crud-list-item-line-separator, 1px solid #ddd);
20
+ }
21
+ dile-checkbox {
22
+ margin-right: 0.5rem;
23
+ --dile-checkbox-unchecked-color: #888;
24
+ }
25
+ main {
26
+ flex-grow: 1;
27
+ overflow: hidden;
28
+ }
29
+ .actions {
30
+ margin-left: 0.5rem;
31
+ display: flex;
32
+ align-items: center;
33
+ text-align: right;
34
+ }
35
+ @media (min-width: 550px) {
16
36
  section {
17
- max-width: 100%;
18
- overflow: hidden;
19
- display: flex;
20
- align-items: center;
21
- padding: var(--dile-crud-list-item-padding, 0.5rem 0.5rem);
22
- border-bottom: var(--dile-crud-list-item-line-separator, 1px solid #ddd);
23
- }
24
- dile-checkbox {
25
- margin-right: 0.5rem;
26
- --dile-checkbox-unchecked-color: #888;
27
- }
28
- main {
29
- flex-grow: 1;
30
- overflow: hidden;
31
- }
32
- .actions {
33
- margin-left: 0.5rem;
34
- display: flex;
35
- align-items: center;
36
- text-align: right;
37
- }
38
- dile-button.action-button {
39
- --dile-button-background-color: var(--dile-crud-list-item-action-button-background-color, transparent);
40
- --dile-button-border-color: var(--dile-crud-list-item-action-button-border-color, transparent);
41
- --dile-button-hover-background-color: var(--dile-crud-list-item-action-button-hover-background-color, var(--dile-neutral-color, transparent));
42
- --dile-button-hover-border-color: var(--dile-crud-list-item-action-button-hover-border-color, transparent);
43
- --dile-button-padding-y: var(--dile-crud-list-item-action-button-padding-y, 0.25rem);
44
- --dile-button-padding-x: var(--dile-crud-list-item-action-button-padding-x, 0.25rem);
45
- --dile-button-icon-size: var(--dile-crud-list-item-action-button-icon-size, 24px);
46
- --dile-button-border-radius: var(--dile-crud-list-item-action-button-border-radius, 2rem);
47
- }
48
- dile-button.action-button.edit {
49
- --dile-button-icon-color: var(--edit-icon-color, var(--dile-alert-neutral-color, #2889a7));
50
- --dile-button-icon-hover-color: var(--dile-crud-list-item-edit-icon-hover-color, var(--edit-icon-color, var(--dile-alert-neutral-color, #2889a7)));
51
- }
52
- dile-button.action-button.delete {
53
- --dile-button-icon-color: var(--delete-icon-color, var(--dile-danger-color, #e33));
54
- --dile-button-icon-hover-color: var(--dile-crud-list-item-delete-icon-hover-color, var(--delete-icon-color, var(--dile-danger-color, #e33)));
55
- }
56
- dile-button.action-button.restore {
57
- --dile-button-icon-color: var(--restore-icon-color, var(--dile-alert-success-color, #00900f));
58
- --dile-button-icon-hover-color: var(--dile-crud-list-item-restore-icon-hover-color, var(--restore-icon-color, var(--dile-alert-success-color, #00900f)));
59
- }
60
- @media(min-width: 550px) {
61
- section {
62
- padding: var(--dile-crud-list-item-padding, 0.5rem 1rem);
63
- }
37
+ padding: var(--dile-crud-list-item-padding, 0.5rem 1rem);
64
38
  }
39
+ }
65
40
  `
66
41
  ];
67
42
 
@@ -72,6 +47,7 @@ export class DileCrudListItem extends LitElement {
72
47
  actionIds: { type: Array },
73
48
  disableEdit: { type: Boolean },
74
49
  disableDelete: { type: Boolean },
50
+ disableRestore: { type: Boolean },
75
51
  /** Hide checkboxes on the item list */
76
52
  hideCheckboxSelection: { type: Boolean },
77
53
  isDeleted: { type: Boolean },
@@ -85,86 +61,64 @@ export class DileCrudListItem extends LitElement {
85
61
  this.disableDelete = false;
86
62
  this.disableRestore = false;
87
63
  this.hideCheckboxSelection = false;
64
+ this.isDeleted = false;
65
+ }
88
66
 
67
+ get hasActions() {
68
+ if (this.isDeleted) {
69
+ return !this.disableRestore;
70
+ }
71
+ return !this.disableEdit || !this.disableDelete;
89
72
  }
90
73
 
91
74
  render() {
92
75
  return html`
93
- <section>
94
- ${this.hideCheckboxSelection ? '' : html`
95
- <dile-checkbox ?checked="${this.includes(this.actionIds, this.itemId)}" @dile-checkbox-changed=${this.checkboxChanged}></dile-checkbox>
76
+ <section>
77
+ ${this.hideCheckboxSelection
78
+ ? ''
79
+ : html`
80
+ <dile-checkbox
81
+ ?checked="${this.includes(this.actionIds, this.itemId)}"
82
+ @dile-checkbox-changed=${this.checkboxChanged}
83
+ ></dile-checkbox>
96
84
  `}
97
- <main>
98
- <slot></slot>
99
- </main>
100
- <div class="actions">
101
- ${this.isDeleted
102
- ? this.restoreActionsTemplate
103
- : this.regularActionsTemplate
104
- }
105
- </div>
106
- </section>
85
+ <main>
86
+ <slot></slot>
87
+ </main>
88
+ ${this.hasActions
89
+ ? html`
90
+ <div class="actions">
91
+ <dile-crud-item-actions
92
+ .item=${this.item}
93
+ itemId="${this.itemId}"
94
+ ?disableEdit=${this.disableEdit}
95
+ ?disableDelete=${this.disableDelete}
96
+ ?disableRestore=${this.disableRestore}
97
+ ?isDeleted=${this.isDeleted}
98
+ ></dile-crud-item-actions>
99
+ </div>
100
+ `
101
+ : nothing
102
+ }
103
+ </section>
107
104
  `;
108
105
  }
109
106
 
110
- get regularActionsTemplate() {
111
- return html`
112
- ${this.disableEdit
113
- ? ''
114
- : html`<dile-button class="action-button edit" icon="material.edit" label="Edit" @click=${this.editClick}></dile-button>`
115
- }
116
- ${this.disableDelete
117
- ? ''
118
- : html`<dile-button class="action-button delete" icon="material.delete" label="Delete" @click=${this.deleteClick}></dile-button>`
119
- }
120
- `
121
- }
122
-
123
- get restoreActionsTemplate() {
124
- return html`
125
- ${this.disableRestore
126
- ? ''
127
- : html`<dile-button class="action-button restore" icon="material.restore-from-trash" label="Restore" @click=${this.restoreClick}></dile-button>`
128
- }
129
- `
130
- }
131
-
132
107
  includes(actionIds, itemId) {
133
108
  const stringIds = actionIds.map(String);
134
109
  return stringIds.includes(String(itemId));
135
110
  }
136
111
 
137
112
  checkboxChanged(e) {
138
- this.dispatchEvent(new CustomEvent('item-checkbox-changed', {
139
- bubbles: true,
140
- composed: true,
141
- detail: {
142
- checked: e.detail.checked,
143
- itemId: this.itemId
144
- }
145
- }));
146
- }
147
-
148
- _dispatchItemEvent(eventName) {
149
- this.dispatchEvent(new CustomEvent(eventName, {
150
- bubbles: true,
151
- composed: true,
152
- detail: {
153
- item: this.item,
154
- itemId: this.itemId,
155
- }
156
- }));
157
- }
158
-
159
- editClick() {
160
- this._dispatchItemEvent('crud-item-edit');
161
- }
162
-
163
- deleteClick() {
164
- this._dispatchItemEvent('crud-item-delete');
165
- }
166
-
167
- restoreClick() {
168
- this._dispatchItemEvent('crud-item-restore');
113
+ this.dispatchEvent(
114
+ new CustomEvent('item-checkbox-changed', {
115
+ bubbles: true,
116
+ composed: true,
117
+ detail: {
118
+ checked: e.detail.checked,
119
+ itemId: this.itemId,
120
+ },
121
+ })
122
+ );
169
123
  }
170
- }
124
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import './crud-sort-form.js';
3
+
4
+ describe('dile-crud-sort-form', () => {
5
+ afterEach(() => {
6
+ document.body.innerHTML = '';
7
+ });
8
+
9
+ const sortOptions = [
10
+ { name: 'name', label: 'Name', direction: 'asc' },
11
+ { name: 'id', label: 'Id', direction: 'asc' },
12
+ ];
13
+
14
+ async function createSortForm(props = {}) {
15
+ const el = document.createElement('dile-crud-sort-form');
16
+ el.sortOptions = props.sortOptions ?? sortOptions;
17
+ if (props.sortField !== undefined) {
18
+ el.sortField = props.sortField;
19
+ }
20
+ document.body.appendChild(el);
21
+ await el.updateComplete;
22
+ return el;
23
+ }
24
+
25
+ it('renders one radio/order-switch pair per configured sort option', async () => {
26
+ const el = await createSortForm();
27
+ expect(el.shadowRoot.querySelectorAll('dile-radio').length).toBe(2);
28
+ expect(el.shadowRoot.querySelectorAll('dile-order-switch').length).toBe(2);
29
+ });
30
+
31
+ it('getDirection returns the configured direction for a known field', async () => {
32
+ const el = await createSortForm();
33
+ await el.shadowRoot.querySelector('dile-order-switch[name="name"]').updateComplete;
34
+ expect(el.getDirection('name')).toBe('asc');
35
+ });
36
+
37
+ it('getDirection returns undefined instead of throwing for a field with no matching order-switch', async () => {
38
+ // Reproduces a DataGrid column marked sortable whose field isn't listed in config.sort.options.
39
+ const el = await createSortForm();
40
+ expect(() => el.getDirection('continent')).not.toThrow();
41
+ expect(el.getDirection('continent')).toBeUndefined();
42
+ });
43
+
44
+ it('getDirection returns undefined for a falsy field without touching the DOM', async () => {
45
+ const el = await createSortForm();
46
+ expect(el.getDirection(null)).toBeUndefined();
47
+ expect(el.getDirection('')).toBeUndefined();
48
+ });
49
+
50
+ // "year" has a static configured direction of "desc" so it can expose the regression:
51
+ // syncing an "asc" sort must not get silently corrected back to "desc".
52
+ const yearOptions = [{ name: 'year', label: 'Year', direction: 'desc' }];
53
+
54
+ it('syncSort reflects an externally-applied sort without re-dispatching sort-changed', async () => {
55
+ const el = await createSortForm({ sortOptions: yearOptions });
56
+
57
+ let dispatchCount = 0;
58
+ el.addEventListener('sort-changed', () => { dispatchCount++; });
59
+
60
+ el.syncSort('year', 'asc');
61
+ await el.updateComplete;
62
+
63
+ expect(el.sortField).toBe('year');
64
+ expect(el.sortDirection).toBe('asc');
65
+ expect(dispatchCount).toBe(0);
66
+ });
67
+
68
+ it('a genuine radio-group selection (not preceded by syncSort) still defaults to the option configured direction', async () => {
69
+ const el = await createSortForm({ sortOptions: yearOptions });
70
+
71
+ let receivedDetail = null;
72
+ el.addEventListener('sort-changed', (e) => { receivedDetail = e.detail; });
73
+
74
+ const radioGroup = el.shadowRoot.querySelector('dile-radio-group');
75
+ radioGroup.dispatchEvent(new CustomEvent('dile-radio-group-changed', {
76
+ detail: { name: 'selector', value: 'year' },
77
+ }));
78
+ await el.updateComplete;
79
+
80
+ expect(el.sortField).toBe('year');
81
+ expect(el.sortDirection).toBe('desc');
82
+ expect(receivedDetail).toEqual({ sortField: 'year', sortDirection: 'desc' });
83
+ });
84
+
85
+ it('clicking the label of an inactive option switches to it using its own configured direction, without flipping it', async () => {
86
+ const el = await createSortForm({
87
+ sortOptions: [
88
+ { name: 'name', label: 'Name', direction: 'asc' },
89
+ { name: 'year', label: 'Year', direction: 'desc' },
90
+ ],
91
+ sortField: 'name',
92
+ });
93
+ el.sortDirection = 'asc';
94
+ await el.updateComplete;
95
+
96
+ let receivedDetail = null;
97
+ el.addEventListener('sort-changed', (e) => { receivedDetail = e.detail; });
98
+
99
+ el.shadowRoot.querySelector('dile-order-switch[name="year"]').shadowRoot.querySelector('span').click();
100
+ await el.updateComplete;
101
+
102
+ expect(el.sortField).toBe('year');
103
+ expect(el.sortDirection).toBe('desc'); // year's own configured default, not flipped
104
+ expect(receivedDetail).toEqual({ sortField: 'year', sortDirection: 'desc' });
105
+ });
106
+
107
+ it('clicking the label of the already-active option toggles its direction instead of doing nothing', async () => {
108
+ // Reproduces the reported confusion: clicking "Name" again while already sorted by
109
+ // name silently did nothing, because the label always re-announced the same value.
110
+ const el = await createSortForm({
111
+ sortOptions: [{ name: 'name', label: 'Name', direction: 'asc' }],
112
+ sortField: 'name',
113
+ });
114
+ el.sortDirection = 'asc';
115
+ await el.updateComplete;
116
+
117
+ let receivedDetail = null;
118
+ el.addEventListener('sort-changed', (e) => { receivedDetail = e.detail; });
119
+
120
+ const label = () => el.shadowRoot.querySelector('dile-order-switch[name="name"]').shadowRoot.querySelector('span');
121
+
122
+ label().click();
123
+ await el.updateComplete;
124
+ expect(el.sortField).toBe('name');
125
+ expect(el.sortDirection).toBe('desc'); // toggled, not a no-op
126
+ expect(receivedDetail).toEqual({ sortField: 'name', sortDirection: 'desc' });
127
+
128
+ label().click();
129
+ await el.updateComplete;
130
+ expect(el.sortDirection).toBe('asc'); // toggles back on a second click
131
+ });
132
+ });
@@ -45,6 +45,19 @@ export class DileCrudSortForm extends DileI18nMixin(LitElement) {
45
45
  constructor() {
46
46
  super();
47
47
  this.sortOptions = [];
48
+ this._suppressChangeEcho = false;
49
+ }
50
+
51
+ // Reflects a sort that was already applied elsewhere (e.g. a DataGrid column header
52
+ // click) without re-dispatching sort-changed. Setting `sortField` below causes the
53
+ // inner <dile-radio-group> to echo a "changed" event on its own (it can't tell a
54
+ // programmatic update from a user click), which radioGroupChanged() would otherwise
55
+ // treat as a real selection and re-derive sortDirection from the option's *static*
56
+ // configured default — silently overriding whichever direction was actually requested.
57
+ syncSort(sortField, sortDirection) {
58
+ this._suppressChangeEcho = true;
59
+ this.sortField = sortField;
60
+ this.sortDirection = sortDirection;
48
61
  }
49
62
 
50
63
  render() {
@@ -63,10 +76,11 @@ export class DileCrudSortForm extends DileI18nMixin(LitElement) {
63
76
  ?checked=${this.sortField === option.name}
64
77
  value="${option.name}"
65
78
  ></dile-radio>
66
- <dile-order-switch
67
- label="${option.label}"
68
- name="${option.name}"
69
- value="${option.direction}"
79
+ <dile-order-switch
80
+ label="${option.label}"
81
+ name="${option.name}"
82
+ value="${option.direction}"
83
+ ?selected=${this.sortField === option.name}
70
84
  @element-changed=${this.elementChanged}
71
85
  ></dile-order-switch>
72
86
  </div>
@@ -83,6 +97,10 @@ export class DileCrudSortForm extends DileI18nMixin(LitElement) {
83
97
  }
84
98
 
85
99
  radioGroupChanged(e) {
100
+ if (this._suppressChangeEcho) {
101
+ this._suppressChangeEcho = false;
102
+ return;
103
+ }
86
104
  this.sortField = e.detail.value;
87
105
  this.sortDirection = this.getDirection(this.sortField);
88
106
  this.dispatchChanged();
@@ -90,7 +108,8 @@ export class DileCrudSortForm extends DileI18nMixin(LitElement) {
90
108
 
91
109
  getDirection(field) {
92
110
  if(field) {
93
- return this.shadowRoot.querySelector(`dile-order-switch[name="${field}"]`).value;
111
+ const orderSwitch = this.shadowRoot.querySelector(`dile-order-switch[name="${field}"]`);
112
+ return orderSwitch ? orderSwitch.value : undefined;
94
113
  }
95
114
  }
96
115
 
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { CrudConfigBuilder } from './CrudConfigBuilder.js';
3
+
4
+ describe('CrudConfigBuilder', () => {
5
+ it('merges customConfig.grid.columns over the default null grid', () => {
6
+ const columns = [{ field: 'id', header: 'ID' }];
7
+
8
+ const config = new CrudConfigBuilder('https://example.test/api/items', {
9
+ grid: { columns },
10
+ }).getConfig();
11
+
12
+ expect(config.grid.columns).toEqual(columns);
13
+ });
14
+
15
+ it('sets the endpoint from the constructor argument', () => {
16
+ const config = new CrudConfigBuilder('https://example.test/api/items', {}).getConfig();
17
+
18
+ expect(config.endpoint).toBe('https://example.test/api/items');
19
+ });
20
+
21
+ it('getConfig(endpoint) returns a copy with an overridden endpoint without mutating the original config', () => {
22
+ const builder = new CrudConfigBuilder('https://example.test/api/items', {
23
+ grid: { columns: [{ field: 'id' }] },
24
+ });
25
+
26
+ const relatedConfig = builder.getConfig('https://example.test/api/items/1/related');
27
+
28
+ expect(relatedConfig.endpoint).toBe('https://example.test/api/items/1/related');
29
+ expect(builder.getConfig().endpoint).toBe('https://example.test/api/items');
30
+ expect(relatedConfig.grid.columns).toEqual([{ field: 'id' }]);
31
+ });
32
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { deepMerge } from './deepMerge.js';
3
+
4
+ describe('deepMerge', () => {
5
+ it('replaces a null target property with a plain object from source', () => {
6
+ const target = { grid: null };
7
+ const source = { grid: { columns: [{ field: 'id' }] } };
8
+
9
+ const result = deepMerge(target, source);
10
+
11
+ expect(result.grid).toEqual({ columns: [{ field: 'id' }] });
12
+ });
13
+
14
+ it('preserves array order and content when merging a columns array under a previously-null key', () => {
15
+ const columns = [
16
+ { field: 'id', header: 'ID' },
17
+ { field: 'name', header: 'Name' },
18
+ { field: 'continent', header: 'Continent' },
19
+ ];
20
+
21
+ const result = deepMerge({ grid: null }, { grid: { columns } });
22
+
23
+ expect(result.grid.columns).toEqual(columns);
24
+ expect(result.grid.columns).not.toBe(columns);
25
+ });
26
+
27
+ it('does not mutate the source when merging into a null target key', () => {
28
+ const source = { grid: { columns: [{ field: 'id' }] } };
29
+
30
+ const result = deepMerge({ grid: null }, source);
31
+ result.grid.columns.push({ field: 'extra' });
32
+
33
+ expect(source.grid.columns.length).toBe(1);
34
+ });
35
+
36
+ it('overwrites configured overwriteProperties instances as-is instead of merging them', () => {
37
+ class Adapter {}
38
+ const targetAdapter = new Adapter();
39
+ const sourceAdapter = new Adapter();
40
+
41
+ const result = deepMerge({ responseAdapter: targetAdapter }, { responseAdapter: sourceAdapter });
42
+
43
+ expect(result.responseAdapter).toBe(sourceAdapter);
44
+ });
45
+ });