Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,44 @@ export const commonStrings = createTranslator('CommonStrings', {
message: 'Options',
context: 'Tooltip for the generic options menu icon',
},
clearAllAction: {
message: 'Clear all',
context: 'Accessible label for the button that clears every selection in a select field',
},
openMenuAction: {
message: 'Open menu',
context: 'Accessible label for the button that opens a dropdown menu',
},
closeMenuAction: {
message: 'Close menu',
context: 'Accessible label for the button that closes a dropdown menu',
},
optionsClickableLabel: {
message: 'Options are clickable',
context: 'Announced to screen reader users when a list of selectable options appears',
},
allOptionsSelectedLabel: {
message: 'All options selected',
context: 'Announced when every option in a list is selected',
},
allOptionsDeselectedLabel: {
message: 'No options selected',
context: 'Announced when no options in a list are selected',
},
optionDeselectedLabel: {
message: 'Option deselected',
context: 'Announced when an option is removed from the selection',
},
partiallySelectedLabel: {
message: 'Partially selected',
context: 'Announced for an option when only some of the options under it are selected',
},
optionSelectedLabel: {
message: 'Selected {label}',
context: 'Announced when an option is selected. {label} is the name of the option',
},
optionRemovedLabel: {
message: 'Removed {label}',
context: 'Announced when an option is removed. {label} is the name of the option',
},
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
<template>

<div>
<DropdownWrapper>
<KMultiSelect
v-if="!expanded"
:value="autocompleteValues"
:options="categoriesList"
itemValue="value"
itemText="text"
:label="translateMetadataString('category')"
:multiple="true"
:autoPromoteParent="false"
clearable
:noResultsText="$tr('noCategoryFoundText')"
:messages="messages"
@input="onKMultiSelectInput"
>
<template #chip="{ option, remove: removeChip }">
<span :ref="'category-chip-' + option.value">
<KChip
:text="option.text"
close
@close="removeChip"
/>
</span>
<KTooltip
:reference="'category-chip-' + option.value"
:refs="$refs"
placement="top"
:text="tooltipText(option.value)"
/>
</template>
</KMultiSelect>

<DropdownWrapper v-if="expanded">
<template #default="{ attach, menuProps }">
<VAutocomplete
:value="autocompleteValues"
Expand All @@ -18,8 +49,8 @@
:menu-props="{
...menuProps,
zIndex: 4,
height: expanded ? 0 : 'auto',
maxHeight: expanded ? 0 : 300,
height: 0,
maxHeight: 0,
}"
:attach="attach"
@click:clear="$nextTick(() => removeAll())"
Expand Down Expand Up @@ -104,14 +135,44 @@
<script>

import camelCase from 'lodash/camelCase';
import KMultiSelect from 'kolibri-design-system/lib/candidate/multiselect/KMultiSelect';
import KChip from 'kolibri-design-system/lib/candidate/multiselect/KChip';
import { getSortedCategories } from 'shared/utils/helpers';
import { commonStrings } from 'shared/strings/commonStrings';
import DropdownWrapper from 'shared/views/form/DropdownWrapper';
import { constantsTranslationMixin, metadataTranslationMixin } from 'shared/mixins';

