@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
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
+ });