@dile/crud 2.7.6 → 2.8.1

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.
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest';
2
+ import { html } from 'lit';
2
3
  import { CrudConfigBuilder } from '../../lib/CrudConfigBuilder.js';
3
4
  import './crud.js';
4
5
 
@@ -274,3 +275,88 @@ describe('dile-crud sort synchronization', () => {
274
275
  expect(lastSetSort).toBeNull();
275
276
  });
276
277
  });
278
+
279
+ describe('dile-crud list/grid view switch', () => {
280
+ afterEach(() => {
281
+ document.body.innerHTML = '';
282
+ });
283
+
284
+ const baseCustomization = {
285
+ disableInsert: true,
286
+ disableHelp: true,
287
+ disableKeywordSearch: true,
288
+ disableSort: true,
289
+ disableFilter: true,
290
+ disablePagination: true,
291
+ hideCheckboxSelection: true,
292
+ };
293
+
294
+ function buildConfig(overrides = {}) {
295
+ return new CrudConfigBuilder('https://example.test/api/countries', {
296
+ customization: { ...baseCustomization, ...(overrides.customization || {}) },
297
+ grid: { columns: [{ field: 'name', header: 'Name' }] },
298
+ templates: { item: (item) => html`<span>${item.name}</span>` },
299
+ ...overrides,
300
+ }).getConfig();
301
+ }
302
+
303
+ it('shows the item template by default and renders no switch button when only one view is configured', async () => {
304
+ const config = new CrudConfigBuilder('https://example.test/api/countries', {
305
+ customization: baseCustomization,
306
+ templates: { item: (item) => html`<span>${item.name}</span>` },
307
+ }).getConfig();
308
+
309
+ const el = document.createElement('dile-crud');
310
+ el.config = config;
311
+ document.body.appendChild(el);
312
+ await el.updateComplete;
313
+
314
+ const switchButtons = [...el.shadowRoot.querySelectorAll('dile-button-icon')]
315
+ .filter((btn) => /view/i.test(btn.textContent));
316
+ expect(switchButtons.length).toBe(0);
317
+ });
318
+
319
+ it('defaults to the item view and toggles to the grid on click when both views are configured', async () => {
320
+ // Rendering per view mode (item vs. grid DOM) is covered in crud-list.spec.js;
321
+ // here we only verify dile-crud's own state, wiring and button behavior, since
322
+ // driving dile-crud-list's real data load would race its own network refresh.
323
+ const config = buildConfig();
324
+
325
+ const el = document.createElement('dile-crud');
326
+ el.config = config;
327
+ document.body.appendChild(el);
328
+ await el.updateComplete;
329
+
330
+ expect(el._viewMode).toBe('item');
331
+ expect(el.listElement.viewMode).toBe('item');
332
+
333
+ const switchButton = [...el.shadowRoot.querySelectorAll('dile-button-icon')]
334
+ .find((btn) => /view/i.test(btn.textContent));
335
+ expect(switchButton).toBeTruthy();
336
+ expect(switchButton.textContent.trim()).toBe('Grid view');
337
+
338
+ switchButton.click();
339
+ await el.updateComplete;
340
+
341
+ expect(el._viewMode).toBe('grid');
342
+ expect(el.listElement.viewMode).toBe('grid');
343
+
344
+ const switchButtonAfter = [...el.shadowRoot.querySelectorAll('dile-button-icon')]
345
+ .find((btn) => /view/i.test(btn.textContent));
346
+ expect(switchButtonAfter.textContent.trim()).toBe('List view');
347
+ });
348
+
349
+ it('does not render the switch button when customization.disableListWiewSwitch is true', async () => {
350
+ const config = buildConfig({ customization: { disableListWiewSwitch: true } });
351
+
352
+ const el = document.createElement('dile-crud');
353
+ el.config = config;
354
+ document.body.appendChild(el);
355
+ await el.updateComplete;
356
+
357
+ const switchButtons = [...el.shadowRoot.querySelectorAll('dile-button-icon')]
358
+ .filter((btn) => /view/i.test(btn.textContent));
359
+ expect(switchButtons.length).toBe(0);
360
+ expect(el.listElement.viewMode).toBe('item');
361
+ });
362
+ });
@@ -4,6 +4,7 @@ import '@dile/ui/components/nav/nav.js';
4
4
  import '@dile/ui/components/select/select.js';
