mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-07-20 21:14:30 -05:00
fix(labels): explain why link-share users can't create labels (#3233)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user