@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
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@afurgeri/crud-vue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Configurable CRUD components for Laravel + Inertia + Vue — tables, forms, detail views, filters, search, mobile cards",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "afurgeri",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"laravel",
|
|
10
|
+
"inertia",
|
|
11
|
+
"vue",
|
|
12
|
+
"crud",
|
|
13
|
+
"datatable",
|
|
14
|
+
"shadcn",
|
|
15
|
+
"tailwind"
|
|
16
|
+
],
|
|
17
|
+
"main": "./src/index.ts",
|
|
18
|
+
"types": "./src/index.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "vitest --run",
|
|
24
|
+
"test:watch": "vitest"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src/**/*"
|
|
28
|
+
],
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@inertiajs/vue3": "^2.0",
|
|
31
|
+
"lucide-vue-next": "^0.460.0",
|
|
32
|
+
"reka-ui": "^2.0",
|
|
33
|
+
"vue": "^3.5"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@vueuse/core": "^12.0",
|
|
37
|
+
"class-variance-authority": "^0.7.1",
|
|
38
|
+
"clsx": "^2.1.1",
|
|
39
|
+
"tailwind-merge": "^2.5.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
43
|
+
"@vue/test-utils": "^2.4.10",
|
|
44
|
+
"jsdom": "^29.1.1"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import CrudCards from '../components/CrudCards.vue';
|
|
4
|
+
|
|
5
|
+
const mockItems = {
|
|
6
|
+
data: [
|
|
7
|
+
{ id: 1, name: 'Product A', status: 'active', show_url: '/items/1', edit_url: '/items/1/edit', delete_url: '/items/1' },
|
|
8
|
+
{ id: 2, name: 'Product B', status: 'inactive', show_url: '/items/2', edit_url: '/items/2/edit', delete_url: '/items/2' },
|
|
9
|
+
],
|
|
10
|
+
links: [],
|
|
11
|
+
from: 1,
|
|
12
|
+
to: 2,
|
|
13
|
+
total: 2,
|
|
14
|
+
current_page: 1,
|
|
15
|
+
last_page: 1,
|
|
16
|
+
per_page: 20,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const cardConfig = {
|
|
20
|
+
title: (item: Record<string, unknown>) => item.name as string,
|
|
21
|
+
subtitle: (item: Record<string, unknown>) => `Status: ${item.status}`,
|
|
22
|
+
meta: [
|
|
23
|
+
{ label: 'ID', value: (item: Record<string, unknown>) => String(item.id) },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const globalStubs = {
|
|
28
|
+
Card: { template: '<div class="card"><slot name="default" /></div>' },
|
|
29
|
+
CardContent: { template: '<div class="card-content"><slot /></div>' },
|
|
30
|
+
DropdownMenu: { template: '<div class="dropdown-menu"><slot /></div>' },
|
|
31
|
+
DropdownMenuContent: { template: '<div class="dropdown-content"><slot /></div>' },
|
|
32
|
+
DropdownMenuItem: { template: '<div class="dropdown-item"><slot /></div>' },
|
|
33
|
+
DropdownMenuSeparator: true,
|
|
34
|
+
DropdownMenuTrigger: { template: '<button class="dropdown-trigger"><slot /></button>' },
|
|
35
|
+
Button: { template: '<button><slot /></button>' },
|
|
36
|
+
Separator: true,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function mountCards(overrides: Record<string, unknown> = {}, slots: Record<string, string> = {}) {
|
|
40
|
+
return mount(CrudCards, {
|
|
41
|
+
props: {
|
|
42
|
+
items: mockItems,
|
|
43
|
+
card: cardConfig,
|
|
44
|
+
showActions: true,
|
|
45
|
+
hasShow: true,
|
|
46
|
+
hasEdit: true,
|
|
47
|
+
hasDelete: true,
|
|
48
|
+
...overrides,
|
|
49
|
+
},
|
|
50
|
+
slots,
|
|
51
|
+
global: { stubs: globalStubs },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('CrudCards slots', () => {
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.restoreAllMocks();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// CSLOT-002 Scenario: Default card actions
|
|
61
|
+
it('renders default actions dropdown when #actions is not provided (CSLOT-002)', () => {
|
|
62
|
+
const wrapper = mountCards();
|
|
63
|
+
|
|
64
|
+
// Should render 2 cards
|
|
65
|
+
expect(wrapper.findAll('.card').length).toBe(2);
|
|
66
|
+
// Default dropdowns should be present in each card
|
|
67
|
+
expect(wrapper.findAll('.dropdown-menu').length).toBe(2);
|
|
68
|
+
// Default View/Edit/Delete items
|
|
69
|
+
const items = wrapper.findAll('.dropdown-item');
|
|
70
|
+
expect(items.length).toBeGreaterThanOrEqual(3);
|
|
71
|
+
const texts = items.map(el => el.text());
|
|
72
|
+
expect(texts.some(t => t.includes('View'))).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// CSLOT-002 Scenario: Custom card actions
|
|
76
|
+
it('renders custom #actions content when slot is provided (CSLOT-002)', () => {
|
|
77
|
+
const wrapper = mountCards({}, {
|
|
78
|
+
actions: '<span class="my-action">Edit {{ item.name }}</span>',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const customActions = wrapper.findAll('.my-action');
|
|
82
|
+
expect(customActions.length).toBe(2);
|
|
83
|
+
expect(customActions[0]!.text()).toBe('Edit Product A');
|
|
84
|
+
expect(customActions[1]!.text()).toBe('Edit Product B');
|
|
85
|
+
|
|
86
|
+
// Default dropdown should NOT appear
|
|
87
|
+
expect(wrapper.findAll('.dropdown-menu').length).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// CSLOT-003 Scenario: Default card content
|
|
91
|
+
it('renders default card content (title, subtitle, meta) when #card-content is not provided (CSLOT-003)', () => {
|
|
92
|
+
const wrapper = mountCards();
|
|
93
|
+
|
|
94
|
+
// Title should be visible
|
|
95
|
+
expect(wrapper.text()).toContain('Product A');
|
|
96
|
+
expect(wrapper.text()).toContain('Product B');
|
|
97
|
+
// Subtitle
|
|
98
|
+
expect(wrapper.text()).toContain('Status: active');
|
|
99
|
+
// Meta
|
|
100
|
+
expect(wrapper.text()).toContain('ID');
|
|
101
|
+
expect(wrapper.text()).toContain('1');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// CSLOT-003 Scenario: Custom card content
|
|
105
|
+
it('renders custom #card-content when slot is provided (CSLOT-003)', () => {
|
|
106
|
+
const wrapper = mountCards({}, {
|
|
107
|
+
'card-content': '<div class="custom-content">{{ item.name }} - (custom)</div>',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const customContents = wrapper.findAll('.custom-content');
|
|
111
|
+
expect(customContents.length).toBe(2);
|
|
112
|
+
expect(customContents[0]!.text()).toBe('Product A - (custom)');
|
|
113
|
+
expect(customContents[1]!.text()).toBe('Product B - (custom)');
|
|
114
|
+
|
|
115
|
+
// Default title/subtitle/meta should NOT appear
|
|
116
|
+
expect(wrapper.text()).not.toContain('Status: active');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import CrudEmptyState from '../components/CrudEmptyState.vue';
|
|
4
|
+
|
|
5
|
+
function mountEmptyState(overrides: Record<string, unknown> = {}, slots: Record<string, string> = {}) {
|
|
6
|
+
return mount(CrudEmptyState, {
|
|
7
|
+
props: {
|
|
8
|
+
hasFilters: false,
|
|
9
|
+
create: { url: '/products/create', label: 'Create' },
|
|
10
|
+
...overrides,
|
|
11
|
+
},
|
|
12
|
+
slots,
|
|
13
|
+
global: {
|
|
14
|
+
stubs: {
|
|
15
|
+
Button: { template: '<button class="btn"><slot /></button>' },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('CrudEmptyState named slots', () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// CSLOT-007 Scenario: Default empty state
|
|
27
|
+
it('renders default icon, message, and actions when no slots provided (CSLOT-007)', () => {
|
|
28
|
+
const wrapper = mountEmptyState();
|
|
29
|
+
|
|
30
|
+
// Default message text
|
|
31
|
+
expect(wrapper.text()).toContain('No records yet');
|
|
32
|
+
// Default action button
|
|
33
|
+
expect(wrapper.text()).toContain('Create');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// CSLOT-007 Scenario: With filters applied (no results found variant)
|
|
37
|
+
it('renders "No results found" when hasFilters is true (CSLOT-007)', () => {
|
|
38
|
+
const wrapper = mountEmptyState({ hasFilters: true });
|
|
39
|
+
|
|
40
|
+
expect(wrapper.text()).toContain('No results found');
|
|
41
|
+
expect(wrapper.text()).toContain('Clear Filters');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// CSLOT-007 Scenario: Custom icon
|
|
45
|
+
it('renders custom #icon content (CSLOT-007)', () => {
|
|
46
|
+
const wrapper = mountEmptyState({}, {
|
|
47
|
+
icon: '<span class="custom-icon">🎯</span>',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const customIcon = wrapper.find('.custom-icon');
|
|
51
|
+
expect(customIcon.exists()).toBe(true);
|
|
52
|
+
expect(customIcon.text()).toBe('🎯');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// CSLOT-007 Scenario: Custom message
|
|
56
|
+
it('renders custom #message content (CSLOT-007)', () => {
|
|
57
|
+
const wrapper = mountEmptyState({}, {
|
|
58
|
+
message: '<p class="custom-msg">Nothing here!</p>',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const customMsg = wrapper.find('.custom-msg');
|
|
62
|
+
expect(customMsg.exists()).toBe(true);
|
|
63
|
+
expect(customMsg.text()).toBe('Nothing here!');
|
|
64
|
+
|
|
65
|
+
// Default message should NOT be present
|
|
66
|
+
expect(wrapper.text()).not.toContain('No records yet');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// CSLOT-007 Scenario: Custom actions
|
|
70
|
+
it('renders custom #actions content (CSLOT-007)', () => {
|
|
71
|
+
const wrapper = mountEmptyState({}, {
|
|
72
|
+
actions: '<button class="custom-action">Import Data</button>',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const customAction = wrapper.find('.custom-action');
|
|
76
|
+
expect(customAction.exists()).toBe(true);
|
|
77
|
+
expect(customAction.text()).toBe('Import Data');
|
|
78
|
+
|
|
79
|
+
// Default Create button should NOT be present
|
|
80
|
+
expect(wrapper.text()).not.toContain('Create');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import CrudForm from '../components/CrudForm.vue';
|
|
4
|
+
|
|
5
|
+
const formFields = [
|
|
6
|
+
{ name: 'name', label: 'Name', type: 'text' as const, required: true },
|
|
7
|
+
{ name: 'email', label: 'Email', type: 'email' as const },
|
|
8
|
+
{ name: 'description', label: 'Description', type: 'textarea' as const },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const globalStubs = {
|
|
12
|
+
Card: { template: '<div class="card"><slot name="default" /></div>' },
|
|
13
|
+
CardHeader: { template: '<div><slot /></div>' },
|
|
14
|
+
CardTitle: { template: '<h2><slot /></h2>' },
|
|
15
|
+
CardDescription: { template: '<p><slot /></p>' },
|
|
16
|
+
CardContent: { template: '<div class="card-content"><slot /></div>' },
|
|
17
|
+
Input: { template: '<input />', props: ['type', 'modelValue', 'placeholder', 'required', 'disabled'] },
|
|
18
|
+
Label: { template: '<label><slot /></label>' },
|
|
19
|
+
InputError: { template: '<span class="input-error"><slot /></span>' },
|
|
20
|
+
Select: { template: '<div class="select"><slot /></div>' },
|
|
21
|
+
SelectContent: { template: '<div><slot /></div>' },
|
|
22
|
+
SelectItem: { template: '<div><slot /></div>' },
|
|
23
|
+
SelectTrigger: { template: '<div><slot /></div>' },
|
|
24
|
+
SelectValue: { template: '<span><slot /></span>' },
|
|
25
|
+
Checkbox: { template: '<input type="checkbox" />' },
|
|
26
|
+
Spinner: { template: '<span class="spinner" />' },
|
|
27
|
+
ComboboxInput: { template: '<div class="combobox" />' },
|
|
28
|
+
Button: { template: '<button class="btn"><slot /></button>' },
|
|
29
|
+
Link: { template: '<a><slot /></a>' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function mountForm(overrides: Record<string, unknown> = {}, slots: Record<string, string> = {}) {
|
|
33
|
+
return mount(CrudForm, {
|
|
34
|
+
props: {
|
|
35
|
+
title: 'Create Product',
|
|
36
|
+
fields: formFields,
|
|
37
|
+
action: '/products',
|
|
38
|
+
method: 'post' as const,
|
|
39
|
+
initialData: { name: '', email: '', description: '' },
|
|
40
|
+
backRoute: '/products',
|
|
41
|
+
...overrides,
|
|
42
|
+
},
|
|
43
|
+
slots,
|
|
44
|
+
global: { stubs: globalStubs },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('CrudForm #field-{name} dynamic slots', () => {
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
vi.restoreAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// CSLOT-005 Scenario: Default field rendering unchanged
|
|
54
|
+
it('renders all fields with default type-specific templates (CSLOT-005)', () => {
|
|
55
|
+
const wrapper = mountForm();
|
|
56
|
+
|
|
57
|
+
// Field labels should be present (uses Label stub)
|
|
58
|
+
expect(wrapper.text()).toContain('Name');
|
|
59
|
+
expect(wrapper.text()).toContain('Email');
|
|
60
|
+
expect(wrapper.text()).toContain('Description');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// CSLOT-005 Scenario: Override a specific field
|
|
64
|
+
it('renders custom #field-{name} slot for a specific field (CSLOT-005)', () => {
|
|
65
|
+
const wrapper = mountForm({}, {
|
|
66
|
+
'field-email': '<div class="custom-email-field">{{ field.label }} — Custom</div>',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const customField = wrapper.find('.custom-email-field');
|
|
70
|
+
expect(customField.exists()).toBe(true);
|
|
71
|
+
expect(customField.text()).toBe('Email — Custom');
|
|
72
|
+
|
|
73
|
+
// Other fields should still render defaults
|
|
74
|
+
expect(wrapper.text()).toContain('Name');
|
|
75
|
+
expect(wrapper.text()).toContain('Description');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// CSLOT-005: Multiple field overrides
|
|
79
|
+
it('supports overriding multiple fields (CSLOT-005)', () => {
|
|
80
|
+
const wrapper = mountForm({}, {
|
|
81
|
+
'field-name': '<div class="custom-name">Name override</div>',
|
|
82
|
+
'field-email': '<div class="custom-email">Email override</div>',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(wrapper.find('.custom-name').exists()).toBe(true);
|
|
86
|
+
expect(wrapper.find('.custom-email').exists()).toBe(true);
|
|
87
|
+
// Description still default
|
|
88
|
+
expect(wrapper.text()).toContain('Description');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// CSLOT-005: Slot receives { field, form } props
|
|
92
|
+
it('passes field and form as slot props (CSLOT-005)', () => {
|
|
93
|
+
const wrapper = mountForm({}, {
|
|
94
|
+
'field-name': '<div class="prop-test">{{ field.name }}|{{ field.type }}</div>',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const propTest = wrapper.find('.prop-test');
|
|
98
|
+
expect(propTest.exists()).toBe(true);
|
|
99
|
+
expect(propTest.text()).toBe('name|text');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import CrudShow from '../components/CrudShow.vue';
|
|
4
|
+
|
|
5
|
+
const entity = {
|
|
6
|
+
id: 1,
|
|
7
|
+
name: 'Test Product',
|
|
8
|
+
status: 'active',
|
|
9
|
+
email: 'test@example.com',
|
|
10
|
+
phone: '+1234567890',
|
|
11
|
+
price: 99.99,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const sections = [
|
|
15
|
+
{
|
|
16
|
+
title: 'Details',
|
|
17
|
+
icon: 'file-text',
|
|
18
|
+
fields: [
|
|
19
|
+
{ label: 'Name', key: 'name', type: 'text' as const },
|
|
20
|
+
{ label: 'Status', key: 'status', type: 'badge' as const, badgeClass: 'bg-green-100 text-green-700' },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
title: 'Contact',
|
|
25
|
+
fields: [
|
|
26
|
+
{ label: 'Email', key: 'email', type: 'email' as const },
|
|
27
|
+
{ label: 'Phone', key: 'phone', type: 'phone' as const },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
title: 'Pricing',
|
|
32
|
+
fields: [
|
|
33
|
+
{ label: 'Price', key: 'price', type: 'money' as const },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const globalStubs = {
|
|
39
|
+
Card: { template: '<div class="card"><slot name="default" /></div>' },
|
|
40
|
+
CardHeader: { template: '<div><slot /></div>' },
|
|
41
|
+
CardTitle: { template: '<h3><slot /></h3>' },
|
|
42
|
+
CardContent: { template: '<div class="card-content"><slot /></div>' },
|
|
43
|
+
Button: { template: '<button class="btn"><slot /></button>' },
|
|
44
|
+
Link: { template: '<a><slot /></a>' },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function mountShow(overrides: Record<string, unknown> = {}, slots: Record<string, string> = {}) {
|
|
48
|
+
return mount(CrudShow, {
|
|
49
|
+
props: {
|
|
50
|
+
title: 'Product Details',
|
|
51
|
+
entity,
|
|
52
|
+
sections,
|
|
53
|
+
backRoute: '/products',
|
|
54
|
+
...overrides,
|
|
55
|
+
},
|
|
56
|
+
slots,
|
|
57
|
+
global: { stubs: globalStubs },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('CrudShow #field-{key} dynamic slots', () => {
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.restoreAllMocks();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// CSLOT-006 Scenario: Default show field rendering
|
|
67
|
+
it('renders fields with built-in type-specific templates (CSLOT-006)', () => {
|
|
68
|
+
const wrapper = mountShow();
|
|
69
|
+
|
|
70
|
+
// Field labels should be present
|
|
71
|
+
expect(wrapper.text()).toContain('Name');
|
|
72
|
+
expect(wrapper.text()).toContain('Status');
|
|
73
|
+
expect(wrapper.text()).toContain('Email');
|
|
74
|
+
expect(wrapper.text()).toContain('Price');
|
|
75
|
+
|
|
76
|
+
// Values (computed from entity) should be present
|
|
77
|
+
expect(wrapper.text()).toContain('Test Product');
|
|
78
|
+
expect(wrapper.text()).toContain('active');
|
|
79
|
+
expect(wrapper.text()).toContain('test@example.com');
|
|
80
|
+
expect(wrapper.text()).toContain('$99.99');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// CSLOT-006 Scenario: Custom show field
|
|
84
|
+
it('renders custom #field-{key} slot override (CSLOT-006)', () => {
|
|
85
|
+
const wrapper = mountShow({}, {
|
|
86
|
+
'field-status': '<span class="custom-status">CUSTOM: {{ value }}</span>',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const customStatus = wrapper.find('.custom-status');
|
|
90
|
+
expect(customStatus.exists()).toBe(true);
|
|
91
|
+
expect(customStatus.text()).toBe('CUSTOM: active');
|
|
92
|
+
|
|
93
|
+
// Other fields still use defaults
|
|
94
|
+
expect(wrapper.text()).toContain('Test Product');
|
|
95
|
+
expect(wrapper.text()).toContain('$99.99');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// CSLOT-006: Slot receives { field, value, entity } props
|
|
99
|
+
it('passes field, value, and entity as slot props (CSLOT-006)', () => {
|
|
100
|
+
const wrapper = mountShow({}, {
|
|
101
|
+
'field-email': '<span class="prop-test">{{ field.label }}: {{ value }} (id={{ entity.id }})</span>',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const propTest = wrapper.find('.prop-test');
|
|
105
|
+
expect(propTest.exists()).toBe(true);
|
|
106
|
+
expect(propTest.text()).toBe('Email: test@example.com (id=1)');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// CSLOT-006: Override a money field
|
|
110
|
+
it('renders custom override for money field type (CSLOT-006)', () => {
|
|
111
|
+
const wrapper = mountShow({}, {
|
|
112
|
+
'field-price': '<span class="custom-price">€{{ value }}</span>',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const customPrice = wrapper.find('.custom-price');
|
|
116
|
+
expect(customPrice.exists()).toBe(true);
|
|
117
|
+
expect(customPrice.text()).toBe('€99.99');
|
|
118
|
+
|
|
119
|
+
// Default $ format should not appear
|
|
120
|
+
expect(wrapper.text()).not.toContain('$99.99');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// CSLOT-006: Multiple field overrides
|
|
124
|
+
it('supports overriding multiple fields (CSLOT-006)', () => {
|
|
125
|
+
const wrapper = mountShow({}, {
|
|
126
|
+
'field-name': '<span class="name-override">NAME: {{ value }}</span>',
|
|
127
|
+
'field-email': '<span class="email-override">EMAIL: {{ value }}</span>',
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(wrapper.find('.name-override').exists()).toBe(true);
|
|
131
|
+
expect(wrapper.find('.email-override').exists()).toBe(true);
|
|
132
|
+
// Defaults for other fields
|
|
133
|
+
expect(wrapper.text()).toContain('$99.99');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import CrudTable from '../components/CrudTable.vue';
|
|
4
|
+
|
|
5
|
+
const mockItems = {
|
|
6
|
+
data: [
|
|
7
|
+
{ id: 1, name: 'Item 1', status: 'active', show_url: '/items/1', edit_url: '/items/1/edit', delete_url: '/items/1' },
|
|
8
|
+
{ id: 2, name: 'Item 2', status: 'inactive', show_url: '/items/2', edit_url: '/items/2/edit', delete_url: '/items/2' },
|
|
9
|
+
],
|
|
10
|
+
links: [],
|
|
11
|
+
from: 1,
|
|
12
|
+
to: 2,
|
|
13
|
+
total: 2,
|
|
14
|
+
current_page: 1,
|
|
15
|
+
last_page: 1,
|
|
16
|
+
per_page: 20,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const columns = [
|
|
20
|
+
{ key: 'name', title: 'Name', sortable: true },
|
|
21
|
+
{ key: 'status', title: 'Status' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Stub all UI dependencies to avoid broken internal imports
|
|
25
|
+
const globalStubs = {
|
|
26
|
+
Table: { template: '<table><slot /></table>' },
|
|
27
|
+
TableHeader: { template: '<thead><slot /></thead>' },
|
|
28
|
+
TableBody: { template: '<tbody><slot /></tbody>' },
|
|
29
|
+
TableRow: { template: '<tr><slot /></tr>' },
|
|
30
|
+
TableHead: { template: '<th><slot /></th>' },
|
|
31
|
+
TableCell: { template: '<td><slot /></td>' },
|
|
32
|
+
DropdownMenu: { template: '<div class="dropdown-menu"><slot /></div>' },
|
|
33
|
+
DropdownMenuContent: { template: '<div class="dropdown-content"><slot /></div>' },
|
|
34
|
+
DropdownMenuItem: { template: '<div class="dropdown-item"><slot /></div>' },
|
|
35
|
+
DropdownMenuSeparator: { template: '<div class="dropdown-sep" />' },
|
|
36
|
+
DropdownMenuTrigger: { template: '<button class="dropdown-trigger"><slot /></button>' },
|
|
37
|
+
Button: { template: '<button><slot /></button>' },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function mountTable(overrides: Record<string, unknown> = {}, slots: Record<string, string> = {}) {
|
|
41
|
+
return mount(CrudTable, {
|
|
42
|
+
props: {
|
|
43
|
+
items: mockItems,
|
|
44
|
+
columns,
|
|
45
|
+
sortField: '',
|
|
46
|
+
sortDirection: 'asc' as const,
|
|
47
|
+
showActions: true,
|
|
48
|
+
hasShow: true,
|
|
49
|
+
hasEdit: true,
|
|
50
|
+
hasDelete: true,
|
|
51
|
+
...overrides,
|
|
52
|
+
},
|
|
53
|
+
slots,
|
|
54
|
+
global: { stubs: globalStubs },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('CrudTable #actions slot', () => {
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.restoreAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// CSLOT-001 Scenario: Default fallback renders actions dropdown
|
|
64
|
+
it('renders default actions dropdown when #actions is not provided (CSLOT-001)', () => {
|
|
65
|
+
const wrapper = mountTable();
|
|
66
|
+
|
|
67
|
+
// Each row should have a dropdown menu (default behavior)
|
|
68
|
+
const dropdowns = wrapper.findAll('.dropdown-menu');
|
|
69
|
+
expect(dropdowns.length).toBe(2);
|
|
70
|
+
|
|
71
|
+
// Default items should include View, Edit, Delete
|
|
72
|
+
const allItems = wrapper.findAll('.dropdown-item');
|
|
73
|
+
expect(allItems.length).toBeGreaterThanOrEqual(3);
|
|
74
|
+
const texts = allItems.map(el => el.text());
|
|
75
|
+
expect(texts.some(t => t.includes('View'))).toBe(true);
|
|
76
|
+
expect(texts.some(t => t.includes('Edit'))).toBe(true);
|
|
77
|
+
expect(texts.some(t => t.includes('Delete'))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// CSLOT-001 Scenario: Consumer overrides actions
|
|
81
|
+
it('renders custom #actions content when slot is provided (CSLOT-001)', () => {
|
|
82
|
+
const wrapper = mountTable({}, {
|
|
83
|
+
actions: '<div class="custom-action">Custom: {{ item.name }}</div>',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const customActions = wrapper.findAll('.custom-action');
|
|
87
|
+
expect(customActions.length).toBe(2);
|
|
88
|
+
expect(customActions[0]!.text()).toBe('Custom: Item 1');
|
|
89
|
+
expect(customActions[1]!.text()).toBe('Custom: Item 2');
|
|
90
|
+
|
|
91
|
+
// Default dropdown items should NOT be present
|
|
92
|
+
const allItems = wrapper.findAll('.dropdown-item');
|
|
93
|
+
expect(allItems.length).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// CSLOT-001 Scenario: Actions slot not rendered when showActions is false
|
|
97
|
+
it('does not render actions column when showActions is false (CSLOT-001)', () => {
|
|
98
|
+
const wrapper = mountTable({ showActions: false });
|
|
99
|
+
|
|
100
|
+
// When showActions is false, no dropdown menus should be present
|
|
101
|
+
const dropdowns = wrapper.findAll('.dropdown-menu');
|
|
102
|
+
expect(dropdowns.length).toBe(0);
|
|
103
|
+
|
|
104
|
+
// Even custom action slots should not appear
|
|
105
|
+
const wrapper2 = mountTable({ showActions: false }, {
|
|
106
|
+
actions: '<span class="should-not-appear">Nope</span>',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(wrapper2.find('.should-not-appear').exists()).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|