5
5
  import '@dile/ui/components/modal/modal.js';
6
6
  import '@dile/ui/components/modal/modal-help.js';
7
+ import '@dile/ui/components/icon/icon.js';
7
8
  import '../../item-delete/crud-item-delete.js';
8
9
  import '../../item-restore/crud-item-restore.js';
9
10
  import '../../list/crud-list.js';
@@ -19,8 +20,9 @@ import '../../action/crud-single-action-dispatcher.js';
19
20
  import { formStyles } from '../../../styles/form-styles.js';
20
21
  import { DileCrudMixin } from '../../../lib/DileCrudMixin.js';
21
22
  import { crudStyles } from '../../../styles/crud-styles.js';
22
- import { addIcon } from '@dile/icons';
23
+ import { addIcon, appsIcon, listIcon } from '@dile/icons';
23
24
  import { DileI18nMixin } from '../../../lib/DileI18nMixin.js';
25
+ import { canSwitchListView } from '../../../lib/listViewMode.js';
24
26
 
25
27
  export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
26
28
  static styles = [
@@ -70,6 +72,29 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
70
72
  --dile-icon-size: var(--dile-crud-insert-icon-size, 24px);
71
73
  --dile-button-icon-separation: var(--dile-crud-insert-icon-separation, 0.3rem);
72
74
  }
