fix(quick-add): don't abort task creation when a label can't be created (#3232)

This commit is contained in:
Tink
2026-07-19 14:45:24 +02:00
committed by GitHub
parent 4ccf5ac4ae
commit 5c2bb6bd2d
5 changed files with 99 additions and 6 deletions
+10 -1
View File
@@ -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<number, number>()
+1
View File
@@ -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?",
+50 -2
View File
@@ -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)
})
})
+9 -3
View File
@@ -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
@@ -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')
})
})