diff --git a/frontend/src/components/tasks/AddTask.vue b/frontend/src/components/tasks/AddTask.vue index fe233ff91..3ff49386d 100644 --- a/frontend/src/components/tasks/AddTask.vue +++ b/frontend/src/components/tasks/AddTask.vue @@ -72,6 +72,7 @@ import {parseSubtasksViaIndention} from '@/helpers/parseSubtasksViaIndention' import TaskRelationService from '@/services/taskRelation' import TaskRelationModel from '@/models/taskRelation' import {getLabelsFromPrefix} from '@/modules/quickAddMagic' +import {error} from '@/message' import {useAuthStore} from '@/stores/auth' import {useTaskStore} from '@/stores/tasks' @@ -140,7 +141,15 @@ async function addTask() { // In the store it will only ever see one task at a time so there's no way to reliably // check if a new label was created before (because everything happens async). const allLabels = tasksToCreate.map(({title}) => getLabelsFromPrefix(title, authStore.settings.frontendSettings.quickAddMagicMode) ?? []) - await taskStore.ensureLabelsExist(allLabels.flat()) + const requestedLabels = [...new Set(allLabels.flat())] + const resolvedLabels = await taskStore.ensureLabelsExist(requestedLabels) + + // Skipped labels (e.g. link shares may not create them) don't block task creation; just tell the user. + const resolvedTitles = new Set(resolvedLabels.map(l => l.title.toLowerCase())) + const failedLabels = requestedLabels.filter(title => !resolvedTitles.has(title.toLowerCase())) + if (failedLabels.length > 0) { + error({message: t('task.label.createFailed', {labels: failedLabels.join(', ')})}) + } const taskCollectionService = new TaskService() const projectIndices = new Map() diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 76e4bbf50..4e01025d9 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -1121,6 +1121,7 @@ "addSuccess": "The label has been added successfully.", "removeSuccess": "The label has been removed successfully.", "addCreateSuccess": "The label has been created and added successfully.", + "createFailed": "These labels could not be created and were not added: {labels}", "delete": { "header": "Delete this label", "text1": "Are you sure you want to delete this label?", diff --git a/frontend/src/stores/tasks.test.ts b/frontend/src/stores/tasks.test.ts index 644d6e6bb..9bb59ded4 100644 --- a/frontend/src/stores/tasks.test.ts +++ b/frontend/src/stores/tasks.test.ts @@ -1,6 +1,27 @@ -import {describe, expect, it} from 'vitest' -import {buildDefaultRemindersForQuickAdd, runWrites} from './tasks' +import {setActivePinia, createPinia} from 'pinia' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +vi.mock('@/router', () => ({ + default: { + currentRoute: {value: {params: {}}}, + isReady: () => Promise.resolve(), + }, +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({t: (key: string) => key}), + createI18n: () => ({global: {t: (key: string) => key}}), +})) + +vi.mock('@/stores/base', () => ({ + useBaseStore: () => ({setHasTasks: vi.fn()}), +})) + +import {buildDefaultRemindersForQuickAdd, runWrites, useTaskStore} from './tasks' +import {useLabelStore} from './labels' +import LabelModel from '@/models/label' import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo' +import type {ILabel} from '@/modelTypes/ILabel' import type {ITaskReminder} from '@/modelTypes/ITaskReminder' const aDefault: ITaskReminder = { @@ -78,3 +99,30 @@ describe('runWrites', () => { expect(completed).toHaveLength(0) }) }) + +describe('ensureLabelsExist', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('skips labels that fail to create and returns the resolved ones', async () => { + const taskStore = useTaskStore() + const labelStore = useLabelStore() + labelStore.setLabels([{id: 1, title: 'existing'}] as ILabel[]) + + vi.spyOn(labelStore, 'createLabel').mockImplementation(async label => { + if (label.title === 'forbidden') { + throw new Error('403') + } + return new LabelModel({id: 99, title: label.title}) + }) + + const result = await taskStore.ensureLabelsExist(['existing', 'created', 'forbidden']) + const titles = result.map(l => l.title) + + expect(titles).toContain('existing') + expect(titles).toContain('created') + expect(titles).not.toContain('forbidden') + expect(result).toHaveLength(2) + }) +}) diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index ac27ff0d2..8ad61fcd2 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -391,16 +391,22 @@ export const useTaskStore = defineStore('task', () => { const mustCreateLabel = all.map(async labelTitle => { let label = validateLabel(Object.values(labelStore.labels), labelTitle) if (typeof label === 'undefined') { - // label not found, create it const labelModel = new LabelModel({ title: labelTitle, hexColor: getRandomColorHex(), }) - label = await labelStore.createLabel(labelModel) + try { + label = await labelStore.createLabel(labelModel) + } catch (e) { + // Link shares may not create labels; skip it instead of aborting task creation. + console.debug('Could not create label from quick add magic', {labelTitle, e}) + return undefined + } } return label }) - return Promise.all(mustCreateLabel) + const resolved = await Promise.all(mustCreateLabel) + return resolved.filter((label): label is LabelModel => typeof label !== 'undefined') } // Do everything that is involved in finding, creating and adding the label to the task diff --git a/frontend/tests/e2e/sharing/linkShare.spec.ts b/frontend/tests/e2e/sharing/linkShare.spec.ts index 8d1d58ef9..c98eeade9 100644 --- a/frontend/tests/e2e/sharing/linkShare.spec.ts +++ b/frontend/tests/e2e/sharing/linkShare.spec.ts @@ -223,3 +223,32 @@ test.describe('Link share: permission tiers', () => { await expect(page.locator('.input[placeholder="Add a task…"]')).toBeVisible() }) }) + +test.describe('Link share: quick add magic labels', () => { + test.beforeEach(async ({page}) => { + await setupApiUrl(page) + }) + + test('creates the task and shows an error when a label cannot be created', async ({page}) => { + await UserFactory.create(1) + const projects = await createProjects() + await TaskFactory.create(1, { + project_id: projects[0].id, + }) + const [share] = await LinkShareFactory.create(1, { + project_id: projects[0].id, + permission: 1, + }) + + await page.goto(`/share/${share.hash}/auth`) + await expect(page.locator('h1.title')).toContainText(projects[0].title) + + const addTaskInput = page.locator('.input[placeholder="Add a task…"]') + await addTaskInput.fill('New task via share *unknownlabel') + await addTaskInput.press('Enter') + + // Link shares may not create labels: the label is skipped with an error, the task is still created. + await expect(page.locator('.global-notification')).toContainText('could not be created') + await expect(page.locator('.tasks')).toContainText('New task via share') + }) +})