@bobbykim/manguito-cms-admin 0.1.0 → 0.2.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 (70) hide show
  1. package/LICENSE.md +21 -0
  2. package/index.html +13 -0
  3. package/package.json +19 -7
  4. package/public/favicon.ico +0 -0
  5. package/src/App.vue +147 -0
  6. package/src/adapters/admin.ts +13 -0
  7. package/src/components/.gitkeep +0 -0
  8. package/src/components/fields/BooleanToggle.vue +63 -0
  9. package/src/components/fields/DatePicker.vue +61 -0
  10. package/src/components/fields/EnumSelect.vue +69 -0
  11. package/src/components/fields/MediaUpload.vue +445 -0
  12. package/src/components/fields/NumberInput.vue +66 -0
  13. package/src/components/fields/ParagraphEmbed.vue +149 -0
  14. package/src/components/fields/ReferenceSelect.vue +275 -0
  15. package/src/components/fields/RichTextEditor.vue +293 -0
  16. package/src/components/fields/TextInput.vue +93 -0
  17. package/src/components/fields/__tests__/BooleanToggle.test.ts +37 -0
  18. package/src/components/fields/__tests__/DatePicker.test.ts +37 -0
  19. package/src/components/fields/__tests__/EnumSelect.test.ts +53 -0
  20. package/src/components/fields/__tests__/NumberInput.test.ts +64 -0
  21. package/src/components/fields/__tests__/ReferenceSelect.test.ts +196 -0
  22. package/src/components/fields/__tests__/RichTextEditor.test.ts +81 -0
  23. package/src/components/fields/__tests__/TextInput.test.ts +72 -0
  24. package/src/components/layout/AppShell.vue +33 -0
  25. package/src/components/layout/Footer.vue +11 -0
  26. package/src/components/layout/Sidebar.vue +254 -0
  27. package/src/components/layout/Topbar.vue +46 -0
  28. package/src/components/shared/ConfirmDialog.vue +38 -0
  29. package/src/components/shared/MediaSelectModal.vue +273 -0
  30. package/src/components/shared/Pagination.vue +93 -0
  31. package/src/components/shared/StatusBadge.vue +14 -0
  32. package/src/components/shared/ToastContainer.vue +44 -0
  33. package/src/composables/.gitkeep +0 -0
  34. package/src/composables/__tests__/useApiClient.test.ts +90 -0
  35. package/src/composables/__tests__/useFormValidation.test.ts +83 -0
  36. package/src/composables/__tests__/usePermission.test.ts +116 -0
  37. package/src/composables/useApiClient.ts +68 -0
  38. package/src/composables/useDirtyState.ts +28 -0
  39. package/src/composables/useFormValidation.ts +107 -0
  40. package/src/composables/useNotification.ts +26 -0
  41. package/src/composables/usePermission.ts +20 -0
  42. package/src/composables/useTabIndicator.ts +25 -0
  43. package/src/env.d.ts +2 -0
  44. package/src/main.ts +21 -0
  45. package/src/router/index.ts +174 -0
  46. package/src/stores/auth.ts +82 -0
  47. package/src/stores/content.ts +40 -0
  48. package/src/stores/media.ts +31 -0
  49. package/src/stores/schema.ts +130 -0
  50. package/src/stores/taxonomy.ts +40 -0
  51. package/src/stores/ui.ts +94 -0
  52. package/src/stores/users.ts +44 -0
  53. package/src/style.css +46 -0
  54. package/src/test-utils/server.ts +54 -0
  55. package/src/types/index.ts +0 -0
  56. package/src/views/.gitkeep +0 -0
  57. package/src/views/ChangePasswordView.vue +134 -0
  58. package/src/views/LoginView.vue +187 -0
  59. package/src/views/RolesView.vue +84 -0
  60. package/src/views/content/ContentFormView.vue +548 -0
  61. package/src/views/content/ContentListView.vue +236 -0
  62. package/src/views/content/ContentSettingsView.vue +122 -0
  63. package/src/views/media/MediaDetailView.vue +222 -0
  64. package/src/views/media/MediaLibraryView.vue +492 -0
  65. package/src/views/taxonomy/TaxonomyFormView.vue +281 -0
  66. package/src/views/taxonomy/TaxonomyListView.vue +198 -0
  67. package/src/views/users/UserFormView.vue +307 -0
  68. package/src/views/users/UserListView.vue +127 -0
  69. package/src/vite-env.d.ts +1 -0
  70. package/vite.config.ts +17 -0
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
4
+ import DatePicker from '../DatePicker.vue'
5
+
6
+ function makeField(overrides: Partial<ParsedField> = {}): ParsedField {
7
+ return {
8
+ name: 'published_at',
9
+ label: 'Published At',
10
+ field_type: 'date',
11
+ required: false,
12
+ nullable: true,
13
+ order: 0,
14
+ validation: { required: false },
15
+ db_column: { column_name: 'published_at', column_type: 'timestamp', nullable: true },
16
+ ui_component: { component: 'date-picker' },
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ describe('DatePicker', () => {
22
+ it('emits an ISO string on change', async () => {
23
+ const wrapper = mount(DatePicker, {
24
+ props: { field: makeField(), modelValue: '' },
25
+ })
26
+ await wrapper.find('input').setValue('2024-06-15T10:30')
27
+ const emitted = wrapper.emitted('update:modelValue')!
28
+ expect(emitted[emitted.length - 1]).toEqual(['2024-06-15T10:30:00.000Z'])
29
+ })
30
+
31
+ it('applies the disabled prop to the input', () => {
32
+ const wrapper = mount(DatePicker, {
33
+ props: { field: makeField(), modelValue: '', disabled: true },
34
+ })
35
+ expect(wrapper.find('input').attributes('disabled')).toBeDefined()
36
+ })
37
+ })
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
4
+ import EnumSelect from '../EnumSelect.vue'
5
+
6
+ function makeField(overrides: Partial<ParsedField> = {}): ParsedField {
7
+ return {
8
+ name: 'status',
9
+ label: 'Status',
10
+ field_type: 'enum',
11
+ required: false,
12
+ nullable: true,
13
+ order: 0,
14
+ validation: { required: false, allowed_values: ['draft', 'published', 'archived'] },
15
+ db_column: { column_name: 'status', column_type: 'varchar', nullable: true },
16
+ ui_component: { component: 'select', options: ['draft', 'published', 'archived'] },
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ describe('EnumSelect', () => {
22
+ it('renders an option for each allowed_value', () => {
23
+ const wrapper = mount(EnumSelect, {
24
+ props: { field: makeField(), modelValue: '' },
25
+ })
26
+ const options = wrapper.findAll('option').map(o => o.text())
27
+ expect(options).toContain('draft')
28
+ expect(options).toContain('published')
29
+ expect(options).toContain('archived')
30
+ })
31
+
32
+ it('emits the selected value on change', async () => {
33
+ const wrapper = mount(EnumSelect, {
34
+ props: { field: makeField(), modelValue: '' },
35
+ })
36
+ await wrapper.find('select').setValue('published')
37
+ const emitted = wrapper.emitted('update:modelValue')!
38
+ expect(emitted[emitted.length - 1]).toEqual(['published'])
39
+ })
40
+
41
+ it('shows "No options available" when allowed_values is empty', () => {
42
+ const wrapper = mount(EnumSelect, {
43
+ props: {
44
+ field: makeField({
45
+ validation: { required: false, allowed_values: [] },
46
+ ui_component: { component: 'select', options: [] },
47
+ }),
48
+ modelValue: '',
49
+ },
50
+ })
51
+ expect(wrapper.text()).toContain('No options available')
52
+ })
53
+ })
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
4
+ import NumberInput from '../NumberInput.vue'
5
+
6
+ function makeField(overrides: Partial<ParsedField> = {}): ParsedField {
7
+ return {
8
+ name: 'count',
9
+ label: 'Count',
10
+ field_type: 'integer',
11
+ required: false,
12
+ nullable: true,
13
+ order: 0,
14
+ validation: { required: false },
15
+ db_column: { column_name: 'count', column_type: 'integer', nullable: true },
16
+ ui_component: { component: 'number-input', step: 1 },
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ describe('NumberInput', () => {
22
+ it('emits numeric value on change', async () => {
23
+ const wrapper = mount(NumberInput, {
24
+ props: { field: makeField(), modelValue: null },
25
+ })
26
+ const input = wrapper.find('input')
27
+ await input.setValue('42')
28
+ const emitted = wrapper.emitted('update:modelValue')!
29
+ expect(emitted[emitted.length - 1]).toEqual([42])
30
+ })
31
+
32
+ it('integer field has step attribute of 1', () => {
33
+ const wrapper = mount(NumberInput, {
34
+ props: { field: makeField({ field_type: 'integer' }), modelValue: null },
35
+ })
36
+ expect(wrapper.find('input').attributes('step')).toBe('1')
37
+ })
38
+
39
+ it('float field has step attribute of 0.01', () => {
40
+ const wrapper = mount(NumberInput, {
41
+ props: {
42
+ field: makeField({
43
+ field_type: 'float',
44
+ db_column: { column_name: 'price', column_type: 'decimal', nullable: true },
45
+ ui_component: { component: 'number-input', step: 0.01 },
46
+ }),
47
+ modelValue: null,
48
+ },
49
+ })
50
+ expect(wrapper.find('input').attributes('step')).toBe('0.01')
51
+ })
52
+
53
+ it('applies min and max from field.validation to input attributes', () => {
54
+ const wrapper = mount(NumberInput, {
55
+ props: {
56
+ field: makeField({ validation: { required: false, min: 0, max: 100 } }),
57
+ modelValue: null,
58
+ },
59
+ })
60
+ const input = wrapper.find('input')
61
+ expect(input.attributes('min')).toBe('0')
62
+ expect(input.attributes('max')).toBe('100')
63
+ })
64
+ })
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { mount, flushPromises } from '@vue/test-utils'
3
+ import { createPinia, setActivePinia } from 'pinia'
4
+ import { http, HttpResponse } from 'msw'
5
+ import type { ParsedField, ParsedContentType } from '@bobbykim/manguito-cms-core'
6
+ import { server } from '../../../test-utils/server'
7
+ import { useSchemaStore } from '../../../stores/schema'
8
+ import ReferenceSelect from '../ReferenceSelect.vue'
9
+
10
+ // useApiClient calls useRouter() — stub it so component setup doesn't throw.
11
+ vi.mock('vue-router', () => ({
12
+ useRouter: () => ({ push: vi.fn() }),
13
+ }))
14
+
15
+ const ADMIN = '/admin'
16
+
17
+ const userContentType: ParsedContentType = {
18
+ schema_type: 'content-type',
19
+ name: 'content--user',
20
+ label: 'User',
21
+ source_file: '',
22
+ only_one: false,
23
+ default_base_path: 'users',
24
+ system_fields: [],
25
+ fields: [
26
+ {
27
+ name: 'title',
28
+ label: 'Title',
29
+ field_type: 'text/plain',
30
+ required: true,
31
+ nullable: false,
32
+ order: 0,
33
+ validation: { required: true },
34
+ db_column: { column_name: 'title', column_type: 'varchar', nullable: false },
35
+ ui_component: { component: 'text-input' },
36
+ },
37
+ ],
38
+ ui: { tabs: [{ name: 'main', label: 'Main', fields: ['title'] }] },
39
+ db: { table_name: 'content_user', junction_tables: [] },
40
+ api: {
41
+ default_base_path: 'users',
42
+ http_methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
43
+ collection_path: '/api/user',
44
+ item_path: '/api/user/:slug',
45
+ },
46
+ }
47
+
48
+ function makeField(overrides: Partial<ParsedField> = {}): ParsedField {
49
+ return {
50
+ name: 'author',
51
+ label: 'Author',
52
+ field_type: 'reference',
53
+ required: false,
54
+ nullable: true,
55
+ order: 0,
56
+ validation: { required: false },
57
+ db_column: { column_name: 'author', column_type: 'uuid', nullable: true },
58
+ ui_component: { component: 'typeahead-select', ref: 'content--user', rel: 'one-to-one' },
59
+ ...overrides,
60
+ }
61
+ }
62
+
63
+ let pinia: ReturnType<typeof createPinia>
64
+
65
+ beforeEach(() => {
66
+ pinia = createPinia()
67
+ setActivePinia(pinia)
68
+ const schemaStore = useSchemaStore()
69
+ schemaStore.setSchema(userContentType)
70
+ vi.useFakeTimers()
71
+ })
72
+
73
+ afterEach(() => {
74
+ vi.useRealTimers()
75
+ })
76
+
77
+ function mountComponent(field: ParsedField, modelValue: unknown = null) {
78
+ return mount(ReferenceSelect, {
79
+ global: { plugins: [pinia] },
80
+ props: { field, modelValue },
81
+ })
82
+ }
83
+
84
+ describe('ReferenceSelect', () => {
85
+ it('does not show dropdown when fewer than 2 chars are typed', async () => {
86
+ const wrapper = mountComponent(makeField())
87
+ await wrapper.find('input').setValue('a')
88
+ await wrapper.find('input').trigger('input')
89
+ await vi.runAllTimersAsync()
90
+ await flushPromises()
91
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
92
+ })
93
+
94
+ it('shows dropdown with result items when MSW returns results', async () => {
95
+ server.use(
96
+ http.get(`${ADMIN}/api/content/content--user`, () =>
97
+ HttpResponse.json({ ok: true, data: [{ id: 'u1', title: 'Alice' }] })
98
+ )
99
+ )
100
+ const wrapper = mountComponent(makeField())
101
+ await wrapper.find('input').setValue('al')
102
+ await wrapper.find('input').trigger('input')
103
+ await vi.runAllTimersAsync()
104
+ await flushPromises()
105
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
106
+ expect(wrapper.text()).toContain('Alice')
107
+ })
108
+
109
+ it('shows "No results found" when MSW returns an empty array', async () => {
110
+ server.use(
111
+ http.get(`${ADMIN}/api/content/content--user`, () =>
112
+ HttpResponse.json({ ok: true, data: [] })
113
+ )
114
+ )
115
+ const wrapper = mountComponent(makeField())
116
+ await wrapper.find('input').setValue('xy')
117
+ await wrapper.find('input').trigger('input')
118
+ await vi.runAllTimersAsync()
119
+ await flushPromises()
120
+ expect(wrapper.text()).toContain('No results found')
121
+ })
122
+
123
+ it('selecting an item emits update:modelValue with the selected id', async () => {
124
+ server.use(
125
+ http.get(`${ADMIN}/api/content/content--user`, () =>
126
+ HttpResponse.json({ ok: true, data: [{ id: 'u1', title: 'Alice' }] })
127
+ )
128
+ )
129
+ const wrapper = mountComponent(makeField())
130
+ await wrapper.find('input').setValue('al')
131
+ await wrapper.find('input').trigger('input')
132
+ await vi.runAllTimersAsync()
133
+ await flushPromises()
134
+
135
+ await wrapper.findAll('[role="option"]')[0]!.trigger('click')
136
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['u1'])
137
+ })
138
+
139
+ it('many-to-many: selecting multiple items shows chips for each', async () => {
140
+ server.use(
141
+ http.get(`${ADMIN}/api/content/content--user`, () =>
142
+ HttpResponse.json({
143
+ ok: true,
144
+ data: [
145
+ { id: 'u1', title: 'Alice' },
146
+ { id: 'u2', title: 'Bob' },
147
+ ],
148
+ })
149
+ )
150
+ )
151
+ const field = makeField({
152
+ ui_component: { component: 'typeahead-select', ref: 'content--user', rel: 'many-to-many' },
153
+ })
154
+ const wrapper = mountComponent(field)
155
+
156
+ // Select first item (Alice)
157
+ await wrapper.find('input').setValue('al')
158
+ await wrapper.find('input').trigger('input')
159
+ await vi.runAllTimersAsync()
160
+ await flushPromises()
161
+ await wrapper.findAll('[role="option"]')[0]!.trigger('click')
162
+ // Simulate parent v-model update so selectedIds reflects the first pick
163
+ await wrapper.setProps({ modelValue: ['u1'] })
164
+ await wrapper.vm.$nextTick()
165
+
166
+ // Select second item (Bob — index 1 since MSW returns both)
167
+ await wrapper.find('input').setValue('bo')
168
+ await wrapper.find('input').trigger('input')
169
+ await vi.runAllTimersAsync()
170
+ await flushPromises()
171
+ await wrapper.findAll('[role="option"]')[1]!.trigger('click')
172
+ await wrapper.vm.$nextTick()
173
+
174
+ const emitted = wrapper.emitted('update:modelValue')!
175
+ const lastEmit = emitted[emitted.length - 1]![0] as string[]
176
+ expect(lastEmit).toContain('u1')
177
+ expect(lastEmit).toContain('u2')
178
+ })
179
+
180
+ it('input is disabled and counter shows X / Y when max_items is reached', async () => {
181
+ server.use(
182
+ http.get(`${ADMIN}/api/content/content--user/:id`, ({ params }) =>
183
+ HttpResponse.json({ ok: true, data: { id: params['id'], title: `User ${params['id']}` } })
184
+ )
185
+ )
186
+ const field = makeField({
187
+ validation: { required: false, max_items: 2 },
188
+ ui_component: { component: 'typeahead-select', ref: 'content--user', rel: 'many-to-many' },
189
+ })
190
+ const wrapper = mountComponent(field, ['u1', 'u2'])
191
+ await flushPromises()
192
+
193
+ expect(wrapper.find('input').attributes('disabled')).toBeDefined()
194
+ expect(wrapper.text()).toContain('2 / 2')
195
+ })
196
+ })
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount, flushPromises } from '@vue/test-utils'
3
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
4
+ import RichTextEditor from '../RichTextEditor.vue'
5
+
6
+ function makeField(overrides: Partial<ParsedField> = {}): ParsedField {
7
+ return {
8
+ name: 'body',
9
+ label: 'Body',
10
+ field_type: 'text/rich',
11
+ required: false,
12
+ nullable: true,
13
+ order: 0,
14
+ validation: { required: false },
15
+ db_column: { column_name: 'body', column_type: 'text', nullable: true },
16
+ ui_component: { component: 'rich-text-editor' },
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ describe('RichTextEditor', () => {
22
+ it('mounts without error', () => {
23
+ expect(() =>
24
+ mount(RichTextEditor, { props: { field: makeField(), modelValue: '' } })
25
+ ).not.toThrow()
26
+ })
27
+
28
+ it('displays the error prop when set', () => {
29
+ const wrapper = mount(RichTextEditor, {
30
+ props: { field: makeField(), modelValue: '', error: 'Content is required' },
31
+ })
32
+ expect(wrapper.text()).toContain('Content is required')
33
+ })
34
+
35
+ it('toolbar buttons have disabled attribute when disabled prop is true', () => {
36
+ const wrapper = mount(RichTextEditor, {
37
+ props: { field: makeField(), modelValue: '', disabled: true },
38
+ })
39
+ const buttons = wrapper.findAll('button')
40
+ expect(buttons.length).toBeGreaterThan(0)
41
+ buttons.forEach(btn => {
42
+ expect(btn.attributes('disabled')).toBeDefined()
43
+ })
44
+ })
45
+
46
+ it('sizes the nested .ProseMirror element itself, not just the outer wrapper, so clicks in empty space focus the editor', async () => {
47
+ const wrapper = mount(RichTextEditor, { props: { field: makeField(), modelValue: '' } })
48
+ await flushPromises()
49
+ const contentWrapper = wrapper.find('.ProseMirror').element.parentElement!
50
+ expect(Array.from(contentWrapper.classList)).toContain('[&_.ProseMirror]:min-h-32')
51
+ expect(Array.from(contentWrapper.classList)).toContain('[&_.ProseMirror]:cursor-text')
52
+ })
53
+
54
+ it('link button toggles the link popover, and Cancel closes it', async () => {
55
+ const wrapper = mount(RichTextEditor, { props: { field: makeField(), modelValue: '' } })
56
+ const linkButton = wrapper.findAll('button').find(b => b.text() === 'Link')
57
+ expect(linkButton).toBeTruthy()
58
+
59
+ expect(wrapper.text()).not.toContain('CSS class')
60
+
61
+ await linkButton!.trigger('click')
62
+ expect(wrapper.text()).toContain('CSS class')
63
+ expect(wrapper.find('input[placeholder="https://example.com"]').exists()).toBe(true)
64
+ expect(wrapper.find('select').exists()).toBe(true)
65
+
66
+ const cancelButton = wrapper.findAll('button').find(b => b.text() === 'Cancel')
67
+ await cancelButton!.trigger('click')
68
+ expect(wrapper.text()).not.toContain('CSS class')
69
+ })
70
+
71
+ it('link button is disabled (no popover) when the field is disabled', async () => {
72
+ const wrapper = mount(RichTextEditor, {
73
+ props: { field: makeField(), modelValue: '', disabled: true },
74
+ })
75
+ const linkButton = wrapper.findAll('button').find(b => b.text() === 'Link')
76
+ expect(linkButton!.attributes('disabled')).toBeDefined()
77
+
78
+ await linkButton!.trigger('click')
79
+ expect(wrapper.text()).not.toContain('CSS class')
80
+ })
81
+ })
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
4
+ import TextInput from '../TextInput.vue'
5
+
6
+ function makeField(overrides: Partial<ParsedField> = {}): ParsedField {
7
+ return {
8
+ name: 'test_field',
9
+ label: 'Test Field',
10
+ field_type: 'text/plain',
11
+ required: false,
12
+ nullable: true,
13
+ order: 0,
14
+ validation: { required: false },
15
+ db_column: { column_name: 'test_field', column_type: 'varchar', nullable: true },
16
+ ui_component: { component: 'text-input' },
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ describe('TextInput', () => {
22
+ it('renders an input element', () => {
23
+ const wrapper = mount(TextInput, {
24
+ props: { field: makeField(), modelValue: '' },
25
+ })
26
+ expect(wrapper.find('input').exists()).toBe(true)
27
+ })
28
+
29
+ it('typing emits update:modelValue with the typed value', async () => {
30
+ const wrapper = mount(TextInput, {
31
+ props: { field: makeField(), modelValue: '' },
32
+ })
33
+ const input = wrapper.find('input')
34
+ await input.setValue('hello world')
35
+ const emitted = wrapper.emitted('update:modelValue')
36
+ expect(emitted).toBeTruthy()
37
+ expect(emitted![emitted!.length - 1]).toEqual(['hello world'])
38
+ })
39
+
40
+ it('renders inline error text when error prop is set', () => {
41
+ const wrapper = mount(TextInput, {
42
+ props: { field: makeField(), modelValue: '', error: 'Required field' },
43
+ })
44
+ expect(wrapper.text()).toContain('Required field')
45
+ })
46
+
47
+ it('does not render error text when error prop is not set', () => {
48
+ const wrapper = mount(TextInput, {
49
+ props: { field: makeField(), modelValue: '' },
50
+ })
51
+ expect(wrapper.find('[role="alert"]').exists()).toBe(false)
52
+ })
53
+
54
+ it('input has disabled attribute when disabled prop is true', () => {
55
+ const wrapper = mount(TextInput, {
56
+ props: { field: makeField(), modelValue: '', disabled: true },
57
+ })
58
+ expect(wrapper.find('input').attributes('disabled')).toBeDefined()
59
+ })
60
+
61
+ it('slug field formats "My Blog Post" to "my-blog-post"', async () => {
62
+ const wrapper = mount(TextInput, {
63
+ props: {
64
+ field: makeField({ name: 'post_slug' }),
65
+ modelValue: '',
66
+ },
67
+ })
68
+ await wrapper.find('input').setValue('My Blog Post')
69
+ const emitted = wrapper.emitted('update:modelValue')!
70
+ expect(emitted[emitted.length - 1]).toEqual(['my-blog-post'])
71
+ })
72
+ })
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ import { useUiStore } from '../../stores/ui'
3
+ import Sidebar from './Sidebar.vue'
4
+ import Topbar from './Topbar.vue'
5
+ import Footer from './Footer.vue'
6
+ import ToastContainer from '../shared/ToastContainer.vue'
7
+
8
+ const uiStore = useUiStore()
9
+ </script>
10
+
11
+ <template>
12
+ <div class="flex h-screen overflow-hidden bg-surface">
13
+ <Sidebar />
14
+
15
+ <!-- Mobile drawer backdrop -->
16
+ <div
17
+ v-if="uiStore.isMobile && uiStore.mobileNavOpen"
18
+ class="fixed inset-0 z-40 bg-[rgba(20,20,40,0.4)] backdrop-blur-[2px]"
19
+ @click="uiStore.closeMobileNav()"
20
+ />
21
+
22
+ <div class="flex min-w-0 flex-1 flex-col">
23
+ <Topbar />
24
+ <main class="flex-1 overflow-y-auto p-6">
25
+ <router-view />
26
+ <Footer />
27
+ </main>
28
+ </div>
29
+
30
+ <!-- Toast stack rendered outside the layout flow so it always overlays -->
31
+ <ToastContainer />
32
+ </div>
33
+ </template>
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ import { useUiStore } from '../../stores/ui'
3
+
4
+ const uiStore = useUiStore()
5
+ </script>
6
+
7
+ <template>
8
+ <footer class="mt-10 border-t border-gray-200 px-1 py-5">
9
+ <p class="text-xs text-gray-400">{{ uiStore.cmsName }}</p>
10
+ </footer>
11
+ </template>