diff --git a/frontend/src/components/input/Multiselect.test.ts b/frontend/src/components/input/Multiselect.test.ts index a89a534de..3b3bb59be 100644 --- a/frontend/src/components/input/Multiselect.test.ts +++ b/frontend/src/components/input/Multiselect.test.ts @@ -120,3 +120,72 @@ describe('Multiselect.vue — combobox Escape semantics', () => { wrapper.unmount() }) }) + +describe('Multiselect.vue — creation-disabled hint', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + document.body.innerHTML = '' + }) + + function mountWithHint(props: Record) { + return mount(Multiselect, { + attachTo: document.body, + props: { + modelValue: [], + searchResults: [], + multiple: true, + label: 'title', + creatable: false, + createPlaceholder: 'create', + selectPlaceholder: 'select', + ...props, + }, + global: {mocks: {$t: (key: string) => key}}, + }) + } + + it('shows a non-interactive hint row when creation is disabled and nothing matches', async () => { + const wrapper = mountWithHint({creationDisabledMessage: 'cannot create here'}) + await openResults(wrapper) + + const hint = wrapper.find('.search-result-hint') + expect(hint.exists()).toBe(true) + expect(hint.text()).toBe('cannot create here') + // Must not be keyboard-selectable: a plain div, not a focusable option button, and no tabindex. + expect(hint.element.tagName).toBe('DIV') + expect(hint.attributes('aria-disabled')).toBe('true') + expect(hint.element.hasAttribute('tabindex')).toBe(false) + + wrapper.unmount() + }) + + it('does not show the hint row without a creationDisabledMessage', async () => { + const wrapper = mountWithHint({}) + await openResults(wrapper) + + expect(wrapper.find('.search-result-hint').exists()).toBe(false) + expect(wrapper.find('[role="listbox"]').exists()).toBe(false) + + wrapper.unmount() + }) + + it('hides the hint row when the query exactly matches an existing option', async () => { + const wrapper = mountWithHint({ + searchResults: [{title: 'Alpha'}], + creationDisabledMessage: 'cannot create here', + }) + const input = wrapper.find('input[role="combobox"]') + await input.setValue('Alpha') + await input.trigger('keyup') + vi.advanceTimersByTime(300) + await nextTick() + + expect(wrapper.find('.search-result-hint').exists()).toBe(false) + + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/input/Multiselect.vue b/frontend/src/components/input/Multiselect.vue index d4e4acd22..ba5ea4a86 100644 --- a/frontend/src/components/input/Multiselect.vue +++ b/frontend/src/components/input/Multiselect.vue @@ -141,6 +141,15 @@ {{ createPlaceholder }} + +
+ {{ creationDisabledMessage }} +
@@ -173,6 +182,8 @@ const props = withDefaults(defineProps<{ name?: string /** If true, will provide an 'add this as a new value' entry which fires an @create event when clicking on it. */ creatable?: boolean + /** When set and `creatable` is false, shows a non-interactive hint row explaining why a non-matching query can't be added. */ + creationDisabledMessage?: string /** The text shown next to the new value option. */ createPlaceholder?: string /** The text shown next to an option. */ @@ -199,6 +210,7 @@ const props = withDefaults(defineProps<{ searchResults: () => [] as T[], label: '', creatable: false, + creationDisabledMessage: '', createPlaceholder: () => useI18n().t('input.multiselect.createPlaceholder'), selectPlaceholder: () => useI18n().t('input.multiselect.selectPlaceholder'), multiple: false, @@ -274,19 +286,23 @@ const searchResultsVisible = computed(() => { return showSearchResults.value && ( (filteredSearchResults.value.length > 0) || - (props.creatable && query.value !== '') + (props.creatable && query.value !== '') || + creationHintVisible.value ) }) -const creatableAvailable = computed(() => { +const queryHasExactMatch = computed(() => { const hasResult = filteredSearchResults.value.some((elem: T) => elementInResults(elem, props.label, query.value as string)) const hasQueryAlreadyAdded = Array.isArray(internalValue.value) && internalValue.value.some((elem: T) => elementInResults(elem, props.label, query.value)) - return props.creatable - && query.value !== '' - && !(hasResult || hasQueryAlreadyAdded) + return hasResult || hasQueryAlreadyAdded }) +const creatableAvailable = computed(() => props.creatable && query.value !== '' && !queryHasExactMatch.value) + +// Shown in place of the create option when creation is disabled and the query matches nothing, so the field doesn't look dead. +const creationHintVisible = computed(() => props.creationDisabledMessage !== '' && !props.creatable && query.value !== '' && !queryHasExactMatch.value) + const filteredSearchResults = computed(() => { const currentInternal = internalValue.value if (props.multiple && currentInternal !== null && Array.isArray(currentInternal)) { @@ -673,6 +689,12 @@ function focus() { } } +.search-result-hint { + padding: .5rem .75rem; + color: var(--grey-500); + font-size: .85rem; +} + .create-icon { color: var(--success); margin-inline-end: .25rem; diff --git a/frontend/src/components/tasks/partials/EditLabels.vue b/frontend/src/components/tasks/partials/EditLabels.vue index 1c0cbec62..0b261cfb0 100644 --- a/frontend/src/components/tasks/partials/EditLabels.vue +++ b/frontend/src/components/tasks/partials/EditLabels.vue @@ -7,6 +7,7 @@ :search-results="foundLabels" label="title" :creatable="creatable" + :creation-disabled-message="creationDisabledMessage" :create-placeholder="$t('task.label.createPlaceholder')" :search-delay="10" :close-after-select="false" @@ -69,10 +70,12 @@ const props = withDefaults(defineProps<{ taskId?: number disabled?: boolean creatable?: boolean + creationDisabledMessage?: string }>(), { taskId: 0, disabled: false, creatable: true, + creationDisabledMessage: '', }) const emit = defineEmits<{ diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 4e01025d9..978fd3169 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -1117,6 +1117,7 @@ "label": { "placeholder": "Type to add a label…", "createPlaceholder": "Add this as new label", + "linkShareCannotCreate": "New labels can't be created from a shared link. Only labels already used in this project can be added.", "removeLabel": "Remove label {label}", "addSuccess": "The label has been added successfully.", "removeSuccess": "The label has been removed successfully.", diff --git a/frontend/src/views/tasks/TaskDetailView.vue b/frontend/src/views/tasks/TaskDetailView.vue index 570267a17..bf61a5fb2 100644 --- a/frontend/src/views/tasks/TaskDetailView.vue +++ b/frontend/src/views/tasks/TaskDetailView.vue @@ -334,6 +334,7 @@ :disabled="!canWrite" :task-id="taskId" :creatable="!authStore.isLinkShareAuth" + :creation-disabled-message="authStore.isLinkShareAuth ? $t('task.label.linkShareCannotCreate') : ''" /> diff --git a/frontend/tests/e2e/sharing/linkShare.spec.ts b/frontend/tests/e2e/sharing/linkShare.spec.ts index c98eeade9..8128a3e19 100644 --- a/frontend/tests/e2e/sharing/linkShare.spec.ts +++ b/frontend/tests/e2e/sharing/linkShare.spec.ts @@ -1,4 +1,6 @@ import {test, expect} from '../../support/fixtures' +import {LabelFactory} from '../../factories/labels' +import {LabelTaskFactory} from '../../factories/label_task' import {LinkShareFactory} from '../../factories/link_sharing' import {TaskFactory} from '../../factories/task' import {UserFactory} from '../../factories/user' @@ -112,6 +114,40 @@ test.describe('Link shares', () => { }) }) +test.describe('Link share: label picker', () => { + test.beforeEach(async ({page}) => { + await setupApiUrl(page) + }) + + test('explains that new labels cannot be created when typing an unknown label', async ({page}) => { + await UserFactory.create(1) + const projects = await createProjects() + const [task] = await TaskFactory.create(1, { + project_id: projects[0].id, + }) + // A label on the task makes the labels field render without clicking "Add Labels" first. + const [label] = await LabelFactory.create(1) + await LabelTaskFactory.create(1, { + task_id: task.id, + label_id: label.id, + }) + const [share] = await LinkShareFactory.create(1, { + project_id: projects[0].id, + permission: 1, + }) + + await page.goto(`/tasks/${task.id}#share-auth-token=${share.hash}`) + + const labelInput = page.locator('.task-view .details.labels-list .multiselect input') + await expect(labelInput).toBeVisible() + await labelInput.fill('label-that-does-not-exist') + + const searchResults = page.locator('.task-view .details.labels-list .multiselect .search-results') + await expect(searchResults.locator('.search-result-hint')).toContainText('New labels can\'t be created from a shared link') + await expect(searchResults.locator('.is-create-option')).toHaveCount(0) + }) +}) + test.describe('Link share: password protection', () => { test.beforeEach(async ({page}) => { await setupApiUrl(page)