diff --git a/src/layouts/legacyLoggedIn.vue b/src/layouts/legacyLoggedIn.vue index 07cb770035..bfe0cb34ca 100644 --- a/src/layouts/legacyLoggedIn.vue +++ b/src/layouts/legacyLoggedIn.vue @@ -101,9 +101,16 @@ export default defineComponent({ const sidebarCategoryItem = item as SidebarCategoryItem; if (sidebarCategoryItem.children.length >= 1) { sidebarCategoryItem.children = sidebarCategoryItem.children.filter( - (child) => - !child.permission || - user.value?.permissions?.includes?.(child.permission) + (child) => { + const hasFeature = + !!child.feature && !!envConfigModule.getEnv[child.feature]; + + return ( + (!child.permission || + user.value?.permissions?.includes?.(child.permission)) && + (!child.feature || hasFeature) + ); + } ); } } @@ -115,8 +122,13 @@ export default defineComponent({ ? user.value?.permissions?.includes?.(item.excludedPermission) : false; + const hasFeatureFlag = + !!item.feature && !!envConfigModule.getEnv[item.feature]; + return ( - !item.permission || (hasRequiredPermission && !hasExcludedPermission) + (!item.permission || + (hasRequiredPermission && !hasExcludedPermission)) && + (!item.feature || hasFeatureFlag) ); }); diff --git a/src/locales/de.json b/src/locales/de.json index 5faec83ca4..68461258e3 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -79,6 +79,7 @@ "common.labels.visibility": "Sichtbarkeit", "common.labels.visible": "Sichtbar", "common.labels.notVisible": "Nicht sichtbar", + "common.labels.externalsource": "Quelle", "common.labels.settings": "Einstellungen", "common.placeholder.birthdate": "20.2.2002", "common.placeholder.dateformat": "TT.MM.JJJJ", @@ -285,6 +286,7 @@ "global.sidebar.addons": "Add-ons", "global.sidebar.calendar": "Termine", "global.sidebar.classes": "Klassen", + "global.sidebar.classes.new": "Klassen (neu)", "global.sidebar.courses": "Kurse", "global.sidebar.files-old": "Meine Dateien", "global.sidebar.files": "Dateien", @@ -731,6 +733,8 @@ "pages.administration.school.index.authSystems.copyLink": "Link kopieren", "pages.administration.school.index.authSystems.edit": "{system} bearbeiten", "pages.administration.school.index.authSystems.delete": "{system} löschen", + "pages.administration.classes.index.title": "Klassen verwalten", + "pages.administration.classes.index.add": "Klasse hinzufügen", "pages.content._id.addToTopic": "Hinzufügen zu", "pages.content._id.collection.selectElements": "Wählen Sie die Elemente, die Sie zum Thema hinzufügen möchten", "pages.content._id.metadata.author": "Autor", diff --git a/src/locales/en.json b/src/locales/en.json index 84ac3bfa25..22191139ef 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -28,7 +28,7 @@ "common.labels.createAt": "Created:", "common.labels.birthdate": "Date of birth", "common.labels.birthday": "Date of Birth", - "common.labels.classes": "classes", + "common.labels.classes": "Classes", "common.labels.close": "Close", "common.labels.collapse": "collapse", "common.labels.collapsed": "collapsed", @@ -79,6 +79,7 @@ "common.labels.visibility": "Visibility", "common.labels.visible": "Visible", "common.labels.notVisible": "Not visible", + "common.labels.externalsource": "Source", "common.labels.settings": "Setting", "common.placeholder.birthdate": "20.2.2002", "common.placeholder.dateformat": "DD.MM.YYYY", @@ -284,6 +285,7 @@ "global.sidebar.addons": "Add-ons", "global.sidebar.calendar": "calendar", "global.sidebar.classes": "Classes", + "global.sidebar.classes": "Classes (new)", "global.sidebar.courses": "Courses", "global.sidebar.files-old": "My Files", "global.sidebar.files": "Files", @@ -729,6 +731,8 @@ "pages.administration.school.index.authSystems.copyLink": "Copy Link", "pages.administration.school.index.authSystems.edit": "Edit {system}", "pages.administration.school.index.authSystems.delete": "Delete {system}", + "pages.administration.classes.index.title": "Manage classes", + "pages.administration.classes.index.add": "Add class", "pages.content._id.addToTopic": "To be added to", "pages.content._id.collection.selectElements": "Select the items you want to add to the topic", "pages.content._id.metadata.author": "Author", diff --git a/src/locales/es.json b/src/locales/es.json index ef541f2eff..63e6bd8d61 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -79,6 +79,7 @@ "common.labels.visibility": "Visibilidad", "common.labels.visible": "Visible", "common.labels.notVisible": "No visible", + "common.labels.externalsource": "Fuente", "common.labels.settings": "Ajustes", "common.placeholder.birthdate": "20.2.2002", "common.placeholder.dateformat": "DD.MM.AAAA", @@ -284,6 +285,7 @@ "global.sidebar.addons": "Complementos", "global.sidebar.calendar": "Calendario", "global.sidebar.classes": "Clases", + "global.sidebar.classes": "Clases (nuevas)", "global.sidebar.courses": "Cursos", "global.sidebar.files-old": "Mis archivos", "global.sidebar.files": "Archivos", @@ -718,6 +720,8 @@ "pages.administration.school.index.authSystems.copyLink": "Copiar enlace", "pages.administration.school.index.authSystems.edit": "Editar {system}", "pages.administration.school.index.authSystems.delete": "Eliminar {system}", + "pages.administration.classes.index.title": "Administrar clases", + "pages.administration.classes.index.add": "Agregar clase", "pages.content._id.addToTopic": "Para ser añadido a", "pages.content._id.collection.selectElements": "Selecciona los elementos que deses añadir al tema", "pages.content._id.metadata.author": "Autor", diff --git a/src/locales/uk.json b/src/locales/uk.json index 2ac9a7eefe..a50c7184b4 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -79,6 +79,7 @@ "common.labels.visibility": "Видимість", "common.labels.visible": "Видимий", "common.labels.notVisible": "Не видно", + "common.labels.externalsource": "Джерело", "common.labels.settings": "Налаштування", "common.placeholder.birthdate": "20.02.2002", "common.placeholder.dateformat": "ДД.ММ.РРРР", @@ -460,6 +461,7 @@ "global.sidebar.addons": "Додаткові компоненти", "global.sidebar.calendar": "календар", "global.sidebar.classes": "Класи", + "global.sidebar.classes": "Класи (новий)", "global.sidebar.courses": "Курси", "global.sidebar.files-old": "Мої файли", "global.sidebar.files": "файли", @@ -806,6 +808,8 @@ "pages.administration.teachers.new.success": "Викладача успішно створено!", "pages.administration.teachers.new.title": "Додати викладача", "pages.administration.teachers.table.edit.ariaLabel": "Редагування вчителя", + "pages.administration.classes.index.title": "Керувати заняттями", + "pages.administration.classes.index.add": "Додати клас", "pages.content._id.addToTopic": "Для додавання в", "pages.content._id.collection.selectElements": "Виберіть елементи, які треба додати до теми", "pages.content._id.metadata.author": "Автор", diff --git a/src/main.ts b/src/main.ts index b60ec670cc..b7ab0d05c4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ import { externalToolsModule, filePathsModule, finishedTasksModule, + groupModule, importUsersModule, loadingStateModule, newsModule, @@ -60,6 +61,7 @@ import { CONTEXT_EXTERNAL_TOOLS_MODULE_KEY, ENV_CONFIG_MODULE_KEY, EXTERNAL_TOOLS_MODULE_KEY, + GROUP_MODULE_KEY, I18N_KEY, NOTIFIER_MODULE_KEY, ROOM_MODULE_KEY, @@ -153,6 +155,7 @@ Vue.use(VueDOMPurifyHTML, { [EXTERNAL_TOOLS_MODULE_KEY.valueOf()]: externalToolsModule, filePathsModule, finishedTasksModule, + [GROUP_MODULE_KEY.valueOf()]: groupModule, importUsersModule, loadingStateModule, newsModule, diff --git a/src/pages/administration/ClassOverview.page.unit.ts b/src/pages/administration/ClassOverview.page.unit.ts new file mode 100644 index 0000000000..2ee0c1e986 --- /dev/null +++ b/src/pages/administration/ClassOverview.page.unit.ts @@ -0,0 +1,205 @@ +import GroupModule from "@/store/group"; +import { createModuleMocks } from "@/utils/mock-store-module"; +import { classInfoResponseFactory, i18nMock } from "@@/tests/test-utils"; +import { MountOptions, Wrapper, mount } from "@vue/test-utils"; +import ClassOverview from "./ClassOverview.page.vue"; +import { GROUP_MODULE_KEY, I18N_KEY } from "@/utils/inject"; +import createComponentMocks from "@@/tests/test-utils/componentMocks"; +import Vue from "vue"; +import { SortOrder } from "@/store/types/sort-order.enum"; +import { Pagination } from "@/store/types/commons"; + +describe("ClassOverview", () => { + const getWrapper = (getters: Partial = {}) => { + document.body.setAttribute("data-app", "true"); + + const groupModule = createModuleMocks(GroupModule, { + getClasses: [classInfoResponseFactory.build()], + getPagination: { + limit: 10, + skip: 0, + total: 30, + }, + ...getters, + }); + + const wrapper: Wrapper = mount(ClassOverview as MountOptions, { + ...createComponentMocks({ + i18n: true, + }), + provide: { + [I18N_KEY.valueOf()]: i18nMock, + [GROUP_MODULE_KEY.valueOf()]: groupModule, + }, + }); + + return { + wrapper, + groupModule, + }; + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("breadcrumbs", () => { + it("should render static breadcrumbs", () => { + const { wrapper } = getWrapper({}); + + const breadcrumbs = wrapper.findAll(".breadcrumbs-item"); + + expect(breadcrumbs.at(0).text()).toEqual( + "pages.administration.index.title" + ); + expect(breadcrumbs.at(1).text()).toEqual( + "pages.administration.classes.index.title" + ); + }); + }); + + describe("onMounted", () => { + describe("when loading the page", () => { + it("should load the classes", async () => { + const { groupModule } = getWrapper(); + + await Vue.nextTick(); + + expect(groupModule.loadClassesForSchool).toHaveBeenCalled(); + }); + }); + }); + + describe("onUpdateSortBy", () => { + describe("when changing the sortBy", () => { + const setup = () => { + const sortBy = "externalSourceName"; + + const { wrapper, groupModule } = getWrapper(); + + return { + sortBy, + wrapper, + groupModule, + }; + }; + + it("should call store to change sort by", async () => { + const { sortBy, wrapper, groupModule } = setup(); + + await wrapper + .find('[data-testid="admin-class-table"]') + .vm.$emit("update:sort-by", sortBy); + + expect(groupModule.loadClassesForSchool).toHaveBeenCalled(); + expect(groupModule.setSortBy).toHaveBeenCalledWith(sortBy); + }); + }); + }); + + describe("updateSortOrder", () => { + describe("when changing the sort order", () => { + const setup = () => { + const sortOrder = true; + + const { wrapper, groupModule } = getWrapper(); + + return { + sortOrder, + wrapper, + groupModule, + }; + }; + + it("should call store to change sort order", async () => { + const { sortOrder, wrapper, groupModule } = setup(); + + await wrapper + .find('[data-testid="admin-class-table"]') + .vm.$emit("update:sort-desc", sortOrder); + + expect(groupModule.loadClassesForSchool).toHaveBeenCalled(); + expect(groupModule.setSortOrder).toHaveBeenCalledWith(SortOrder.DESC); + }); + }); + }); + + describe("onUpdateItemsPerPage", () => { + describe("when changing the number of items per page", () => { + const setup = () => { + const itemsPerPage = 20; + + const pagination: Pagination = { + limit: 10, + skip: 0, + total: 30, + }; + + const { wrapper, groupModule } = getWrapper({ + getPagination: { + limit: 10, + skip: 0, + total: 30, + }, + }); + + return { + itemsPerPage, + pagination, + wrapper, + groupModule, + }; + }; + + it("should call store to change the limit in pagination", async () => { + const { itemsPerPage, wrapper, groupModule, pagination } = setup(); + + await wrapper + .find('[data-testid="admin-class-table"]') + .vm.$emit("update:items-per-page", itemsPerPage); + + expect(groupModule.loadClassesForSchool).toHaveBeenCalled(); + expect(groupModule.setPagination).toHaveBeenCalledWith({ + ...pagination, + limit: itemsPerPage, + }); + }); + }); + }); + + describe("onUpdateCurrentPage", () => { + describe("when changing the table page", () => { + const setup = () => { + const page = 2; + const pagination: Pagination = { + limit: 10, + skip: 0, + total: 30, + }; + + pagination.skip = (page - 1) * pagination.limit; + + const { wrapper, groupModule } = getWrapper(); + + return { + page, + pagination, + wrapper, + groupModule, + }; + }; + + it("should call store to update current page", async () => { + const { page, wrapper, groupModule, pagination } = setup(); + + await wrapper + .find('[data-testid="admin-class-table"]') + .vm.$emit("update:page", page); + + expect(groupModule.loadClassesForSchool).toHaveBeenCalled(); + expect(groupModule.setPage).toHaveBeenCalledWith(page); + expect(groupModule.setPagination).toHaveBeenCalledWith(pagination); + }); + }); + }); +}); diff --git a/src/pages/administration/ClassOverview.page.vue b/src/pages/administration/ClassOverview.page.vue new file mode 100644 index 0000000000..3c1e6d40b0 --- /dev/null +++ b/src/pages/administration/ClassOverview.page.vue @@ -0,0 +1,147 @@ + + + diff --git a/src/router/routes.ts b/src/router/routes.ts index 2c15d42731..23436085c6 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -95,6 +95,12 @@ export const routes: Array = [ name: "administration-teachers-new", beforeEnter: createPermissionGuard(["teacher_create"]), }, + { + path: "/administration/groups/classes", + component: () => import("@/pages/administration/ClassOverview.page.vue"), + name: "administration-groups-classes", + beforeEnter: createPermissionGuard(["class_list"]), + }, { path: "/cfiles", component: () => import("@/pages/files/FilesOverview.page.vue"), diff --git a/src/router/vue-client-route.js b/src/router/vue-client-route.js index 753129f447..bd218f8241 100644 --- a/src/router/vue-client-route.js +++ b/src/router/vue-client-route.js @@ -23,6 +23,7 @@ const vueRoutes = [ `^/administration/school-settings/tool-configuration/?$`, `^/administration/school-settings/tool-configuration/${mongoId}/?$`, `^/administration/migration/?$`, + `^/administration/groups/classes/?$`, `^/cfiles/?$`, `^/cfiles/teams/?$`, `^/cfiles/teams/.+`, diff --git a/src/serverApi/v3/api.ts b/src/serverApi/v3/api.ts index 806a09f5c6..43010f03a9 100644 --- a/src/serverApi/v3/api.ts +++ b/src/serverApi/v3/api.ts @@ -359,6 +359,62 @@ export enum ChangeLanguageParamsLanguageEnum { Uk = 'uk' } +/** + * + * @export + * @interface ClassInfoResponse + */ +export interface ClassInfoResponse { + /** + * + * @type {string} + * @memberof ClassInfoResponse + */ + name: string; + /** + * + * @type {string} + * @memberof ClassInfoResponse + */ + externalSourceName?: string; + /** + * + * @type {Array} + * @memberof ClassInfoResponse + */ + teachers: Array; +} +/** + * + * @export + * @interface ClassInfoSearchListResponse + */ +export interface ClassInfoSearchListResponse { + /** + * The items for the current page. + * @type {Array} + * @memberof ClassInfoSearchListResponse + */ + data: Array; + /** + * The total amount of items. + * @type {number} + * @memberof ClassInfoSearchListResponse + */ + total: number; + /** + * The amount of items skipped from the start. + * @type {number} + * @memberof ClassInfoSearchListResponse + */ + skip: number; + /** + * The page size of the response. + * @type {number} + * @memberof ClassInfoSearchListResponse + */ + limit: number; +} /** * * @export @@ -8618,6 +8674,161 @@ export class DefaultApi extends BaseAPI implements DefaultApiInterface { } +/** + * GroupApi - axios parameter creator + * @export + */ +export const GroupApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Get a list of classes and groups of type class for the current users school. + * @param {number} [skip] Number of elements (not pages) to be skipped + * @param {number} [limit] Page limit, defaults to 10. + * @param {'asc' | 'desc'} [sortOrder] + * @param {'name' | 'externalSourceName'} [sortBy] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + groupControllerFindClassesForSchool: async (skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', options: any = {}): Promise => { + const localVarPath = `/groups/class`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (skip !== undefined) { + localVarQueryParameter['skip'] = skip; + } + + if (limit !== undefined) { + localVarQueryParameter['limit'] = limit; + } + + if (sortOrder !== undefined) { + localVarQueryParameter['sortOrder'] = sortOrder; + } + + if (sortBy !== undefined) { + localVarQueryParameter['sortBy'] = sortBy; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * GroupApi - functional programming interface + * @export + */ +export const GroupApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = GroupApiAxiosParamCreator(configuration) + return { + /** + * + * @summary Get a list of classes and groups of type class for the current users school. + * @param {number} [skip] Number of elements (not pages) to be skipped + * @param {number} [limit] Page limit, defaults to 10. + * @param {'asc' | 'desc'} [sortOrder] + * @param {'name' | 'externalSourceName'} [sortBy] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async groupControllerFindClassesForSchool(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.groupControllerFindClassesForSchool(skip, limit, sortOrder, sortBy, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * GroupApi - factory interface + * @export + */ +export const GroupApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = GroupApiFp(configuration) + return { + /** + * + * @summary Get a list of classes and groups of type class for the current users school. + * @param {number} [skip] Number of elements (not pages) to be skipped + * @param {number} [limit] Page limit, defaults to 10. + * @param {'asc' | 'desc'} [sortOrder] + * @param {'name' | 'externalSourceName'} [sortBy] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + groupControllerFindClassesForSchool(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', options?: any): AxiosPromise { + return localVarFp.groupControllerFindClassesForSchool(skip, limit, sortOrder, sortBy, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * GroupApi - interface + * @export + * @interface GroupApi + */ +export interface GroupApiInterface { + /** + * + * @summary Get a list of classes and groups of type class for the current users school. + * @param {number} [skip] Number of elements (not pages) to be skipped + * @param {number} [limit] Page limit, defaults to 10. + * @param {'asc' | 'desc'} [sortOrder] + * @param {'name' | 'externalSourceName'} [sortBy] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof GroupApiInterface + */ + groupControllerFindClassesForSchool(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', options?: any): AxiosPromise; + +} + +/** + * GroupApi - object-oriented interface + * @export + * @class GroupApi + * @extends {BaseAPI} + */ +export class GroupApi extends BaseAPI implements GroupApiInterface { + /** + * + * @summary Get a list of classes and groups of type class for the current users school. + * @param {number} [skip] Number of elements (not pages) to be skipped + * @param {number} [limit] Page limit, defaults to 10. + * @param {'asc' | 'desc'} [sortOrder] + * @param {'name' | 'externalSourceName'} [sortBy] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof GroupApi + */ + public groupControllerFindClassesForSchool(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', options?: any) { + return GroupApiFp(this.configuration).groupControllerFindClassesForSchool(skip, limit, sortOrder, sortBy, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * LessonApi - axios parameter creator * @export diff --git a/src/store/env-config.ts b/src/store/env-config.ts index 6670afca2a..b8c1828a52 100644 --- a/src/store/env-config.ts +++ b/src/store/env-config.ts @@ -44,6 +44,7 @@ export default class EnvConfigModule extends VuexModule { FILES_STORAGE__MAX_FILE_SIZE: 0, FEATURE_SHOW_OUTDATED_USERS: false, FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION: false, + FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED: false, }; loadingErrorCount = 0; status: Status = ""; @@ -186,6 +187,10 @@ export default class EnvConfigModule extends VuexModule { return this.env.FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED ?? false; } + get getShowNewClassViewEnabled(): boolean { + return this.env.FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED ?? false; + } + get getEnv(): Envs { return this.env; } diff --git a/src/store/group.ts b/src/store/group.ts new file mode 100644 index 0000000000..cb9c751f24 --- /dev/null +++ b/src/store/group.ts @@ -0,0 +1,146 @@ +import { + ClassInfoSearchListResponse, + GroupApiFactory, + GroupApiInterface, +} from "@/serverApi/v3"; +import { $axios, mapAxiosErrorToResponseError } from "@/utils/api"; +import { AxiosResponse } from "axios"; +import { Action, Module, Mutation, VuexModule } from "vuex-module-decorators"; +import { ClassInfo } from "./types/class-info"; +import { BusinessError, Pagination } from "./types/commons"; +import { SortOrder } from "./types/sort-order.enum"; +import { GroupMapper } from "./group/group.mapper"; + +@Module({ + name: "groupModule", + namespaced: true, + stateFactory: true, +}) +export default class GroupModule extends VuexModule { + private classes: ClassInfo[] = []; + + private loading = false; + private businessError: BusinessError | null = null; + + private pagination: Pagination = { + limit: 10, + skip: 0, + total: 0, + }; + + private sortBy = "name"; + private sortOrder: SortOrder = SortOrder.ASC; + private page = 1; + + private get groupApi(): GroupApiInterface { + return GroupApiFactory(undefined, "/v3", $axios); + } + + get getClasses(): ClassInfo[] { + return this.classes; + } + + get getLoading(): boolean { + return this.loading; + } + + get getBusinessError(): BusinessError | null { + return this.businessError; + } + + get getPagination(): Pagination { + return this.pagination; + } + + get getSortBy(): string { + return this.sortBy; + } + + get getSortOrder(): SortOrder { + return this.sortOrder; + } + + get getPage(): number { + return this.page; + } + + @Mutation + setClasses(classes: ClassInfo[]): void { + this.classes = classes; + } + + @Mutation + setLoading(loading: boolean): void { + this.loading = loading; + } + + @Mutation + setBusinessError(businessError: BusinessError | null): void { + this.businessError = businessError; + } + + @Mutation + resetBusinessError(): void { + this.businessError = null; + } + + @Mutation + setPagination(pagination: Pagination): void { + this.pagination = pagination; + } + + @Mutation + setSortBy(sortBy: string): void { + this.sortBy = sortBy; + } + + @Mutation + setSortOrder(sortOrder: SortOrder): void { + this.sortOrder = sortOrder; + } + + @Mutation + setPage(page: number): void { + this.page = page; + } + + @Action + async loadClassesForSchool(): Promise { + this.setLoading(true); + try { + const sortBy = + this.getSortBy === "name" || this.getSortBy === "externalSourceName" + ? this.getSortBy + : undefined; + + const response: AxiosResponse = + await this.groupApi.groupControllerFindClassesForSchool( + this.pagination.skip, + this.pagination.limit, + this.getSortOrder, + sortBy + ); + const mappedClasses: ClassInfo[] = GroupMapper.mapToClassInfo( + response.data.data + ); + + this.setPagination({ + limit: response.data.limit, + skip: response.data.skip, + total: response.data.total, + }); + this.setClasses(mappedClasses); + } catch (error) { + const apiError = mapAxiosErrorToResponseError(error); + + console.log(apiError); + + this.setBusinessError({ + error: apiError, + statusCode: apiError.code, + message: `${apiError.type}: ${apiError.message}`, + }); + } + this.setLoading(false); + } +} diff --git a/src/store/group.unit.ts b/src/store/group.unit.ts new file mode 100644 index 0000000000..16ec637a3c --- /dev/null +++ b/src/store/group.unit.ts @@ -0,0 +1,254 @@ +import * as serverApi from "@/serverApi/v3/api"; +import { + ClassInfoResponse, + ClassInfoSearchListResponse, + GroupApiInterface, +} from "@/serverApi/v3"; +import { + axiosErrorFactory, + businessErrorFactory, + classInfoResponseFactory, + classInfoSearchListResponseFactory, +} from "@@/tests/test-utils"; +import { ClassInfo } from "./types/class-info"; +import { BusinessError, Pagination } from "./types/commons"; +import { SortOrder } from "./types/sort-order.enum"; +import GroupModule from "./group"; +import { DeepMocked, createMock } from "@golevelup/ts-jest"; +import { mockApiResponse } from "@@/tests/test-utils/mockApiResponse"; +import { mapAxiosErrorToResponseError } from "@/utils/api"; + +describe("GroupModule", () => { + let module: GroupModule; + + let apiMock: DeepMocked; + + beforeEach(() => { + module = new GroupModule({}); + + apiMock = createMock(); + + jest.spyOn(serverApi, "GroupApiFactory").mockReturnValue(apiMock); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("getter/setter", () => { + describe("Loading", () => { + it("should return the default state", () => { + const result = module.getLoading; + + expect(result).toEqual(false); + }); + + it("should return the changed state", () => { + module.setLoading(true); + + expect(module.getLoading).toEqual(true); + }); + }); + + describe("Error", () => { + it("should return the default state", () => { + const result = module.getBusinessError; + + expect(result).toBeNull(); + }); + + it("should return the changed state", () => { + const businessError = businessErrorFactory.build({ + message: "error message", + }); + + module.setBusinessError(businessError); + + expect(module.getBusinessError).toEqual(businessError); + }); + + it("should reset the error", () => { + const businessError = businessErrorFactory.build(); + + module.setBusinessError(businessError); + module.resetBusinessError(); + + expect(module.getBusinessError).toBeNull(); + }); + }); + + describe("Classes", () => { + it("should return the default state", () => { + const classes: ClassInfo[] = module.getClasses; + + expect(classes).toEqual([]); + }); + + it("should return the changed state", () => { + const classes: ClassInfo[] = [ + { name: "3a", externalSourceName: "Klasse", teachers: ["Carlie"] }, + ]; + + module.setClasses(classes); + + expect(module.getClasses).toEqual(classes); + }); + }); + + describe("Pagination", () => { + it("should return the default state", () => { + const pagination: Pagination = module.getPagination; + + expect(pagination).toEqual({ + limit: 10, + skip: 0, + total: 0, + }); + }); + + it("should return the changed state", () => { + const pagination: Pagination = { + limit: 20, + skip: 10, + total: 30, + }; + + module.setPagination(pagination); + + expect(module.getPagination).toEqual(pagination); + }); + }); + + describe("SortBy", () => { + it("should return the default state", () => { + const sortBy = module.getSortBy; + + expect(sortBy).toEqual("name"); + }); + + it("should return the changed state", () => { + const sortBy = "externalSource"; + + module.setSortBy(sortBy); + + expect(module.getSortBy).toEqual(sortBy); + }); + }); + + describe("SortOrder", () => { + it("should return the default state", () => { + const sortOrder: SortOrder = module.getSortOrder; + + expect(sortOrder).toEqual(SortOrder.ASC); + }); + + it("should return the changed state", () => { + const sortOrder: SortOrder = SortOrder.DESC; + + module.setSortOrder(sortOrder); + + expect(module.getSortOrder).toEqual(sortOrder); + }); + }); + + describe("Page", () => { + it("should return the default state", () => { + const page = module.getPage; + + expect(page).toEqual(1); + }); + + it("should return the changed state", () => { + const page = 2; + + module.setPage(page); + + expect(module.getPage).toEqual(page); + }); + }); + }); + + describe("loadClassesForSchool", () => { + describe("when the api returns a response", () => { + const setup = () => { + const classes: ClassInfoResponse[] = [classInfoResponseFactory.build()]; + const sortBy = "name"; + const sortOrder: SortOrder = SortOrder.ASC; + const pagination: Pagination = { + limit: 10, + skip: 0, + total: 25, + }; + + const response: ClassInfoSearchListResponse = + classInfoSearchListResponseFactory.build({ + data: classes, + total: pagination.total, + skip: pagination.skip, + limit: pagination.limit, + }); + + apiMock.groupControllerFindClassesForSchool.mockResolvedValue( + mockApiResponse({ data: response }) + ); + + return { + response, + classes, + sortBy, + sortOrder, + pagination, + }; + }; + + it("should update the classes", async () => { + const { pagination, sortBy, sortOrder } = setup(); + + await module.loadClassesForSchool(); + + expect( + apiMock.groupControllerFindClassesForSchool + ).toHaveBeenCalledWith( + pagination.skip, + pagination.limit, + sortOrder, + sortBy + ); + }); + + it("should set the state", async () => { + const { classes, pagination } = setup(); + + await module.loadClassesForSchool(); + + expect(module.getClasses).toEqual(classes); + expect(module.getPagination).toEqual(pagination); + }); + }); + + describe("when the api returns an error", () => { + const setup = () => { + const error = axiosErrorFactory.build(); + const apiError = mapAxiosErrorToResponseError(error); + + apiMock.groupControllerFindClassesForSchool.mockRejectedValue(error); + + return { + apiError, + }; + }; + + it("should update the stores error", async () => { + const { apiError } = setup(); + + await module.loadClassesForSchool(); + + expect(module.getBusinessError).toEqual({ + error: apiError, + statusCode: apiError.code, + message: `${apiError.type}: ${apiError.message}`, + }); + }); + }); + }); +}); diff --git a/src/store/group/group.mapper.ts b/src/store/group/group.mapper.ts new file mode 100644 index 0000000000..5649b10a3b --- /dev/null +++ b/src/store/group/group.mapper.ts @@ -0,0 +1,16 @@ +import { ClassInfoResponse } from "@/serverApi/v3"; +import { ClassInfo } from "../types/class-info"; + +export class GroupMapper { + static mapToClassInfo(response: ClassInfoResponse[]): ClassInfo[] { + const mapped: ClassInfo[] = response.map( + (classInfoResponse: ClassInfoResponse): ClassInfo => ({ + name: classInfoResponse.name, + externalSourceName: classInfoResponse.externalSourceName, + teachers: classInfoResponse.teachers, + }) + ); + + return mapped; + } +} diff --git a/src/store/group/index.ts b/src/store/group/index.ts new file mode 100644 index 0000000000..92fe9df07c --- /dev/null +++ b/src/store/group/index.ts @@ -0,0 +1 @@ +export * from "./group.mapper"; diff --git a/src/store/store-accessor.ts b/src/store/store-accessor.ts index d5b832c660..1d5f5d26cf 100644 --- a/src/store/store-accessor.ts +++ b/src/store/store-accessor.ts @@ -16,6 +16,7 @@ import EnvConfigModule from "@/store/env-config"; import ExternalToolsModule from "@/store/external-tools"; import FilePaths from "@/store/filePaths"; import FinishedTasksModule from "@/store/finished-tasks"; +import GroupModule from "@/store/group"; import ImportUsersModule from "@/store/import-users"; import LoadingStateModule from "@/store/loading-state"; import NewsModule from "@/store/news"; @@ -51,6 +52,7 @@ export let envConfigModule: EnvConfigModule; export let externalToolsModule: ExternalToolsModule; export let filePathsModule: FilePaths; export let finishedTasksModule: FinishedTasksModule; +export let groupModule: GroupModule; export let importUsersModule: ImportUsersModule; export let loadingStateModule: LoadingStateModule; export let newsModule: NewsModule; @@ -82,6 +84,7 @@ export function initializeStores(store: Store): void { externalToolsModule = getModule(ExternalToolsModule, store); filePathsModule = getModule(FilePaths, store); finishedTasksModule = getModule(FinishedTasksModule, store); + groupModule = getModule(GroupModule, store); importUsersModule = getModule(ImportUsersModule, store); loadingStateModule = getModule(LoadingStateModule, store); newsModule = getModule(NewsModule, store); @@ -116,6 +119,7 @@ export const modules = { externalToolsModule: ExternalToolsModule, filePathsModule: FilePaths, finishedTasksModule: FinishedTasksModule, + groupModule: GroupModule, importUsersModule: ImportUsersModule, loadingStateModule: LoadingStateModule, newsModule: NewsModule, diff --git a/src/store/types/class-info.ts b/src/store/types/class-info.ts new file mode 100644 index 0000000000..cf4fd99f00 --- /dev/null +++ b/src/store/types/class-info.ts @@ -0,0 +1,5 @@ +export type ClassInfo = { + name: string; + externalSourceName?: string; + teachers: string[]; +}; diff --git a/src/store/types/env-config.ts b/src/store/types/env-config.ts index ebf05f07f7..517d303ce6 100644 --- a/src/store/types/env-config.ts +++ b/src/store/types/env-config.ts @@ -46,5 +46,6 @@ export type Envs = { FEATURE_SHOW_OUTDATED_USERS?: boolean; FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION?: boolean; FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED?: boolean; + FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED?: boolean; FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED?: boolean; }; diff --git a/src/store/types/sort-order.enum.ts b/src/store/types/sort-order.enum.ts new file mode 100644 index 0000000000..547e97a1ea --- /dev/null +++ b/src/store/types/sort-order.enum.ts @@ -0,0 +1,4 @@ +export enum SortOrder { + ASC = "asc", + DESC = "desc", +} diff --git a/src/utils/inject/injection-keys.ts b/src/utils/inject/injection-keys.ts index eb886347b6..e4ea062db7 100644 --- a/src/utils/inject/injection-keys.ts +++ b/src/utils/inject/injection-keys.ts @@ -12,6 +12,7 @@ import StatusAlertsModule from "@/store/status-alerts"; import SchoolExternalToolsModule from "@/store/school-external-tools"; import UserLoginMigrationModule from "@/store/user-login-migrations"; import SystemsModule from "@/store/systems"; +import GroupModule from "@/store/group"; import PrivacyPolicyModule from "@/store/privacy-policy"; import TermsOfUseModule from "@/store/terms-of-use"; import SchoolsModule from "@/store/schools"; @@ -39,6 +40,8 @@ export const USER_LOGIN_MIGRATION_MODULE_KEY: InjectionKey = Symbol("systemsModule"); +export const GROUP_MODULE_KEY: InjectionKey = + Symbol("groupModule"); export const PRIVACY_POLICY_MODULE_KEY: InjectionKey = Symbol("privacyPolicyModule"); export const TERMS_OF_USE_MODULE_KEY: InjectionKey = diff --git a/src/utils/sidebar-base-items.ts b/src/utils/sidebar-base-items.ts index 7c092c42be..1eb9d34607 100644 --- a/src/utils/sidebar-base-items.ts +++ b/src/utils/sidebar-base-items.ts @@ -1,3 +1,5 @@ +import { Envs } from "@/store/types/env-config"; + export type SidebarItemBase = { title: string; icon: string; @@ -5,6 +7,7 @@ export type SidebarItemBase = { permission?: string; excludedPermission?: string; activeForUrls: string[]; + feature?: keyof Envs; }; export type SidebarItemExternalLink = { @@ -177,6 +180,14 @@ const getSidebarItems = ( testId: "Klassen", activeForUrls: ["^/administration/classes($|/.*)"], }, + { + title: "global.sidebar.classes.new", + icon: "$class", + href: "/administration/groups/classes", + testId: "Klassen (neu)", + activeForUrls: ["^/administration/groups/classes($|/.*)"], + feature: "FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED", + }, ], }, { @@ -215,6 +226,14 @@ const getSidebarItems = ( testId: "Klassen", activeForUrls: ["^/administration/classes($|/.*)"], }, + { + title: "global.sidebar.classes.new", + icon: "$class", + href: "/administration/groups/classes", + testId: "Klassen (neu)", + activeForUrls: ["^/administration/groups/classes($|/.*)"], + feature: "FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED", + }, { title: "global.sidebar.teams", icon: "$mdiAccountGroupOutline", diff --git a/tests/test-utils/factory/classInfoResponseFactory.ts b/tests/test-utils/factory/classInfoResponseFactory.ts new file mode 100644 index 0000000000..97183c1ad9 --- /dev/null +++ b/tests/test-utils/factory/classInfoResponseFactory.ts @@ -0,0 +1,10 @@ +import { ClassInfoResponse } from "@/serverApi/v3"; +import { Factory } from "fishery"; + +export const classInfoResponseFactory = Factory.define( + () => ({ + name: "className", + externalSourceName: "Source", + teachers: ["TestTeacher"], + }) +); diff --git a/tests/test-utils/factory/classInfoSearchListResponseFactory.ts b/tests/test-utils/factory/classInfoSearchListResponseFactory.ts new file mode 100644 index 0000000000..5a838e4bbd --- /dev/null +++ b/tests/test-utils/factory/classInfoSearchListResponseFactory.ts @@ -0,0 +1,11 @@ +import { ClassInfoSearchListResponse } from "@/serverApi/v3"; +import { Factory } from "fishery"; +import { classInfoResponseFactory } from "./classInfoResponseFactory"; + +export const classInfoSearchListResponseFactory = + Factory.define(() => ({ + data: [classInfoResponseFactory.build()], + limit: 10, + skip: 0, + total: 25, + })); diff --git a/tests/test-utils/factory/index.ts b/tests/test-utils/factory/index.ts index 3ace1dd447..11c85ddc7e 100644 --- a/tests/test-utils/factory/index.ts +++ b/tests/test-utils/factory/index.ts @@ -5,6 +5,8 @@ export * from "./boardCardFactory"; export * from "./boardResponseFactory"; export * from "./businessErrorFactory"; export * from "./cardSkeletonResponseFactory"; +export * from "./classInfoSearchListResponseFactory"; +export * from "./classInfoResponseFactory"; export * from "./columnResponseFactory"; export * from "./contextExternalToolConfigurationTemplate.factory"; export * from "./contextExternalToolConfigurationTemplateResponseFactory";