@afurgeri/crud-vue 0.1.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.
Files changed (95) hide show
  1. package/package.json +46 -0
  2. package/src/__test__/CrudCards.test.ts +118 -0
  3. package/src/__test__/CrudEmptyState.test.ts +82 -0
  4. package/src/__test__/CrudForm.test.ts +101 -0
  5. package/src/__test__/CrudShow.test.ts +135 -0
  6. package/src/__test__/CrudTable.test.ts +111 -0
  7. package/src/__test__/CrudToolbar.test.ts +102 -0
  8. package/src/__test__/setup.ts +43 -0
  9. package/src/__test__/useCrud.test.ts +349 -0
  10. package/src/components/CrudCards.vue +105 -0
  11. package/src/components/CrudDeleteDialog.vue +80 -0
  12. package/src/components/CrudEmptyState.vue +58 -0
  13. package/src/components/CrudFilters.vue +194 -0
  14. package/src/components/CrudForm.vue +232 -0
  15. package/src/components/CrudPage.vue +206 -0
  16. package/src/components/CrudPagination.vue +130 -0
  17. package/src/components/CrudSearch.vue +42 -0
  18. package/src/components/CrudShow.vue +216 -0
  19. package/src/components/CrudTable.vue +146 -0
  20. package/src/components/CrudToolbar.vue +86 -0
  21. package/src/components/InputError.vue +13 -0
  22. package/src/components/ui/button/Button.vue +27 -0
  23. package/src/components/ui/button/index.ts +36 -0
  24. package/src/components/ui/card/Card.vue +22 -0
  25. package/src/components/ui/card/CardAction.vue +17 -0
  26. package/src/components/ui/card/CardContent.vue +17 -0
  27. package/src/components/ui/card/CardDescription.vue +17 -0
  28. package/src/components/ui/card/CardFooter.vue +17 -0
  29. package/src/components/ui/card/CardHeader.vue +17 -0
  30. package/src/components/ui/card/CardTitle.vue +17 -0
  31. package/src/components/ui/card/index.ts +7 -0
  32. package/src/components/ui/checkbox/Checkbox.vue +37 -0
  33. package/src/components/ui/checkbox/index.ts +1 -0
  34. package/src/components/ui/combobox/ComboboxInput.vue +83 -0
  35. package/src/components/ui/combobox/index.ts +1 -0
  36. package/src/components/ui/dialog/Dialog.vue +17 -0
  37. package/src/components/ui/dialog/DialogClose.vue +14 -0
  38. package/src/components/ui/dialog/DialogContent.vue +49 -0
  39. package/src/components/ui/dialog/DialogDescription.vue +25 -0
  40. package/src/components/ui/dialog/DialogFooter.vue +15 -0
  41. package/src/components/ui/dialog/DialogHeader.vue +17 -0
  42. package/src/components/ui/dialog/DialogOverlay.vue +23 -0
  43. package/src/components/ui/dialog/DialogScrollContent.vue +59 -0
  44. package/src/components/ui/dialog/DialogTitle.vue +25 -0
  45. package/src/components/ui/dialog/DialogTrigger.vue +14 -0
  46. package/src/components/ui/dialog/index.ts +10 -0
  47. package/src/components/ui/dropdown-menu/DropdownMenu.vue +17 -0
  48. package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +41 -0
  49. package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +39 -0
  50. package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +14 -0
  51. package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +30 -0
  52. package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +22 -0
  53. package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +22 -0
  54. package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +42 -0
  55. package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +26 -0
  56. package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +17 -0
  57. package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +19 -0
  58. package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +31 -0
  59. package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +30 -0
  60. package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +16 -0
  61. package/src/components/ui/dropdown-menu/index.ts +16 -0
  62. package/src/components/ui/input/Input.vue +33 -0
  63. package/src/components/ui/input/index.ts +1 -0
  64. package/src/components/ui/label/Label.vue +28 -0
  65. package/src/components/ui/label/index.ts +1 -0
  66. package/src/components/ui/select/Select.vue +15 -0
  67. package/src/components/ui/select/SelectContent.vue +49 -0
  68. package/src/components/ui/select/SelectGroup.vue +17 -0
  69. package/src/components/ui/select/SelectItem.vue +41 -0
  70. package/src/components/ui/select/SelectItemText.vue +12 -0
  71. package/src/components/ui/select/SelectLabel.vue +14 -0
  72. package/src/components/ui/select/SelectScrollDownButton.vue +22 -0
  73. package/src/components/ui/select/SelectScrollUpButton.vue +22 -0
  74. package/src/components/ui/select/SelectSeparator.vue +15 -0
  75. package/src/components/ui/select/SelectTrigger.vue +29 -0
  76. package/src/components/ui/select/SelectValue.vue +12 -0
  77. package/src/components/ui/select/index.ts +11 -0
  78. package/src/components/ui/separator/Separator.vue +28 -0
  79. package/src/components/ui/separator/index.ts +1 -0
  80. package/src/components/ui/spinner/Spinner.vue +17 -0
  81. package/src/components/ui/spinner/index.ts +1 -0
  82. package/src/components/ui/table/Table.vue +16 -0
  83. package/src/components/ui/table/TableBody.vue +14 -0
  84. package/src/components/ui/table/TableCaption.vue +14 -0
  85. package/src/components/ui/table/TableCell.vue +21 -0
  86. package/src/components/ui/table/TableEmpty.vue +34 -0
  87. package/src/components/ui/table/TableFooter.vue +14 -0
  88. package/src/components/ui/table/TableHead.vue +14 -0
  89. package/src/components/ui/table/TableHeader.vue +14 -0
  90. package/src/components/ui/table/TableRow.vue +14 -0
  91. package/src/components/ui/table/index.ts +9 -0
  92. package/src/composables/useCrud.ts +328 -0
  93. package/src/index.ts +33 -0
  94. package/src/lib/utils.ts +18 -0
  95. package/src/types/crud.ts +133 -0
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import CrudToolbar from '../components/CrudToolbar.vue';
4
+
5
+ function mountToolbar(overrides: Record<string, unknown> = {}, slots: Record<string, string> = {}) {
6
+ return mount(CrudToolbar, {
7
+ props: {
8
+ title: 'Products',
9
+ description: 'Manage your products',
10
+ search: '',
11
+ searchConfig: { fields: ['name'], placeholder: 'Search...' },
12
+ createConfig: { url: '/products/create', label: 'Create Product' },
13
+ hasActiveFilters: false,
14
+ filtersVisible: false,
15
+ ...overrides,
16
+ },
17
+ slots,
18
+ global: {
19
+ stubs: {
20
+ CrudSearch: { template: '<div class="crud-search"><slot /></div>' },
21
+ Button: { template: '<button class="btn"><slot /></button>' },
22
+ },
23
+ },
24
+ });
25
+ }
26
+
27
+ describe('CrudToolbar named slots', () => {
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ });
31
+
32
+ // CSLOT-004 Scenario: Title prepend slot injects content
33
+ it('renders #title-prepend content before the title (CSLOT-004)', () => {
34
+ const wrapper = mountToolbar({}, {
35
+ 'title-prepend': '<span class="prepend-icon">⭐</span>',
36
+ });
37
+
38
+ const prepend = wrapper.find('.prepend-icon');
39
+ expect(prepend.exists()).toBe(true);
40
+ expect(prepend.text()).toBe('⭐');
41
+
42
+ // The title should still be present
43
+ expect(wrapper.text()).toContain('Products');
44
+ });
45
+
46
+ // CSLOT-004 Scenario: Title append slot
47
+ it('renders #title-append content after the description (CSLOT-004)', () => {
48
+ const wrapper = mountToolbar({}, {
49
+ 'title-append': '<span class="append-badge">New</span>',
50
+ });
51
+
52
+ const append = wrapper.find('.append-badge');
53
+ expect(append.exists()).toBe(true);
54
+ expect(append.text()).toBe('New');
55
+ });
56
+
57
+ // CSLOT-004 Scenario: Toolbar actions slot
58
+ it('renders #toolbar-actions content (CSLOT-004)', () => {
59
+ const wrapper = mountToolbar({}, {
60
+ 'toolbar-actions': '<button class="export-btn">Export</button>',
61
+ });
62
+
63
+ const exportBtn = wrapper.find('.export-btn');
64
+ expect(exportBtn.exists()).toBe(true);
65
+ expect(exportBtn.text()).toBe('Export');
66
+ });
67
+
68
+ // CSLOT-004 Scenario: Toolbar append slot
69
+ it('renders #toolbar-append content (CSLOT-004)', () => {
70
+ const wrapper = mountToolbar({}, {
71
+ 'toolbar-append': '<span class="toolbar-end">End</span>',
72
+ });
73
+
74
+ const endEl = wrapper.find('.toolbar-end');
75
+ expect(endEl.exists()).toBe(true);
76
+ expect(endEl.text()).toBe('End');
77
+ });
78
+
79
+ // CSLOT-004 Scenario: Unused slots render nothing
80
+ it('renders nothing in slot positions when no slots are provided (CSLOT-004)', () => {
81
+ const wrapper = mountToolbar();
82
+
83
+ // Toolbar should render without errors
84
+ expect(wrapper.find('h2').exists()).toBe(true);
85
+ expect(wrapper.text()).toContain('Products');
86
+ });
87
+
88
+ // All 4 slots work together
89
+ it('renders all 4 slots simultaneously', () => {
90
+ const wrapper = mountToolbar({}, {
91
+ 'title-prepend': '<span class="pre">Pre</span>',
92
+ 'title-append': '<span class="post">Post</span>',
93
+ 'toolbar-actions': '<span class="act">Act</span>',
94
+ 'toolbar-append': '<span class="end">End</span>',
95
+ });
96
+
97
+ expect(wrapper.find('.pre').exists()).toBe(true);
98
+ expect(wrapper.find('.post').exists()).toBe(true);
99
+ expect(wrapper.find('.act').exists()).toBe(true);
100
+ expect(wrapper.find('.end').exists()).toBe(true);
101
+ });
102
+ });
@@ -0,0 +1,43 @@
1
+ import { vi } from 'vitest';
2
+
3
+ vi.mock('@inertiajs/vue3', () => {
4
+ function createMockForm(initialData: Record<string, unknown>) {
5
+ const data = { ...initialData };
6
+ const form: Record<string, unknown> = {
7
+ ...data,
8
+ processing: false,
9
+ errors: {},
10
+ post: vi.fn(),
11
+ put: vi.fn(),
12
+ patch: vi.fn(),
13
+ delete: vi.fn(),
14
+ reset: vi.fn(),
15
+ clearErrors: vi.fn(),
16
+ setError: vi.fn(),
17
+ submit: vi.fn(),
18
+ transform: vi.fn((cb: (data: unknown) => unknown) => {
19
+ return cb(data);
20
+ }),
21
+ };
22
+ return form;
23
+ }
24
+
25
+ return {
26
+ router: {
27
+ get: vi.fn(),
28
+ post: vi.fn(),
29
+ put: vi.fn(),
30
+ patch: vi.fn(),
31
+ delete: vi.fn(),
32
+ visit: vi.fn(),
33
+ reload: vi.fn(),
34
+ },
35
+ useForm: (initialData?: Record<string, unknown>) =>
36
+ createMockForm(initialData ?? {}),
37
+ Link: {
38
+ name: 'Link',
39
+ template: '<a :href="href"><slot /></a>',
40
+ props: ['href', 'as', 'method', 'data', 'preserveScroll', 'preserveState', 'only'],
41
+ },
42
+ };
43
+ });
@@ -0,0 +1,349 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { useCrud, buildFilters } from '../composables/useCrud';
3
+ import { router } from '@inertiajs/vue3';
4
+ import type { CrudFilter, UseCrudOptions } from '../types/crud';
5
+
6
+ const mockRouterGet = router.get as ReturnType<typeof vi.fn>;
7
+
8
+ function mockLocation(url: string) {
9
+ const parsed = new URL(url);
10
+ Object.defineProperty(window, 'location', {
11
+ value: {
12
+ href: url,
13
+ search: parsed.search,
14
+ pathname: parsed.pathname,
15
+ origin: parsed.origin,
16
+ },
17
+ writable: true,
18
+ configurable: true,
19
+ });
20
+ }
21
+
22
+ function getUrlSearchParams() {
23
+ const url = new URL(window.location.href);
24
+ return url.searchParams;
25
+ }
26
+
27
+ const textFilter: CrudFilter = {
28
+ field: 'name',
29
+ label: 'Name',
30
+ type: 'text',
31
+ };
32
+
33
+ const selectFilter: CrudFilter = {
34
+ field: 'status',
35
+ label: 'Status',
36
+ type: 'select',
37
+ options: [{ label: 'Active', value: 'active' }],
38
+ };
39
+
40
+ const numberFilter: CrudFilter = {
41
+ field: 'age',
42
+ label: 'Age',
43
+ type: 'number',
44
+ };
45
+
46
+ const dateFilter: CrudFilter = {
47
+ field: 'created_at',
48
+ label: 'Created',
49
+ type: 'date',
50
+ };
51
+
52
+ describe('useCrud hooks', () => {
53
+ beforeEach(() => {
54
+ vi.useFakeTimers();
55
+ mockLocation('https://example.com/products');
56
+ mockRouterGet.mockClear();
57
+ });
58
+
59
+ afterEach(() => {
60
+ vi.useRealTimers();
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ // --- UCEXT-005: Backward compatibility ---
65
+
66
+ describe('backward compatibility', () => {
67
+ it('returns the same UseCrudReturn shape without options', () => {
68
+ const crud = useCrud([textFilter], ['name']);
69
+
70
+ expect(crud).toHaveProperty('search');
71
+ expect(crud).toHaveProperty('activeFilters');
72
+ expect(crud).toHaveProperty('sortField');
73
+ expect(crud).toHaveProperty('sortDirection');
74
+ expect(crud).toHaveProperty('hasActiveFilters');
75
+ expect(crud).toHaveProperty('filtersVisible');
76
+ expect(crud).toHaveProperty('onSearchInput');
77
+ expect(crud).toHaveProperty('onFilterChange');
78
+ expect(crud).toHaveProperty('onSortToggle');
79
+ expect(crud).toHaveProperty('applyFilters');
80
+ expect(crud).toHaveProperty('clearFilters');
81
+ expect(crud).toHaveProperty('toggleFilters');
82
+ expect(crud).toHaveProperty('getSortParam');
83
+ expect(crud.search.value).toBe('');
84
+ });
85
+
86
+ it('applies defaultSort when no URL sort param exists', () => {
87
+ mockLocation('https://example.com/products');
88
+
89
+ const crud = useCrud([], [], undefined, 'name');
90
+
91
+ // The default sort is applied via a setTimeout(0)
92
+ vi.advanceTimersByTime(10);
93
+
94
+ expect(mockRouterGet).toHaveBeenCalled();
95
+ const callUrl = mockRouterGet.mock.calls[0]?.[0] as string;
96
+ expect(callUrl).toContain('sort=name');
97
+ });
98
+
99
+ it('respects search debounce default of 300ms', () => {
100
+ const crud = useCrud([textFilter], ['name']);
101
+ crud.onSearchInput('test');
102
+
103
+ // Should NOT have triggered yet (300ms not passed)
104
+ vi.advanceTimersByTime(200);
105
+ const earlyCalls = mockRouterGet.mock.calls.filter(
106
+ (c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('search=test'),
107
+ );
108
+ expect(earlyCalls.length).toBe(0);
109
+
110
+ // After 300ms, it triggers
111
+ vi.advanceTimersByTime(150);
112
+ const lateCalls = mockRouterGet.mock.calls.filter(
113
+ (c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('search=test'),
114
+ );
115
+ expect(lateCalls.length).toBe(1);
116
+ });
117
+ });
118
+
119
+ // --- UCEXT-004: onBeforeReload ---
120
+
121
+ describe('onBeforeReload', () => {
122
+ it('transforms params when hook returns URLSearchParams (UCEXT-004)', () => {
123
+ const options: UseCrudOptions = {
124
+ onBeforeReload: (p) => {
125
+ p.set('extra', 'val');
126
+ return p;
127
+ },
128
+ };
129
+
130
+ const crud = useCrud([textFilter], ['name'], undefined, undefined, options);
131
+ crud.activeFilters.value = { name: 'foo' };
132
+ crud.applyFilters();
133
+
134
+ // onBeforeReload is called during reloadWithParams
135
+ const callUrl = mockRouterGet.mock.calls.find(
136
+ (c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('extra=val'),
137
+ );
138
+ expect(callUrl).toBeDefined();
139
+ });
140
+
141
+ it('prevents navigation when hook returns false (UCEXT-004)', () => {
142
+ const options: UseCrudOptions = {
143
+ onBeforeReload: () => false,
144
+ };
145
+
146
+ const crud = useCrud([textFilter], ['name'], undefined, undefined, options);
147
+ mockRouterGet.mockClear();
148
+ crud.onSearchInput('should-not-navigate');
149
+ vi.advanceTimersByTime(500);
150
+
151
+ // router.get should NOT have been called
152
+ expect(mockRouterGet).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it('proceeds normally when hook returns void', () => {
156
+ const options: UseCrudOptions = {
157
+ onBeforeReload: () => {
158
+ // void return
159
+ },
160
+ };
161
+
162
+ const crud = useCrud([textFilter], ['name'], undefined, undefined, options);
163
+ crud.onSearchInput('proceed');
164
+ vi.advanceTimersByTime(500);
165
+
166
+ const callUrl = mockRouterGet.mock.calls.find(
167
+ (c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('search=proceed'),
168
+ );
169
+ expect(callUrl).toBeDefined();
170
+ });
171
+
172
+ it('supports async onBeforeReload', async () => {
173
+ const options: UseCrudOptions = {
174
+ onBeforeReload: async (p) => {
175
+ await Promise.resolve();
176
+ p.set('async', 'true');
177
+ return p;
178
+ },
179
+ };
180
+
181
+ const crud = useCrud([textFilter], ['name'], undefined, undefined, options);
182
+ crud.activeFilters.value = { name: 'foo' };
183
+ await crud.applyFilters();
184
+
185
+ // Wait for async onBeforeReload to resolve
186
+ await vi.advanceTimersByTimeAsync(100);
187
+
188
+ const callUrl = mockRouterGet.mock.calls.find(
189
+ (c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('async=true'),
190
+ );
191
+ expect(callUrl).toBeDefined();
192
+ });
193
+ });
194
+
195
+ // --- UCEXT-004: onError ---
196
+
197
+ describe('onError', () => {
198
+ it('calls onError when URL init JSON parsing fails (UCEXT-004)', () => {
199
+ const onError = vi.fn();
200
+ const options: UseCrudOptions = { onError };
201
+
202
+ mockLocation('https://example.com/products?filters=NOT_VALID_JSON');
203
+
204
+ useCrud([textFilter], ['name'], undefined, undefined, options);
205
+
206
+ expect(onError).toHaveBeenCalled();
207
+ expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
208
+ });
209
+
210
+ it('does NOT call onError when no filters param exists', () => {
211
+ const onError = vi.fn();
212
+ const options: UseCrudOptions = { onError };
213
+
214
+ mockLocation('https://example.com/products');
215
+
216
+ useCrud([textFilter], ['name'], undefined, undefined, options);
217
+
218
+ expect(onError).not.toHaveBeenCalled();
219
+ });
220
+ });
221
+
222
+ // --- UCEXT-004: buildCustomFilters ---
223
+
224
+ describe('buildCustomFilters', () => {
225
+ it('replaces default filter building (UCEXT-004)', () => {
226
+ const options: UseCrudOptions = {
227
+ buildCustomFilters: () => ({ eq: { custom: 1 } }),
228
+ };
229
+
230
+ const crud = useCrud([textFilter], ['name'], undefined, undefined, options);
231
+ crud.activeFilters.value = { name: 'foo' };
232
+ crud.applyFilters();
233
+
234
+ const callUrl = mockRouterGet.mock.calls.find(
235
+ (c: unknown[]) => typeof c[0] === 'string',
236
+ )?.[0] as string | undefined;
237
+
238
+ expect(callUrl).toBeDefined();
239
+ // The custom filter doc should be in the filters param
240
+ expect(callUrl).toContain('filters=');
241
+ const url = new URL(callUrl!, 'https://example.com');
242
+ const filtersParam = url.searchParams.get('filters');
243
+ const decoded = JSON.parse(decodeURIComponent(filtersParam!));
244
+ expect(decoded).toEqual({ eq: { custom: 1 } });
245
+ });
246
+
247
+ it('uses default buildFilters when buildCustomFilters not provided', () => {
248
+ const crud = useCrud([textFilter], ['name'], undefined, undefined);
249
+ crud.activeFilters.value = { name: 'foo' };
250
+ crud.applyFilters();
251
+
252
+ const callUrl = mockRouterGet.mock.calls.find(
253
+ (c: unknown[]) => typeof c[0] === 'string',
254
+ )?.[0] as string | undefined;
255
+
256
+ expect(callUrl).toBeDefined();
257
+ const url = new URL(callUrl!, 'https://example.com');
258
+ const filtersParam = url.searchParams.get('filters');
259
+ const decoded = JSON.parse(decodeURIComponent(filtersParam!));
260
+ expect(decoded).toEqual({ like: { name: 'foo' } });
261
+ });
262
+ });
263
+
264
+ // --- UCEXT-004: searchDebounce ---
265
+
266
+ describe('searchDebounce', () => {
267
+ it('uses custom debounce value (UCEXT-004)', () => {
268
+ const options: UseCrudOptions = { searchDebounce: 500 };
269
+ const crud = useCrud([textFilter], ['name'], undefined, undefined, options);
270
+ mockRouterGet.mockClear();
271
+
272
+ crud.onSearchInput('test');
273
+
274
+ // At 300ms, nothing should fire
275
+ vi.advanceTimersByTime(300);
276
+ let calls = mockRouterGet.mock.calls.filter(
277
+ (c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('search=test'),
278
+ );
279
+ expect(calls.length).toBe(0);
280
+
281
+ // At 500ms, it should fire
282
+ vi.advanceTimersByTime(250);
283
+ calls = mockRouterGet.mock.calls.filter(
284
+ (c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('search=test'),
285
+ );
286
+ expect(calls.length).toBe(1);
287
+ });
288
+
289
+ it('disables debounce when searchDebounce is 0 (UCEXT-004)', () => {
290
+ const options: UseCrudOptions = { searchDebounce: 0 };
291
+ const crud = useCrud([textFilter], ['name'], undefined, undefined, options);
292
+ mockRouterGet.mockClear();
293
+
294
+ crud.onSearchInput('instant');
295
+
296
+ // Should fire synchronously (no setTimeout)
297
+ const calls = mockRouterGet.mock.calls.filter(
298
+ (c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('search=instant'),
299
+ );
300
+ expect(calls.length).toBe(1);
301
+ });
302
+ });
303
+ });
304
+
305
+ // --- UCEXT-003: buildFilters export ---
306
+
307
+ describe('buildFilters (exported)', () => {
308
+ it('handles text filter type', () => {
309
+ const config: CrudFilter[] = [textFilter];
310
+ const result = buildFilters({ name: 'foo' }, config);
311
+ expect(result).toEqual({ like: { name: 'foo' } });
312
+ });
313
+
314
+ it('handles select filter type', () => {
315
+ const config: CrudFilter[] = [selectFilter];
316
+ const result = buildFilters({ status: 'active' }, config);
317
+ expect(result).toEqual({ eq: { status: 'active' } });
318
+ });
319
+
320
+ it('handles number filter type with min and max', () => {
321
+ const config: CrudFilter[] = [numberFilter];
322
+ const result = buildFilters({ age: { min: 18, max: 65 } }, config);
323
+ expect(result).toEqual({ gte: { age: 18 }, lte: { age: 65 } });
324
+ });
325
+
326
+ it('handles date filter type', () => {
327
+ const config: CrudFilter[] = [dateFilter];
328
+ const result = buildFilters(
329
+ { created_at: { from: '2024-01-01', to: '2024-12-31' } },
330
+ config,
331
+ );
332
+ expect(result).toEqual({
333
+ date_gte: { created_at: '2024-01-01' },
334
+ date_lte: { created_at: '2024-12-31' },
335
+ });
336
+ });
337
+
338
+ it('skips empty values', () => {
339
+ const config: CrudFilter[] = [textFilter];
340
+ const result = buildFilters({ name: '' }, config);
341
+ expect(result).toEqual({});
342
+ });
343
+
344
+ it('produces empty FilterDocument for empty state', () => {
345
+ const config: CrudFilter[] = [];
346
+ const result = buildFilters({}, config);
347
+ expect(result).toEqual({});
348
+ });
349
+ });
@@ -0,0 +1,105 @@
1
+ <script setup lang="ts">
2
+ import { Button } from './ui/button';
3
+ import { Card, CardContent } from './ui/card';
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuSeparator,
9
+ DropdownMenuTrigger,
10
+ } from './ui/dropdown-menu';
11
+ import { Separator } from './ui/separator';
12
+ import type { CrudCardConfig, Paginator } from '../types/crud';
13
+ import { router } from '@inertiajs/vue3';
14
+ import { MoreHorizontal, Pencil, Eye, Trash2 } from 'lucide-vue-next';
15
+
16
+ defineProps<{
17
+ items: Paginator;
18
+ card: CrudCardConfig;
19
+ showActions: boolean;
20
+ hasShow: boolean;
21
+ hasEdit: boolean;
22
+ hasDelete: boolean;
23
+ }>();
24
+
25
+ const emit = defineEmits<{
26
+ delete: [item: Record<string, unknown>];
27
+ }>();
28
+ </script>
29
+
30
+ <template>
31
+ <div class="space-y-3 p-4">
32
+ <Card
33
+ v-for="(item, idx) in items.data"
34
+ :key="(item.id as string | number) ?? idx"
35
+ class="overflow-hidden transition-shadow hover:shadow-md"
36
+ >
37
+ <CardContent class="p-4">
38
+ <div class="flex items-start justify-between gap-3">
39
+ <div class="min-w-0 flex-1">
40
+ <slot name="card-content" :item="item" :card="card">
41
+ <h4 class="truncate text-base font-semibold text-foreground">
42
+ {{ card.title(item) }}
43
+ </h4>
44
+ <p
45
+ v-if="card.subtitle"
46
+ class="mt-0.5 truncate text-sm text-muted-foreground"
47
+ >
48
+ {{ card.subtitle(item) }}
49
+ </p>
50
+ </slot>
51
+ </div>
52
+
53
+ <slot name="actions" :item="item">
54
+ <DropdownMenu v-if="showActions">
55
+ <DropdownMenuTrigger as-child>
56
+ <Button variant="ghost" size="icon" class="h-8 w-8 shrink-0">
57
+ <MoreHorizontal class="h-4 w-4" />
58
+ </Button>
59
+ </DropdownMenuTrigger>
60
+ <DropdownMenuContent align="end" class="w-36">
61
+ <DropdownMenuItem
62
+ v-if="hasShow && item.show_url"
63
+ @click="router.get(item.show_url as string)"
64
+ >
65
+ <Eye class="mr-2 h-4 w-4" />
66
+ View
67
+ </DropdownMenuItem>
68
+ <DropdownMenuItem
69
+ v-if="hasEdit && item.edit_url"
70
+ @click="router.get(item.edit_url as string)"
71
+ >
72
+ <Pencil class="mr-2 h-4 w-4" />
73
+ Edit
74
+ </DropdownMenuItem>
75
+ <DropdownMenuSeparator v-if="hasDelete && item.delete_url" />
76
+ <DropdownMenuItem
77
+ v-if="hasDelete && item.delete_url"
78
+ class="text-destructive focus:text-destructive"
79
+ @click="emit('delete', item)"
80
+ >
81
+ <Trash2 class="mr-2 h-4 w-4" />
82
+ Delete
83
+ </DropdownMenuItem>
84
+ </DropdownMenuContent>
85
+ </DropdownMenu>
86
+ </slot>
87
+ </div>
88
+
89
+ <template v-if="card.meta && card.meta.length > 0">
90
+ <Separator class="my-3" />
91
+ <div class="grid grid-cols-1 gap-y-1.5">
92
+ <div
93
+ v-for="meta in card.meta"
94
+ :key="meta.label"
95
+ class="flex items-baseline justify-between"
96
+ >
97
+ <span class="text-xs text-muted-foreground">{{ meta.label }}</span>
98
+ <span class="text-xs font-medium text-foreground">{{ meta.value(item) }}</span>
99
+ </div>
100
+ </div>
101
+ </template>
102
+ </CardContent>
103
+ </Card>
104
+ </div>
105
+ </template>
@@ -0,0 +1,80 @@
1
+ <script setup lang="ts">
2
+ import { Button } from './ui/button';
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from './ui/dialog';
11
+ import { useForm } from '@inertiajs/vue3';
12
+ import { AlertTriangle } from 'lucide-vue-next';
13
+ import { ref } from 'vue';
14
+
15
+ const props = defineProps<{
16
+ title?: string;
17
+ entityName?: string;
18
+ }>();
19
+
20
+ const emit = defineEmits<{
21
+ deleted: [];
22
+ }>();
23
+
24
+ const open = ref(false);
25
+ const itemToDelete = ref<{ id: number | string; name?: string; delete_url?: string } | null>(null);
26
+
27
+ const form = useForm({});
28
+
29
+ function confirm(item: { id: number | string; name?: string; delete_url?: string }) {
30
+ itemToDelete.value = item;
31
+ open.value = true;
32
+ }
33
+
34
+ function close() {
35
+ open.value = false;
36
+ itemToDelete.value = null;
37
+ }
38
+
39
+ function deleteItem() {
40
+ if (!itemToDelete.value?.delete_url) return;
41
+
42
+ form.delete(itemToDelete.value.delete_url, {
43
+ onSuccess: () => {
44
+ close();
45
+ emit('deleted');
46
+ },
47
+ onFinish: () => close(),
48
+ });
49
+ }
50
+
51
+ defineExpose({ confirm, close });
52
+ </script>
53
+
54
+ <template>
55
+ <Dialog :open="open" @update:open="open = $event">
56
+ <DialogContent class="sm:max-w-md">
57
+ <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
58
+ <AlertTriangle class="h-6 w-6 text-destructive" />
59
+ </div>
60
+
61
+ <DialogHeader class="text-center">
62
+ <DialogTitle>{{ title ?? 'Delete Record' }}</DialogTitle>
63
+ <DialogDescription>
64
+ Are you sure you want to delete
65
+ <span class="font-semibold text-foreground">
66
+ {{ itemToDelete?.name ?? `#${itemToDelete?.id}` }}
67
+ </span>?
68
+ This action cannot be undone.
69
+ </DialogDescription>
70
+ </DialogHeader>
71
+
72
+ <DialogFooter class="sm:justify-center">
73
+ <Button variant="outline" @click="close"> Cancel </Button>
74
+ <Button variant="destructive" :disabled="form.processing" @click="deleteItem">
75
+ Delete
76
+ </Button>
77
+ </DialogFooter>
78
+ </DialogContent>
79
+ </Dialog>
80
+ </template>