@dile/crud 2.7.5 → 2.8.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.
@@ -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,26 @@ 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
+ }
78
+ .view-switch dile-icon {
79
+ cursor: pointer;
80
+ --dile-icon-color: var(--dile-on-crud-action-color, #fff);
81
+ --dile-icon-rounded-background-color: var(--dile-crud-action-color, #888);
82
+ }
83
+ .view-switch dile-icon:focus-visible {
84
+ outline: 2px solid var(--dile-crud-action-focus-color, #12354d);
85
+ outline-offset: 2px;
86
+ }
87
+ @media (min-width: 605px) {
88
+ .view-switch dile-button-icon {
89
+ display: block;
90
+ }
91
+ .view-switch dile-icon {
92
+ display: none;
93
+ }
94
+ }
73
95
  @media(min-width: 400px) {
74
96
  .simplecard {
75
97
  --dile-card-border: none;
@@ -99,6 +121,7 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
99
121
  relationId: { type: String },
100
122
  filtersAlwaysVisible: { type: Boolean },
101
123
  singleActionDispatcher: { type: String },
124
+ _viewMode: { state: true },
102
125
  };
103
126
  }
104
127
 
@@ -106,6 +129,7 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
106
129
  super();
107
130
  this.actionIds = [];
108
131
  this.filtersAlwaysVisible = false;
132
+ this._viewMode = 'item';
109
133
  }
110
134
 
111
135
  // GETTERS ELEMENTOS
@@ -216,6 +240,7 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
216
240
  @crud-item-restore=${this.itemRestoreRequest}
217
241
  @insert-requested=${this.openInsert}
218
242
  .actionIds=${this.actionIds}
243
+ .viewMode=${this._viewMode}
219
244
  belongsTo=${this.belongsTo}
220
245
  relationId=${this.relationId}
221
246
  language="${this.language}"
@@ -241,6 +266,7 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
241
266
  : html`<div slot="menu">${this.helpTemplate}</div>`
242
267
  }
243
268
  <div class="actions" slot="actions">
269
+ ${this.canSwitchListView ? this.listViewSwitchTemplate : ''}
244
270
  ${this.actionsTemplate}
245
271
  ${this.config.customization.disableFilter || this.filtersAlwaysVisible
246
272
  ? ''
@@ -287,6 +313,37 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
287
313
  `
288
314
  }
289
315
 
316
+ get canSwitchListView() {
317
+ return canSwitchListView(this.config);
318
+ }
319
+
320
+ get listViewSwitchTemplate() {
321
+ const label = this._viewMode === 'grid'
322
+ ? this.listViewLabelComputed(this.config.labels.listViewAction, this.translations)
323
+ : this.gridViewLabelComputed(this.config.labels.gridViewAction, this.translations);
324
+ const icon = this._viewMode === 'grid' ? listIcon : appsIcon;
325
+ return html`
326
+ <div class="view-switch action-controller">
327
+ <dile-icon
328
+ rounded
329
+ tabindex="0"
330
+ role="button"
331
+ aria-label="${label}"
332
+ title="${label}"
333
+ .icon=${icon}
334
+ @click=${this.toggleListView}
335
+ @keydown=${this.viewSwitchIconKeydown}
336
+ ></dile-icon>
337
+ <dile-button-icon
338
+ @click=${this.toggleListView}
339
+ .icon=${icon}
340
+ >
341
+ ${label}
342
+ </dile-button-icon>
343
+ </div>
344
+ `
345
+ }
346
+
290
347
  get filtersAlwaysVisibleTemplate() {
291
348
  return html`
292
349
  <dile-crud-filters-inline
@@ -304,10 +361,30 @@ export class DileCrud extends DileI18nMixin(DileCrudMixin(LitElement)) {
304
361
  && (customization.disableFilter || this.filtersAlwaysVisible)
305
362
  && customization.disablePagination
306
363
  && customization.disableSort
307
- && customization.hideCheckboxSelection;
364
+ && customization.hideCheckboxSelection
365
+ && !this.canSwitchListView;
308
366
  }
309
367
  // BEHAVIOURS
310
368
 
369
+ toggleListView() {
370
+ this._viewMode = this._viewMode === 'grid' ? 'item' : 'grid';
371
+ }
372
+
373
+ viewSwitchIconKeydown(e) {
374
+ if (e.key === 'Enter' || e.key === ' ') {
375
+ e.preventDefault();
376
+ this.toggleListView();
377
+ }
378
+ }
379
+
380
+ listViewLabelComputed(label, translations) {
381
+ return label ? label : translations?.list_view_label ? translations.list_view_label : 'List view';
382
+ }
383
+
384
+ gridViewLabelComputed(label, translations) {
385
+ return label ? label : translations?.grid_view_label ? translations.grid_view_label : 'Grid view';
386
+ }
387
+
311
388
  openInsert() {
312
389
  this.dispatchEvent(new CustomEvent('crud-item-insert', { bubbles: true, composed: true }));
313
390
  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: "Vista de lista",
76
+ grid_view_label: "Vista de cuadrícula",
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.5",
3
+ "version": "2.8.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.12.3",
28
+ "@dile/ui": "^3.12.4",
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": "907871657a1af5e98886a7f1cb36ab39574605a2"
35
+ "gitHead": "305c109a3c920517956067fb52e51fa75e55896f"
36
36
  }