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
@@ -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,13 +135,15 @@
<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 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],
props: {
/**
Expand Down Expand Up @@ -177,6 +210,22 @@
option.text.toLowerCase().includes(searchQuery),
);
},
messages() {
return {
clearText: () => this.$tr('clearText'),
open: () => this.$tr('openMenu'),
close: () => this.$tr('closeMenu'),
clickable: () => this.$tr('optionsClickable'),
allOptionsSelected: () => this.$tr('allOptionsSelected'),
allOptionsDeselected: () => this.$tr('allOptionsDeselected'),
optionDeselected: () => this.$tr('optionDeselected'),
partiallySelected: () => this.$tr('partiallySelected'),
itemsSelected: ({ count }) => this.$tr('itemsSelected', { count }),
selected: ({ label }) => this.$tr('categorySelected', { label }),
removed: ({ label }) => this.$tr('categoryRemoved', { label }),
cleared: () => this.$tr('allCategoriesCleared'),
};
},
},
methods: {
treeItemStyle(item) {
Expand All @@ -201,6 +250,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 +339,18 @@
},
$trs: {
noCategoryFoundText: 'Category not found',
clearText: 'Clear all',
openMenu: 'Open menu',
closeMenu: 'Close menu',
optionsClickable: 'Options are clickable',
allOptionsSelected: 'All options selected',
allOptionsDeselected: 'No options selected',
optionDeselected: 'Option deselected',
partiallySelected: 'Partially selected',
itemsSelected: '{count, plural, one {# category selected} other {# categories selected}}',
categorySelected: 'Selected {label}',
categoryRemoved: 'Removed {label}',
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({});
});
});
});
Loading