export default {
name: 'CategoryOptions',
components: { DropdownWrapper },
components: { KMultiSelect, KChip, DropdownWrapper },
mixins: [constantsTranslationMixin, metadataTranslationMixin],
setup() {
const {
clearAllAction$,
openMenuAction$,
closeMenuAction$,
optionsClickableLabel$,
allOptionsSelectedLabel$,
allOptionsDeselectedLabel$,
optionDeselectedLabel$,
partiallySelectedLabel$,
optionSelectedLabel$,
optionRemovedLabel$,
} = commonStrings;

return {
clearAllAction$,
openMenuAction$,
closeMenuAction$,
optionsClickableLabel$,
allOptionsSelectedLabel$,
allOptionsDeselectedLabel$,
optionDeselectedLabel$,
partiallySelectedLabel$,
optionSelectedLabel$,
optionRemovedLabel$,
};
},
props: {
/**
* This prop receives an object with the following structure:
Expand Down Expand Up @@ -177,6 +238,22 @@
option.text.toLowerCase().includes(searchQuery),
);
},
messages() {
return {
clearText: this.clearAllAction$,
open: this.openMenuAction$,
close: this.closeMenuAction$,
clickable: this.optionsClickableLabel$,
allOptionsSelected: this.allOptionsSelectedLabel$,
allOptionsDeselected: this.allOptionsDeselectedLabel$,
optionDeselected: this.optionDeselectedLabel$,
partiallySelected: this.partiallySelectedLabel$,
itemsSelected: ({ count }) => this.$tr('itemsSelected', { count }),
selected: this.optionSelectedLabel$,
removed: this.optionRemovedLabel$,
cleared: () => this.$tr('allCategoriesCleared'),
};
},
},
methods: {
treeItemStyle(item) {
Expand All @@ -201,6 +278,21 @@
removeAll() {
this.selected = {};
},
// Rebuilds the { category: [nodeIds] } object from KMultiSelect's flat
// array. Categories not applied to every edited node are invisible to
// KMultiSelect (see autocompleteValues), so they are carried over untouched.
onKMultiSelectInput(newValues) {
const newSelected = {};
Object.entries(this.selected).forEach(([category, ids]) => {
if (ids.length !== this.nodeIds.length) {
newSelected[category] = ids;
}
});
newValues.forEach(value => {
newSelected[value] = this.nodeIds;
});
this.selected = newSelected;
},
tooltipText(optionId) {
const option = this.categoriesList.find(option => option.value === optionId);
if (!option) {
Expand Down Expand Up @@ -275,6 +367,8 @@
},
$trs: {
noCategoryFoundText: 'Category not found',
itemsSelected: '{count, plural, one {# category selected} other {# categories selected}}',
allCategoriesCleared: 'All categories cleared',
},
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,81 +1,140 @@
import { shallowMount } from '@vue/test-utils';
import { render, screen, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import VueRouter from 'vue-router';
import CategoryOptions from '../CategoryOptions.vue';

function makeWrapper({ value = {}, nodeIds = ['node1'] } = {}) {
return shallowMount(CategoryOptions, {
propsData: {
value,
nodeIds,
},
const SCHOOL = 'd&WXdXWF';
const ARTS = 'd&WXdXWF.5QAjgfv7';
const DANCE = 'd&WXdXWF.5QAjgfv7.BUMJJBnS';
const MUSIC = 'd&WXdXWF.5QAjgfv7.u0aKjT4i';

const SCHOOL_LABEL = 'School';
const ARTS_LABEL = 'Arts';
const DANCE_LABEL = 'Dance';
const MUSIC_LABEL = 'Music';
const DANCE_PATH = 'School - Arts - Dance';

const NODE_1 = 'node1';
const NODE_2 = 'node2';

function renderComponent({ value = {}, nodeIds = [NODE_1], expanded = false } = {}) {
return render(CategoryOptions, {
props: { value, nodeIds, expanded },
routes: new VueRouter(),
});
}

function lastInput(emitted) {
const events = emitted().input;
return events[events.length - 1][0];
}

describe('CategoryOptions', () => {
it('smoke test', () => {
const wrapper = makeWrapper();
expect(wrapper.exists()).toBe(true);
it('renders the category field', () => {
renderComponent();
expect(screen.getByText('Category')).toBeInTheDocument();
});

it('emits expected data', () => {
const wrapper = makeWrapper();
const value = 'string';
wrapper.vm.$emit('input', value);
describe('dropdown mode (KMultiSelect)', () => {
it('shows a chip only for categories applied to every edited node', () => {
renderComponent({
value: {
[DANCE]: [NODE_1, NODE_2],
[MUSIC]: [NODE_1],
},
nodeIds: [NODE_1, NODE_2],
});

expect(wrapper.emitted().input).toBeTruthy();
expect(wrapper.emitted().input.length).toBe(1);
expect(wrapper.emitted().input[0]).toEqual([value]);
});
// The closed dropdown stays in the DOM (v-show), so queries must not look inside it.
const chipsArea = within(screen.getByRole('group'));
expect(chipsArea.getAllByText(DANCE_LABEL).length).toBeGreaterThan(0);
expect(chipsArea.queryByText(MUSIC_LABEL)).not.toBeInTheDocument();
});

describe('display', () => {
it('has a tooltip that displays the tree for value of an item', () => {
const wrapper = makeWrapper();
const item = 'd&WXdXWF.5QAjgfv7.BUMJJBnS'; // 'Dance'
const expectedToolTip = 'School - Arts - Dance';
it('shows the full category path in the chip tooltip', async () => {
renderComponent({ value: { [DANCE]: [NODE_1] } });

expect(wrapper.vm.tooltipText(item)).toEqual(expectedToolTip);
expect(await screen.findByText(DANCE_PATH)).toBeInTheDocument();
});
it(`dropdown has 'levels' key necessary to display the nested structure of categories`, () => {
const wrapper = makeWrapper();
const dropdown = wrapper.vm.categoriesList;
const everyCategoryHasLevelsKey = dropdown.every(item => 'level' in item);

expect(everyCategoryHasLevelsKey).toBeTruthy();
it('renders parent categories as named groups in the dropdown', async () => {
renderComponent();

await userEvent.click(screen.getByRole('combobox'));

const school = await screen.findByRole('group', { name: SCHOOL_LABEL });
expect(within(school).getByRole('option', { name: DANCE_LABEL })).toBeInTheDocument();
});

it('emits the selection as an object applying each category to all edited nodes', async () => {
const { emitted } = renderComponent({ nodeIds: [NODE_1, NODE_2] });

await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(await screen.findByRole('option', { name: SCHOOL_LABEL }));

expect(lastInput(emitted)).toEqual({
[SCHOOL]: [NODE_1, NODE_2],
});
});

it('preserves partially applied categories when the selection changes', async () => {
const { emitted } = renderComponent({
value: { [MUSIC]: [NODE_1] },
nodeIds: [NODE_1, NODE_2],
});

await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(await screen.findByRole('option', { name: SCHOOL_LABEL }));

expect(lastInput(emitted)).toEqual({
[MUSIC]: [NODE_1],
[SCHOOL]: [NODE_1, NODE_2],
});
});
});

describe('interactions', () => {
it('when user checks an item, that is emitted to the parent component', () => {
const wrapper = makeWrapper();
const item = 'abcd';
wrapper.vm.$emit = jest.fn();
wrapper.vm.add(item);
it('removes a category when its chip close button is clicked', async () => {
const { emitted } = renderComponent({ value: { [DANCE]: [NODE_1] } });

expect(wrapper.vm.$emit.mock.calls[0][0]).toBe('input');
expect(wrapper.vm.$emit.mock.calls[0][1]).toEqual({ abcd: ['node1'] });
await userEvent.click(screen.getByRole('button', { name: `Remove ${DANCE_LABEL}` }));

expect(lastInput(emitted)).toEqual({});
});
it('when user unchecks an item, that is emitted to the parent component', () => {
const wrapper = makeWrapper();
const item = 'defj';
wrapper.vm.$emit = jest.fn();
wrapper.vm.remove(item);

expect(wrapper.vm.$emit.mock.calls[0][0]).toBe('input');
expect(wrapper.vm.$emit.mock.calls[0][1]).toEqual({});

it('emits an empty object when the selection is cleared', async () => {
const { emitted } = renderComponent({ value: { [DANCE]: [NODE_1] } });

await userEvent.click(screen.getByRole('button', { name: 'Clear all' }));

expect(lastInput(emitted)).toEqual({});
});

it('renders the flat checkbox list instead of KMultiSelect in expanded mode', () => {
renderComponent({ expanded: true });

expect(screen.queryByRole('button', { name: 'Open menu' })).not.toBeInTheDocument();
expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0);
});
});

describe('close button on chip interactions', () => {
it('in the autocomplete bar, the chip is removed when user clicks on its close button', async () => {
const wrapper = makeWrapper({
value: {
'remove me': ['node1'],
'keep me': ['node1'],
},
describe('expanded mode', () => {
it('emits the added category applied to all edited nodes when checked', async () => {
const { emitted } = renderComponent({ expanded: true, nodeIds: [NODE_1] });

await userEvent.click(screen.getByRole('checkbox', { name: DANCE_LABEL }));

expect(lastInput(emitted)).toEqual({ [DANCE]: [NODE_1] });
});

it('removes a category and its stored descendants when unchecked', async () => {
const { emitted } = renderComponent({
expanded: true,
value: { [ARTS]: [NODE_1], [DANCE]: [NODE_1] },
nodeIds: [NODE_1],
});
const originalChipsLength = Object.keys(wrapper.vm.selected).length;
wrapper.vm.remove('remove me');

expect(wrapper.emitted().input.length).toEqual(originalChipsLength - 1);
await userEvent.click(screen.getByRole('checkbox', { name: ARTS_LABEL }));

expect(lastInput(emitted)).toEqual({});
});
});
});