75
+ .view-switch dile-button-icon {
76
+ display: none;
77
+ --dile-button-background-color: var(--dile-crud-view-switch-background-color, var(--dile-primary-color, #7BB93D));
78
+ --dile-button-text-color: var(--dile-crud-view-switch-text-color, var(--dile-on-primary-color, #fff));
79
+ --dile-button-border-color: var(--dile-crud-view-switch-border-color, var(--dile-primary-dark-color, #12354d));
80
+ }
81
+ .view-switch dile-icon {
82
+ cursor: pointer;
83
+ --dile-icon-color: var(--dile-crud-view-switch-text-color, var(--dile-on-crud-action-color, #fff));
84
+ --dile-icon-rounded-background-color: var(--dile-crud-view-switch-background-color, var(--dile-crud-action-color, #888));
85
+ }
86
+ .view-switch dile-icon:focus-visible {
87
+ outline: 2px solid var(--dile-crud-action-focus-color, #12354d);
88
+ outline-offset: 2px;
89
+ }
90
+ @media (min-width: 605px) {
91
+ .view-switch dile-button-icon {
92
+ display: block;
93
+ }
94
+ .view-switch dile-icon {
95
+ display: none;
96
+ }
97
+ }
73
98
  @media(min-width: 400px) {
74
99
  .simplecard {
75
100
  --dile-card-border: none;
@@ -99,6 +124,7 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
99
124
  relationId: { type: String },
100
125
  filtersAlwaysVisible: { type: Boolean },
101
126
  singleActionDispatcher: { type: String },
127
+ _viewMode: { state: true },
102
128
  };
103
129
  }
104
130
 
@@ -106,6 +132,7 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
106
132
  super();
107
133
  this.actionIds = [];
108
134
  this.filtersAlwaysVisible = false;
135
+ this._viewMode = 'item';
109
136
  }
110
137
 
111
138
  // GETTERS ELEMENTOS
@@ -216,6 +243,7 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
216
243
  @crud-item-restore=${this.itemRestoreRequest}
217
244
  @insert-requested=${this.openInsert}
218
245
  .actionIds=${this.actionIds}
246
+ .viewMode=${this._viewMode}
219
247
  belongsTo=${this.belongsTo}
220
248
  relationId=${this.relationId}
221
249
  language="${this.language}"
@@ -241,6 +269,7 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
241
269
  : html`<div slot="menu">${this.helpTemplate}</div>`
242
270
  }
243
271
  <div class="actions" slot="actions">
272
+ ${this.canSwitchListView ? this.listViewSwitchTemplate : ''}
244
273
  ${this.actionsTemplate}
245
274
  ${this.config.customization.disableFilter || this.filtersAlwaysVisible
246
275
  ? ''
@@ -287,6 +316,37 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
287
316
  `
288
317
  }
289
318
 
319
+ get canSwitchListView() {
320
+ return canSwitchListView(this.config);
321
+ }
322
+
323
+ get listViewSwitchTemplate() {
324
+ const label = this._viewMode === 'grid'
325
+ ? this.listViewLabelComputed(this.config.labels.listViewAction, this.translations)
326
+ : this.gridViewLabelComputed(this.config.labels.gridViewAction, this.translations);
327
+ const icon = this._viewMode === 'grid' ? listIcon : appsIcon;
328
+ return html`
329
+ <div class="view-switch action-controller">
330
+ <dile-icon
331
+ rounded
332
+ tabindex="0"
333
+ role="button"
334
+ aria-label="${label}"
335
+ title="${label}"
336
+ .icon=${icon}
337
+ @click=${this.toggleListView}
338
+ @keydown=${this.viewSwitchIconKeydown}
339
+ ></dile-icon>
340
+ <dile-button-icon
341
+ @click=${this.toggleListView}
342
+ .icon=${icon}
343
+ >
344
+ ${label}
345
+ </dile-button-icon>
346
+ </div>
347
+ `
348
+ }
349
+
290
350
  get filtersAlwaysVisibleTemplate() {
291
351
  return html`
292
352
  <dile-crud-filters-inline
@@ -304,10 +364,30 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
304
364
  && (customization.disableFilter || this.filtersAlwaysVisible)
305
365
  && customization.disablePagination
306
366
  && customization.disableSort
307
- && customization.hideCheckboxSelection;
367
+ && customization.hideCheckboxSelection
368
+ && !this.canSwitchListView;
308
369
  }
309
370
  // BEHAVIOURS
310
371
 
372
+ toggleListView() {
373
+ this._viewMode = this._viewMode === 'grid' ? 'item' : 'grid';
374
+ }
375
+
376
+ viewSwitchIconKeydown(e) {
377
+ if (e.key === 'Enter' || e.key === ' ') {
378
+ e.preventDefault();
379
+ this.toggleListView();
380
+ }
381
+ }
382
+
383
+ listViewLabelComputed(label, translations) {
384
+ return label ? label : translations?.list_view_label ? translations.list_view_label : 'List view';
385
+ }
386
+
387
+ gridViewLabelComputed(label, translations) {
388
+ return label ? label : translations?.grid_view_label ? translations.grid_view_label : 'Grid view';
389
+ }
390
+
311
391
  openInsert() {
312
392
  this.dispatchEvent(new CustomEvent('crud-item-insert', { bubbles: true, composed: true }));
313
393
  if (this.config.insertOperation?.type === 'modal') {
@@ -316,4 +316,58 @@ describe('dile-crud-list grid template integration', () => {
316
316
  expect(innerGrid.sortField).toBe('name');
317
317
  expect(innerGrid.sortDirection).toBe('asc');
318
318
  });
319
+
320
+ it('shows the item template by default when both templates.item and grid.columns are configured', async () => {
321
+ const config = new CrudConfigBuilder('https://example.test/api/customers', {
322
+ grid: { columns: [{ field: 'name', header: 'Name' }] },
323
+ templates: { item: (item) => html`<span class="item-name">${item.name}</span>` },
324
+ }).getConfig();
325
+
326
+ const el = document.createElement('dile-crud-list');
327
+ el.disableLoadOnStart = true;
328
+ el.config = config;
329
+ el.elements = sampleElements;
330
+ el.loading = false;
331
+ document.body.appendChild(el);
332
+ await el.updateComplete;
333
+
334
+ expect(el.shadowRoot.querySelectorAll('dile-crud-list-item').length).toBe(2);
335
+ expect(el.shadowRoot.querySelector('.grid-container')).toBeNull();
336
+ });
337
+
338
+ it('shows the grid when viewMode is "grid" and both templates.item and grid.columns are configured', async () => {
339
+ const config = new CrudConfigBuilder('https://example.test/api/customers', {
340
+ grid: { columns: [{ field: 'name', header: 'Name' }] },
341
+ templates: { item: (item) => html`<span class="item-name">${item.name}</span>` },
342
+ }).getConfig();
343
+
344
+ const el = document.createElement('dile-crud-list');
345
+ el.disableLoadOnStart = true;
346
+ el.config = config;
347
+ el.elements = sampleElements;
348
+ el.loading = false;
349
+ el.viewMode = 'grid';
350
+ document.body.appendChild(el);
351
+ await el.updateComplete;
352
+
353
+ expect(el.shadowRoot.querySelectorAll('dile-crud-list-item').length).toBe(0);
354
+ expect(el.shadowRoot.querySelector('.grid-container')).toBeTruthy();
355
+ expect(el.shadowRoot.querySelector('dile-crud-data-grid')).toBeTruthy();
356
+ });
357
+
358
+ it('still shows the grid when only grid.columns is configured, regardless of viewMode', async () => {
359
+ const config = new CrudConfigBuilder('https://example.test/api/customers', {
360
+ grid: { columns: [{ field: 'name', header: 'Name' }] },
361
+ }).getConfig();
362
+
363
+ const el = document.createElement('dile-crud-list');
364
+ el.disableLoadOnStart = true;
365
+ el.config = config;
366
+ el.elements = sampleElements;
367
+ el.loading = false;
368
+ document.body.appendChild(el);
369
+ await el.updateComplete;
370
+
371
+ expect(el.shadowRoot.querySelector('dile-crud-data-grid')).toBeTruthy();
372
+ });
319
373
  });
@@ -9,6 +9,7 @@ import '../crud-select-all.js';
9
9
  import '../crud-list-service.js';
10
10
  import '../crud-data-grid.js';
11
11
  import { DileI18nMixin } from '../../../lib/DileI18nMixin.js';
12
+ import { hasGridView, hasItemView } from '../../../lib/listViewMode.js';
12
13
 
13
14
  export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
14
15
  static styles = [
@@ -95,6 +96,7 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
95
96
  relationId: { type: String },
96
97
  disableLoadOnStart: { type: Boolean },
97
98
  listError: { type: Object },
99
+ viewMode: { type: String },
98
100
  };
99
101
  }
100
102
 
@@ -112,6 +114,7 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
112
114
  this.loading = true;
113
115
  this.disableLoadOnStart = false;
114
116
  this.listError = null;
117
+ this.viewMode = 'item';
115
118
  }
116
119
 
117
120
  firstUpdated() {
@@ -250,7 +253,12 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
250
253
  }
251
254
 
252
255
  get elementsTemplate() {
253
- if (this.config?.templates?.grid && typeof this.config.templates.grid === 'function') {
256
+ // When both a grid and an item view are configured, the item template is
257
+ // shown by default and the grid is only rendered once the user switches
258
+ // view via dile-crud's navActionsTemplate (see listViewMode.js).
259
+ const showGrid = hasGridView(this.config) && (!hasItemView(this.config) || this.viewMode === 'grid');
260
+
261
+ if (showGrid && this.config?.templates?.grid && typeof this.config.templates.grid === 'function') {
254
262
  return html`
255
263
  <div
256
264
  class="grid-container"
@@ -261,7 +269,7 @@ export class DileCrudList extends DileI18nMixin(DileLoading(LitElement)) {
261
269
  </div>
262
270
  `;
263
271
  }
264
- if (this.config?.grid?.columns && Array.isArray(this.config.grid.columns)) {
272
+ if (showGrid && this.config?.grid?.columns && Array.isArray(this.config.grid.columns)) {
265
273
  return html`
266
274
  <div
267
275
  class="grid-container"
@@ -25,6 +25,10 @@ export class DileCrudListOptions extends LitElement {
25
25
  cursor: pointer;
26
26
  --dile-icon-rounded-background-color: var(--dile-crud-action-color, #888);
27
27
  }
28
+ dile-icon:focus-visible {
29
+ outline: 2px solid var(--dile-crud-action-focus-color, #12354d);
30
+ outline-offset: 2px;
31
+ }
28
32
  @media (min-width: 605px) {
29
33
  dile-button-icon {
30
34
  display: block;
@@ -47,7 +51,15 @@ export class DileCrudListOptions extends LitElement {
47
51
  return html`
48
52
  <dile-menu-overlay horizontalAlign="under_right" verticalAlign="bottom">
49
53
  <div slot="trigger">
50
- <dile-icon rounded .icon="${this.icon}"></dile-icon>
54
+ <dile-icon
55
+ rounded
56
+ tabindex="0"
57
+ role="button"
58
+ aria-label="${this.label}"
59
+ title="${this.label}"
60
+ .icon="${this.icon}"
61
+ @keydown=${this.iconKeydown}
62
+ ></dile-icon>
51
63
  <dile-button-icon label="${this.label}" .icon="${this.icon}" gray>${this.label}</dile-button-icon>
52
64
  </div>
53
65
  <div class="content" slot="content">
@@ -57,8 +69,18 @@ export class DileCrudListOptions extends LitElement {
57
69
  `;
58
70
  }
59
71
 
72
+ // The small-screen dile-icon has no native focus/activation support, unlike the
73
+ // real <button> inside dile-button-icon shown at wider widths, so it needs its
74
+ // own keyboard handling; the resulting click bubbles up to dile-menu-overlay's
75
+ // trigger listener the same way a pointer click already does.
76
+ iconKeydown(e) {
77
+ if (e.key === 'Enter' || e.key === ' ') {
78
+ e.preventDefault();
79
+ e.target.click();
80
+ }
81
+ }
60
82
 
61
83
  close() {
62
- this.shadowRoot.querySelector('dile-menu-overlay').close();
63
- }
84
+ this.shadowRoot.querySelector('dile-menu-overlay').close();
85
+ }
64
86
  }
@@ -33,6 +33,7 @@ export const defaultConfig = {
33
33
  disableFilter: true,
34
34
  disableHelp: true,
35
35
  disableListActionsOnSingleComponent: false,
36
+ disableListWiewSwitch: false,
36
37
  },
37
38
  responseAdapter: new ResponseApiAdapter(),
38
39
  requestAdapter: new RequestApiAdapter(),
package/lib/i18n/en.js CHANGED
@@ -72,4 +72,6 @@ export const translations = {
72
72
  select_relation_placeholder: "Select...",
73
73
  list_load_error: "Error loading the list. Please try again.",
74
74
  retry_label: "Retry",
75
+ list_view_label: "List view",
76
+ grid_view_label: "Grid view",
75
77
  };
package/lib/i18n/es.js CHANGED
@@ -72,4 +72,6 @@ export const translations = {
72
72
  select_relation_placeholder: "Selecciona...",
73
73
  list_load_error: "Error al cargar la lista. Por favor, intenta de nuevo.",
74
74
  retry_label: "Reintentar",
75
+ list_view_label: "Lista",
76
+ grid_view_label: "Rejilla",
75
77
  };
@@ -0,0 +1,17 @@
1
+ import { defaultConfig } from './defaultConfig.js';
2
+
3
+ export function hasGridView(config) {
4
+ return typeof config?.templates?.grid === 'function'
5
+ || Array.isArray(config?.grid?.columns);
6
+ }
7
+
8
+ export function hasItemView(config) {
9
+ return typeof config?.templates?.item === 'function'
10
+ && config.templates.item !== defaultConfig.templates.item;
11
+ }
12
+
13
+ export function canSwitchListView(config) {
14
+ return hasGridView(config)
15
+ && hasItemView(config)
16
+ && !config?.customization?.disableListWiewSwitch;
17
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { html } from 'lit';
3
+ import { CrudConfigBuilder } from './CrudConfigBuilder.js';
4
+ import { hasGridView, hasItemView, canSwitchListView } from './listViewMode.js';
5
+
6
+ describe('listViewMode', () => {
7
+ it('detects a grid view from templates.grid', () => {
8
+ const config = new CrudConfigBuilder('https://example.test/api', {
9
+ templates: { grid: () => html`` },
10
+ }).getConfig();
11
+
12
+ expect(hasGridView(config)).toBe(true);
13
+ });
14
+
15
+ it('detects a grid view from grid.columns', () => {
16
+ const config = new CrudConfigBuilder('https://example.test/api', {
17
+ grid: { columns: [{ field: 'id', header: 'ID' }] },
18
+ }).getConfig();
19
+
20
+ expect(hasGridView(config)).toBe(true);
21
+ });
22
+
23
+ it('reports no grid view when neither is configured', () => {
24
+ const config = new CrudConfigBuilder('https://example.test/api', {
25
+ templates: { item: () => html`` },
26
+ }).getConfig();
27
+
28
+ expect(hasGridView(config)).toBe(false);
29
+ });
30
+
31
+ it('detects an item view only when templates.item was explicitly configured', () => {
32
+ const withItem = new CrudConfigBuilder('https://example.test/api', {
33
+ templates: { item: () => html`` },
34
+ }).getConfig();
35
+ const withoutItem = new CrudConfigBuilder('https://example.test/api', {
36
+ grid: { columns: [{ field: 'id', header: 'ID' }] },
37
+ }).getConfig();
38
+
39
+ expect(hasItemView(withItem)).toBe(true);
40
+ expect(hasItemView(withoutItem)).toBe(false);
41
+ });
42
+
43
+ it('allows switching only when both views are configured and the switch is not disabled', () => {
44
+ const both = new CrudConfigBuilder('https://example.test/api', {
45
+ grid: { columns: [{ field: 'id', header: 'ID' }] },
46
+ templates: { item: () => html`` },
47
+ }).getConfig();
48
+
49
+ expect(canSwitchListView(both)).toBe(true);
50
+ });
51
+
52
+ it('disallows switching when customization.disableListWiewSwitch is true', () => {
53
+ const config = new CrudConfigBuilder('https://example.test/api', {
54
+ grid: { columns: [{ field: 'id', header: 'ID' }] },
55
+ templates: { item: () => html`` },
56
+ customization: { disableListWiewSwitch: true },
57
+ }).getConfig();
58
+
59
+ expect(canSwitchListView(config)).toBe(false);
60
+ });
61
+
62
+ it('disallows switching when only one view is configured', () => {
63
+ const gridOnly = new CrudConfigBuilder('https://example.test/api', {
64
+ grid: { columns: [{ field: 'id', header: 'ID' }] },
65
+ }).getConfig();
66
+ const itemOnly = new CrudConfigBuilder('https://example.test/api', {
67
+ templates: { item: () => html`` },
68
+ }).getConfig();
69
+
70
+ expect(canSwitchListView(gridOnly)).toBe(false);
71
+ expect(canSwitchListView(itemOnly)).toBe(false);
72
+ });
73
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/crud",
3
- "version": "2.7.6",
3
+ "version": "2.8.1",
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",
@@ -32,5 +32,5 @@
32
32
  "publishConfig": {
33
33
  "access": "public"
34
34
  },
35
- "gitHead": "29dbec2b9bc0a8f5613158409950ffd3456af179"
35
+ "gitHead": "a6cf598429db0f791f20a77f64ae14f10d42cf56"
36
36
  }