diff --git a/src/components/data-group/GroupApi.composable.ts b/src/components/data-group/GroupApi.composable.ts new file mode 100644 index 0000000000..a3e0f0ff58 --- /dev/null +++ b/src/components/data-group/GroupApi.composable.ts @@ -0,0 +1,22 @@ +import { $axios } from "@/utils/api"; +import { GroupApiFactory, GroupResponse } from "@/serverApi/v3"; +import { AxiosResponse } from "axios"; +import { GroupMapper } from "./GroupMapper"; +import { Group } from "@data-group"; + +export const useGroupApi = () => { + const groupApi = GroupApiFactory(undefined, "/v3", $axios); + + const getGroup = async (groupId: string): Promise => { + const response: AxiosResponse = + await groupApi.groupControllerGetGroup(groupId); + + const group: Group = GroupMapper.mapToGroup(response.data); + + return group; + }; + + return { + getGroup, + }; +}; diff --git a/src/components/data-group/GroupApi.composable.unit.ts b/src/components/data-group/GroupApi.composable.unit.ts new file mode 100644 index 0000000000..24652ab740 --- /dev/null +++ b/src/components/data-group/GroupApi.composable.unit.ts @@ -0,0 +1,64 @@ +import * as serverApi from "@/serverApi/v3/api"; +import { GroupResponse } from "@/serverApi/v3/api"; +import { mockApiResponse } from "@@/tests/test-utils"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { groupResponseFactory } from "@@/tests/test-utils/factory/groupResponseFactory"; +import { Group, GroupType, GroupUserRole, useGroupApi } from "@data-group"; + +describe("GroupApi.composable", () => { + let groupApi: DeepMocked; + + beforeEach(() => { + groupApi = createMock(); + + jest.spyOn(serverApi, "GroupApiFactory").mockReturnValue(groupApi); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getGroup", () => { + const setup = () => { + const group: GroupResponse = groupResponseFactory.build(); + + groupApi.groupControllerGetGroup.mockResolvedValue( + mockApiResponse({ data: group }) + ); + + return { + group, + }; + }; + + it("should call the api for groups", async () => { + setup(); + + await useGroupApi().getGroup("groupId"); + + expect(groupApi.groupControllerGetGroup).toHaveBeenCalledWith("groupId"); + }); + + it("should return a group", async () => { + const { group } = setup(); + + const result: Group = await useGroupApi().getGroup("groupId"); + + expect(result).toEqual({ + id: group.id, + name: group.name, + type: GroupType.Class, + organizationId: group.organizationId, + users: [ + { + id: group.users[0].id, + firstName: group.users[0].firstName, + lastName: group.users[0].lastName, + role: GroupUserRole.Student, + }, + ], + externalSource: group.externalSource, + }); + }); + }); +}); diff --git a/src/components/data-group/GroupMapper.ts b/src/components/data-group/GroupMapper.ts new file mode 100644 index 0000000000..2c929534bf --- /dev/null +++ b/src/components/data-group/GroupMapper.ts @@ -0,0 +1,62 @@ +import { + GroupResponse, + GroupResponseTypeEnum, + GroupUserResponse, + GroupUserResponseRoleEnum, +} from "@/serverApi/v3"; +import { Group, GroupType, GroupUser, GroupUserRole } from "./types"; + +export const GroupTypeMapping: Record = { + [GroupResponseTypeEnum.Class]: GroupType.Class, +}; + +export const GroupUserRoleMapping: Partial< + Record +> = { + [GroupUserResponseRoleEnum.Administrator]: GroupUserRole.Administrator, + [GroupUserResponseRoleEnum.Student]: GroupUserRole.Student, + [GroupUserResponseRoleEnum.Teacher]: GroupUserRole.Teacher, +}; + +export const GroupUserRoleNameTranslationMapping: Record< + GroupUserRole, + string +> = { + [GroupUserRole.Administrator]: "common.roleName.administrator", + [GroupUserRole.Student]: "common.roleName.student", + [GroupUserRole.Teacher]: "common.roleName.teacher", + [GroupUserRole.Unknown]: "common.labels.unknown", +}; + +export class GroupMapper { + static mapToGroup(groupResponse: GroupResponse): Group { + return { + id: groupResponse.id, + name: groupResponse.name, + type: GroupTypeMapping[groupResponse.type], + users: groupResponse.users.map((user) => + GroupMapper.mapToGroupUser(user) + ), + externalSource: groupResponse.externalSource, + organizationId: groupResponse.organizationId, + }; + } + + private static mapToGroupUser( + groupUserResponse: GroupUserResponse + ): GroupUser { + return { + id: groupUserResponse.id, + firstName: groupUserResponse.firstName, + lastName: groupUserResponse.lastName, + role: + GroupUserRoleMapping[groupUserResponse.role] ?? GroupUserRole.Unknown, + }; + } + + static getTranslationKey(role: GroupUserRole): string { + const translationKey: string = GroupUserRoleNameTranslationMapping[role]; + + return translationKey; + } +} diff --git a/src/components/data-group/GroupState.composable.ts b/src/components/data-group/GroupState.composable.ts new file mode 100644 index 0000000000..5e038297ba --- /dev/null +++ b/src/components/data-group/GroupState.composable.ts @@ -0,0 +1,34 @@ +import { useErrorHandler } from "@/components/error-handling/ErrorHandler.composable"; +import { ref, Ref } from "vue"; +import { Group, useGroupApi } from "@data-group"; + +export const useGroupState = () => { + const { handleError } = useErrorHandler(); + const { getGroup } = useGroupApi(); + + const isLoading: Ref = ref(false); + const group: Ref = ref(); + + const fetchGroup = async (groupId: string): Promise => { + isLoading.value = true; + + try { + const fetchedGroup: Group = await getGroup(groupId); + group.value = fetchedGroup; + } catch (error) { + // TODO: fix this + handleError(error, { + 404: undefined, + 500: undefined, + }); + } + + isLoading.value = false; + }; + + return { + isLoading, + group, + fetchGroup, + }; +}; diff --git a/src/components/data-group/GroupState.composable.unit.ts b/src/components/data-group/GroupState.composable.unit.ts new file mode 100644 index 0000000000..4f052e516a --- /dev/null +++ b/src/components/data-group/GroupState.composable.unit.ts @@ -0,0 +1,85 @@ +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { useErrorHandler } from "../error-handling/ErrorHandler.composable"; +import { Group, useGroupApi, useGroupState } from "./index"; +import { groupFactory } from "@@/tests/test-utils/factory/groupFactory"; + +jest.mock("@data-group/GroupApi.composable"); +jest.mock("@/components/error-handling/ErrorHandler.composable"); + +describe("GroupState.composable", () => { + let useGroupApiMock: DeepMocked>; + let useErrorHandlerMock: DeepMocked>; + + beforeEach(() => { + useGroupApiMock = createMock>(); + useErrorHandlerMock = createMock>(); + + jest.mocked(useGroupApi).mockReturnValue(useGroupApiMock); + jest.mocked(useErrorHandler).mockReturnValue(useErrorHandlerMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("when no data is loaded", () => { + it("should not have data", async () => { + const { group } = useGroupState(); + + expect(group.value).toBeUndefined(); + }); + }); + + describe("when data is loaded", () => { + const setup = () => { + const groupMock: Group = groupFactory.build(); + + useGroupApiMock.getGroup.mockResolvedValue(groupMock); + + return { + groupMock, + ...useGroupState(), + }; + }; + + it("should call the api to get a group", async () => { + const { fetchGroup } = setup(); + + await fetchGroup("groupId"); + + expect(useGroupApiMock.getGroup).toHaveBeenCalledWith("groupId"); + }); + + it("should set the group in the state", async () => { + const { fetchGroup, group, groupMock } = setup(); + + await fetchGroup("groupId"); + + expect(group.value).toEqual(groupMock); + }); + }); + + describe("when an error occurs during loading", () => { + const setup = () => { + const error = new Error("unable to load"); + + useGroupApiMock.getGroup.mockRejectedValue(error); + + return { + error, + ...useGroupState(), + }; + }; + + it("should handle the error", async () => { + const { fetchGroup, error } = setup(); + + await fetchGroup("groupId"); + + expect(useErrorHandlerMock.handleError).toHaveBeenCalledWith(error, { + 404: undefined, + 500: undefined, + }); + }); + }); +}); diff --git a/src/components/data-group/index.ts b/src/components/data-group/index.ts new file mode 100644 index 0000000000..704edeb582 --- /dev/null +++ b/src/components/data-group/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./GroupState.composable"; +export * from "./GroupApi.composable"; +export * from "./GroupMapper"; diff --git a/src/components/data-group/types/ExternalSource.ts b/src/components/data-group/types/ExternalSource.ts new file mode 100644 index 0000000000..d1d81fe202 --- /dev/null +++ b/src/components/data-group/types/ExternalSource.ts @@ -0,0 +1,5 @@ +export type ExternalSource = { + externalId: string; + + systemId: string; +}; diff --git a/src/components/data-group/types/Group.ts b/src/components/data-group/types/Group.ts new file mode 100644 index 0000000000..cdef29af8e --- /dev/null +++ b/src/components/data-group/types/Group.ts @@ -0,0 +1,17 @@ +import { ExternalSourceResponse } from "@/serverApi/v3"; +import { GroupType } from "./GroupType"; +import { GroupUser } from "./GroupUser"; + +export type Group = { + id: string; + + name: string; + + type: GroupType; + + users: GroupUser[]; + + externalSource?: ExternalSourceResponse; + + organizationId?: string; +}; diff --git a/src/components/data-group/types/GroupType.ts b/src/components/data-group/types/GroupType.ts new file mode 100644 index 0000000000..75cb9dc123 --- /dev/null +++ b/src/components/data-group/types/GroupType.ts @@ -0,0 +1,3 @@ +export enum GroupType { + Class = "class", +} diff --git a/src/components/data-group/types/GroupUser.ts b/src/components/data-group/types/GroupUser.ts new file mode 100644 index 0000000000..4cf1307044 --- /dev/null +++ b/src/components/data-group/types/GroupUser.ts @@ -0,0 +1,11 @@ +import { GroupUserRole } from "./GroupUserRole"; + +export type GroupUser = { + id: string; + + firstName: string; + + lastName: string; + + role: GroupUserRole; +}; diff --git a/src/components/data-group/types/GroupUserRole.ts b/src/components/data-group/types/GroupUserRole.ts new file mode 100644 index 0000000000..b8d491d30c --- /dev/null +++ b/src/components/data-group/types/GroupUserRole.ts @@ -0,0 +1,6 @@ +export enum GroupUserRole { + Administrator = "administrator", + Student = "student", + Teacher = "teacher", + Unknown = "unknown", +} diff --git a/src/components/data-group/types/index.ts b/src/components/data-group/types/index.ts new file mode 100644 index 0000000000..c151275d4c --- /dev/null +++ b/src/components/data-group/types/index.ts @@ -0,0 +1,5 @@ +export * from "./Group"; +export * from "./GroupType"; +export * from "./GroupUser"; +export * from "./GroupUserRole"; +export * from "./ExternalSource"; diff --git a/src/components/data-system/SystemApi.composable.ts b/src/components/data-system/SystemApi.composable.ts new file mode 100644 index 0000000000..b67365e102 --- /dev/null +++ b/src/components/data-system/SystemApi.composable.ts @@ -0,0 +1,33 @@ +import { PublicSystemResponse, SystemsApiFactory } from "@/serverApi/v3"; +import { $axios } from "@/utils/api"; +import { AxiosResponse } from "axios"; +import { System } from "./type"; +import { useErrorHandler } from "@/components/error-handling/ErrorHandler.composable"; + +export const useSystemApi = () => { + const { handleError } = useErrorHandler(); + const systemApi = SystemsApiFactory(undefined, "/v3", $axios); + + const getSystem = async (systemId: string): Promise => { + try { + const response: AxiosResponse = + await systemApi.systemControllerGetSystem(systemId); + + const system: System = { + id: response.data.id, + displayName: response.data.displayName ?? "", + }; + + return system; + } catch (error) { + handleError(error, { + 404: undefined, + 500: undefined, + }); + } + }; + + return { + getSystem, + }; +}; diff --git a/src/components/data-system/SystemApi.composable.unit.ts b/src/components/data-system/SystemApi.composable.unit.ts new file mode 100644 index 0000000000..eb9a7357e2 --- /dev/null +++ b/src/components/data-system/SystemApi.composable.unit.ts @@ -0,0 +1,87 @@ +import * as serverApi from "@/serverApi/v3/api"; +import { PublicSystemResponse, SystemsApiInterface } from "@/serverApi/v3/api"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { mockApiResponse } from "@@/tests/test-utils"; +import { useSystemApi } from "@data-system"; +import { useErrorHandler } from "@/components/error-handling/ErrorHandler.composable"; + +jest.mock("@/components/error-handling/ErrorHandler.composable"); + +describe("SystemApi.composable", () => { + let systemApi: DeepMocked; + let useErrorHandlerMock: DeepMocked>; + + beforeEach(() => { + systemApi = createMock(); + useErrorHandlerMock = createMock>(); + + jest.spyOn(serverApi, "SystemsApiFactory").mockReturnValue(systemApi); + jest.mocked(useErrorHandler).mockReturnValue(useErrorHandlerMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getSystem", () => { + const setup = () => { + const system: PublicSystemResponse = { + id: "systemId", + displayName: "displayName", + }; + + systemApi.systemControllerGetSystem.mockResolvedValue( + mockApiResponse({ data: system }) + ); + + return { + system, + }; + }; + + describe("when the api call succeeds", () => { + it("should call the api for systems", async () => { + setup(); + + await useSystemApi().getSystem("systemId"); + + expect(systemApi.systemControllerGetSystem).toHaveBeenCalledWith( + "systemId" + ); + }); + + it("should return a system", async () => { + const { system } = setup(); + + const result = await useSystemApi().getSystem("systemId"); + + expect(result).toEqual({ + id: system.id, + displayName: system.displayName, + }); + }); + }); + + describe("when the api call fails", () => { + const setup = () => { + const error = new Error(); + systemApi.systemControllerGetSystem.mockRejectedValue(error); + + return { + error, + }; + }; + + it("should call the error handler", async () => { + const { error } = setup(); + + await useSystemApi().getSystem("systemId"); + + expect(useErrorHandlerMock.handleError).toHaveBeenCalledWith(error, { + 404: undefined, + 500: undefined, + }); + }); + }); + }); +}); diff --git a/src/components/data-system/index.ts b/src/components/data-system/index.ts new file mode 100644 index 0000000000..f76af4c02e --- /dev/null +++ b/src/components/data-system/index.ts @@ -0,0 +1,2 @@ +export * from "./type"; +export * from "./SystemApi.composable"; diff --git a/src/components/data-system/type/System.ts b/src/components/data-system/type/System.ts new file mode 100644 index 0000000000..4db6241e1d --- /dev/null +++ b/src/components/data-system/type/System.ts @@ -0,0 +1,5 @@ +export interface System { + id: string; + + displayName: string; +} diff --git a/src/components/data-system/type/index.ts b/src/components/data-system/type/index.ts new file mode 100644 index 0000000000..bfc398b708 --- /dev/null +++ b/src/components/data-system/type/index.ts @@ -0,0 +1 @@ +export * from "./System"; diff --git a/src/components/page-class-members/ClassMembers.page.unit.ts b/src/components/page-class-members/ClassMembers.page.unit.ts new file mode 100644 index 0000000000..c32d2e1e3d --- /dev/null +++ b/src/components/page-class-members/ClassMembers.page.unit.ts @@ -0,0 +1,210 @@ +import { mount, MountOptions } from "@vue/test-utils"; +import Vue, { ref } from "vue"; +import createComponentMocks from "@@/tests/test-utils/componentMocks"; +import { I18N_KEY, NOTIFIER_MODULE_KEY } from "@/utils/inject"; +import { i18nMock } from "@@/tests/test-utils"; +import ClassMembersPage from "@/components/page-class-members/ClassMembers.page.vue"; +import { Group, useGroupState } from "@data-group"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { groupFactory } from "@@/tests/test-utils/factory"; +import ClassMembersInfoBox from "@/components/page-class-members/ClassMembersInfoBox.vue"; +import { createModuleMocks } from "@/utils/mock-store-module"; +import NotifierModule from "@/store/notifier"; + +jest.mock("@data-group"); + +describe("@pages/ClassMembers.page.vue", () => { + let useGroupStateMock: DeepMocked>; + + const getWrapper = ( + propsData: { groupId: string }, + group: Group = groupFactory.build() + ) => { + document.body.setAttribute("data-app", "true"); + + useGroupStateMock.isLoading = ref(false); + useGroupStateMock.group = ref(group); + + const notifierModule = createModuleMocks(NotifierModule); + + const wrapper = mount(ClassMembersPage as MountOptions, { + ...createComponentMocks({ i18n: true }), + propsData: { ...propsData }, + provide: { + [I18N_KEY.valueOf()]: i18nMock, + [NOTIFIER_MODULE_KEY.valueOf()]: notifierModule, + }, + }); + + return { + wrapper, + }; + }; + + beforeEach(() => { + useGroupStateMock = createMock>(); + + jest.mocked(useGroupState).mockReturnValue(useGroupStateMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("title", () => { + const setup = () => { + const group: Group = groupFactory.build(); + + const { wrapper } = getWrapper( + { + groupId: "groupId", + }, + group + ); + + return { + wrapper, + group, + }; + }; + + it("should render static title", () => { + const { wrapper, group } = setup(); + + const title = wrapper.find("h1"); + + expect(title.text()).toContain(`common.labels.class '${group.name}'`); + }); + + it("should show subtitle that group is from external system", () => { + const { wrapper } = setup(); + + const title = wrapper.find("h1"); + const subtitle = title.find("span"); + + expect(subtitle.text()).toEqual("(page-class-members.title.info)"); + }); + }); + + describe("breadcrumbs", () => { + const setup = () => { + const group: Group = groupFactory.build(); + + const { wrapper } = getWrapper( + { + groupId: "groupId", + }, + group + ); + + return { + wrapper, + group, + }; + }; + + it("should render static breadcrumbs", () => { + const { wrapper } = setup(); + + 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" + ); + }); + + it("should render dynamic class name breadcrumb", () => { + const { wrapper, group } = setup(); + + const breadcrumb = wrapper.findAll(".breadcrumbs-item").at(2); + + expect(breadcrumb.text()).toEqual(`common.labels.class '${group.name}'`); + }); + }); + + describe("onMounted", () => { + it("should load the group for given groupId", async () => { + getWrapper({ + groupId: "groupId", + }); + + expect(useGroupStateMock.fetchGroup).toHaveBeenCalledWith("groupId"); + }); + }); + + describe("datatable", () => { + const setup = () => { + const group = groupFactory.build(); + + const { wrapper } = getWrapper( + { + groupId: "groupId", + }, + group + ); + + return { + wrapper, + group, + }; + }; + + it("should render datatable", () => { + const { wrapper } = setup(); + + const datatable = wrapper.findComponent({ name: "v-data-table" }); + + expect(datatable.exists()).toBeTruthy(); + }); + + it("should render datatable with correct headers", () => { + const { wrapper } = setup(); + + const datatable = wrapper.findComponent({ name: "v-data-table" }); + + expect(datatable.props("headers")).toEqual([ + { + text: "common.labels.name", + value: "lastName", + }, + { + text: "common.labels.firstName", + value: "firstName", + }, + { + text: "common.labels.role", + value: "roleName", + }, + ]); + }); + + it("should render datatable with correct items", () => { + const { wrapper, group } = setup(); + + const datatable = wrapper.findComponent({ name: "v-data-table" }); + + expect(datatable.props("items")).toEqual([ + { + firstName: group.users[0].firstName, + lastName: group.users[0].lastName, + roleName: "undefined", + }, + ]); + }); + }); + + describe("ClassMembersInfoBox", () => { + it("should render ClassMembersInfoBox", () => { + const { wrapper } = getWrapper({ + groupId: "groupId", + }); + + const infoBox = wrapper.findComponent(ClassMembersInfoBox); + + expect(infoBox.exists()).toBeTruthy(); + }); + }); +}); diff --git a/src/components/page-class-members/ClassMembers.page.vue b/src/components/page-class-members/ClassMembers.page.vue new file mode 100644 index 0000000000..4aa41d2b06 --- /dev/null +++ b/src/components/page-class-members/ClassMembers.page.vue @@ -0,0 +1,133 @@ + + + diff --git a/src/components/page-class-members/ClassMembersInfoBox.unit.ts b/src/components/page-class-members/ClassMembersInfoBox.unit.ts new file mode 100644 index 0000000000..72e57615e8 --- /dev/null +++ b/src/components/page-class-members/ClassMembersInfoBox.unit.ts @@ -0,0 +1,117 @@ +import { mount, MountOptions } from "@vue/test-utils"; +import Vue from "vue"; +import createComponentMocks from "@@/tests/test-utils/componentMocks"; +import { I18N_KEY } from "@/utils/inject"; +import { i18nMock } from "@@/tests/test-utils"; +import ClassMembersInfoBox from "@/components/page-class-members/ClassMembersInfoBox.vue"; +import { useSystemApi } from "@data-system"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import flushPromises from "flush-promises"; + +jest.mock("@data-system"); + +describe("ClassMembersInfoBox", () => { + let useSystemApiMock: DeepMocked>; + + const getWrapper = (propsData: { systemId: string }) => { + document.body.setAttribute("data-app", "true"); + + const wrapper = mount(ClassMembersInfoBox as MountOptions, { + ...createComponentMocks({ i18n: true }), + propsData: { ...propsData }, + provide: { + [I18N_KEY.valueOf()]: i18nMock, + }, + }); + + return { + wrapper, + }; + }; + + beforeEach(() => { + useSystemApiMock = createMock>(); + + jest.mocked(useSystemApi).mockReturnValue(useSystemApiMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("alert", () => { + const setup = async () => { + useSystemApiMock.getSystem.mockResolvedValue({ + id: "systemId", + displayName: "asdf", + }); + + const { wrapper } = getWrapper({ + systemId: "systemId", + }); + + await flushPromises(); + + return { + wrapper, + }; + }; + + it("should render alert component", async () => { + const { wrapper } = await setup(); + + const alert = wrapper.findComponent({ name: "v-alert" }); + + expect(alert.text()).toEqual( + 'page-class-members.systemInfoText {"systemName":"asdf"}' + ); + }); + }); + + describe("text", () => { + it("should render text", () => { + const { wrapper } = getWrapper({ + systemId: "systemId", + }); + + const text = wrapper.find('[data-testid="class-members-info-box-text"]'); + + expect(text.text()).toEqual( + "page-class-members.classMembersInfoBox.text" + ); + }); + }); + + describe("onMounted", () => { + it("should load the system", async () => { + getWrapper({ + systemId: "systemId", + }); + + expect(useSystemApiMock.getSystem).toHaveBeenCalledWith("systemId"); + }); + }); + + describe("watch", () => { + const setup = () => { + const { wrapper } = getWrapper({ + systemId: "systemId", + }); + + return { + wrapper, + }; + }; + + it("should load the system when systemId changes", async () => { + const { wrapper } = setup(); + + wrapper.setProps({ systemId: "systemId2" }); + + await Vue.nextTick(); + + expect(useSystemApiMock.getSystem).toHaveBeenCalledWith("systemId"); + expect(useSystemApiMock.getSystem).toHaveBeenCalledWith("systemId2"); + }); + }); +}); diff --git a/src/components/page-class-members/ClassMembersInfoBox.vue b/src/components/page-class-members/ClassMembersInfoBox.vue new file mode 100644 index 0000000000..d715ce8ca3 --- /dev/null +++ b/src/components/page-class-members/ClassMembersInfoBox.vue @@ -0,0 +1,78 @@ + + diff --git a/src/components/page-class-members/index.ts b/src/components/page-class-members/index.ts new file mode 100644 index 0000000000..e382dbbd37 --- /dev/null +++ b/src/components/page-class-members/index.ts @@ -0,0 +1,3 @@ +import ClassMembersPage from "./ClassMembers.page.vue"; + +export { ClassMembersPage }; diff --git a/src/locales/de.json b/src/locales/de.json index 330047a4df..d588d1fa94 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -37,6 +37,7 @@ "common.labels.consent": "Einverständnis", "common.labels.createdAt": "Erstellungsdatum", "common.labels.course": "Kurs", + "common.labels.class": "Klasse", "common.labels.date": "Datum", "common.labels.expand": "expandieren", "common.labels.expanded": "geöffnet", @@ -82,6 +83,8 @@ "common.labels.notVisible": "Nicht sichtbar", "common.labels.externalsource": "Quelle", "common.labels.settings": "Einstellungen", + "common.labels.role": "Rolle", + "common.labels.unknown": "Unbekannt", "common.placeholder.birthdate": "20.2.2002", "common.placeholder.dateformat": "TT.MM.JJJJ", "common.placeholder.email": "clara.fall@mail.de", @@ -744,6 +747,12 @@ "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.administration.classes.deleteDialog.title": "Klasse löschen?", + "pages.administration.classes.deleteDialog.content": "Möchten Sie wirklich die Klasse \"{itemName}\" löschen?", + "pages.administration.classes.manage": "Klasse verwalten", + "pages.administration.classes.edit": "Klasse bearbeiten", + "pages.administration.classes.delete": "Klasse löschen", + "pages.administration.classes.createSuccessor": "Klasse in das nächste Schuljahr versetzen", "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", @@ -1021,5 +1030,9 @@ "feature-board-file-element.placeholder.uploadFile": "Datei hochladen", "feature-board-external-tool-element.placeholder.selectTool": "Tool auswählen...", "feature-board-external-tool-element.dialog.title": "Auswahl & Einstellungen", - "util-validators-invalid-url": "Bitte geben Sie eine gültige URL ein." + "util-validators-invalid-url": "Bitte geben Sie eine gültige URL ein.", + "page-class-members.title.info": "importiert aus einem externen System", + "page-class-members.systemInfoText": "Daten der Klasse werden mit {systemName} synchronisiert. Die Klassenliste kann vorübergehend veraltet sein, bis sie mit dem neusten Stand in {systemName} abgeglichen wird. Die Daten werden nach jeder Anmeldung eines Klassenmitglieds in der Niedersächsischen Bildungscloud aktualisiert.", + "page-class-members.classMembersInfoBox.title": "Schüler:innen sind noch nicht in der Niedersächsischen Bildungscloud?", + "page-class-members.classMembersInfoBox.text": "

Eine Einverständniserklärung bei der Registrierung von Schüler:innen muss nicht eingeholt werden. Die Nutzung der Niedersächsischen Bildungscloud ist im niedersächsischen Schulgesetz (§ 31 Abs. 5 NSchG) geregelt.

Falls die Schule die Daten der Nutzenden über ein externes System bezieht bzw. übermittelt bekommt, sind keine weiteren Schritte in der Cloud notwendig. Die Registrierung erfolgt über das externe System.

Anderenfalls können über den Verwaltungsbereich der Cloud Einladungen zur Registrierung per Link versendet werden:

  • Versand von Registrierungslinks an die hinterlegten E-Mail-Adressen (auch direkt beim Importieren/Anlegen möglich)
  • Registrierungslinks als QR-Druckbogen drucken, ausschneiden und QR-Zettel an Schüler:innen verteilen
  • Einen oder mehrere Nutzer:innen auswählen, z.B. alle Schüler:innen einer Klasse, und dann die gewünschte Aktion durchführen
  • Alternativ möglich: Wechseln in den Bearbeiten-Modus des Nutzerprofils und den individuellen Registrierungslink direkt abrufen, um ihn händisch zu versenden

" } diff --git a/src/locales/en.json b/src/locales/en.json index f424329836..6510976854 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -38,6 +38,7 @@ "common.labels.consent": "Consent", "common.labels.createdAt": "Created At", "common.labels.course": "Course", + "common.labels.class": "Class", "common.labels.expand": "expand", "common.labels.expanded": "expanded", "common.labels.email": "Email", @@ -82,6 +83,8 @@ "common.labels.notVisible": "Not visible", "common.labels.externalsource": "Source", "common.labels.settings": "Setting", + "common.labels.role": "Role", + "common.labels.unknown": "Unknown", "common.placeholder.birthdate": "20.2.2002", "common.placeholder.dateformat": "DD.MM.YYYY", "common.placeholder.email": "clara.fall@mail.de", @@ -742,6 +745,12 @@ "pages.administration.school.index.authSystems.delete": "Delete {system}", "pages.administration.classes.index.title": "Manage classes", "pages.administration.classes.index.add": "Add class", + "pages.administration.classes.deleteDialog.title": "Delete class?", + "pages.administration.classes.deleteDialog.content": "Are you sure you want to delete class \"{itemName}\"?", + "pages.administration.classes.manage": "Manage class", + "pages.administration.classes.edit": "Edit class", + "pages.administration.classes.delete": "Delete class", + "pages.administration.classes.createSuccessor": "Move class to the next school year", "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", @@ -1021,5 +1030,9 @@ "ui-confirmation-dialog.ask-delete.card": "Delete {type} {title}?", "feature-board-file-element.placeholder.uploadFile": "Upload file", "feature-board-external-tool-element.placeholder.selectTool": "Select Tool...", - "feature-board-external-tool-element.dialog.title": "Selection & Settings" + "feature-board-external-tool-element.dialog.title": "Selection & Settings", + "page-class-members.title.info": "imported from an external system", + "page-class-members.systemInfoText": "Class data is synchronized with {systemName}. The class list may be temporarily out of date until it is updated with the latest version in {systemName}. The data is updated every time a class member registers in the Niedersächsischen Bildungscloud.", + "page-class-members.classMembersInfoBox.title": "Students are not yet in the Niedersächsischen Bildungscloud?", + "page-class-members.classMembersInfoBox.text": "

A declaration of consent does not need to be obtained when registering students. The use of the Niedersächsischen Bildungscloud is regulated in the Lower Saxony Schools Act (Section 31 Para. 5 NSchG).

If the school obtains or receives the user's data via an external system, no further steps are necessary in the cloud. Registration takes place via the external system.

Otherwise, invitations to register can be sent via link via the administration area of the cloud:

  • Sending registration links to the stored email addresses (also possible to create directly when importing)
  • Print registration links as QR print sheets, cut them out and distribute QR slips to students
  • Select one or more users, e.g. all students in a class , and then carry out the desired action
  • Alternatively possible: Switch to edit mode of the user profile and retrieve the individual registration link directly in order to send it manually

" } diff --git a/src/locales/es.json b/src/locales/es.json index aa345d84c8..60895f60b1 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -38,6 +38,7 @@ "common.labels.consent": "Consentimiento", "common.labels.createdAt": "Creado en", "common.labels.course": "Curso", + "common.labels.class": "Clase", "common.labels.expand": "expandir", "common.labels.expanded": "expandido", "common.labels.email": "Correo electrónico", @@ -82,6 +83,8 @@ "common.labels.notVisible": "No visible", "common.labels.externalsource": "Fuente", "common.labels.settings": "Ajustes", + "common.labels.role": "Papel", + "common.labels.unknown": "Desconocido", "common.placeholder.birthdate": "20.2.2002", "common.placeholder.dateformat": "DD.MM.AAAA", "common.placeholder.email": "clara.fall@mail.de", @@ -731,6 +734,12 @@ "pages.administration.school.index.authSystems.delete": "Eliminar {system}", "pages.administration.classes.index.title": "Administrar clases", "pages.administration.classes.index.add": "Agregar clase", + "pages.administration.classes.deleteDialog.title": "¿Eliminar clase?", + "pages.administration.classes.deleteDialog.content": "¿Está seguro de que desea eliminar la clase \"{itemName}\"?", + "pages.administration.classes.manage": "Administrar clase", + "pages.administration.classes.edit": "Editar clase", + "pages.administration.classes.delete": "Eliminar clase", + "pages.administration.classes.createSuccessor": "Mover la clase al próximo año escolar", "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", @@ -1017,5 +1026,9 @@ "ui-confirmation-dialog.ask-delete.card": "¿Eliminar {type} {title}?", "feature-board-file-element.placeholder.uploadFile": "Cargar archivo", "feature-board-external-tool-element.placeholder.selectTool": "Herramienta de selección...", - "feature-board-external-tool-element.dialog.title": "Selección y configuración" + "feature-board-external-tool-element.dialog.title": "Selección y configuración", + "page-class-members.title.info": "importado desde un sistema externo", + "page-class-members.systemInfoText": "Los datos de la clase se sincronizan con {systemName}. La lista de clases puede estar temporalmente desactualizada hasta que se actualice con la última versión en {systemName}. Los datos se actualizan cada vez que un miembro del grupo se registra en Niedersächsischen Bildungscloud.", + "page-class-members.classMembersInfoBox.title": "¿Los estudiantes aún no están en la Niedersächsischen Bildungscloud?", + "page-class-members.classMembersInfoBox.text": "

No es necesario obtener una declaración de consentimiento al registrar estudiantes. El uso de Niedersächsischen Bildungscloud está regulado por la Ley de escuelas de Baja Sajonia (artículo 31, párrafo 5 de la NSchG).

Si la escuela obtiene o recibe los datos del usuario a través de un sistema externo, no es necesario realizar ningún otro paso en el proceso nube. El registro se realiza a través del sistema externo.

De lo contrario, las invitaciones para registrarse se pueden enviar mediante un enlace a través del área de administración de la nube:

  • Envío de enlaces de registro a las direcciones de correo electrónico almacenadas (También es posible crear directamente al importar)
  • Imprima enlaces de registro como hojas de impresión QR, recórtelas y distribuya recibos QR a los estudiantes
  • Seleccione uno o más usuarios, p.e. todos los estudiantes de una clase y luego llevar a cabo la acción deseada
  • Alternativamente posible: cambiar al modo de edición del perfil de usuario y recuperar el enlace de registro individual directamente para enviarlo manualmente

" } diff --git a/src/locales/uk.json b/src/locales/uk.json index 732fa4ef9f..1ee1440e46 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -37,6 +37,7 @@ "common.labels.complete.lastName": "Повне прізвище", "common.labels.consent": "Згода", "common.labels.course": "Курс", + "common.labels.class": "Клас", "common.labels.createdAt": "Створено в", "common.labels.description": "Опис", "common.labels.email": "Електронна пошта", @@ -82,6 +83,8 @@ "common.labels.notVisible": "Не видно", "common.labels.externalsource": "Джерело", "common.labels.settings": "Налаштування", + "common.labels.role": "Роль", + "common.labels.unknown": "Невідомий", "common.placeholder.birthdate": "20.02.2002", "common.placeholder.dateformat": "ДД.ММ.РРРР", "common.placeholder.email": "clara.fall@mail.de", @@ -822,6 +825,12 @@ "pages.administration.teachers.table.edit.ariaLabel": "Редагування вчителя", "pages.administration.classes.index.title": "Керувати заняттями", "pages.administration.classes.index.add": "Додати клас", + "pages.administration.classes.deleteDialog.title": "Видалити клас?", + "pages.administration.classes.deleteDialog.content": "Ви впевнені, що хочете видалити клас \"{itemName}\"?", + "pages.administration.classes.manage": "Керувати класом", + "pages.administration.classes.edit": "Редагувати клас", + "pages.administration.classes.delete": "Видалити клас", + "pages.administration.classes.createSuccessor": "Перенести клас на наступний навчальний рік", "pages.content._id.addToTopic": "Для додавання в", "pages.content._id.collection.selectElements": "Виберіть елементи, які треба додати до теми", "pages.content._id.metadata.author": "Автор", @@ -1047,5 +1056,9 @@ "ui-confirmation-dialog.ask-delete.card": "{type} {title} буде видалена. Ви впевнені, що хочете видалити?", "feature-board-file-element.placeholder.uploadFile": "Cargar archivo", "feature-board-external-tool-element.placeholder.selectTool": "Виберіть інструмент...", - "feature-board-external-tool-element.dialog.title": "Вибір і налаштування" + "feature-board-external-tool-element.dialog.title": "Вибір і налаштування", + "page-class-members.title.info": "імпортовані із зовнішньої системи", + "page-class-members.systemInfoText": "Дані класу синхронізуються з {systemName}. Список класів може бути тимчасово застарілим, поки його не буде оновлено останньою версією в {systemName}. Дані оновлюються кожного разу, коли учасник класу реєструється в Niedersächsischen Bildungscloud.", + "page-class-members.classMembersInfoBox.title": "Студенти ще не в Niedersächsischen Bildungscloud?", + "page-class-members.classMembersInfoBox.text": "

Заява про згоду не потрібна під час реєстрації студентів. Використання Niedersächsischen Bildungscloud регулюється Законом про школи Нижньої Саксонії (розділ 31, параграф 5 NSchG).

Якщо школа отримує або отримує дані користувача через зовнішню систему, жодних подальших дій у хмара. Реєстрація відбувається через зовнішню систему.

Інакше запрошення до реєстрації можна надіслати за посиланням через область адміністрування хмари:

  • Надіслати посилання на реєстрацію на збережені адреси електронної пошти (також можна створювати безпосередньо під час імпорту)
  • Друкуйте реєстраційні посилання як QR-аркуші для друку, вирізайте їх і роздавайте QR-бланки студентам
  • Виберіть одного або кількох користувачів, напр. усіх студентів у класі, а потім виконайте потрібну дію
  • Як альтернатива: перейдіть у режим редагування профілю користувача та отримайте індивідуальне реєстраційне посилання безпосередньо, щоб надіслати його вручну

" } diff --git a/src/pages/administration/ClassOverview.page.unit.ts b/src/pages/administration/ClassOverview.page.unit.ts index 5b9a24a22c..690f712554 100644 --- a/src/pages/administration/ClassOverview.page.unit.ts +++ b/src/pages/administration/ClassOverview.page.unit.ts @@ -1,20 +1,29 @@ +import AuthModule from "@/store/auth"; import GroupModule from "@/store/group"; +import { ClassInfo, ClassRootType } from "@/store/types/class-info"; +import { Pagination } from "@/store/types/commons"; +import { SortOrder } from "@/store/types/sort-order.enum"; +import { AUTH_MODULE_KEY, GROUP_MODULE_KEY, I18N_KEY } from "@/utils/inject"; import { createModuleMocks } from "@/utils/mock-store-module"; import { classInfoFactory, 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 { mount, MountOptions, Wrapper } from "@vue/test-utils"; import Vue from "vue"; -import { SortOrder } from "@/store/types/sort-order.enum"; -import { Pagination } from "@/store/types/commons"; +import ClassOverview from "./ClassOverview.page.vue"; describe("ClassOverview", () => { const getWrapper = (getters: Partial = {}) => { document.body.setAttribute("data-app", "true"); const groupModule = createModuleMocks(GroupModule, { - getClasses: [classInfoFactory.build()], + getClasses: [ + classInfoFactory.build(), + classInfoFactory.build({ + externalSourceName: undefined, + type: ClassRootType.Class, + isUpgradable: true, + }), + ], getPagination: { limit: 10, skip: 0, @@ -23,6 +32,10 @@ describe("ClassOverview", () => { ...getters, }); + const authModule = createModuleMocks(AuthModule, { + getUserPermissions: ["CLASS_EDIT".toLowerCase()], + }); + const wrapper: Wrapper = mount(ClassOverview as MountOptions, { ...createComponentMocks({ i18n: true, @@ -30,6 +43,7 @@ describe("ClassOverview", () => { provide: { [I18N_KEY.valueOf()]: i18nMock, [GROUP_MODULE_KEY.valueOf()]: groupModule, + [AUTH_MODULE_KEY.valueOf()]: authModule, }, }); @@ -70,6 +84,37 @@ describe("ClassOverview", () => { }); }); + describe("when there are classes or groups to display", () => { + const setup = () => { + const classes: ClassInfo[] = [ + classInfoFactory.build(), + classInfoFactory.build({ + externalSourceName: undefined, + type: ClassRootType.Class, + isUpgradable: true, + }), + ]; + + const { wrapper, groupModule } = getWrapper({ + getClasses: classes, + }); + + return { + classes, + wrapper, + groupModule, + }; + }; + + it("should display the entries in the table", async () => { + const { classes, wrapper } = setup(); + + const table = wrapper.find('[data-testid="admin-class-table"]'); + + expect(table.props("items")).toEqual(classes); + }); + }); + describe("onUpdateSortBy", () => { describe("when changing the sortBy", () => { const setup = () => { @@ -87,9 +132,10 @@ describe("ClassOverview", () => { it("should call store to change sort by", async () => { const { sortBy, wrapper, groupModule } = setup(); - await wrapper + wrapper .find('[data-testid="admin-class-table"]') .vm.$emit("update:sort-by", sortBy); + await Vue.nextTick(); expect(groupModule.loadClassesForSchool).toHaveBeenCalled(); expect(groupModule.setSortBy).toHaveBeenCalledWith(sortBy); @@ -114,9 +160,10 @@ describe("ClassOverview", () => { it("should call store to change sort order", async () => { const { sortOrder, wrapper, groupModule } = setup(); - await wrapper + wrapper .find('[data-testid="admin-class-table"]') .vm.$emit("update:sort-desc", sortOrder); + await Vue.nextTick(); expect(groupModule.loadClassesForSchool).toHaveBeenCalled(); expect(groupModule.setSortOrder).toHaveBeenCalledWith(SortOrder.DESC); @@ -154,9 +201,10 @@ describe("ClassOverview", () => { it("should call store to change the limit in pagination", async () => { const { itemsPerPage, wrapper, groupModule, pagination } = setup(); - await wrapper + wrapper .find('[data-testid="admin-class-table"]') .vm.$emit("update:items-per-page", itemsPerPage); + await Vue.nextTick(); expect(groupModule.loadClassesForSchool).toHaveBeenCalled(); expect(groupModule.setPagination).toHaveBeenCalledWith({ @@ -192,9 +240,10 @@ describe("ClassOverview", () => { it("should call store to update current page", async () => { const { page, wrapper, groupModule, pagination } = setup(); - await wrapper + wrapper .find('[data-testid="admin-class-table"]') .vm.$emit("update:page", page); + await Vue.nextTick(); expect(groupModule.loadClassesForSchool).toHaveBeenCalled(); expect(groupModule.setPage).toHaveBeenCalledWith(page); @@ -202,4 +251,273 @@ describe("ClassOverview", () => { }); }); }); + + describe("action buttons", () => { + describe("when legacy classes are available", () => { + const setup = () => { + const { wrapper } = getWrapper(); + + return { + wrapper, + }; + }; + + it("should render 4 buttons", () => { + const { wrapper } = setup(); + + const manageBtn = wrapper.find( + '[data-testid="legacy-class-table-manage-btn"]' + ); + + const editBtn = wrapper.find('[data-testid="class-table-edit-btn"]'); + + const deleteBtn = wrapper.find( + '[data-testid="class-table-delete-btn"]' + ); + + const successorBtn = wrapper.find( + '[data-testid="class-table-successor-btn"]' + ); + + expect(manageBtn.exists()).toBeTruthy(); + expect(editBtn.exists()).toBeTruthy(); + expect(deleteBtn.exists()).toBeTruthy(); + expect(successorBtn.exists()).toBeTruthy(); + }); + }); + + describe("when no classes are available", () => { + const setup = () => { + const { wrapper } = getWrapper({ + getClasses: [classInfoFactory.build()], + }); + + return { + wrapper, + }; + }; + + it("should render only manage button which refers to members page", () => { + const { wrapper } = setup(); + + const manageBtn = wrapper.find( + '[data-testid="class-table-members-manage-btn"]' + ); + + const editBtn = wrapper.find('[data-testid="class-table-edit-btn"]'); + + const deleteBtn = wrapper.find( + '[data-testid="class-table-delete-btn"]' + ); + + const successorBtn = wrapper.find( + '[data-testid="class-table-successor-btn"]' + ); + + expect(manageBtn.exists()).toBeTruthy(); + expect(editBtn.exists()).toBeFalsy(); + expect(deleteBtn.exists()).toBeFalsy(); + expect(successorBtn.exists()).toBeFalsy(); + }); + }); + + describe("when clicking on the manage class button", () => { + describe("when group class root type is class", () => { + const setup = () => { + const { wrapper, groupModule } = getWrapper(); + + const classId: string = groupModule.getClasses[1].id; + + return { + wrapper, + classId, + }; + }; + + it("should redirect to legacy class manage page", async () => { + const { wrapper, classId } = setup(); + + const manageBtn = wrapper.find( + '[data-testid="legacy-class-table-manage-btn"]' + ); + + expect(manageBtn.attributes().href).toStrictEqual( + `/administration/classes/${classId}/manage` + ); + expect( + manageBtn.findComponent({ name: "router-link" }).exists() + ).toBeFalsy(); + }); + }); + + describe("when class root type is group", () => { + const setup = () => { + const { wrapper, groupModule } = getWrapper(); + + const classId: string = groupModule.getClasses[0].id; + + return { + wrapper, + classId, + }; + }; + + it("should redirect to group class members page", async () => { + const { wrapper } = setup(); + + const manageBtn = wrapper.find( + '[data-testid="class-table-members-manage-btn"]' + ); + + const routerLink = manageBtn.findComponent({ name: "router-link" }); + + expect(manageBtn.attributes().href).toBeUndefined(); + expect(routerLink.exists()).toBeTruthy(); + }); + }); + }); + + describe("when clicking on the edit class button", () => { + const setup = () => { + const { wrapper, groupModule } = getWrapper(); + + const classId: string = groupModule.getClasses[1].id; + + return { + wrapper, + classId, + }; + }; + + it("should redirect to legacy class edit page", async () => { + const { wrapper, classId } = setup(); + + const editBtn = wrapper.find('[data-testid="class-table-edit-btn"]'); + + expect(editBtn.attributes().href).toStrictEqual( + `/administration/classes/${classId}/edit` + ); + }); + }); + + describe("when class is upgradable", () => { + describe("when clicking on the upgrade class button", () => { + const setup = () => { + const { wrapper, groupModule } = getWrapper(); + + const classId: string = groupModule.getClasses[1].id; + + return { + wrapper, + classId, + }; + }; + + it("should redirect to legacy class upgrade page", async () => { + const { wrapper, classId } = setup(); + + const successorBtn = wrapper.find( + '[data-testid="class-table-successor-btn"]' + ); + + expect(successorBtn.attributes().href).toStrictEqual( + `/administration/classes/${classId}/createSuccessor` + ); + }); + }); + }); + + describe("when class is not upgradable", () => { + const setup = () => { + const { wrapper } = getWrapper({ + getClasses: [ + classInfoFactory.build({ + externalSourceName: undefined, + type: ClassRootType.Class, + isUpgradable: false, + }), + ], + }); + + return { + wrapper, + }; + }; + + it("should display the upgrade button as disabled", () => { + const { wrapper } = setup(); + + const successorBtn = wrapper.find( + '[data-testid="class-table-successor-btn"]' + ); + + expect(successorBtn.props("disabled")).toEqual(true); + }); + }); + + describe("when clicking on the delete class button", () => { + const setup = () => { + const { wrapper } = getWrapper(); + + return { + wrapper, + }; + }; + + it("should open the delete dialog", async () => { + const { wrapper } = setup(); + + await wrapper + .find('[data-testid="class-table-delete-btn"]') + .trigger("click"); + + const dialog = wrapper.find('[data-testid="delete-dialog"]'); + + expect(dialog.props("isOpen")).toBeTruthy(); + }); + }); + + describe("when delete dialog is open", () => { + const setup = () => { + const { wrapper, groupModule } = getWrapper(); + + return { + wrapper, + groupModule, + }; + }; + + describe("when clicking on cancel button", () => { + it("should not delete class", async () => { + const { wrapper, groupModule } = setup(); + + await wrapper + .find('[data-testid="class-table-delete-btn"]') + .trigger("click"); + + const dialog = wrapper.find('[data-testid="delete-dialog"]'); + + await dialog.find('[data-testid="dialog-cancel"').trigger("click"); + + expect(groupModule.deleteClass).not.toHaveBeenCalled(); + }); + }); + + describe("when clicking on confirm button", () => { + it("should delete class", async () => { + const { wrapper, groupModule } = setup(); + + await wrapper + .find('[data-testid="class-table-delete-btn"]') + .trigger("click"); + + const dialog = wrapper.find('[data-testid="delete-dialog"]'); + + await dialog.find('[data-testid="dialog-confirm"').trigger("click"); + + expect(groupModule.deleteClass).toHaveBeenCalled(); + }); + }); + }); + }); }); diff --git a/src/pages/administration/ClassOverview.page.vue b/src/pages/administration/ClassOverview.page.vue index 55114c83e6..e9654a09c4 100644 --- a/src/pages/administration/ClassOverview.page.vue +++ b/src/pages/administration/ClassOverview.page.vue @@ -21,7 +21,108 @@ @update:sort-desc="updateSortOrder" @update:items-per-page="onUpdateItemsPerPage" @update:page="onUpdateCurrentPage" - /> + > + + + + +

+ {{ t("pages.administration.classes.deleteDialog.title") }} +

+ +
import { Breadcrumb } from "@/components/templates/default-wireframe.types"; import DefaultWireframe from "@/components/templates/DefaultWireframe.vue"; -import { computed, ComputedRef, defineComponent, onMounted } from "vue"; -import GroupModule from "@/store/group"; import { useI18n } from "@/composables/i18n.composable"; +import GroupModule from "@/store/group"; +import { ClassInfo, ClassRootType } from "@/store/types/class-info"; import { Pagination } from "@/store/types/commons"; -import { ClassInfo } from "@/store/types/class-info"; -import { GROUP_MODULE_KEY, injectStrict } from "@/utils/inject"; import { SortOrder } from "@/store/types/sort-order.enum"; +import { + AUTH_MODULE_KEY, + GROUP_MODULE_KEY, + injectStrict, +} from "@/utils/inject"; +import { RenderHTML } from "@feature-render-html"; +import { + mdiAccountGroupOutline, + mdiArrowUp, + mdiPencilOutline, + mdiTrashCanOutline, +} from "@mdi/js"; +import { + computed, + ComputedRef, + defineComponent, + onMounted, + ref, + Ref, +} from "vue"; +import VCustomDialog from "@/components/organisms/vCustomDialog.vue"; +import AuthModule from "@/store/auth"; export default defineComponent({ - components: { DefaultWireframe }, + components: { DefaultWireframe, RenderHTML, VCustomDialog }, setup() { const groupModule: GroupModule = injectStrict(GROUP_MODULE_KEY); + const authModule: AuthModule = injectStrict(AUTH_MODULE_KEY); const { t } = useI18n(); @@ -72,6 +194,34 @@ export default defineComponent({ () => groupModule.getClasses ); + const hasPermission: ComputedRef = computed(() => + authModule.getUserPermissions.includes("CLASS_EDIT".toLowerCase()) + ); + + const showClassAction = (item: ClassInfo) => + hasPermission.value && item.type === ClassRootType.Class; + + const showGroupAction = (item: ClassInfo) => + hasPermission.value && item.type === ClassRootType.Group; + + const isDeleteDialogOpen: Ref = ref(false); + + const selectedItem: Ref = ref(); + + const selectedItemName: ComputedRef = computed( + () => selectedItem.value?.name || "???" + ); + + const onClickDeleteIcon = (selectedClass: ClassInfo) => { + selectedItem.value = selectedClass; + isDeleteDialogOpen.value = true; + }; + + const onCancelClassDeletion = () => { + selectedItem.value = undefined; + isDeleteDialogOpen.value = false; + }; + const pagination: ComputedRef = computed( () => groupModule.getPagination ); @@ -98,8 +248,19 @@ export default defineComponent({ text: t("common.labels.teacher"), sortable: true, }, + { + value: "actions", + text: "", + sortable: false, + }, ]; + const onConfirmClassDeletion = async () => { + if (selectedItem.value) { + await groupModule.deleteClass(selectedItem.value.id); + } + }; + const onUpdateSortBy = async (sortBy: string) => { groupModule.setSortBy(sortBy); await groupModule.loadClassesForSchool(); @@ -121,9 +282,7 @@ export default defineComponent({ }; onMounted(() => { - (async () => { - await groupModule.loadClassesForSchool(); - })(); + groupModule.loadClassesForSchool(); }); return { @@ -132,14 +291,27 @@ export default defineComponent({ breadcrumbs, headers, classes, + hasPermission, + showClassAction, + showGroupAction, page, sortBy, sortOrder, pagination, + selectedItem, + selectedItemName, + isDeleteDialogOpen, + onClickDeleteIcon, + onCancelClassDeletion, + onConfirmClassDeletion, onUpdateSortBy, updateSortOrder, onUpdateCurrentPage, onUpdateItemsPerPage, + mdiAccountGroupOutline, + mdiPencilOutline, + mdiTrashCanOutline, + mdiArrowUp, }; }, }); diff --git a/src/router/routes.ts b/src/router/routes.ts index 63a0f970f8..1d0e8bb747 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -99,7 +99,17 @@ export const routes: Array = [ path: "/administration/groups/classes", component: () => import("@/pages/administration/ClassOverview.page.vue"), name: "administration-groups-classes", - beforeEnter: createPermissionGuard(["class_list"]), + beforeEnter: createPermissionGuard(["class_list", "group_list"]), + }, + { + path: `/administration/groups/classes/:groupId(${REGEX_ID})`, + name: "administration-groups-classes-members", + component: async () => + (await import("@page-class-members")).ClassMembersPage, + beforeEnter: createPermissionGuard(["group_view"]), + props: (route: Route) => ({ + groupId: route.params.groupId, + }), }, { path: "/cfiles", diff --git a/src/router/vue-client-route.js b/src/router/vue-client-route.js index bd218f8241..eda489f3bb 100644 --- a/src/router/vue-client-route.js +++ b/src/router/vue-client-route.js @@ -24,6 +24,7 @@ const vueRoutes = [ `^/administration/school-settings/tool-configuration/${mongoId}/?$`, `^/administration/migration/?$`, `^/administration/groups/classes/?$`, + `^/administration/groups/classes/${mongoId}/?$`, `^/cfiles/?$`, `^/cfiles/teams/?$`, `^/cfiles/teams/.+`, diff --git a/src/serverApi/v3/api.ts b/src/serverApi/v3/api.ts index 057eee0784..c11606e3c8 100644 --- a/src/serverApi/v3/api.ts +++ b/src/serverApi/v3/api.ts @@ -401,6 +401,12 @@ export interface ClassInfoResponse { * @memberof ClassInfoResponse */ schoolYear?: string; + /** + * + * @type {boolean} + * @memberof ClassInfoResponse + */ + isUpgradable?: boolean; } /** @@ -1463,6 +1469,25 @@ export interface EntityNotFoundError { */ details?: object; } +/** + * + * @export + * @interface ExternalSourceResponse + */ +export interface ExternalSourceResponse { + /** + * + * @type {string} + * @memberof ExternalSourceResponse + */ + externalId: string; + /** + * + * @type {string} + * @memberof ExternalSourceResponse + */ + systemId: string; +} /** * * @export @@ -1860,6 +1885,116 @@ export interface ForbiddenOperationError { */ details?: object; } +/** + * + * @export + * @interface GroupResponse + */ +export interface GroupResponse { + /** + * + * @type {string} + * @memberof GroupResponse + */ + id: string; + /** + * + * @type {string} + * @memberof GroupResponse + */ + name: string; + /** + * + * @type {string} + * @memberof GroupResponse + */ + type: GroupResponseTypeEnum; + /** + * + * @type {Array} + * @memberof GroupResponse + */ + users: Array; + /** + * + * @type {ExternalSourceResponse} + * @memberof GroupResponse + */ + externalSource?: ExternalSourceResponse; + /** + * + * @type {string} + * @memberof GroupResponse + */ + organizationId?: string; +} + +/** + * @export + * @enum {string} + */ +export enum GroupResponseTypeEnum { + Class = 'class' +} + +/** + * + * @export + * @interface GroupUserResponse + */ +export interface GroupUserResponse { + /** + * + * @type {string} + * @memberof GroupUserResponse + */ + id: string; + /** + * + * @type {string} + * @memberof GroupUserResponse + */ + firstName: string; + /** + * + * @type {string} + * @memberof GroupUserResponse + */ + lastName: string; + /** + * + * @type {string} + * @memberof GroupUserResponse + */ + role: GroupUserResponseRoleEnum; +} + +/** + * @export + * @enum {string} + */ +export enum GroupUserResponseRoleEnum { + Administrator = 'administrator', + CourseAdministrator = 'courseAdministrator', + CourseStudent = 'courseStudent', + CourseSubstitutionTeacher = 'courseSubstitutionTeacher', + CourseTeacher = 'courseTeacher', + Demo = 'demo', + DemoStudent = 'demoStudent', + DemoTeacher = 'demoTeacher', + Expert = 'expert', + Helpdesk = 'helpdesk', + Student = 'student', + Superhero = 'superhero', + Teacher = 'teacher', + Teamadministrator = 'teamadministrator', + Teamexpert = 'teamexpert', + Teamleader = 'teamleader', + Teammember = 'teammember', + Teamowner = 'teamowner', + User = 'user' +} + /** * * @export @@ -3264,7 +3399,7 @@ export interface OauthConfigResponse { * @type {string} * @memberof OauthConfigResponse */ - logoutEndpoint: string; + logoutEndpoint?: string; /** * Issuer * @type {string} @@ -4098,7 +4233,7 @@ export interface SubmissionContainerContentBody { * @type {string} * @memberof SubmissionContainerContentBody */ - dueDate?: string | null; + dueDate?: string; } /** * @@ -8861,6 +8996,44 @@ export const GroupApiAxiosParamCreator = function (configuration?: Configuration + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Get a group by id. + * @param {string} groupId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + groupControllerGetGroup: async (groupId: string, options: any = {}): Promise => { + // verify required parameter 'groupId' is not null or undefined + assertParamExists('groupControllerGetGroup', 'groupId', groupId) + const localVarPath = `/groups/{groupId}` + .replace(`{${"groupId"}}`, encodeURIComponent(String(groupId))); + // 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) + + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -8894,6 +9067,17 @@ export const GroupApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.groupControllerFindClassesForSchool(skip, limit, sortOrder, sortBy, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @summary Get a group by id. + * @param {string} groupId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async groupControllerGetGroup(groupId: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.groupControllerGetGroup(groupId, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, } }; @@ -8917,6 +9101,16 @@ export const GroupApiFactory = function (configuration?: Configuration, basePath 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)); }, + /** + * + * @summary Get a group by id. + * @param {string} groupId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + groupControllerGetGroup(groupId: string, options?: any): AxiosPromise { + return localVarFp.groupControllerGetGroup(groupId, options).then((request) => request(axios, basePath)); + }, }; }; @@ -8939,6 +9133,16 @@ export interface GroupApiInterface { */ groupControllerFindClassesForSchool(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', options?: any): AxiosPromise; + /** + * + * @summary Get a group by id. + * @param {string} groupId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof GroupApiInterface + */ + groupControllerGetGroup(groupId: string, options?: any): AxiosPromise; + } /** @@ -8962,6 +9166,18 @@ export class GroupApi extends BaseAPI implements GroupApiInterface { 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)); } + + /** + * + * @summary Get a group by id. + * @param {string} groupId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof GroupApi + */ + public groupControllerGetGroup(groupId: string, options?: any) { + return GroupApiFp(this.configuration).groupControllerGetGroup(groupId, options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/src/store/group.ts b/src/store/group.ts index cb9c751f24..d27dc28750 100644 --- a/src/store/group.ts +++ b/src/store/group.ts @@ -6,10 +6,10 @@ import { import { $axios, mapAxiosErrorToResponseError } from "@/utils/api"; import { AxiosResponse } from "axios"; import { Action, Module, Mutation, VuexModule } from "vuex-module-decorators"; +import { GroupMapper } from "./group/group.mapper"; 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", @@ -104,6 +104,29 @@ export default class GroupModule extends VuexModule { this.page = page; } + @Action + async deleteClass(classId: string): Promise { + this.setLoading(true); + + try { + await $axios.delete(`/v1/classes/${classId}`); + + await this.loadClassesForSchool(); + } catch (error) { + const apiError = mapAxiosErrorToResponseError(error); + + console.log(apiError); + + this.setBusinessError({ + error: apiError, + statusCode: apiError.code, + message: `${apiError.type}: ${apiError.message}`, + }); + } + + this.setLoading(false); + } + @Action async loadClassesForSchool(): Promise { this.setLoading(true); diff --git a/src/store/group.unit.ts b/src/store/group.unit.ts index 074411b43b..3c92c477b0 100644 --- a/src/store/group.unit.ts +++ b/src/store/group.unit.ts @@ -1,33 +1,38 @@ -import * as serverApi from "@/serverApi/v3/api"; import { ClassInfoResponse, ClassInfoSearchListResponse, GroupApiInterface, } from "@/serverApi/v3"; +import * as serverApi from "@/serverApi/v3/api"; +import { initializeAxios, mapAxiosErrorToResponseError } from "@/utils/api"; import { axiosErrorFactory, businessErrorFactory, classInfoResponseFactory, classInfoSearchListResponseFactory, } from "@@/tests/test-utils"; +import { classInfoFactory } from "@@/tests/test-utils/factory/classInfoFactory"; +import { mockApiResponse } from "@@/tests/test-utils/mockApiResponse"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { AxiosInstance } from "axios"; +import GroupModule from "./group"; import { ClassInfo, ClassRootType } from "./types/class-info"; import { BusinessError, Pagination } from "./types/commons"; import { SortOrder } from "./types/sort-order.enum"; -import GroupModule from "./group"; -import { createMock, DeepMocked } from "@golevelup/ts-jest"; -import { mockApiResponse } from "@@/tests/test-utils/mockApiResponse"; -import { mapAxiosErrorToResponseError } from "@/utils/api"; describe("GroupModule", () => { let module: GroupModule; let apiMock: DeepMocked; + let axiosMock: DeepMocked; beforeEach(() => { module = new GroupModule({}); apiMock = createMock(); + axiosMock = createMock(); + initializeAxios(axiosMock); jest.spyOn(serverApi, "GroupApiFactory").mockReturnValue(apiMock); }); @@ -257,4 +262,72 @@ describe("GroupModule", () => { }); }); }); + + describe("deleteClass", () => { + describe("when called", () => { + const setup = () => { + const class1: ClassInfo = classInfoFactory.build(); + + module.setClasses([class1]); + + return { + class1, + }; + }; + + it("should delete the class", async () => { + const { class1 } = setup(); + + await module.deleteClass(class1.id); + + expect(axiosMock.delete).toHaveBeenCalled(); + }); + + it("should load classes for school", async () => { + const { class1 } = setup(); + + await module.deleteClass(class1.id); + + expect(apiMock.groupControllerFindClassesForSchool).toHaveBeenCalled(); + }); + }); + + describe("when an error occurs during the api call", () => { + const setup = () => { + const error = axiosErrorFactory.build(); + const apiError = mapAxiosErrorToResponseError(error); + const class1: ClassInfo = classInfoFactory.build(); + const class2: ClassInfo = classInfoFactory.build(); + + module.setClasses([class1, class2]); + axiosMock.delete.mockRejectedValue(error); + + return { + apiError, + class1, + class2, + }; + }; + + it("should update the stores error", async () => { + const { apiError, class1 } = setup(); + + await module.deleteClass(class1.id); + + expect(module.getBusinessError).toEqual({ + error: apiError, + statusCode: apiError.code, + message: `${apiError.type}: ${apiError.message}`, + }); + }); + + it("should not remove the class from the store", async () => { + const { class1, class2 } = setup(); + + await module.deleteClass(class1.id); + + expect(module.getClasses).toEqual([class1, class2]); + }); + }); + }); }); diff --git a/src/store/group/group.mapper.ts b/src/store/group/group.mapper.ts index 8335273cfd..602b759f63 100644 --- a/src/store/group/group.mapper.ts +++ b/src/store/group/group.mapper.ts @@ -18,6 +18,7 @@ export class GroupMapper { teachers: classInfoResponse.teachers, type: ClassRootTypeMapping[classInfoResponse.type], id: classInfoResponse.id, + isUpgradable: classInfoResponse.isUpgradable, }) ); diff --git a/src/store/types/class-info.ts b/src/store/types/class-info.ts index 22080dd13a..bb36fe2ac2 100644 --- a/src/store/types/class-info.ts +++ b/src/store/types/class-info.ts @@ -4,6 +4,7 @@ export type ClassInfo = { teachers: string[]; type: ClassRootType; id: string; + isUpgradable?: boolean; }; export enum ClassRootType { diff --git a/tests/test-utils/factory/classInfoFactory.ts b/tests/test-utils/factory/classInfoFactory.ts index adbaf1b509..3d44a9797c 100644 --- a/tests/test-utils/factory/classInfoFactory.ts +++ b/tests/test-utils/factory/classInfoFactory.ts @@ -5,6 +5,6 @@ export const classInfoFactory = Factory.define(({ sequence }) => ({ name: `className${sequence}`, externalSourceName: "Source", teachers: ["TestTeacher"], - type: ClassRootType.Class, + type: ClassRootType.Group, id: `id-${sequence}`, })); diff --git a/tests/test-utils/factory/groupFactory.ts b/tests/test-utils/factory/groupFactory.ts new file mode 100644 index 0000000000..1b0515b078 --- /dev/null +++ b/tests/test-utils/factory/groupFactory.ts @@ -0,0 +1,21 @@ +import { Factory } from "fishery"; +import { Group, GroupType, GroupUserRole } from "@data-group"; + +export const groupFactory = Factory.define(({ sequence }) => ({ + id: `group-${sequence}`, + name: `Group ${sequence}`, + type: GroupType.Class, + organizationId: `organization-${sequence}`, + users: [ + { + id: `user-${sequence}`, + firstName: `Users firstname ${sequence}`, + lastName: `Users lastname ${sequence}`, + role: GroupUserRole.Student, + }, + ], + externalSource: { + externalId: `external-id-${sequence}`, + systemId: `system-id-${sequence}`, + }, +})); diff --git a/tests/test-utils/factory/groupResponseFactory.ts b/tests/test-utils/factory/groupResponseFactory.ts new file mode 100644 index 0000000000..f55d9d3101 --- /dev/null +++ b/tests/test-utils/factory/groupResponseFactory.ts @@ -0,0 +1,27 @@ +import { + GroupResponse, + GroupResponseTypeEnum, + GroupUserResponseRoleEnum, +} from "@/serverApi/v3"; +import { Factory } from "fishery"; + +export const groupResponseFactory = Factory.define( + ({ sequence }) => ({ + id: `group-${sequence}`, + name: `Group ${sequence}`, + type: GroupResponseTypeEnum.Class, + organizationId: `organization-${sequence}`, + users: [ + { + id: `user-${sequence}`, + firstName: `Users firstname ${sequence}`, + lastName: `Users lastname ${sequence}`, + role: GroupUserResponseRoleEnum.Student, + }, + ], + externalSource: { + externalId: `external-id-${sequence}`, + systemId: `system-id-${sequence}`, + }, + }) +); diff --git a/tests/test-utils/factory/index.ts b/tests/test-utils/factory/index.ts index 4c1ccfa61b..f374d67408 100644 --- a/tests/test-utils/factory/index.ts +++ b/tests/test-utils/factory/index.ts @@ -42,3 +42,5 @@ export * from "./videoConferenceInfoFactory"; export * from "./videoConferenceInfoResponseFactory"; export * from "./videoConferenceJoinResponseFactory"; export * from "./toolReferenceResponseFactory"; +export * from "./groupResponseFactory"; +export * from "./groupFactory"; diff --git a/tsconfig.json b/tsconfig.json index 99d972f024..a0c2321d40 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,8 @@ "paths": { "@data-board": ["src/components/data-board"], "@data-external-tool": ["src/components/data-external-tool"], + "@data-group": ["src/components/data-group"], + "@data-system": ["src/components/data-system"], "@feature-board-file-element": [ "src/components/feature-board-file-element" ], @@ -46,6 +48,7 @@ "@util-board": ["src/components/util-board"], "@util-validators": ["src/components/util-validators"], "@page-board": ["src/components/page-board"], + "@page-class-members": ["src/components/page-class-members"], "@/*": ["src/*"], "@@/*": ["*"] }, diff --git a/vue.config.js b/vue.config.js index fd93d2a3d8..64aa926ac4 100644 --- a/vue.config.js +++ b/vue.config.js @@ -22,6 +22,8 @@ module.exports = defineConfig({ alias: { "@data-board": getDir("src/components/data-board"), "@data-external-tool": getDir("src/components/data-external-tool"), + "@data-group": getDir("src/components/data-group"), + "@data-system": getDir("src/components/data-system"), "@feature-board-file-element": getDir( "src/components/feature-board-file-element" ), @@ -52,6 +54,7 @@ module.exports = defineConfig({ "@util-board": getDir("src/components/util-board"), "@util-validators": getDir("src/components/util-validators"), "@page-board": getDir("src/components/page-board"), + "@page-class-members": getDir("src/components/page-class-members"), }, extensions: [".js", ".ts", ".vue", ".json"], plugins: [new ThemeResolverPlugin(__dirname, replacements)],