From 26ef3dd8882673ceba8c96e99ff56333d0b0184a Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Fri, 10 Apr 2026 19:31:43 +0300 Subject: [PATCH] feat: manage categories button/modal --- eslint.config.cjs | 2 +- .../CategoryManager/CategoryFormDialog.vue | 206 +++++++++ .../CategoryManager/CategoryManagerDialog.vue | 226 ++++++++++ src/components/CategoryManager/index.ts | 2 + .../CategoryPicker/CategoryPicker.test.ts | 111 ++--- .../CategoryPicker/CategoryPicker.vue | 174 +------- .../HouseSettingsDialog.vue | 392 +----------------- src/views/ChecklistDetail.vue | 19 + src/views/ChecklistsView.vue | 17 + 9 files changed, 509 insertions(+), 640 deletions(-) create mode 100644 src/components/CategoryManager/CategoryFormDialog.vue create mode 100644 src/components/CategoryManager/CategoryManagerDialog.vue create mode 100644 src/components/CategoryManager/index.ts diff --git a/eslint.config.cjs b/eslint.config.cjs index 76bb9e1..92f64ab 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -12,7 +12,7 @@ module.exports = [ ...tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended), { rules: { - 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': [ 'warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, diff --git a/src/components/CategoryManager/CategoryFormDialog.vue b/src/components/CategoryManager/CategoryFormDialog.vue new file mode 100644 index 0000000..f72066c --- /dev/null +++ b/src/components/CategoryManager/CategoryFormDialog.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/src/components/CategoryManager/CategoryManagerDialog.vue b/src/components/CategoryManager/CategoryManagerDialog.vue new file mode 100644 index 0000000..1a52457 --- /dev/null +++ b/src/components/CategoryManager/CategoryManagerDialog.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/src/components/CategoryManager/index.ts b/src/components/CategoryManager/index.ts new file mode 100644 index 0000000..781460f --- /dev/null +++ b/src/components/CategoryManager/index.ts @@ -0,0 +1,2 @@ +export { default as CategoryManagerDialog } from './CategoryManagerDialog.vue' +export { default as CategoryFormDialog } from './CategoryFormDialog.vue' diff --git a/src/components/CategoryPicker/CategoryPicker.test.ts b/src/components/CategoryPicker/CategoryPicker.test.ts index 8808c32..64b5de0 100644 --- a/src/components/CategoryPicker/CategoryPicker.test.ts +++ b/src/components/CategoryPicker/CategoryPicker.test.ts @@ -60,6 +60,17 @@ vi.mock('@nextcloud/vue/components/NcButton', () => ({ props: ['variant', 'disabled', 'type'], }, })) +// Mock the shared CategoryFormDialog with a minimal stub — tests verify +// interaction via its props/events, not its internal markup. +vi.mock('@/components/CategoryManager/CategoryFormDialog.vue', () => ({ + default: { + name: 'CategoryFormDialog', + template: + '
{{ error }}
', + props: ['open', 'category', 'saving', 'error'], + emits: ['update:open', 'save'], + }, +})) // Mock useCategories composable const mockItems = ref([]) @@ -223,63 +234,17 @@ describe('CategoryPicker', () => { global: {}, }) - // Dialog should not be visible initially - expect(wrapper.findComponent({ name: 'NcDialog' }).exists()).toBe(false) - - // Simulate selecting the create option - const select = wrapper.findComponent({ name: 'NcSelect' }) - select.vm.$emit('option:selected', { label: 'Create new category …', create: true }) - await wrapper.vm.$nextTick() - - expect(wrapper.findComponent({ name: 'NcDialog' }).exists()).toBe(true) - }) - - it('create form has name field, icon grid, and color swatches', async () => { - const wrapper = mount(CategoryPicker, { - props: { houseId: 10, modelValue: null }, - global: {}, - }) - - // Open the create dialog - const select = wrapper.findComponent({ name: 'NcSelect' }) - select.vm.$emit('option:selected', { label: 'Create new category …', create: true }) - await wrapper.vm.$nextTick() - - // Name text field - expect(wrapper.findComponent({ name: 'NcTextField' }).exists()).toBe(true) - - // Icon grid - const iconGrid = wrapper.find('.pantry-create-cat__icon-grid') - expect(iconGrid.exists()).toBe(true) - const iconButtons = wrapper.findAll('.pantry-create-cat__icon-button') - expect(iconButtons.length).toBe(19) // 19 icons in CATEGORY_ICONS - - // Color swatches - const colorGrid = wrapper.find('.pantry-create-cat__color-grid') - expect(colorGrid.exists()).toBe(true) - const colorSwatches = wrapper.findAll('.pantry-create-cat__color-swatch') - expect(colorSwatches.length).toBe(10) // 10 colors in CATEGORY_COLORS - }) - - it('shows create and cancel buttons in dialog actions', async () => { - const wrapper = mount(CategoryPicker, { - props: { houseId: 10, modelValue: null }, - global: {}, - }) + // Dialog not visible initially + expect(wrapper.findComponent({ name: 'CategoryFormDialog' }).props('open')).toBe(false) const select = wrapper.findComponent({ name: 'NcSelect' }) select.vm.$emit('option:selected', { label: 'Create new category …', create: true }) await wrapper.vm.$nextTick() - const buttons = wrapper.findAllComponents({ name: 'NcButton' }) - // Cancel button + Create button (inside dialog actions) - expect(buttons.length).toBeGreaterThanOrEqual(2) - const buttonTexts = buttons.map((b) => b.text()) - expect(buttonTexts).toContain('Cancel') - expect(buttonTexts).toContain('Create') + expect(wrapper.findComponent({ name: 'CategoryFormDialog' }).props('open')).toBe(true) }) - it('emits update:modelValue with created category id after creation', async () => { + it('emits update:modelValue with created category id after save event', async () => { const createdCategory = makeCategory({ id: 42, name: 'New Cat' }) mockCreate.mockResolvedValueOnce(createdCategory) @@ -288,21 +253,15 @@ describe('CategoryPicker', () => { global: {}, }) - // Open create dialog + // Open dialog const select = wrapper.findComponent({ name: 'NcSelect' }) select.vm.$emit('option:selected', { label: 'Create new category …', create: true }) await wrapper.vm.$nextTick() - // Set name via the component's internal state - const vm = wrapper.vm as unknown as { newName: string } - vm.newName = 'New Cat' - await wrapper.vm.$nextTick() + // Trigger save from the CategoryFormDialog + const formDialog = wrapper.findComponent({ name: 'CategoryFormDialog' }) + formDialog.vm.$emit('save', { name: 'New Cat', icon: 'tag', color: '#ef4444' }) - // Submit the form - const form = wrapper.find('.pantry-create-cat') - await form.trigger('submit') - - // Wait for async create to resolve await vi.waitFor(() => { expect(wrapper.emitted('update:modelValue')).toBeTruthy() }) @@ -311,7 +270,7 @@ describe('CategoryPicker', () => { expect(emitted[emitted.length - 1]).toEqual([42]) }) - it('calls create with name, icon, and color', async () => { + it('calls create with name, icon, and color from the save event', async () => { const createdCategory = makeCategory({ id: 50, name: 'Snacks' }) mockCreate.mockResolvedValueOnce(createdCategory) @@ -320,21 +279,12 @@ describe('CategoryPicker', () => { global: {}, }) - // Open create dialog const select = wrapper.findComponent({ name: 'NcSelect' }) select.vm.$emit('option:selected', { label: 'Create new category …', create: true }) await wrapper.vm.$nextTick() - // Set the name - const vm = wrapper.vm as unknown as { newName: string; newIcon: string; newColor: string } - vm.newName = 'Snacks' - vm.newIcon = 'snacks' - vm.newColor = '#ef4444' - await wrapper.vm.$nextTick() - - // Submit - const form = wrapper.find('.pantry-create-cat') - await form.trigger('submit') + const formDialog = wrapper.findComponent({ name: 'CategoryFormDialog' }) + formDialog.vm.$emit('save', { name: 'Snacks', icon: 'snacks', color: '#ef4444' }) await vi.waitFor(() => { expect(mockCreate).toHaveBeenCalled() @@ -347,7 +297,7 @@ describe('CategoryPicker', () => { }) }) - it('shows error when create fails', async () => { + it('passes error prop to CategoryFormDialog when create fails', async () => { mockCreate.mockRejectedValueOnce(new Error('Server error')) const wrapper = mount(CategoryPicker, { @@ -355,23 +305,18 @@ describe('CategoryPicker', () => { global: {}, }) - // Open create dialog const select = wrapper.findComponent({ name: 'NcSelect' }) select.vm.$emit('option:selected', { label: 'Create new category …', create: true }) await wrapper.vm.$nextTick() - const vm = wrapper.vm as unknown as { newName: string } - vm.newName = 'Test' - await wrapper.vm.$nextTick() - - const form = wrapper.find('.pantry-create-cat') - await form.trigger('submit') + const formDialog = wrapper.findComponent({ name: 'CategoryFormDialog' }) + formDialog.vm.$emit('save', { name: 'Test', icon: 'tag', color: '#ef4444' }) await vi.waitFor(() => { - expect(wrapper.find('.pantry-create-cat__error').exists()).toBe(true) + expect(wrapper.findComponent({ name: 'CategoryFormDialog' }).props('error')).toBe( + 'Server error', + ) }) - - expect(wrapper.find('.pantry-create-cat__error').text()).toBe('Server error') }) }) diff --git a/src/components/CategoryPicker/CategoryPicker.vue b/src/components/CategoryPicker/CategoryPicker.vue index c99892a..4e7cf26 100644 --- a/src/components/CategoryPicker/CategoryPicker.vue +++ b/src/components/CategoryPicker/CategoryPicker.vue @@ -43,64 +43,13 @@ - -
- - -
- -
- -
-
- -
- -
-
-
- -

{{ createError }}

- - -
+ @save="submitCreate" + /> @@ -108,17 +57,10 @@ import { computed, onMounted, ref, watch } from 'vue' import { t } from '@nextcloud/l10n' import NcSelect from '@nextcloud/vue/components/NcSelect' -import NcDialog from '@nextcloud/vue/components/NcDialog' -import NcTextField from '@nextcloud/vue/components/NcTextField' -import NcButton from '@nextcloud/vue/components/NcButton' import PlusIcon from '@icons/Plus.vue' import { useCategories } from '@/composables/useCategories' -import { - CATEGORY_COLORS, - CATEGORY_ICONS, - DEFAULT_CATEGORY_ICON_KEY, - categoryIconComponent, -} from './categoryIcons' +import { categoryIconComponent } from './categoryIcons' +import CategoryFormDialog from '@/components/CategoryManager/CategoryFormDialog.vue' import type { Category } from '@/api/types' const props = defineProps<{ @@ -190,33 +132,21 @@ function onSelect(opt: SelectOption | SelectOption[] | null): void { // ----- create dialog ----- const showCreate = ref(false) -const newName = ref('') -const newIcon = ref(DEFAULT_CATEGORY_ICON_KEY) -const newColor = ref(CATEGORY_COLORS[3]!) const saving = ref(false) const createError = ref(null) function openCreate() { // Reset the NcSelect so it doesn't stay on the "Create new …" ghost option. selected.value = selected.value?.category ? selected.value : null - newName.value = '' - newIcon.value = DEFAULT_CATEGORY_ICON_KEY - newColor.value = CATEGORY_COLORS[3]! createError.value = null showCreate.value = true } -async function submitCreate() { - const name = newName.value.trim() - if (!name) return +async function submitCreate(data: { name: string; icon: string; color: string }) { saving.value = true createError.value = null try { - const created = await create({ - name, - icon: newIcon.value, - color: newColor.value, - }) + const created = await create(data) emit('update:modelValue', created.id) showCreate.value = false } catch (e) { @@ -232,14 +162,6 @@ function iconFor(key: string) { const strings = { placeholder: t('pantry', 'Pick a category'), - createTitle: t('pantry', 'New category'), - nameLabel: t('pantry', 'Name'), - namePlaceholder: t('pantry', 'e.g. Produce, Dairy'), - iconLabel: t('pantry', 'Icon'), - colorLabel: t('pantry', 'Color'), - create: t('pantry', 'Create'), - saving: t('pantry', 'Saving …'), - cancel: t('pantry', 'Cancel'), } @@ -279,82 +201,4 @@ const strings = { text-overflow: ellipsis; } } - -.pantry-create-cat { - display: flex; - flex-direction: column; - gap: 1rem; - padding: 0.5rem 0; - min-width: 340px; - - &__sub { - display: block; - font-size: 0.85rem; - font-weight: 600; - color: var(--color-text-maxcontrast); - margin-bottom: 0.35rem; - } - - &__icon-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); - gap: 0.35rem; - } - - &__icon-button { - aspect-ratio: 1; - display: flex; - align-items: center; - justify-content: center; - border: 1px solid var(--color-border); - border-radius: var(--border-radius, 8px); - background: var(--color-main-background); - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - background: var(--color-background-hover); - } - - &--active { - border-color: currentColor; - box-shadow: 0 0 0 2px currentColor; - } - } - - &__color-grid { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; - } - - &__color-swatch { - width: 28px; - height: 28px; - border-radius: 999px; - border: 2px solid transparent; - cursor: pointer; - transition: transform 0.15s ease; - - &:hover { - transform: scale(1.08); - } - - &--active { - border-color: var(--color-main-text); - transform: scale(1.1); - } - } - - &__error { - margin: 0; - color: var(--color-error); - } -} - -@media (max-width: 500px) { - .pantry-create-cat { - min-width: 0; - } -} diff --git a/src/components/HouseSettingsDialog/HouseSettingsDialog.vue b/src/components/HouseSettingsDialog/HouseSettingsDialog.vue index f11fc29..d75efa0 100644 --- a/src/components/HouseSettingsDialog/HouseSettingsDialog.vue +++ b/src/components/HouseSettingsDialog/HouseSettingsDialog.vue @@ -92,47 +92,6 @@ - -
- -
- -
-

{{ strings.dangerBody }}

@@ -184,132 +143,6 @@ {{ strings.deleteButton }} - - -
- -
- -
- -
-
-
- -
-
-
-

{{ catError }}

- - -
- - -
- -
- -
- -
-
-
- -
-
-
-

{{ catError }}

- - -
- - -

{{ deleteCatConfirmBody }}

- -
diff --git a/src/views/ChecklistsView.vue b/src/views/ChecklistsView.vue index 6bc2fec..dff89d6 100644 --- a/src/views/ChecklistsView.vue +++ b/src/views/ChecklistsView.vue @@ -2,6 +2,12 @@
@@ -192,7 +204,9 @@ import NcTextField from '@nextcloud/vue/components/NcTextField' import NcActions from '@nextcloud/vue/components/NcActions' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import PageToolbar from '@/components/PageToolbar' +import { CategoryManagerDialog } from '@/components/CategoryManager' import PlusIcon from '@icons/Plus.vue' +import TagIcon from '@icons/Tag.vue' import ClipboardCheckIcon from '@icons/ClipboardCheck.vue' import PencilIcon from '@icons/Pencil.vue' import DeleteIcon from '@icons/Delete.vue' @@ -216,6 +230,8 @@ watch( () => load(), ) +const showCategoryManager = ref(false) + const showCreate = ref(false) const newName = ref('') const newDescription = ref('') @@ -283,6 +299,7 @@ async function submitDelete() { const strings = { title: t('pantry', 'Checklists'), newList: t('pantry', 'New list'), + manageCategories: t('pantry', 'Manage categories'), create: t('pantry', 'Create'), save: t('pantry', 'Save'), cancel: t('pantry', 'Cancel'),