mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat: manage categories button/modal
This commit is contained in:
@@ -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: '^_' },
|
||||
|
||||
206
src/components/CategoryManager/CategoryFormDialog.vue
Normal file
206
src/components/CategoryManager/CategoryFormDialog.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<NcDialog
|
||||
:name="dialogName"
|
||||
:open="open"
|
||||
close-on-click-outside
|
||||
@update:open="$emit('update:open', $event)"
|
||||
>
|
||||
<form class="pantry-cat-form" autocomplete="off" @submit.prevent="submit">
|
||||
<NcTextField
|
||||
v-model="nameValue"
|
||||
:label="strings.nameLabel"
|
||||
:placeholder="strings.namePlaceholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div>
|
||||
<label class="pantry-cat-form__sub">{{ strings.iconLabel }}</label>
|
||||
<div class="pantry-cat-form__icon-grid">
|
||||
<button
|
||||
v-for="opt in CATEGORY_ICONS"
|
||||
:key="opt.key"
|
||||
type="button"
|
||||
class="pantry-cat-form__icon-button"
|
||||
:class="{ 'pantry-cat-form__icon-button--active': iconValue === opt.key }"
|
||||
:title="opt.label"
|
||||
:style="{ color: colorValue }"
|
||||
@click="iconValue = opt.key"
|
||||
>
|
||||
<component :is="opt.component" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="pantry-cat-form__sub">{{ strings.colorLabel }}</label>
|
||||
<div class="pantry-cat-form__color-grid">
|
||||
<button
|
||||
v-for="c in CATEGORY_COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="pantry-cat-form__color-swatch"
|
||||
:class="{ 'pantry-cat-form__color-swatch--active': colorValue === c }"
|
||||
:style="{ backgroundColor: c }"
|
||||
:aria-label="c"
|
||||
@click="colorValue = c"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="pantry-cat-form__error">{{ error }}</p>
|
||||
</form>
|
||||
<template #actions>
|
||||
<NcButton @click="$emit('update:open', false)">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="primary" :disabled="saving || !nameValue.trim()" @click="submit">
|
||||
{{ saving ? strings.saving : category ? strings.save : strings.create }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import type { Category } from '@/api/types'
|
||||
import {
|
||||
CATEGORY_COLORS,
|
||||
CATEGORY_ICONS,
|
||||
DEFAULT_CATEGORY_ICON_KEY,
|
||||
} from '@/components/CategoryPicker/categoryIcons'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/** Existing category to edit, or null/undefined to create a new one. */
|
||||
category?: Category | null
|
||||
saving?: boolean
|
||||
error?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
save: [data: { name: string; icon: string; color: string }]
|
||||
}>()
|
||||
|
||||
const nameValue = ref('')
|
||||
const iconValue = ref<string>(DEFAULT_CATEGORY_ICON_KEY)
|
||||
const colorValue = ref<string>(CATEGORY_COLORS[3]!)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
if (props.category) {
|
||||
nameValue.value = props.category.name
|
||||
iconValue.value = props.category.icon
|
||||
colorValue.value = props.category.color
|
||||
} else {
|
||||
nameValue.value = ''
|
||||
iconValue.value = DEFAULT_CATEGORY_ICON_KEY
|
||||
colorValue.value = CATEGORY_COLORS[3]!
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const dialogName = computed(() => (props.category ? strings.editTitle : strings.createTitle))
|
||||
|
||||
function submit() {
|
||||
const name = nameValue.value.trim()
|
||||
if (!name) return
|
||||
emit('save', { name, icon: iconValue.value, color: colorValue.value })
|
||||
}
|
||||
|
||||
const strings = {
|
||||
createTitle: t('pantry', 'New category'),
|
||||
editTitle: t('pantry', 'Edit category'),
|
||||
nameLabel: t('pantry', 'Name'),
|
||||
namePlaceholder: t('pantry', 'e.g. Produce, Dairy'),
|
||||
iconLabel: t('pantry', 'Icon:'),
|
||||
colorLabel: t('pantry', 'Color:'),
|
||||
cancel: t('pantry', 'Cancel'),
|
||||
create: t('pantry', 'Create'),
|
||||
save: t('pantry', 'Save'),
|
||||
saving: t('pantry', 'Saving …'),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pantry-cat-form {
|
||||
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 {
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.pantry-cat-form {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
226
src/components/CategoryManager/CategoryManagerDialog.vue
Normal file
226
src/components/CategoryManager/CategoryManagerDialog.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<NcDialog
|
||||
:name="strings.title"
|
||||
:open="open"
|
||||
size="normal"
|
||||
close-on-click-outside
|
||||
@update:open="$emit('update:open', $event)"
|
||||
>
|
||||
<div v-if="catLoading" class="pantry-center">
|
||||
<NcLoadingIcon :size="28" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<p v-if="catItems.length === 0" class="pantry-cat-hint">
|
||||
{{ strings.noCategoriesHint }}
|
||||
</p>
|
||||
<ul v-else class="pantry-cat-list">
|
||||
<li v-for="cat in catItems" :key="cat.id" class="pantry-cat-list__item">
|
||||
<span class="pantry-cat-list__icon" :style="{ color: cat.color }">
|
||||
<component :is="categoryIconComponent(cat.icon)" :size="20" />
|
||||
</span>
|
||||
<span class="pantry-cat-list__name">{{ cat.name }}</span>
|
||||
<div class="pantry-cat-list__actions">
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
:aria-label="strings.editCategory"
|
||||
@click="startEditCat(cat)"
|
||||
>
|
||||
<template #icon><PencilIcon :size="18" /></template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
:aria-label="strings.deleteCategory"
|
||||
@click="confirmDeleteCat(cat)"
|
||||
>
|
||||
<template #icon><DeleteIcon :size="18" /></template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template #actions>
|
||||
<NcButton variant="primary" @click="openCreateCat">
|
||||
<template #icon><PlusIcon :size="20" /></template>
|
||||
{{ strings.newCategory }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
|
||||
<!-- Create/edit form -->
|
||||
<CategoryFormDialog
|
||||
:open="showForm"
|
||||
:category="editingCat"
|
||||
:saving="catSaving"
|
||||
:error="catError"
|
||||
@update:open="closeForm"
|
||||
@save="submitForm"
|
||||
/>
|
||||
|
||||
<!-- Delete category confirm -->
|
||||
<NcDialog
|
||||
v-if="deletingCat"
|
||||
:name="strings.deleteCategoryTitle"
|
||||
:open="!!deletingCat"
|
||||
close-on-click-outside
|
||||
@update:open="(v) => !v && (deletingCat = null)"
|
||||
>
|
||||
<p>{{ deleteCatConfirmBody }}</p>
|
||||
<template #actions>
|
||||
<NcButton @click="deletingCat = null">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="error" @click="submitDeleteCat">{{ strings.delete }}</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
import DeleteIcon from '@icons/Delete.vue'
|
||||
import PencilIcon from '@icons/Pencil.vue'
|
||||
import type { Category } from '@/api/types'
|
||||
import { useCategories } from '@/composables/useCategories'
|
||||
import { categoryIconComponent } from '@/components/CategoryPicker/categoryIcons'
|
||||
import CategoryFormDialog from './CategoryFormDialog.vue'
|
||||
|
||||
const props = defineProps<{ open: boolean; houseId: number }>()
|
||||
defineEmits<{ 'update:open': [value: boolean] }>()
|
||||
|
||||
const categories = useCategories(props.houseId)
|
||||
const catItems = computed(() => categories.items.value)
|
||||
const catLoading = computed(() => categories.loading.value)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) categories.load()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// -------- Form state --------
|
||||
const showForm = ref(false)
|
||||
const editingCat = ref<Category | null>(null)
|
||||
const deletingCat = ref<Category | null>(null)
|
||||
const catSaving = ref(false)
|
||||
const catError = ref<string | null>(null)
|
||||
|
||||
function openCreateCat() {
|
||||
editingCat.value = null
|
||||
catError.value = null
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function startEditCat(cat: Category) {
|
||||
editingCat.value = cat
|
||||
catError.value = null
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function closeForm(v: boolean) {
|
||||
if (!v) {
|
||||
showForm.value = false
|
||||
editingCat.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteCat(cat: Category) {
|
||||
deletingCat.value = cat
|
||||
}
|
||||
|
||||
const deleteCatConfirmBody = computed(() =>
|
||||
t('pantry', 'Are you sure you want to delete the category "{name}"?', {
|
||||
name: deletingCat.value?.name ?? '',
|
||||
}),
|
||||
)
|
||||
|
||||
async function submitForm(data: { name: string; icon: string; color: string }) {
|
||||
catSaving.value = true
|
||||
catError.value = null
|
||||
try {
|
||||
if (editingCat.value) {
|
||||
await categories.update(editingCat.value.id, data)
|
||||
} else {
|
||||
await categories.create(data)
|
||||
}
|
||||
showForm.value = false
|
||||
editingCat.value = null
|
||||
} catch (e) {
|
||||
catError.value =
|
||||
(e as Error).message ||
|
||||
(editingCat.value
|
||||
? t('pantry', 'Could not update category.')
|
||||
: t('pantry', 'Could not create category.'))
|
||||
} finally {
|
||||
catSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDeleteCat() {
|
||||
const target = deletingCat.value
|
||||
if (!target) return
|
||||
await categories.remove(target.id)
|
||||
deletingCat.value = null
|
||||
}
|
||||
|
||||
const strings = {
|
||||
title: t('pantry', 'Manage categories'),
|
||||
noCategoriesHint: t('pantry', 'No categories yet. Categories help organize checklist items.'),
|
||||
newCategory: t('pantry', 'New category'),
|
||||
cancel: t('pantry', 'Cancel'),
|
||||
delete: t('pantry', 'Delete'),
|
||||
editCategory: t('pantry', 'Edit'),
|
||||
deleteCategory: t('pantry', 'Delete'),
|
||||
deleteCategoryTitle: t('pantry', 'Delete category'),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pantry-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pantry-cat-hint {
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.pantry-cat-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1rem 0;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2
src/components/CategoryManager/index.ts
Normal file
2
src/components/CategoryManager/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as CategoryManagerDialog } from './CategoryManagerDialog.vue'
|
||||
export { default as CategoryFormDialog } from './CategoryFormDialog.vue'
|
||||
@@ -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:
|
||||
'<div v-if="open" class="mock-cat-form-dialog"><slot /><span class="error" v-if="error">{{ error }}</span></div>',
|
||||
props: ['open', 'category', 'saving', 'error'],
|
||||
emits: ['update:open', 'save'],
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useCategories composable
|
||||
const mockItems = ref<Category[]>([])
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -43,64 +43,13 @@
|
||||
</template>
|
||||
</NcSelect>
|
||||
|
||||
<NcDialog
|
||||
v-if="showCreate"
|
||||
:name="strings.createTitle"
|
||||
<CategoryFormDialog
|
||||
:open="showCreate"
|
||||
close-on-click-outside
|
||||
:saving="saving"
|
||||
:error="createError"
|
||||
@update:open="showCreate = $event"
|
||||
>
|
||||
<form class="pantry-create-cat" autocomplete="off" @submit.prevent="submitCreate">
|
||||
<NcTextField
|
||||
v-model="newName"
|
||||
:label="strings.nameLabel"
|
||||
:placeholder="strings.namePlaceholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="pantry-create-cat__sub">{{ strings.iconLabel }}</label>
|
||||
<div class="pantry-create-cat__icon-grid">
|
||||
<button
|
||||
v-for="opt in CATEGORY_ICONS"
|
||||
:key="opt.key"
|
||||
type="button"
|
||||
class="pantry-create-cat__icon-button"
|
||||
:class="{ 'pantry-create-cat__icon-button--active': newIcon === opt.key }"
|
||||
:title="opt.label"
|
||||
:style="{ color: newColor }"
|
||||
@click="newIcon = opt.key"
|
||||
>
|
||||
<component :is="opt.component" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="pantry-create-cat__sub">{{ strings.colorLabel }}</label>
|
||||
<div class="pantry-create-cat__color-grid">
|
||||
<button
|
||||
v-for="c in CATEGORY_COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="pantry-create-cat__color-swatch"
|
||||
:class="{ 'pantry-create-cat__color-swatch--active': newColor === c }"
|
||||
:style="{ backgroundColor: c }"
|
||||
:aria-label="c"
|
||||
@click="newColor = c"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="createError" class="pantry-create-cat__error">{{ createError }}</p>
|
||||
</form>
|
||||
<template #actions>
|
||||
<NcButton @click="showCreate = false">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="primary" :disabled="saving || !newName.trim()" @click="submitCreate">
|
||||
{{ saving ? strings.saving : strings.create }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
@save="submitCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<string>(DEFAULT_CATEGORY_ICON_KEY)
|
||||
const newColor = ref<string>(CATEGORY_COLORS[3]!)
|
||||
const saving = ref(false)
|
||||
const createError = ref<string | null>(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'),
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -92,47 +92,6 @@
|
||||
</template>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<NcAppSettingsSection id="house-categories" :name="strings.categoriesSection">
|
||||
<div v-if="catLoading" class="pantry-center">
|
||||
<NcLoadingIcon :size="28" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<p v-if="catItems.length === 0" class="pantry-hint">
|
||||
{{ strings.noCategoriesHint }}
|
||||
</p>
|
||||
<ul v-else class="pantry-cat-list">
|
||||
<li v-for="cat in catItems" :key="cat.id" class="pantry-cat-list__item">
|
||||
<span class="pantry-cat-list__icon" :style="{ color: cat.color }">
|
||||
<component :is="categoryIconComponent(cat.icon)" :size="20" />
|
||||
</span>
|
||||
<span class="pantry-cat-list__name">{{ cat.name }}</span>
|
||||
<div class="pantry-cat-list__actions">
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
:aria-label="strings.editCategory"
|
||||
@click="startEditCat(cat)"
|
||||
>
|
||||
<template #icon><PencilIcon :size="18" /></template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
:aria-label="strings.deleteCategory"
|
||||
@click="confirmDeleteCat(cat)"
|
||||
>
|
||||
<template #icon><DeleteIcon :size="18" /></template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="pantry-cat-add">
|
||||
<NcButton variant="primary" @click="openCreateCat">
|
||||
<template #icon><PlusIcon :size="20" /></template>
|
||||
{{ strings.newCategory }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</template>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<NcAppSettingsSection v-if="isOwner" id="house-danger" :name="strings.dangerSection">
|
||||
<p class="pantry-danger-hint">{{ strings.dangerBody }}</p>
|
||||
<NcButton variant="error" @click="confirmingDelete = true">
|
||||
@@ -184,132 +143,6 @@
|
||||
<NcButton variant="error" @click="deleteHouse">{{ strings.deleteButton }}</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
|
||||
<NcDialog
|
||||
v-if="showCreateCat"
|
||||
:name="strings.createCategoryTitle"
|
||||
:open="showCreateCat"
|
||||
close-on-click-outside
|
||||
@update:open="showCreateCat = $event"
|
||||
>
|
||||
<form class="pantry-cat-form" autocomplete="off" @submit.prevent="submitCreateCat">
|
||||
<NcTextField
|
||||
v-model="catName"
|
||||
:label="strings.catNameLabel"
|
||||
:placeholder="strings.catNamePlaceholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div>
|
||||
<label class="pantry-cat-form__sub">{{ strings.iconLabel }}</label>
|
||||
<div class="pantry-cat-form__icon-grid">
|
||||
<button
|
||||
v-for="opt in CATEGORY_ICONS"
|
||||
:key="opt.key"
|
||||
type="button"
|
||||
class="pantry-cat-form__icon-button"
|
||||
:class="{ 'pantry-cat-form__icon-button--active': catIcon === opt.key }"
|
||||
:title="opt.label"
|
||||
:style="{ color: catColor }"
|
||||
@click="catIcon = opt.key"
|
||||
>
|
||||
<component :is="opt.component" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="pantry-cat-form__sub">{{ strings.colorLabel }}</label>
|
||||
<div class="pantry-cat-form__color-grid">
|
||||
<button
|
||||
v-for="c in CATEGORY_COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="pantry-cat-form__color-swatch"
|
||||
:class="{ 'pantry-cat-form__color-swatch--active': catColor === c }"
|
||||
:style="{ backgroundColor: c }"
|
||||
:aria-label="c"
|
||||
@click="catColor = c"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="catError" class="pantry-form-error">{{ catError }}</p>
|
||||
</form>
|
||||
<template #actions>
|
||||
<NcButton @click="showCreateCat = false">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="primary" :disabled="catSaving || !catName.trim()" @click="submitCreateCat">
|
||||
{{ catSaving ? strings.saving : strings.createCategory }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
|
||||
<NcDialog
|
||||
v-if="editingCat"
|
||||
:name="strings.editCategoryTitle"
|
||||
:open="!!editingCat"
|
||||
close-on-click-outside
|
||||
@update:open="(v) => !v && (editingCat = null)"
|
||||
>
|
||||
<form class="pantry-cat-form" autocomplete="off" @submit.prevent="submitEditCat">
|
||||
<NcTextField
|
||||
v-model="catName"
|
||||
:label="strings.catNameLabel"
|
||||
:placeholder="strings.catNamePlaceholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div>
|
||||
<label class="pantry-cat-form__sub">{{ strings.iconLabel }}</label>
|
||||
<div class="pantry-cat-form__icon-grid">
|
||||
<button
|
||||
v-for="opt in CATEGORY_ICONS"
|
||||
:key="opt.key"
|
||||
type="button"
|
||||
class="pantry-cat-form__icon-button"
|
||||
:class="{ 'pantry-cat-form__icon-button--active': catIcon === opt.key }"
|
||||
:title="opt.label"
|
||||
:style="{ color: catColor }"
|
||||
@click="catIcon = opt.key"
|
||||
>
|
||||
<component :is="opt.component" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="pantry-cat-form__sub">{{ strings.colorLabel }}</label>
|
||||
<div class="pantry-cat-form__color-grid">
|
||||
<button
|
||||
v-for="c in CATEGORY_COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="pantry-cat-form__color-swatch"
|
||||
:class="{ 'pantry-cat-form__color-swatch--active': catColor === c }"
|
||||
:style="{ backgroundColor: c }"
|
||||
:aria-label="c"
|
||||
@click="catColor = c"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="catError" class="pantry-form-error">{{ catError }}</p>
|
||||
</form>
|
||||
<template #actions>
|
||||
<NcButton @click="editingCat = null">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="primary" :disabled="catSaving || !catName.trim()" @click="submitEditCat">
|
||||
{{ catSaving ? strings.saving : strings.save }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
|
||||
<NcDialog
|
||||
v-if="deletingCat"
|
||||
:name="strings.deleteCategoryTitle"
|
||||
:open="!!deletingCat"
|
||||
close-on-click-outside
|
||||
@update:open="(v) => !v && (deletingCat = null)"
|
||||
>
|
||||
<p>{{ deleteCatConfirmBody }}</p>
|
||||
<template #actions>
|
||||
<NcButton @click="deletingCat = null">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="error" @click="submitDeleteCat">{{ strings.deleteCategory }}</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -326,18 +159,10 @@ import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
import DeleteIcon from '@icons/Delete.vue'
|
||||
import PencilIcon from '@icons/Pencil.vue'
|
||||
import * as houseApi from '@/api/houses'
|
||||
import type { Category, HouseMember, HouseRole } from '@/api/types'
|
||||
import type { HouseMember, HouseRole } from '@/api/types'
|
||||
import { useCurrentHouse } from '@/composables/useCurrentHouse'
|
||||
import { useHouses } from '@/composables/useHouses'
|
||||
import { useCategories } from '@/composables/useCategories'
|
||||
import {
|
||||
CATEGORY_COLORS,
|
||||
CATEGORY_ICONS,
|
||||
DEFAULT_CATEGORY_ICON_KEY,
|
||||
categoryIconComponent,
|
||||
} from '@/components/CategoryPicker/categoryIcons'
|
||||
|
||||
const props = defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
|
||||
@@ -366,7 +191,6 @@ watch(
|
||||
if (isOpen) {
|
||||
syncFromHouse()
|
||||
void loadMembers()
|
||||
void loadCategories()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -455,96 +279,6 @@ async function leave() {
|
||||
await router.push({ name: 'home' })
|
||||
}
|
||||
|
||||
// -------- Categories --------
|
||||
const catComposable = computed(() => {
|
||||
const id = houseIdNum.value
|
||||
return id !== null ? useCategories(id) : null
|
||||
})
|
||||
const catItems = computed(() => catComposable.value?.items.value ?? [])
|
||||
const catLoading = computed(() => catComposable.value?.loading.value ?? false)
|
||||
|
||||
async function loadCategories() {
|
||||
await catComposable.value?.load(true)
|
||||
}
|
||||
|
||||
const showCreateCat = ref(false)
|
||||
const editingCat = ref<Category | null>(null)
|
||||
const deletingCat = ref<Category | null>(null)
|
||||
const catName = ref('')
|
||||
const catIcon = ref(DEFAULT_CATEGORY_ICON_KEY)
|
||||
const catColor = ref(CATEGORY_COLORS[3]!)
|
||||
const catSaving = ref(false)
|
||||
const catError = ref<string | null>(null)
|
||||
|
||||
function openCreateCat() {
|
||||
catName.value = ''
|
||||
catIcon.value = DEFAULT_CATEGORY_ICON_KEY
|
||||
catColor.value = CATEGORY_COLORS[3]!
|
||||
catError.value = null
|
||||
showCreateCat.value = true
|
||||
}
|
||||
|
||||
function startEditCat(cat: Category) {
|
||||
editingCat.value = cat
|
||||
catName.value = cat.name
|
||||
catIcon.value = cat.icon
|
||||
catColor.value = cat.color
|
||||
catError.value = null
|
||||
}
|
||||
|
||||
function confirmDeleteCat(cat: Category) {
|
||||
deletingCat.value = cat
|
||||
}
|
||||
|
||||
const deleteCatConfirmBody = computed(() =>
|
||||
t('pantry', 'Are you sure you want to delete the category "{name}"?', {
|
||||
name: deletingCat.value?.name ?? '',
|
||||
}),
|
||||
)
|
||||
|
||||
async function submitCreateCat() {
|
||||
const name = catName.value.trim()
|
||||
if (!name) return
|
||||
catSaving.value = true
|
||||
catError.value = null
|
||||
try {
|
||||
await catComposable.value?.create({ name, icon: catIcon.value, color: catColor.value })
|
||||
showCreateCat.value = false
|
||||
} catch (e) {
|
||||
catError.value = (e as Error).message || t('pantry', 'Could not create category.')
|
||||
} finally {
|
||||
catSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEditCat() {
|
||||
const target = editingCat.value
|
||||
if (!target) return
|
||||
const name = catName.value.trim()
|
||||
if (!name) return
|
||||
catSaving.value = true
|
||||
catError.value = null
|
||||
try {
|
||||
await catComposable.value?.update(target.id, {
|
||||
name,
|
||||
icon: catIcon.value,
|
||||
color: catColor.value,
|
||||
})
|
||||
editingCat.value = null
|
||||
} catch (e) {
|
||||
catError.value = (e as Error).message || t('pantry', 'Could not update category.')
|
||||
} finally {
|
||||
catSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDeleteCat() {
|
||||
const target = deletingCat.value
|
||||
if (!target) return
|
||||
await catComposable.value?.remove(target.id)
|
||||
deletingCat.value = null
|
||||
}
|
||||
|
||||
// -------- Danger zone --------
|
||||
const confirmingDelete = ref(false)
|
||||
|
||||
@@ -601,19 +335,6 @@ const strings = {
|
||||
userIdLabel: t('pantry', 'Account ID'),
|
||||
userIdPlaceholder: t('pantry', 'The Nextcloud username'),
|
||||
roleLabel: t('pantry', 'Role'),
|
||||
categoriesSection: t('pantry', 'Categories'),
|
||||
noCategoriesHint: t('pantry', 'No categories yet. Categories help organize checklist items.'),
|
||||
newCategory: t('pantry', 'New category'),
|
||||
createCategory: t('pantry', 'Create'),
|
||||
createCategoryTitle: t('pantry', 'New category'),
|
||||
editCategory: t('pantry', 'Edit'),
|
||||
editCategoryTitle: t('pantry', 'Edit category'),
|
||||
deleteCategory: t('pantry', 'Delete'),
|
||||
deleteCategoryTitle: t('pantry', 'Delete category'),
|
||||
catNameLabel: t('pantry', 'Name'),
|
||||
catNamePlaceholder: t('pantry', 'e.g. Produce, Dairy'),
|
||||
iconLabel: t('pantry', 'Icon:'),
|
||||
colorLabel: t('pantry', 'Color:'),
|
||||
dangerSection: t('pantry', 'Danger zone'),
|
||||
dangerBody: t(
|
||||
'pantry',
|
||||
@@ -691,115 +412,4 @@ const strings = {
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.pantry-cat-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pantry-cat-add {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pantry-cat-form {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.pantry-cat-form {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
{{ opt.label }}
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
<NcButton variant="primary" @click="showCategoryManager = true">
|
||||
<template #icon>
|
||||
<TagIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.manageCategories }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</PageToolbar>
|
||||
|
||||
@@ -123,6 +129,12 @@
|
||||
:house-id="houseIdNum"
|
||||
@update:open="(v) => !v && (previewing = null)"
|
||||
/>
|
||||
|
||||
<CategoryManagerDialog
|
||||
:open="showCategoryManager"
|
||||
:house-id="houseIdNum"
|
||||
@update:open="showCategoryManager = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -138,12 +150,14 @@ import ArrowLeftIcon from '@icons/ArrowLeft.vue'
|
||||
import SortIcon from '@icons/Sort.vue'
|
||||
import RadioboxBlankIcon from '@icons/RadioboxBlank.vue'
|
||||
import RadioboxMarkedIcon from '@icons/RadioboxMarked.vue'
|
||||
import TagIcon from '@icons/Tag.vue'
|
||||
import PageToolbar from '@/components/PageToolbar'
|
||||
import { ChecklistAddForm } from '@/components/ChecklistAddForm'
|
||||
import { ChecklistItemRow } from '@/components/ChecklistItemRow'
|
||||
import { ChecklistItemEditDialog } from '@/components/ChecklistItemEditDialog'
|
||||
import { ChecklistItemViewDialog } from '@/components/ChecklistItemViewDialog'
|
||||
import { ChecklistImagePreview } from '@/components/ChecklistImagePreview'
|
||||
import { CategoryManagerDialog } from '@/components/CategoryManager'
|
||||
import { checklistIconComponent } from '@/components/ChecklistIconPicker'
|
||||
import { useChecklistItems } from '@/composables/useChecklist'
|
||||
import { useCategories } from '@/composables/useCategories'
|
||||
@@ -419,12 +433,17 @@ function openPreview(item: ChecklistItem) {
|
||||
previewing.value = item
|
||||
}
|
||||
|
||||
// ----- Category manager -----
|
||||
|
||||
const showCategoryManager = ref(false)
|
||||
|
||||
const strings = {
|
||||
back: t('pantry', 'Back to lists'),
|
||||
emptyTitle: t('pantry', 'No items yet'),
|
||||
emptyBody: t('pantry', 'Add items using the form above.'),
|
||||
sortLabel: t('pantry', 'Sort order'),
|
||||
doneTitle: t('pantry', 'Done'),
|
||||
manageCategories: t('pantry', 'Manage categories'),
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<div class="pantry-lists">
|
||||
<PageToolbar :title="strings.title">
|
||||
<template #actions>
|
||||
<NcButton variant="primary" @click="showCategoryManager = true">
|
||||
<template #icon>
|
||||
<TagIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.manageCategories }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" @click="showCreate = true">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
@@ -177,6 +183,12 @@
|
||||
<NcButton variant="error" @click="submitDelete">{{ strings.delete }}</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
|
||||
<CategoryManagerDialog
|
||||
:open="showCategoryManager"
|
||||
:house-id="houseIdNum"
|
||||
@update:open="showCategoryManager = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user