diff --git a/package-lock.json b/package-lock.json index ae82ac22a2..8b903f07a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,6 @@ "vue": "^3.3.4", "vue-dndrop": "^1.3.1", "vue-dompurify-html": "^4.1.4", - "vue-filter-ui": "^0.8.0", "vue-i18n": "^9.2.2", "vue-router": "^4.2.4", "vue3-mq": "^3.1.3", @@ -16922,6 +16921,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -18414,52 +18414,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/vue-filter-ui": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/vue-filter-ui/-/vue-filter-ui-0.8.0.tgz", - "integrity": "sha512-F8+kquUqGn5KhGAxGmVUYvrHkTICSjjsKMicYSIuNLJYFLfwZbxAQGXEafM+QjareD9AcdTTPxyVU0eATD/NVw==", - "dependencies": { - "vue": "^2.6.10" - } - }, - "node_modules/vue-filter-ui/node_modules/@vue/compiler-sfc": { - "version": "2.7.16", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.16.tgz", - "integrity": "sha512-KWhJ9k5nXuNtygPU7+t1rX6baZeqOYLEforUPjgNDBnLicfHCoi48H87Q8XyLZOrNNsmhuwKqtpDQWjEFe6Ekg==", - "dependencies": { - "@babel/parser": "^7.23.5", - "postcss": "^8.4.14", - "source-map": "^0.6.1" - }, - "optionalDependencies": { - "prettier": "^1.18.2 || ^2.0.0" - } - }, - "node_modules/vue-filter-ui/node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "optional": true, - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/vue-filter-ui/node_modules/vue": { - "version": "2.7.16", - "resolved": "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz", - "integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==", - "deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.", - "dependencies": { - "@vue/compiler-sfc": "2.7.16", - "csstype": "^3.1.0" - } - }, "node_modules/vue-hot-reload-api": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz", diff --git a/package.json b/package.json index b8f5d7b4de..f3d4ad8d3a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "vue": "^3.3.4", "vue-dndrop": "^1.3.1", "vue-dompurify-html": "^4.1.4", - "vue-filter-ui": "^0.8.0", "vue-i18n": "^9.2.2", "vue-router": "^4.2.4", "vue3-mq": "^3.1.3", diff --git a/src/components/organisms/DataFilter/DataFilter.md b/src/components/organisms/DataFilter/DataFilter.md deleted file mode 100644 index 61d8ce1f20..0000000000 --- a/src/components/organisms/DataFilter/DataFilter.md +++ /dev/null @@ -1,65 +0,0 @@ -# DataFilter - -The DataFilter provides an UI that allows the user to select filters. It filters its input data with the selected filters and emits the filtered data in an `update:filtered-data` event. - -## Basic Usage - -To use the DataTable you have to specify the following props: - -### `data` (required) - -The `data` prop must be a flat Array of Objects with an undefined structure. You can pass in whatever you like. - -### `filters` (required) - -An array of the possible filters. As it is passed to vue-filter-ui it the property has to follow the structure of the filter-property of [vue-filter-ui](http://docs.vue-filter-ui.surge.sh/2-Configuration.html#filter). - -```js -import InputDefault from "@/components/organisms/DataFilter/Inputs/Default"; - -const filters = [ - { - title: "Items per page", - chipTemplate: "Items per page: %1", - required: true, - filter: [ - { - attribute: "$limit", - operator: "<", - input: InputDefault, - options: [ - { value: 25, label: "25" }, - { value: 50, label: "50" }, - { value: 100, label: "100" }, - ], - }, - ], - }, - // ... -]; -``` - -The `title` value will be displayed in the dropdown menu to select the filter. - -The `chipTemplate` will be displayed when the filter was selected. `%1` will be replaced by the selected value of the first attribute of the `filter`, `%2` by the selected value of the second attribute and so on. - -If you set `required` to `true`, the user cannot unselect the filter once it was selected. - -The `filter` property describes the filter condition. Each entry in the array describes the filter rules for one attribute of the `data`. If the `filter` array contains several items the filter will combine all conditions with a logical 'AND'. See [vue-filter-ui documentation](http://docs.vue-filter-ui.surge.sh/2-Configuration.html#filter) for an detailed explanation of the filter property. - -### `activeFilters` - -The `activeFilters` property can be used to select filters programmatically. - -```js -const activeFilters = [ - { - attribute: "$limit", - operator: "<", - value: 25, - }, - // ... -]; -``` - -It can contain one filter rule for each data attribute. Each rule has to specifiy the filter value and the operator. diff --git a/src/components/organisms/DataFilter/DataFilter.unit.ts b/src/components/organisms/DataFilter/DataFilter.unit.ts new file mode 100644 index 0000000000..092e5438a4 --- /dev/null +++ b/src/components/organisms/DataFilter/DataFilter.unit.ts @@ -0,0 +1,125 @@ +import { ComponentMountingOptions, mount } from "@vue/test-utils"; +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import DataFilter from "./DataFilter.vue"; +import { useDataTableFilter } from "./composables/filter.composable"; +import { computed, ref } from "vue"; + +jest.mock("./composables/filter.composable"); + +const mockedUseBoardApi = jest.mocked(useDataTableFilter); +describe("@components/DataFilter/DataFilter.vue", () => { + const updateFilterMock = jest.fn(); + const removeFilterMock = jest.fn(); + const removeChipFilterMock = jest.fn(); + const defaultFilterMenuItems = [ + { label: "Registration", value: "consentStatus" }, + { label: "Class(es)", value: "classes" }, + { label: "Creation date", value: "createdAt" }, + { label: "Last migrated on", value: "lastLoginSystemChange" }, + { label: "Obsolete since", value: "outdatedSince" }, + ]; + + const setup = (options: ComponentMountingOptions = {}) => { + mockedUseBoardApi.mockReturnValue({ + defaultFilterMenuItems, + filterChipTitles: ref([{ item: "classes", title: "Class(es) = 1A" }]), + filterMenuItems: ref([]), + filterQuery: ref({}), + isDateFiltering: computed(() => false), + isSelectFiltering: computed(() => false), + registrationOptions: { + student: [], + teacher: [], + }, + selectedFilterType: ref("classes"), + userType: "", + removeChipFilter: removeChipFilterMock, + removeFilter: removeFilterMock, + updateFilter: updateFilterMock, + }); + return mount(DataFilter, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + }, + ...options, + }); + }; + + describe("should render the component", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should render the component", () => { + const wrapper = setup({ props: { filterFor: "student" } }); + expect(wrapper.exists()).toBe(true); + expect(mockedUseBoardApi).toHaveBeenCalledWith("student"); + }); + + it("should emit 'update:filter' when chip components be closed", async () => { + const wrapper = setup(); + const filterChipsComponent = wrapper.getComponent({ + name: "FilterChips", + }); + filterChipsComponent.vm.$emit("remove:filter"); + + expect(wrapper.emitted()).toHaveProperty("update:filter"); + }); + + it("should emit 'update:filter' when chip components be closed", async () => { + const wrapper = setup({ props: { filterFor: "student" } }); + const filterChipsComponent = wrapper.getComponent({ + name: "FilterChips", + }); + await filterChipsComponent.vm.$emit("remove:filter"); + + expect(wrapper.emitted()).toHaveProperty("update:filter"); + expect(removeChipFilterMock).toHaveBeenCalled(); + }); + + describe("filter dialog", () => { + it("should set the 'dialogOpen' false when 'close' event be emitted", async () => { + const wrapper = setup({ props: { filterFor: "student" } }); + + const filterDialogComponent = wrapper.getComponent({ + name: "FilterDialog", + }); + expect(filterDialogComponent.props("isOpen")).toBe(false); + wrapper.vm.dialogOpen = true; + await wrapper.vm.$nextTick(); + + expect(filterDialogComponent.props("isOpen")).toBe(true); + + await filterDialogComponent.vm.$emit("dialog-closed"); + + expect(filterDialogComponent.props("isOpen")).toBe(false); + expect(wrapper.vm.dialogOpen).toBe(false); + }); + + it("should call updateFilter method", async () => { + const wrapper = setup({ props: { filterFor: "teacher" } }); + wrapper.vm.dialogOpen = true; + + const filterDialogComponent = wrapper.getComponent({ + name: "FilterDialog", + }); + + await filterDialogComponent.vm.$emit("remove:filter"); + expect(removeFilterMock).toHaveBeenCalled(); + }); + }); + + it("should set filter title", () => { + const wrapper = setup(); + + const filterTitleElement = wrapper.find('[data-testid="filter-title"]'); + + expect(filterTitleElement.text()).toContain( + "components.organisms.DataFilter.add" + ); + }); + }); +}); diff --git a/src/components/organisms/DataFilter/DataFilter.vue b/src/components/organisms/DataFilter/DataFilter.vue index 52d9b2ac6c..eeaa75d9a6 100644 --- a/src/components/organisms/DataFilter/DataFilter.vue +++ b/src/components/organisms/DataFilter/DataFilter.vue @@ -1,138 +1,154 @@ - + diff --git a/src/components/organisms/DataFilter/DataFilterChips.vue b/src/components/organisms/DataFilter/DataFilterChips.vue deleted file mode 100644 index df625f9a15..0000000000 --- a/src/components/organisms/DataFilter/DataFilterChips.vue +++ /dev/null @@ -1,151 +0,0 @@ - - - - - diff --git a/src/components/organisms/DataFilter/DataFilterLayout.unit.js b/src/components/organisms/DataFilter/DataFilterLayout.unit.js deleted file mode 100644 index a7d81a7d01..0000000000 --- a/src/components/organisms/DataFilter/DataFilterLayout.unit.js +++ /dev/null @@ -1,5 +0,0 @@ -import DataFilterLayout from "./DataFilterLayout"; - -describe("@/components/organisms/DataFilter/DataFilterLayout", () => { - it(...rendersSlotContent(DataFilterLayout, ["select", "chips", "modal"])); -}); diff --git a/src/components/organisms/DataFilter/DataFilterLayout.vue b/src/components/organisms/DataFilter/DataFilterLayout.vue deleted file mode 100644 index 022a2a3a71..0000000000 --- a/src/components/organisms/DataFilter/DataFilterLayout.vue +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/src/components/organisms/DataFilter/DataFilterModal.vue b/src/components/organisms/DataFilter/DataFilterModal.vue deleted file mode 100644 index b416061c36..0000000000 --- a/src/components/organisms/DataFilter/DataFilterModal.vue +++ /dev/null @@ -1,47 +0,0 @@ - - - diff --git a/src/components/organisms/DataFilter/DataFilterSelect.vue b/src/components/organisms/DataFilter/DataFilterSelect.vue deleted file mode 100644 index 8b030ae0b2..0000000000 --- a/src/components/organisms/DataFilter/DataFilterSelect.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - - - diff --git a/src/components/organisms/DataFilter/FilterDialog.unit.ts b/src/components/organisms/DataFilter/FilterDialog.unit.ts new file mode 100644 index 0000000000..c96e472fe9 --- /dev/null +++ b/src/components/organisms/DataFilter/FilterDialog.unit.ts @@ -0,0 +1,40 @@ +import { ComponentMountingOptions, mount } from "@vue/test-utils"; +import { createTestingVuetify } from "@@/tests/test-utils/setup"; +import FilterDialog from "./FilterDialog.vue"; + +describe("@components/DataFilter/FilterDialog.vue", () => { + const mountComponent = ( + options: ComponentMountingOptions = {} + ) => { + return mount(FilterDialog, { + global: { + plugins: [createTestingVuetify()], + }, + ...options, + }); + }; + + describe("should render the component", () => { + it("should render the component", () => { + const wrapper = mountComponent(); + expect(wrapper.exists()).toBe(true); + }); + + it("should pass the props to dialog component", async () => { + const wrapper = mountComponent(); + await wrapper.setProps({ isOpen: true }); + const dialog = wrapper.getComponent({ name: "v-dialog" }); + + expect(dialog.props("modelValue")).toBe(true); + expect(dialog.props("maxWidth")).toBe(480); + }); + + it("should emit the 'dialog:close' event on close", async () => { + const wrapper = mountComponent(); + const dialog = wrapper.getComponent({ name: "v-dialog" }); + await dialog.vm.$emit("click:outside"); + + expect(wrapper.emitted()).toHaveProperty("dialog-closed"); + }); + }); +}); diff --git a/src/components/organisms/DataFilter/FilterDialog.vue b/src/components/organisms/DataFilter/FilterDialog.vue new file mode 100644 index 0000000000..88b77d5143 --- /dev/null +++ b/src/components/organisms/DataFilter/FilterDialog.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/components/organisms/DataFilter/composables/filter.composable.ts b/src/components/organisms/DataFilter/composables/filter.composable.ts new file mode 100644 index 0000000000..02baaaace8 --- /dev/null +++ b/src/components/organisms/DataFilter/composables/filter.composable.ts @@ -0,0 +1,208 @@ +import { computed, onMounted, ref } from "vue"; + +import { + FilterOption, + FilterOptionsType, + FilterQuery, + Registration, + SelectOptionsType, + User, + UserBasedRegistrationOptions, + ChipTitle, + FilterItem, + UpdateFilterParamType, +} from "../types"; +import { useI18n } from "vue-i18n"; +import { useFilterLocalStorage } from "./localStorage.composable"; +import { printDate } from "@/plugins/datetime"; + +export const useDataTableFilter = (userType: string) => { + const { t } = useI18n(); + const { setFilterState, getFilterStorage, initializeUserType } = + useFilterLocalStorage(); + initializeUserType(userType); + + const filterQuery = ref({}); + + const defaultFilterMenuItems: SelectOptionsType[] = [ + { + label: t("common.labels.registration"), + value: FilterOption.REGISTRATION, + }, + { + label: t("utils.adminFilter.class.title"), + value: FilterOption.CLASSES, + }, + { + label: t("utils.adminFilter.date.title"), + value: FilterOption.CREATION_DATE, + }, + { + label: t("utils.adminFilter.lastMigration.title"), + value: FilterOption.LAST_MIGRATION_ON, + }, + { + label: t("utils.adminFilter.outdatedSince.title"), + value: FilterOption.OBSOLOTE_SINCE, + }, + ]; + + const registrationOptions: UserBasedRegistrationOptions = { + [User.STUDENT]: [ + { + label: t("pages.administration.students.legend.icon.success"), + value: Registration.COMPLETE, + }, + { + label: t("utils.adminFilter.consent.label.parentsAgreementMissing"), + value: Registration.PARENT_AGREED, + }, + { + label: t("utils.adminFilter.consent.label.missing"), + value: Registration.MISSING, + }, + ], + [User.TEACHER]: [ + { + label: t("pages.administration.students.legend.icon.success"), + value: Registration.COMPLETE, + }, + + { + label: t("utils.adminFilter.consent.label.missing"), + value: Registration.MISSING, + }, + ], + }; + + const selectedFilterType = ref(); + + const isDateFiltering = computed(() => { + return ( + selectedFilterType.value == FilterOption.CREATION_DATE || + selectedFilterType.value == FilterOption.LAST_MIGRATION_ON || + selectedFilterType.value == FilterOption.OBSOLOTE_SINCE + ); + }); + + const isSelectFiltering = computed(() => { + return ( + selectedFilterType.value == FilterOption.CLASSES || + selectedFilterType.value == FilterOption.REGISTRATION + ); + }); + + const filterMenuItems = ref([]); + + const filterChipTitles = ref>([]); + + const updateFilter = (value: UpdateFilterParamType) => { + if (!selectedFilterType.value) return; + filterQuery.value[selectedFilterType.value] = value; + + filterMenuItems.value = defaultFilterMenuItems.filter( + (item: SelectOptionsType) => !(item.value in filterQuery.value) + ); + + setFilterChipTitles(); + + setFilterState(filterQuery.value); + setFilterMenuItems(); + }; + + const removeFilter = () => { + if (selectedFilterType.value) + delete filterQuery.value[selectedFilterType.value]; + + setFilterChipTitles(); + setFilterState(filterQuery.value); + setFilterMenuItems(); + }; + + const removeChipFilter = (val: FilterOption) => { + delete filterQuery.value[val]; + + setFilterState(filterQuery.value); + setFilterMenuItems(); + }; + + const setFilterMenuItems = () => { + filterMenuItems.value = defaultFilterMenuItems.filter( + (item: SelectOptionsType) => !(item.value in filterQuery.value) + ); + }; + + const prepareChipTitles = (chipItem: FilterItem) => { + if (chipItem[0] == FilterOption.REGISTRATION) { + const statusKeyMap = { + [Registration.COMPLETE]: t( + "pages.administration.students.legend.icon.success" + ), + [Registration.MISSING]: t("utils.adminFilter.consent.label.missing"), + [Registration.PARENT_AGREED]: t( + "utils.adminFilter.consent.label.parentsAgreementMissing" + ), + }; + const status = chipItem[1].map((val) => { + return statusKeyMap[val as Registration]; + }); + + return status.join(` ${t("common.words.and")} `); + } + + if (chipItem[0] == FilterOption.CLASSES) + return `${t("utils.adminFilter.class.title")} = ${chipItem[1].join( + ", " + )}`; + + if (chipItem[0] == FilterOption.CREATION_DATE) + return `${t("utils.adminFilter.date.created")} ${printDate( + chipItem[1].$gte + )} ${t("common.words.and")} ${printDate(chipItem[1].$lte)}`; + + if (chipItem[0] == FilterOption.LAST_MIGRATION_ON) + return `${t("utils.adminFilter.lastMigration.title")} ${printDate( + chipItem[1].$gte + )} ${t("common.words.and")} ${printDate(chipItem[1].$lte)}`; + + if (chipItem[0] == FilterOption.OBSOLOTE_SINCE) + return `${t("utils.adminFilter.outdatedSince.title")} ${printDate( + chipItem[1].$gte + )} ${t("common.words.and")} ${printDate(chipItem[1].$lte)}`; + return []; + }; + + const setFilterChipTitles = () => { + const items = Object.entries(filterQuery.value).reduce( + (acc: Array, item) => { + return acc.concat({ + item: item[0], + title: prepareChipTitles(item as FilterItem), + }); + }, + [] + ); + filterChipTitles.value = (items as ChipTitle[]) || []; + }; + + onMounted(() => { + filterQuery.value = getFilterStorage() ?? {}; + if (filterQuery.value) setFilterChipTitles(); + setFilterMenuItems(); + }); + + return { + defaultFilterMenuItems, + filterChipTitles, + filterMenuItems, + filterQuery, + isDateFiltering, + isSelectFiltering, + registrationOptions, + selectedFilterType, + userType, + removeChipFilter, + removeFilter, + updateFilter, + }; +}; diff --git a/src/components/organisms/DataFilter/composables/filter.composable.unit.ts b/src/components/organisms/DataFilter/composables/filter.composable.unit.ts new file mode 100644 index 0000000000..bfa722c960 --- /dev/null +++ b/src/components/organisms/DataFilter/composables/filter.composable.unit.ts @@ -0,0 +1,283 @@ +import { mountComposable } from "@@/tests/test-utils"; +import { useDataTableFilter } from "./filter.composable"; +import { FilterOption, Registration, UpdateFilterParamType } from "../types"; + +const defaultState = { + pagination: {}, + filter: { + "pages.administration.students.index": { + query: {}, + }, + "pages.administration.teachers.index": { + query: {}, + }, + }, + sorting: {}, + version: 1, +}; + +jest.mock("@vueuse/core", () => { + return { + ...jest.requireActual("@vueuse/core"), + useStorage: jest.fn().mockReturnValue({ value: defaultState }), + }; +}); + +jest.mock("vue-i18n", () => { + return { + ...jest.requireActual("vue-i18n"), + useI18n: jest.fn().mockReturnValue({ t: (key: string) => key }), + }; +}); + +const setup = (userType: string) => { + return mountComposable(() => useDataTableFilter(userType)); +}; + +const removeAllFilters = () => { + const { removeFilter, selectedFilterType, filterQuery } = setup("student"); + selectedFilterType.value = FilterOption.LAST_MIGRATION_ON; + removeFilter(); + selectedFilterType.value = FilterOption.REGISTRATION; + removeFilter(); + selectedFilterType.value = FilterOption.CLASSES; + removeFilter(); + selectedFilterType.value = FilterOption.CREATION_DATE; + removeFilter(); + selectedFilterType.value = FilterOption.OBSOLOTE_SINCE; + removeFilter(); + filterQuery.value = {}; +}; + +describe("filter composable", () => { + it("should return filterQuery", () => { + const { filterQuery } = setup("student"); + + expect(filterQuery.value).toEqual({}); + }); + + it("should return defaultFilterMenuItems", () => { + const { defaultFilterMenuItems } = setup("student"); + + expect(defaultFilterMenuItems.length).toEqual(5); + expect(defaultFilterMenuItems[0].value).toEqual(FilterOption.REGISTRATION); + expect(defaultFilterMenuItems[1].value).toEqual(FilterOption.CLASSES); + expect(defaultFilterMenuItems[2].value).toEqual(FilterOption.CREATION_DATE); + expect(defaultFilterMenuItems[3].value).toEqual( + FilterOption.LAST_MIGRATION_ON + ); + expect(defaultFilterMenuItems[4].value).toEqual( + FilterOption.OBSOLOTE_SINCE + ); + }); + + it("should return user based registrationOptions", () => { + const { registrationOptions } = setup("student"); + const { student, teacher } = registrationOptions; + + expect(1 == 1).toEqual(true); + + expect(student.length).toEqual(3); + expect(teacher.length).toEqual(2); + + expect(student[0].value).toEqual(Registration.COMPLETE); + expect(student[1].value).toEqual(Registration.PARENT_AGREED); + expect(student[2].value).toEqual(Registration.MISSING); + + expect(teacher[0].value).toEqual(Registration.COMPLETE); + expect(teacher[1].value).toEqual(Registration.MISSING); + }); + + describe("selectedFilterType", () => { + it("should return correct selectedFilterType", () => { + const { selectedFilterType } = setup("student"); + + expect(selectedFilterType.value).toEqual(undefined); + }); + + it("should return 'isDateFiltering' value to be true", () => { + const { selectedFilterType, isDateFiltering, isSelectFiltering } = + setup("student"); + selectedFilterType.value = FilterOption.CREATION_DATE; + + expect(isDateFiltering.value).toBe(true); + expect(isSelectFiltering.value).toBe(false); + }); + + it("should return 'isSelectFiltering' value to be true", () => { + const { selectedFilterType, isDateFiltering, isSelectFiltering } = + setup("student"); + selectedFilterType.value = FilterOption.CLASSES; + + expect(isSelectFiltering.value).toBe(true); + expect(isDateFiltering.value).toBe(false); + }); + }); + + describe("filterMenuItems", () => { + it("should return default filterMenuItems", () => { + const { filterMenuItems } = setup("teacher"); + + expect(filterMenuItems.value.length).toEqual(5); + expect(filterMenuItems.value[0].value).toEqual(FilterOption.REGISTRATION); + expect(filterMenuItems.value[1].value).toEqual(FilterOption.CLASSES); + expect(filterMenuItems.value[2].value).toEqual( + FilterOption.CREATION_DATE + ); + expect(filterMenuItems.value[3].value).toEqual( + FilterOption.LAST_MIGRATION_ON + ); + expect(filterMenuItems.value[4].value).toEqual( + FilterOption.OBSOLOTE_SINCE + ); + }); + + it("should should return the filtered menu items after selection", () => { + const { filterMenuItems, selectedFilterType, updateFilter } = + setup("student"); + selectedFilterType.value = FilterOption.CLASSES; + updateFilter(["1A"] as UpdateFilterParamType); + + expect(filterMenuItems.value.length).toEqual(4); + + const found = filterMenuItems.value.find( + (item) => item.value == FilterOption.CLASSES + ); + expect(found).toBeUndefined(); + }); + }); + + describe("removeFilter", () => { + beforeEach(() => { + removeAllFilters(); + }); + it("should remove filter from filterQuery", () => { + const { filterQuery, removeFilter, selectedFilterType } = + setup("student"); + + const filters = { + [FilterOption.CREATION_DATE]: { + $gte: "2024-01-09T12:21:24.655Z", + $lte: "2024-01-30T23:00:00.000Z", + }, + [FilterOption.CLASSES]: ["1A"], + }; + + filterQuery.value = filters; + selectedFilterType.value = FilterOption.CREATION_DATE; + + removeFilter(); + + expect(filterQuery.value).toEqual({ [FilterOption.CLASSES]: ["1A"] }); + }); + }); + + describe("setFilterChipTitles", () => { + it("should set filter chip titles", () => { + const { + filterChipTitles, + filterQuery, + updateFilter, + selectedFilterType, + } = setup("teacher"); + + const filters = { + [FilterOption.CREATION_DATE]: { + $gte: "2024-01-09T12:21:24.655Z", + $lte: "2024-01-30T23:00:00.000Z", + }, + [FilterOption.CLASSES]: ["1A"], + [FilterOption.REGISTRATION]: ["ok"], + [FilterOption.LAST_MIGRATION_ON]: { + $gte: "2024-01-09T13:07:08.771Z", + $lte: "2024-01-29T23:00:00.000Z", + }, + [FilterOption.OBSOLOTE_SINCE]: { + $gte: "2024-01-09T13:07:19.885Z", + $lte: "2024-01-21T23:00:00.000Z", + }, + }; + + filterQuery.value = filters; + + selectedFilterType.value = FilterOption.CLASSES; + updateFilter(filters[FilterOption.CLASSES] as UpdateFilterParamType); + + selectedFilterType.value = FilterOption.CREATION_DATE; + updateFilter( + filters[FilterOption.CREATION_DATE] as UpdateFilterParamType + ); + + selectedFilterType.value = FilterOption.REGISTRATION; + updateFilter(filters[FilterOption.REGISTRATION] as UpdateFilterParamType); + + selectedFilterType.value = FilterOption.LAST_MIGRATION_ON; + updateFilter( + filters[FilterOption.LAST_MIGRATION_ON] as UpdateFilterParamType + ); + + selectedFilterType.value = FilterOption.OBSOLOTE_SINCE; + updateFilter( + filters[FilterOption.OBSOLOTE_SINCE] as UpdateFilterParamType + ); + + expect(filterChipTitles.value.length).toEqual(5); + expect(filterChipTitles.value[0].item).toEqual( + FilterOption.CREATION_DATE + ); + expect(filterChipTitles.value[0].title).toEqual( + "utils.adminFilter.date.created 09.01.2024 common.words.and 30.01.2024" + ); + + expect(filterChipTitles.value[1].item).toEqual(FilterOption.CLASSES); + expect(filterChipTitles.value[1].title).toEqual( + "utils.adminFilter.class.title = 1A" + ); + expect(filterChipTitles.value[2].item).toEqual(FilterOption.REGISTRATION); + expect(filterChipTitles.value[2].title).toEqual( + "pages.administration.students.legend.icon.success" + ); + + expect(filterChipTitles.value[3].item).toEqual( + FilterOption.LAST_MIGRATION_ON + ); + expect(filterChipTitles.value[3].title).toEqual( + "utils.adminFilter.lastMigration.title 09.01.2024 common.words.and 29.01.2024" + ); + + expect(filterChipTitles.value[4].item).toEqual( + FilterOption.OBSOLOTE_SINCE + ); + expect(filterChipTitles.value[4].title).toEqual( + "utils.adminFilter.outdatedSince.title 09.01.2024 common.words.and 21.01.2024" + ); + }); + + it("should return an empty array when no filter is set ", () => { + removeAllFilters(); + const { filterChipTitles } = setup("student"); + + expect(filterChipTitles.value.length).toEqual(0); + }); + }); + + describe("removeChipFilter", () => { + it("should remove chip filter", () => { + const { filterQuery, removeChipFilter } = setup("student"); + + const filters = { + [FilterOption.CREATION_DATE]: { + $gte: "2024-01-09T12:21:24.655Z", + $lte: "2024-01-30T23:00:00.000Z", + }, + [FilterOption.CLASSES]: ["1A"], + }; + + filterQuery.value = filters; + + removeChipFilter(FilterOption.CREATION_DATE); + + expect(filterQuery.value).toEqual({ [FilterOption.CLASSES]: ["1A"] }); + }); + }); +}); diff --git a/src/components/organisms/DataFilter/composables/localStorage.composable.ts b/src/components/organisms/DataFilter/composables/localStorage.composable.ts new file mode 100644 index 0000000000..3a41188cd7 --- /dev/null +++ b/src/components/organisms/DataFilter/composables/localStorage.composable.ts @@ -0,0 +1,56 @@ +import { useStorage } from "@vueuse/core"; +import { UiState, User } from "../types"; +import { ref } from "vue"; + +export const useFilterLocalStorage = () => { + const userType = ref(); + + const initializeUserType = (user: string) => { + userType.value = user; + }; + + type FilterStorage = { + [User.STUDENT]: string; + [User.TEACHER]: string; + }; + + const filterStorageKey: FilterStorage = { + [User.STUDENT]: "pages.administration.students.index", + [User.TEACHER]: "pages.administration.teachers.index", + }; + + const defaultState: UiState = { + pagination: {}, + filter: { + [filterStorageKey[User.STUDENT]]: { + query: {}, + }, + [filterStorageKey[User.TEACHER]]: { + query: {}, + }, + }, + sorting: {}, + version: 1, + }; + + const state = useStorage("uiState", defaultState); + + const getFilterStorage = () => { + return userType.value == User.STUDENT + ? state.value.filter["pages.administration.students.index"]?.query + : state.value.filter["pages.administration.teachers.index"]?.query; + }; + + const setFilterState = (val: object) => { + if (userType.value == User.STUDENT) + state.value.filter["pages.administration.students.index"] = { + query: val, + }; + if (userType.value == User.TEACHER) + state.value.filter["pages.administration.teachers.index"] = { + query: val, + }; + }; + + return { getFilterStorage, setFilterState, initializeUserType, state }; +}; diff --git a/src/components/organisms/DataFilter/composables/localStorage.composable.unit.ts b/src/components/organisms/DataFilter/composables/localStorage.composable.unit.ts new file mode 100644 index 0000000000..d71543b7fa --- /dev/null +++ b/src/components/organisms/DataFilter/composables/localStorage.composable.unit.ts @@ -0,0 +1,67 @@ +import { useFilterLocalStorage } from "./localStorage.composable"; +import { useStorage } from "@vueuse/core"; + +const defaultState = { + pagination: {}, + filter: { + "pages.administration.students.index": { + query: {}, + }, + "pages.administration.teachers.index": { + query: {}, + }, + }, + sorting: {}, + version: 1, +}; + +jest.mock("@vueuse/core", () => { + return { + ...jest.requireActual("@vueuse/core"), + useStorage: jest.fn().mockReturnValue({ value: defaultState }), + }; +}); + +describe("localStorage composable", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the default state", () => { + const { initializeUserType } = useFilterLocalStorage(); + initializeUserType("student"); + expect(useStorage).toHaveBeenCalledWith("uiState", defaultState); + }); + + it("should return the correct filter storage for students' filter", () => { + const { initializeUserType, getFilterStorage, setFilterState, state } = + useFilterLocalStorage(); + initializeUserType("student"); + const testQuery = { test: "test" }; + + setFilterState(testQuery); + + expect(getFilterStorage()).toEqual(testQuery); + expect( + state.value.filter["pages.administration.students.index"]?.query + ).toEqual(testQuery); + + expect( + state.value.filter["pages.administration.teachers.index"]?.query + ).toEqual({}); + }); + + it("should return the correct filter storage for teachers' filter", () => { + const { initializeUserType, getFilterStorage, setFilterState, state } = + useFilterLocalStorage(); + initializeUserType("teacher"); + const testQuery = { test: "teachers" }; + + setFilterState(testQuery); + + expect(getFilterStorage()).toEqual(testQuery); + expect( + state.value.filter["pages.administration.teachers.index"]?.query + ).toEqual(testQuery); + }); +}); diff --git a/src/components/organisms/DataFilter/defaultFilters.js b/src/components/organisms/DataFilter/defaultFilters.js deleted file mode 100644 index 7c3faef57f..0000000000 --- a/src/components/organisms/DataFilter/defaultFilters.js +++ /dev/null @@ -1,93 +0,0 @@ -export const supportedFilterTypes = ["date", "number", "select", "text"]; - -export const supportedOperators = { - date: ["=", "before", "after"], - number: ["=", "<", "<="], - select: ["="], - text: ["=", "<", "<="], -}; - -const filterTextIncludes = (value, targetValue) => { - return (value || "") - .toString() - .toString() - .toLowerCase() - .includes(targetValue.toString().toLowerCase()); -}; -const filterTextEqual = (value, targetValue) => { - return ( - (value || "").toString().toLowerCase() === - targetValue.toString().toLowerCase() - ); -}; -const filterTextLess = (value, targetValue) => { - return ( - (value || "").toString().toLowerCase() < - targetValue.toString().toLowerCase() - ); -}; -const filterTextLessEqual = (value, targetValue) => { - return ( - (value || "").toString().toLowerCase() <= - targetValue.toString().toLowerCase() - ); -}; - -const filterNumberEqual = (value, targetValue) => { - return Number(value) === Number(targetValue); -}; -const filterNumberLess = (value, targetValue) => { - return Number(value) < Number(targetValue); -}; -const filterNumberLessEqual = (value, targetValue) => { - return Number(value) <= Number(targetValue); -}; - -const filterSelect = (value, targetValue) => { - return targetValue.some((option) => { - return ( - option.checked && - option.value && - option.value.toString() === (value || "").toString() - ); - }); -}; - -const filterDateDefault = (value, targetValue) => { - return filterDateEqual(value, targetValue); -}; -const filterDateEqual = (value, targetValue) => { - return new Date(value).getTime() === new Date(targetValue).getTime(); -}; -const filterDateBefore = (value, targetValue) => { - return new Date(value) <= new Date(targetValue); -}; -const filterDateAfter = (value, targetValue) => { - return new Date(value) >= new Date(targetValue); -}; - -export const defaultFilters = { - date: { - default: filterDateDefault, - "=": filterDateEqual, - before: filterDateBefore, - after: filterDateAfter, - }, - number: { - default: filterNumberEqual, - "=": filterNumberEqual, - "<": filterNumberLess, - "<=": filterNumberLessEqual, - }, - select: { - default: filterSelect, - "=": filterSelect, - }, - text: { - default: filterTextIncludes, - "=": filterTextEqual, - "<": filterTextLess, - "<=": filterTextLessEqual, - includes: filterTextIncludes, - }, -}; diff --git a/src/components/organisms/DataFilter/defaultFilters.unit.js b/src/components/organisms/DataFilter/defaultFilters.unit.js deleted file mode 100644 index 3a29cc4d62..0000000000 --- a/src/components/organisms/DataFilter/defaultFilters.unit.js +++ /dev/null @@ -1,140 +0,0 @@ -import { defaultFilters } from "./defaultFilters"; - -describe("@/components/organisms/DataFilter/defaultFilters", () => { - describe("it can filter strings", () => { - it.each([ - ["test", "te", true], - ["test", "a", false], - ["Test", "te", true], - [11, 1, true], - [11, 2, false], - ])("%p includes %p is %s", (a, b, expected) => { - expect(defaultFilters["text"]["default"](a, b)).toBe(expected); - expect(defaultFilters["text"]["includes"](a, b)).toBe(expected); - }); - - it.each([ - ["test", "test", true], - ["test", "te", false], - ["Test", "test", true], - ])("%p = %p is %s", (a, b, expected) => { - expect(defaultFilters["text"]["="](a, b)).toBe(expected); - }); - - it.each([ - ["test", "test", false], - ["te", "test", true], - ["Test", "test", false], - ])("%p < %p is %s", (a, b, expected) => { - expect(defaultFilters["text"]["<"](a, b)).toBe(expected); - }); - - it.each([ - ["test", "test", true], - ["test", "te", false], - ["Test", "test", true], - ])("%p <= %p is %s", (a, b, expected) => { - expect(defaultFilters["text"]["<="](a, b)).toBe(expected); - }); - }); - - describe("it can filter numbers", () => { - it.each([ - [1, 1, true], - [1, 2, false], - ["11", "11", true], - ["11", "12", false], - ])("%s = %s is %s", (a, b, expected) => { - expect(defaultFilters["number"]["default"](a, b)).toBe(expected); - expect(defaultFilters["number"]["="](a, b)).toBe(expected); - }); - - it.each([ - [2, 1, false], - [1, 1, false], - [0, 1, true], - ["2", "1", false], - ["1", "1", false], - ["0", "1", true], - ])("%s < %s is %s", (a, b, expected) => { - expect(defaultFilters["number"]["<"](a, b)).toBe(expected); - }); - - it.each([ - [1, 2, true], - [1, 1, true], - [2, 1, false], - ["1", "2", true], - ["1", "1", true], - ["2", "1", false], - ])("%s <= %s is %s", (a, b, expected) => { - expect(defaultFilters["number"]["<="](a, b)).toBe(expected); - }); - }); - - describe("it can filter options", () => { - it.each([ - [ - "test1", - [ - { checked: true, value: "test1" }, - { checked: true, value: "test2" }, - ], - true, - ], - [ - "test2", - [ - { checked: true, value: "test1" }, - { checked: true, value: "test2" }, - ], - true, - ], - [ - "test3", - [ - { checked: true, value: "test1" }, - { checked: true, value: "test2" }, - ], - false, - ], - ])("%s is selected in %o is %s", (a, b, expected) => { - expect(defaultFilters["select"]["default"](a, b)).toBe(expected); - }); - }); - - describe("it can filter dates", () => { - it.each([ - ["2019-01-01 01:00", "2019-01-01 01:00", true], - ["2019-01-01 01:00", "2019-01-01 02:00", false], - ["2019-01-01", "2019-01-01", true], - ["2019-01-01", "2019-01-02", false], - ])("%s = %s is %s", (a, b, expected) => { - expect(defaultFilters["date"]["default"](a, b)).toBe(expected); - expect(defaultFilters["date"]["="](a, b)).toBe(expected); - }); - - it.each([ - ["2019-01-01 01:00", "2019-01-01 01:00", true], - ["2019-01-01 01:00", "2019-01-01 02:00", true], - ["2019-01-01", "2019-01-01", true], - ["2019-01-01", "2019-01-02", true], - ["2019-01-01 02:00", "2019-01-01 01:00", false], - ["2019-01-02", "2019-01-01", false], - ])("%s is before %s is %s", (a, b, expected) => { - expect(defaultFilters["date"]["before"](a, b)).toBe(expected); - }); - - it.each([ - ["2019-01-01 01:00", "2019-01-01 02:00", false], - ["2019-01-01 01:00", "2019-01-01 01:00", true], - ["2019-01-01 02:00", "2019-01-01 01:00", true], - ["2019-01-01", "2019-01-01", true], - ["2019-01-02", "2019-01-01", true], - ["2019-01-01 01:00", "2019-01-01 02:00", false], - ["2019-01-01", "2019-01-02", false], - ])("%s is after %s is %s", (a, b, expected) => { - expect(defaultFilters["date"]["after"](a, b)).toBe(expected); - }); - }); -}); diff --git a/src/components/organisms/DataFilter/filterComponents/DateBetween.unit.ts b/src/components/organisms/DataFilter/filterComponents/DateBetween.unit.ts new file mode 100644 index 0000000000..65873044f7 --- /dev/null +++ b/src/components/organisms/DataFilter/filterComponents/DateBetween.unit.ts @@ -0,0 +1,145 @@ +import { ComponentMountingOptions, shallowMount } from "@vue/test-utils"; +import DateBetween from "./DateBetween.vue"; +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import { nextTick } from "vue"; + +const mockProps = { + selectedDate: { + $gte: "2023-12-20T23:00:00.000Z", + $lte: "2199-12-31T23:00:00.000Z", + }, +}; + +const mountComponent = ( + options: ComponentMountingOptions = {} +) => { + return shallowMount(DateBetween, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + }, + ...options, + }); +}; + +describe("@components/DataFilter/filterComponents/DateBetween.vue", () => { + describe("should render the component", () => { + it("should render the date picker components", async () => { + jest.useFakeTimers(); + const testDate = new Date(2024, 0, 1); + jest.setSystemTime(testDate); + + const wrapper = mountComponent(); + + const datePickerFromComponent = wrapper.findAllComponents({ + name: "date-picker", + }); + + expect(datePickerFromComponent).toHaveLength(2); + expect(datePickerFromComponent[0].props("date")).toStrictEqual( + testDate.toISOString() + ); + expect(datePickerFromComponent[0].props("label")).toStrictEqual( + "utils.adminFilter.date.label.from" + ); + + expect(datePickerFromComponent[1].props("date")).toStrictEqual(""); + expect(datePickerFromComponent[1].props("label")).toStrictEqual( + "utils.adminFilter.date.label.until" + ); + }); + + it("should render the date picker components with the selected date", async () => { + const wrapper = mountComponent({ + props: mockProps, + }); + await nextTick(); + + const datePickerFromComponent = wrapper.findAllComponents({ + name: "date-picker", + }); + + expect(datePickerFromComponent).toHaveLength(2); + expect(datePickerFromComponent[0].props("date")).toStrictEqual( + mockProps.selectedDate.$gte + ); + expect(datePickerFromComponent[0].props("label")).toStrictEqual( + "utils.adminFilter.date.label.from" + ); + + expect(datePickerFromComponent[1].props("date")).toStrictEqual( + mockProps.selectedDate.$lte + ); + expect(datePickerFromComponent[1].props("label")).toStrictEqual( + "utils.adminFilter.date.label.until" + ); + }); + + it("should render the filter action buttons component", async () => { + const wrapper = mountComponent(); + + const filterActionButtonsComponent = wrapper.getComponent({ + name: "FilterActionButtons", + }); + + expect(filterActionButtonsComponent).toBeDefined(); + }); + }); + + describe("should emit the events", () => { + describe("when add button is clicked", () => { + it("should emit 'update:filter'", async () => { + const wrapper = mountComponent(); + + const actionButtonComponent = wrapper.getComponent({ + name: "FilterActionButtons", + }); + + await actionButtonComponent.vm.$emit("update:filter"); + expect(wrapper.emitted()).toHaveProperty("update:filter"); + }); + + it("should emit 'remove:filter' if dateSelection value is undefined", async () => { + const wrapper = mountComponent(); + const datePickerComponent = wrapper.findAllComponents({ + name: "date-picker", + }); + + await datePickerComponent[0].vm.$emit("update:date", undefined); + + const actionButtonComponent = wrapper.getComponent({ + name: "FilterActionButtons", + }); + + await actionButtonComponent.vm.$emit("update:filter"); + + expect(wrapper.emitted()).not.toHaveProperty("update:filter"); + expect(wrapper.emitted()).toHaveProperty("remove:filter"); + }); + }); + + it("should emit 'remove:filter 'when remove button is clicked", async () => { + const wrapper = mountComponent(); + + const actionButtonComponent = wrapper.getComponent({ + name: "FilterActionButtons", + }); + + await actionButtonComponent.vm.$emit("remove:filter"); + expect(wrapper.emitted()).toHaveProperty("remove:filter"); + }); + + it("should emit 'dialog-closed' when remove button is clicked", async () => { + const wrapper = mountComponent(); + + const actionButtonComponent = wrapper.getComponent({ + name: "FilterActionButtons", + }); + + await actionButtonComponent.vm.$emit("dialog-closed"); + expect(wrapper.emitted()).toHaveProperty("dialog-closed"); + }); + }); +}); diff --git a/src/components/organisms/DataFilter/filterComponents/DateBetween.vue b/src/components/organisms/DataFilter/filterComponents/DateBetween.vue new file mode 100644 index 0000000000..75bce744a6 --- /dev/null +++ b/src/components/organisms/DataFilter/filterComponents/DateBetween.vue @@ -0,0 +1,83 @@ + + diff --git a/src/components/organisms/DataFilter/filterComponents/FilterActionButton.unit.ts b/src/components/organisms/DataFilter/filterComponents/FilterActionButton.unit.ts new file mode 100644 index 0000000000..04e7113768 --- /dev/null +++ b/src/components/organisms/DataFilter/filterComponents/FilterActionButton.unit.ts @@ -0,0 +1,46 @@ +import { ComponentMountingOptions, mount } from "@vue/test-utils"; +import FilterActionButtons from "./FilterActionButtons.vue"; +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; + +describe("@components/DataFilter/filterComponents/FilterActionButtons.vue", () => { + const mountComponent = ( + options: ComponentMountingOptions = {} + ) => { + return mount(FilterActionButtons, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + }, + ...options, + }); + }; + it('should emit "remove:filter" event when onRemoveFilter is called', async () => { + const wrapper = mountComponent(); + const removeButton = wrapper.getComponent( + '[data-testid="remove-filter-button"]' + ); + + await removeButton.trigger("click"); + expect(wrapper.emitted()).toHaveProperty("remove:filter"); + }); + + it('should emit "dialog-closed" event when onCancel is called', async () => { + const wrapper = mountComponent(); + const cancelButton = wrapper.getComponent( + '[data-testid="cancel-filter-button"]' + ); + + await cancelButton.trigger("click"); + expect(wrapper.emitted()).toHaveProperty("dialog-closed"); + }); + + it('should emit "update:filter" event when onAddFilter is called', async () => { + const wrapper = mountComponent(); + const addButton = wrapper.getComponent('[data-testid="add-filter-button"]'); + + await addButton.trigger("click"); + expect(wrapper.emitted()).toHaveProperty("update:filter"); + }); +}); diff --git a/src/components/organisms/DataFilter/filterComponents/FilterActionButtons.vue b/src/components/organisms/DataFilter/filterComponents/FilterActionButtons.vue new file mode 100644 index 0000000000..79fbee103d --- /dev/null +++ b/src/components/organisms/DataFilter/filterComponents/FilterActionButtons.vue @@ -0,0 +1,46 @@ + + diff --git a/src/components/organisms/DataFilter/filterComponents/FilterChips.unit.ts b/src/components/organisms/DataFilter/filterComponents/FilterChips.unit.ts new file mode 100644 index 0000000000..d46fcad997 --- /dev/null +++ b/src/components/organisms/DataFilter/filterComponents/FilterChips.unit.ts @@ -0,0 +1,59 @@ +import { ComponentMountingOptions, mount } from "@vue/test-utils"; +import { createTestingVuetify } from "@@/tests/test-utils/setup"; +import FilterChips from "./FilterChips.vue"; + +describe("@components/DataFilter/filterComponents/FilterChips.vue", () => { + const mockProps = [ + { item: "classes", title: "Klasse(n) = 1C, 1D" }, + { item: "consentStatus", title: "Schülereinverständnis fehlt" }, + ]; + const mountComponent = ( + options: ComponentMountingOptions = {} + ) => { + return mount(FilterChips, { + global: { + plugins: [createTestingVuetify()], + }, + ...options, + }); + }; + + describe("should render the component", () => { + it("should render the chips", async () => { + const wrapper = mountComponent(); + await wrapper.setProps({ filters: mockProps }); + const chips = wrapper.findAllComponents(".v-chip"); + expect(chips.length).toBe(2); + expect(chips[0].text()).toBe(mockProps[0].title); + expect(chips[1].text()).toBe(mockProps[1].title); + }); + }); + + describe("should emit the event", () => { + it("should emit remove:filter on close event", async () => { + const wrapper = mountComponent(); + await wrapper.setProps({ filters: mockProps }); + + const chipComponent = wrapper.getComponent({ name: "v-chip" }); + await chipComponent.vm.$emit("click:close"); + + expect(wrapper.emitted()).toHaveProperty("remove:filter"); + expect(wrapper.emitted()["remove:filter"]).toStrictEqual([ + [mockProps[0].item], + ]); + }); + + it("should emit click:filter on click event", async () => { + const wrapper = mountComponent(); + await wrapper.setProps({ filters: mockProps }); + + const chips = wrapper.findAllComponents(".v-chip"); + await chips[1].trigger("click"); + + expect(wrapper.emitted()).toHaveProperty("click:filter"); + expect(wrapper.emitted()["click:filter"]).toStrictEqual([ + [mockProps[1].item], + ]); + }); + }); +}); diff --git a/src/components/organisms/DataFilter/filterComponents/FilterChips.vue b/src/components/organisms/DataFilter/filterComponents/FilterChips.vue new file mode 100644 index 0000000000..ce8e9fbdf4 --- /dev/null +++ b/src/components/organisms/DataFilter/filterComponents/FilterChips.vue @@ -0,0 +1,39 @@ + + diff --git a/src/components/organisms/DataFilter/filterComponents/ListSelection.unit.ts b/src/components/organisms/DataFilter/filterComponents/ListSelection.unit.ts new file mode 100644 index 0000000000..7ab8614910 --- /dev/null +++ b/src/components/organisms/DataFilter/filterComponents/ListSelection.unit.ts @@ -0,0 +1,139 @@ +import { ComponentMountingOptions, mount } from "@vue/test-utils"; +import ListSelection from "./ListSelection.vue"; +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; + +const mockProps = { + selectionList: [ + { label: "1A", value: "1A" }, + { label: "1C", value: "1C" }, + { label: "1D", value: "1D" }, + ], + selectedList: ["1A", "1C"], +}; + +const mountComponent = ( + options: ComponentMountingOptions = {} +) => { + return mount(ListSelection, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + }, + ...options, + }); +}; + +describe("@components/DataFilter/filterComponents/ListSelection.vue", () => { + describe("should render the component", () => { + it("should render the checkboxes", async () => { + const wrapper = mountComponent({ + props: { + selectionList: mockProps.selectionList, + selectedList: mockProps.selectedList, + }, + }); + + const checkboxComponents = wrapper.findAllComponents( + '[data-testid="list-selection"]' + ); + + expect(checkboxComponents[0].text()).toBe( + mockProps.selectionList[0].label + ); + expect(checkboxComponents[1].text()).toBe( + mockProps.selectionList[1].label + ); + expect(checkboxComponents[2].text()).toBe( + mockProps.selectionList[2].label + ); + + expect(wrapper.vm.selectedList).toStrictEqual(mockProps.selectedList); + }); + }); + + describe("should emit the events", () => { + describe("when add button is clicked", () => { + it("should emit update:filter", async () => { + const wrapper = mountComponent({ + props: { + selectionList: mockProps.selectionList, + selectedList: mockProps.selectedList, + }, + }); + + const checkboxComponent = wrapper.getComponent({ name: "v-checkbox" }); + const actionButtonComponent = wrapper.getComponent({ + name: "FilterActionButtons", + }); + + await checkboxComponent.vm.$emit( + "update:modelValue", + mockProps.selectedList + ); + await actionButtonComponent.vm.$emit("update:filter"); + + expect(wrapper.emitted()).toHaveProperty("update:filter"); + expect(wrapper.emitted()["update:filter"][0]).toStrictEqual([ + ["1A", "1C"], + ]); + expect(wrapper.emitted()).toHaveProperty("dialog-closed"); + }); + + it("should emit remove:filter if selectedList prop is empty", async () => { + const wrapper = mountComponent({ + props: { + selectionList: mockProps.selectionList, + selectedList: [], + }, + }); + + const checkboxComponent = wrapper.getComponent({ name: "v-checkbox" }); + const actionButtonComponent = wrapper.getComponent({ + name: "FilterActionButtons", + }); + + await checkboxComponent.vm.$emit("update:modelValue", []); + await actionButtonComponent.vm.$emit("update:filter"); + + expect(wrapper.emitted()).toHaveProperty("remove:filter"); + expect(wrapper.emitted()).toHaveProperty("dialog-closed"); + }); + }); + + it("should emit dialog-closed when cancel button is clicked", async () => { + const wrapper = mountComponent({ + props: { + selectionList: mockProps.selectionList, + selectedList: [], + }, + }); + + const actionButtonComponent = wrapper.getComponent({ + name: "FilterActionButtons", + }); + + await actionButtonComponent.vm.$emit("dialog-closed"); + + expect(wrapper.emitted()).toHaveProperty("dialog-closed"); + }); + + it("should emit remove:filter when remove button is clicked", async () => { + const wrapper = mountComponent({ + props: { + selectionList: mockProps.selectionList, + selectedList: [], + }, + }); + + const actionButtonComponent = wrapper.getComponent({ + name: "FilterActionButtons", + }); + + await actionButtonComponent.vm.$emit("remove:filter"); + + expect(wrapper.emitted()).toHaveProperty("remove:filter"); + }); + }); +}); diff --git a/src/components/organisms/DataFilter/filterComponents/ListSelection.vue b/src/components/organisms/DataFilter/filterComponents/ListSelection.vue new file mode 100644 index 0000000000..d359c3a137 --- /dev/null +++ b/src/components/organisms/DataFilter/filterComponents/ListSelection.vue @@ -0,0 +1,61 @@ + + diff --git a/src/components/organisms/DataFilter/filterComponents/index.ts b/src/components/organisms/DataFilter/filterComponents/index.ts new file mode 100644 index 0000000000..59031b5228 --- /dev/null +++ b/src/components/organisms/DataFilter/filterComponents/index.ts @@ -0,0 +1,6 @@ +import ListSelection from "./ListSelection.vue"; +import DateBetween from "./DateBetween.vue"; +import FilterActionButtons from "./FilterActionButtons.vue"; +import FilterChips from "./FilterChips.vue"; + +export { DateBetween, FilterActionButtons, FilterChips, ListSelection }; diff --git a/src/components/organisms/DataFilter/inputs/Checkbox.unit.js b/src/components/organisms/DataFilter/inputs/Checkbox.unit.js deleted file mode 100644 index 946bd8fb3c..0000000000 --- a/src/components/organisms/DataFilter/inputs/Checkbox.unit.js +++ /dev/null @@ -1,93 +0,0 @@ -import InputCheckbox from "./Checkbox"; - -describe("@/components/organisms/DataFilter/inputs/Checkbox", () => { - it("can preselect an option", async () => { - const expectedValue = ["A", "B"]; - const wrapper = mount(InputCheckbox, { - ...createComponentMocks({ - vuetify: true, - }), - propsData: { - label: "Chechbox", - value: expectedValue, - options: [ - { value: "A", label: "Checkbox 1" }, - { value: "B", label: "Checkbox 2" }, - { value: "C", label: "Checkbox 3" }, - ], - }, - }); - expect( - wrapper.find(`input[type="checkbox"][value="A"]:checked`).exists() - ).toBe(true); - expect( - wrapper.find(`input[type="checkbox"][value="B"]:checked`).exists() - ).toBe(true); - expect( - wrapper.find(`input[type="checkbox"][value="C"]:checked`).exists() - ).toBe(false); - }); - - it("can choose an option", async () => { - const wrapper = mount(InputCheckbox, { - ...createComponentMocks({ - vuetify: true, - }), - propsData: { - label: "Checkbox", - value: [], - options: [ - { value: "A", label: "Checkbox 1" }, - { value: "B", label: "Checkbox 2" }, - { value: "C", label: "Checkbox 3" }, - ], - }, - }); - const options = wrapper.findAll(`input[type="checkbox"]`); - const selectedOption = options.at(0); - const expectedValue = selectedOption.attributes("value"); - selectedOption.setChecked(true); - expect(wrapper.emitted("input")).toStrictEqual([[[expectedValue]]]); - }); - - it("can deselect an option", async () => { - const wrapper = mount(InputCheckbox, { - ...createComponentMocks({ - vuetify: true, - }), - propsData: { - label: "Checkbox", - value: ["A"], - options: [ - { value: "A", label: "Checkbox 1" }, - { value: "B", label: "Checkbox 2" }, - { value: "C", label: "Checkbox 3" }, - ], - }, - }); - const options = wrapper.findAll(`input[type="checkbox"][value="A"]`); - const selectedOption = options.at(0); - selectedOption.setChecked(false); - expect(wrapper.emitted("input")).toStrictEqual([[[]]]); - }); - - it("throws error if label in option is missing", () => { - expect(() => - mount(InputCheckbox, { - propsData: { - options: [{ value: "A" }], - }, - }) - ).toThrow(new Error(`option 0 is missing a label`)); - }); - - it("throws error if value in option is missing", () => { - expect(() => - mount(InputCheckbox, { - propsData: { - options: [{ label: "Checkbox 1" }], - }, - }) - ).toThrow(new Error(`option 0 is missing a value`)); - }); -}); diff --git a/src/components/organisms/DataFilter/inputs/Checkbox.vue b/src/components/organisms/DataFilter/inputs/Checkbox.vue deleted file mode 100644 index 440661bbf4..0000000000 --- a/src/components/organisms/DataFilter/inputs/Checkbox.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - - diff --git a/src/components/organisms/DataFilter/inputs/Default.unit.js b/src/components/organisms/DataFilter/inputs/Default.unit.js deleted file mode 100644 index eb7241bf8e..0000000000 --- a/src/components/organisms/DataFilter/inputs/Default.unit.js +++ /dev/null @@ -1,3 +0,0 @@ -describe("@/components/organisms/DataFilter/inputs/Default", () => { - it.todo("write tests"); -}); diff --git a/src/components/organisms/DataFilter/inputs/Default.vue b/src/components/organisms/DataFilter/inputs/Default.vue deleted file mode 100644 index 6bea12af5b..0000000000 --- a/src/components/organisms/DataFilter/inputs/Default.vue +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/src/components/organisms/DataFilter/inputs/Inputs.md b/src/components/organisms/DataFilter/inputs/Inputs.md deleted file mode 100644 index 0d13a9da92..0000000000 --- a/src/components/organisms/DataFilter/inputs/Inputs.md +++ /dev/null @@ -1,15 +0,0 @@ -# DataFilter/Inputs - -additional props can be passed to the inputs using the "attributes" config key. Check [the docs](http://docs.vue-filter-ui.surge.sh/Customize/5-Input.html#interface) for more details. - -## Default - -same as BaseInput. Should always be used, except for radio buttons and checkboxes. - -## Checkbox - -can be used as a multiselect like input. Behaves like multiple BaseCheckbox components that all have the same name and an array as v-model. - -## Radio - -can be used as a select like input. Behaves like multiple BaseRadio components that all have the same name and a string as the value. diff --git a/src/components/organisms/DataFilter/inputs/Radio.unit.js b/src/components/organisms/DataFilter/inputs/Radio.unit.js deleted file mode 100644 index 97facfcd3e..0000000000 --- a/src/components/organisms/DataFilter/inputs/Radio.unit.js +++ /dev/null @@ -1,59 +0,0 @@ -import InputRadio from "./Radio"; - -describe("@/components/organisms/DataFilter/inputs/Radio", () => { - it("can preselect an option", async () => { - const expectedValue = "25"; - const wrapper = mount(InputRadio, { - propsData: { - label: "Radio", - value: expectedValue, - options: [ - { value: "10", label: "Radio1" }, - { value: "25", label: "Radio2" }, - { value: "100", label: "Radio3" }, - ], - }, - }); - const selectedOption = wrapper.get(`input[type="radio"]:checked`); - expect(selectedOption.attributes("value")).toStrictEqual(expectedValue); - }); - - it("can choose an option", async () => { - const wrapper = mount(InputRadio, { - propsData: { - label: "Radio", - value: "100", - options: [ - { value: "10", label: "Radio1" }, - { value: "25", label: "Radio2" }, - { value: "100", label: "Radio3" }, - ], - }, - }); - const options = wrapper.findAll(`input[type="radio"]`); - const selectedOption = options.at(0); - const expectedValue = selectedOption.attributes("value"); - selectedOption.setChecked(true); - expect(wrapper.emitted("input")).toStrictEqual([[expectedValue]]); - }); - - it("throws error if label in option is missing", () => { - expect(() => - mount(InputRadio, { - propsData: { - options: [{ value: "10" }], - }, - }) - ).toThrow(new Error(`option 0 is missing a label`)); - }); - - it("throws error if value in option is missing", () => { - expect(() => - mount(InputRadio, { - propsData: { - options: [{ label: "Label 10" }], - }, - }) - ).toThrow(new Error(`option 0 is missing a value`)); - }); -}); diff --git a/src/components/organisms/DataFilter/inputs/Radio.vue b/src/components/organisms/DataFilter/inputs/Radio.vue deleted file mode 100644 index d74cb36365..0000000000 --- a/src/components/organisms/DataFilter/inputs/Radio.vue +++ /dev/null @@ -1,83 +0,0 @@ - - - - diff --git a/src/components/organisms/DataFilter/types/enums.ts b/src/components/organisms/DataFilter/types/enums.ts new file mode 100644 index 0000000000..f469839c0d --- /dev/null +++ b/src/components/organisms/DataFilter/types/enums.ts @@ -0,0 +1,20 @@ +enum FilterOption { + REGISTRATION = "consentStatus", + CLASSES = "classes", + CREATION_DATE = "createdAt", + LAST_MIGRATION_ON = "lastLoginSystemChange", + OBSOLOTE_SINCE = "outdatedSince", +} + +enum User { + STUDENT = "student", + TEACHER = "teacher", +} + +enum Registration { + COMPLETE = "ok", + PARENT_AGREED = "parentsAgreed", + MISSING = "missing", +} + +export { FilterOption, User, Registration }; diff --git a/src/components/organisms/DataFilter/types/index.ts b/src/components/organisms/DataFilter/types/index.ts new file mode 100644 index 0000000000..cca03d0283 --- /dev/null +++ b/src/components/organisms/DataFilter/types/index.ts @@ -0,0 +1,27 @@ +import { FilterOption, Registration, User } from "./enums"; +import { + ChipTitle, + DateSelection, + FilterItem, + FilterOptionsType, + FilterQuery, + SelectOptionsType, + UiState, + UpdateFilterParamType, + UserBasedRegistrationOptions, +} from "./types"; + +export { + FilterOption, + Registration, + User, + ChipTitle, + DateSelection, + FilterItem, + FilterOptionsType, + FilterQuery, + SelectOptionsType, + UiState, + UpdateFilterParamType, + UserBasedRegistrationOptions, +}; diff --git a/src/components/organisms/DataFilter/types/types.ts b/src/components/organisms/DataFilter/types/types.ts new file mode 100644 index 0000000000..42d833cd12 --- /dev/null +++ b/src/components/organisms/DataFilter/types/types.ts @@ -0,0 +1,68 @@ +import { FilterOption, User } from "./enums"; + +type FilterOptionsType = + | "consentStatus" + | "classes" + | "createdAt" + | "lastLoginSystemChange" + | "outdatedSince"; + +type ChipTitle = { + item: string; + title: string; +}; + +type FilterItem = [string, string[] & DateSelection]; + +type SelectOptionsType = { + label: string; + value: string; +}; + +type UserBasedRegistrationOptions = { + [User.STUDENT]: SelectOptionsType[]; + [User.TEACHER]: SelectOptionsType[]; +}; + +type FilterQuery = { + [FilterOption.LAST_MIGRATION_ON]?: DateSelection; + [FilterOption.REGISTRATION]?: string[]; + [FilterOption.CLASSES]?: string[]; + [FilterOption.CREATION_DATE]?: DateSelection; + [FilterOption.OBSOLOTE_SINCE]?: DateSelection; +}; + +type Query = { + query: FilterQuery; +}; + +type StorageFilterState = { + "pages.administration.students.index"?: Query; + "pages.administration.teachers.index"?: Query; +}; + +type UiState = { + pagination: object; + filter: StorageFilterState; + sorting: object; + version: number; +}; + +type DateSelection = { + $gte: string; + $lte: string; +}; + +type UpdateFilterParamType = string[] & DateSelection; + +export type { + ChipTitle, + DateSelection, + FilterItem, + FilterOptionsType, + FilterQuery, + SelectOptionsType, + UiState, + UpdateFilterParamType, + UserBasedRegistrationOptions, +}; diff --git a/src/components/organisms/DataTable/DataTable.data-factory.js b/src/components/organisms/DataTable/DataTable.data-factory.js index a34f496c30..893c0e9424 100644 --- a/src/components/organisms/DataTable/DataTable.data-factory.js +++ b/src/components/organisms/DataTable/DataTable.data-factory.js @@ -1,9 +1,5 @@ import users from "./testUserData"; -import InputCheckbox from "@/components/organisms/DataFilter/inputs/Checkbox"; -import InputRadio from "@/components/organisms/DataFilter/inputs/Radio"; -import InputDefault from "@/components/organisms/DataFilter/inputs/Default"; - const tableData = (n) => users.slice(0, n); const tableColumns = [ @@ -69,7 +65,6 @@ const tableFilters = [ { attribute: "$limit", operator: "<", - input: InputRadio, options: [ { value: 25, label: "25" }, { value: 50, label: "50" }, @@ -86,7 +81,6 @@ const tableFilters = [ attribute: "age", applyNegated: true, operator: "<=", - input: InputDefault, label: "Alter", attributes: { type: "number", @@ -102,7 +96,6 @@ const tableFilters = [ { attribute: "birthday", operator: "=", - input: InputDefault, label: "Geburtstag", attributes: { type: "date", @@ -118,7 +111,6 @@ const tableFilters = [ { attribute: "agreed", operator: "=", - input: InputCheckbox, options: [ { value: "true", label: "Einverständniserklärung vorhanden" }, { value: "false", label: "Keine Einverständniserklärung" }, @@ -133,7 +125,6 @@ const tableFilters = [ { attribute: "firstName", operator: "includes", - input: InputDefault, label: "Vorname", attributes: { type: "text", diff --git a/src/locales/de.json b/src/locales/de.json index 1a3e7697f2..429fabd9f6 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1024,8 +1024,8 @@ "utils.adminFilter.consent.parentsAgreed": "nur Eltern haben zugestimmt", "utils.adminFilter.consent.title": "Registrierungen", "utils.adminFilter.date.created": "Erstellt zwischen", - "utils.adminFilter.date.label.from": "Erstellungsdatum von", - "utils.adminFilter.date.label.until": "Erstellungsdatum bis", + "utils.adminFilter.date.label.from": "Datum von", + "utils.adminFilter.date.label.until": "Datum bis", "utils.adminFilter.date.title": "Erstellungsdatum", "utils.adminFilter.outdatedSince.title": "Veraltet seit", "utils.adminFilter.outdatedSince.label.from": "Veraltet seit von", diff --git a/src/locales/en.json b/src/locales/en.json index 19a8855ae8..4e23cbdf87 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1023,8 +1023,8 @@ "utils.adminFilter.consent.parentsAgreed": "only parents have agreed", "utils.adminFilter.consent.title": "Registrations", "utils.adminFilter.date.created": "Created between", - "utils.adminFilter.date.label.from": "Creation date from", - "utils.adminFilter.date.label.until": "Creation date until", + "utils.adminFilter.date.label.from": "Date from", + "utils.adminFilter.date.label.until": "Date until", "utils.adminFilter.date.title": "Creation date", "utils.adminFilter.outdatedSince.title": "Obsolete since", "utils.adminFilter.outdatedSince.label.from": "Obsolete since from", diff --git a/src/locales/es.json b/src/locales/es.json index caa5f77bf0..e58b42adfd 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1019,8 +1019,8 @@ "utils.adminFilter.consent.parentsAgreed": "solo están de acuerdo los padres", "utils.adminFilter.consent.title": "Registros", "utils.adminFilter.date.created": "Creado entre", - "utils.adminFilter.date.label.from": "Fecha de creación desde", - "utils.adminFilter.date.label.until": "Fecha de creación hasta", + "utils.adminFilter.date.label.from": "Fecha desde", + "utils.adminFilter.date.label.until": "Fecha hasta", "utils.adminFilter.date.title": "Fecha de creación", "utils.adminFilter.outdatedSince.title": "Obsoleto desde", "utils.adminFilter.outdatedSince.label.from": "Obsoleto desde", diff --git a/src/locales/uk.json b/src/locales/uk.json index ea709030b3..f3bac457f6 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -1049,8 +1049,8 @@ "utils.adminFilter.consent.parentsAgreed": "згоду надали лише батьки", "utils.adminFilter.consent.title": "Реєстрації", "utils.adminFilter.date.created": "Створено між", - "utils.adminFilter.date.label.from": "Дата створення від", - "utils.adminFilter.date.label.until": "Дата створення до", + "utils.adminFilter.date.label.from": "Дата від", + "utils.adminFilter.date.label.until": "Дата до", "utils.adminFilter.date.title": "Дата створення", "utils.adminFilter.outdatedSince.title": "Застаріло з тих пір", "utils.adminFilter.outdatedSince.label.from": "Застаріло з тих пір від", diff --git a/src/pages/administration/StudentOverview.page.vue b/src/pages/administration/StudentOverview.page.vue index 2bbf78789a..d0e05a357a 100644 --- a/src/pages/administration/StudentOverview.page.vue +++ b/src/pages/administration/StudentOverview.page.vue @@ -32,11 +32,10 @@ - + acc.concat({ + label: item.displayName, + value: item.displayName, + }), + [] + ); + }, }, }; diff --git a/src/pages/administration/TeacherOverview.page.vue b/src/pages/administration/TeacherOverview.page.vue index a271608c9d..6aadd50f85 100644 --- a/src/pages/administration/TeacherOverview.page.vue +++ b/src/pages/administration/TeacherOverview.page.vue @@ -32,11 +32,10 @@ - + acc.concat({ + label: item.displayName, + value: item.displayName, + }), + [] + ); + }, }, mounted() { document.title = buildPageTitle(