@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.
- package/package.json +46 -0
- package/src/__test__/CrudCards.test.ts +118 -0
- package/src/__test__/CrudEmptyState.test.ts +82 -0
- package/src/__test__/CrudForm.test.ts +101 -0
- package/src/__test__/CrudShow.test.ts +135 -0
- package/src/__test__/CrudTable.test.ts +111 -0
- package/src/__test__/CrudToolbar.test.ts +102 -0
- package/src/__test__/setup.ts +43 -0
- package/src/__test__/useCrud.test.ts +349 -0
- package/src/components/CrudCards.vue +105 -0
- package/src/components/CrudDeleteDialog.vue +80 -0
- package/src/components/CrudEmptyState.vue +58 -0
- package/src/components/CrudFilters.vue +194 -0
- package/src/components/CrudForm.vue +232 -0
- package/src/components/CrudPage.vue +206 -0
- package/src/components/CrudPagination.vue +130 -0
- package/src/components/CrudSearch.vue +42 -0
- package/src/components/CrudShow.vue +216 -0
- package/src/components/CrudTable.vue +146 -0
- package/src/components/CrudToolbar.vue +86 -0
- package/src/components/InputError.vue +13 -0
- package/src/components/ui/button/Button.vue +27 -0
- package/src/components/ui/button/index.ts +36 -0
- package/src/components/ui/card/Card.vue +22 -0
- package/src/components/ui/card/CardAction.vue +17 -0
- package/src/components/ui/card/CardContent.vue +17 -0
- package/src/components/ui/card/CardDescription.vue +17 -0
- package/src/components/ui/card/CardFooter.vue +17 -0
- package/src/components/ui/card/CardHeader.vue +17 -0
- package/src/components/ui/card/CardTitle.vue +17 -0
- package/src/components/ui/card/index.ts +7 -0
- package/src/components/ui/checkbox/Checkbox.vue +37 -0
- package/src/components/ui/checkbox/index.ts +1 -0
- package/src/components/ui/combobox/ComboboxInput.vue +83 -0
- package/src/components/ui/combobox/index.ts +1 -0
- package/src/components/ui/dialog/Dialog.vue +17 -0
- package/src/components/ui/dialog/DialogClose.vue +14 -0
- package/src/components/ui/dialog/DialogContent.vue +49 -0
- package/src/components/ui/dialog/DialogDescription.vue +25 -0
- package/src/components/ui/dialog/DialogFooter.vue +15 -0
- package/src/components/ui/dialog/DialogHeader.vue +17 -0
- package/src/components/ui/dialog/DialogOverlay.vue +23 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +59 -0
- package/src/components/ui/dialog/DialogTitle.vue +25 -0
- package/src/components/ui/dialog/DialogTrigger.vue +14 -0
- package/src/components/ui/dialog/index.ts +10 -0
- package/src/components/ui/dropdown-menu/DropdownMenu.vue +17 -0
- package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +41 -0
- package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +39 -0
- package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +14 -0
- package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +30 -0
- package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +22 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +22 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +42 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +26 -0
- package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +17 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +19 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +31 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +30 -0
- package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +16 -0
- package/src/components/ui/dropdown-menu/index.ts +16 -0
- package/src/components/ui/input/Input.vue +33 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/label/Label.vue +28 -0
- package/src/components/ui/label/index.ts +1 -0
- package/src/components/ui/select/Select.vue +15 -0
- package/src/components/ui/select/SelectContent.vue +49 -0
- package/src/components/ui/select/SelectGroup.vue +17 -0
- package/src/components/ui/select/SelectItem.vue +41 -0
- package/src/components/ui/select/SelectItemText.vue +12 -0
- package/src/components/ui/select/SelectLabel.vue +14 -0
- package/src/components/ui/select/SelectScrollDownButton.vue +22 -0
- package/src/components/ui/select/SelectScrollUpButton.vue +22 -0
- package/src/components/ui/select/SelectSeparator.vue +15 -0
- package/src/components/ui/select/SelectTrigger.vue +29 -0
- package/src/components/ui/select/SelectValue.vue +12 -0
- package/src/components/ui/select/index.ts +11 -0
- package/src/components/ui/separator/Separator.vue +28 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/spinner/Spinner.vue +17 -0
- package/src/components/ui/spinner/index.ts +1 -0
- package/src/components/ui/table/Table.vue +16 -0
- package/src/components/ui/table/TableBody.vue +14 -0
- package/src/components/ui/table/TableCaption.vue +14 -0
- package/src/components/ui/table/TableCell.vue +21 -0
- package/src/components/ui/table/TableEmpty.vue +34 -0
- package/src/components/ui/table/TableFooter.vue +14 -0
- package/src/components/ui/table/TableHead.vue +14 -0
- package/src/components/ui/table/TableHeader.vue +14 -0
- package/src/components/ui/table/TableRow.vue +14 -0
- package/src/components/ui/table/index.ts +9 -0
- package/src/composables/useCrud.ts +328 -0
- package/src/index.ts +33 -0
- package/src/lib/utils.ts +18 -0
- 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>
|