fix(labels): explain why link-share users can't create labels (#3233)

This commit is contained in:
Tink
2026-07-19 14:45:37 +02:00
committed by GitHub
parent 5c2bb6bd2d
commit b5f34ff7da
6 changed files with 137 additions and 5 deletions
@@ -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<string, unknown>) {
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()
})
})
+27 -5
View File
@@ -141,6 +141,15 @@
{{ createPlaceholder }}
</span>
</BaseButton>
<div
v-if="creationHintVisible"
class="search-result-hint"
role="option"
aria-disabled="true"
>
{{ creationDisabledMessage }}
</div>
</div>
</CustomTransition>
</div>
@@ -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;
@@ -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<{
+1
View File
@@ -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.",
@@ -334,6 +334,7 @@
:disabled="!canWrite"
:task-id="taskId"
:creatable="!authStore.isLinkShareAuth"
:creation-disabled-message="authStore.isLinkShareAuth ? $t('task.label.linkShareCannotCreate') : ''"
/>
</div>
@@ -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)