From f2da88a386f82611200286f97cb8de848288dae3 Mon Sep 17 00:00:00 2001 From: virgilchiriac <17074330+virgilchiriac@users.noreply.github.com> Date: Fri, 3 Nov 2023 12:52:12 +0100 Subject: [PATCH 1/4] BC-4453 - update api (#2893) --- src/serverApi/v3/api.ts | 97 +++++++++++++++++++ .../factory/submissionItemResponseFactory.ts | 1 + 2 files changed, 98 insertions(+) diff --git a/src/serverApi/v3/api.ts b/src/serverApi/v3/api.ts index 87daf55d22..efdc2f78c3 100644 --- a/src/serverApi/v3/api.ts +++ b/src/serverApi/v3/api.ts @@ -4328,6 +4328,12 @@ export interface SubmissionItemResponse { * @memberof SubmissionItemResponse */ userId: string; + /** + * + * @type {Array} + * @memberof SubmissionItemResponse + */ + elements: Array; } /** * @@ -7956,6 +7962,50 @@ export class BoardElementApi extends BaseAPI implements BoardElementApiInterface */ export const BoardSubmissionApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @summary Create a new element in a submission item. + * @param {string} submissionItemId The id of the submission item. + * @param {CreateContentElementBodyParams} createContentElementBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardSubmissionControllerCreateElement: async (submissionItemId: string, createContentElementBodyParams: CreateContentElementBodyParams, options: any = {}): Promise => { + // verify required parameter 'submissionItemId' is not null or undefined + assertParamExists('boardSubmissionControllerCreateElement', 'submissionItemId', submissionItemId) + // verify required parameter 'createContentElementBodyParams' is not null or undefined + assertParamExists('boardSubmissionControllerCreateElement', 'createContentElementBodyParams', createContentElementBodyParams) + const localVarPath = `/board-submissions/{submissionItemId}/elements` + .replace(`{${"submissionItemId"}}`, encodeURIComponent(String(submissionItemId))); + // 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: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(createContentElementBodyParams, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @summary Get a list of submission items by their parent container. @@ -8048,6 +8098,18 @@ export const BoardSubmissionApiAxiosParamCreator = function (configuration?: Con export const BoardSubmissionApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = BoardSubmissionApiAxiosParamCreator(configuration) return { + /** + * + * @summary Create a new element in a submission item. + * @param {string} submissionItemId The id of the submission item. + * @param {CreateContentElementBodyParams} createContentElementBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async boardSubmissionControllerCreateElement(submissionItemId: string, createContentElementBodyParams: CreateContentElementBodyParams, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.boardSubmissionControllerCreateElement(submissionItemId, createContentElementBodyParams, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @summary Get a list of submission items by their parent container. @@ -8081,6 +8143,17 @@ export const BoardSubmissionApiFp = function(configuration?: Configuration) { export const BoardSubmissionApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = BoardSubmissionApiFp(configuration) return { + /** + * + * @summary Create a new element in a submission item. + * @param {string} submissionItemId The id of the submission item. + * @param {CreateContentElementBodyParams} createContentElementBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardSubmissionControllerCreateElement(submissionItemId: string, createContentElementBodyParams: CreateContentElementBodyParams, options?: any): AxiosPromise { + return localVarFp.boardSubmissionControllerCreateElement(submissionItemId, createContentElementBodyParams, options).then((request) => request(axios, basePath)); + }, /** * * @summary Get a list of submission items by their parent container. @@ -8111,6 +8184,17 @@ export const BoardSubmissionApiFactory = function (configuration?: Configuration * @interface BoardSubmissionApi */ export interface BoardSubmissionApiInterface { + /** + * + * @summary Create a new element in a submission item. + * @param {string} submissionItemId The id of the submission item. + * @param {CreateContentElementBodyParams} createContentElementBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardSubmissionApiInterface + */ + boardSubmissionControllerCreateElement(submissionItemId: string, createContentElementBodyParams: CreateContentElementBodyParams, options?: any): AxiosPromise; + /** * * @summary Get a list of submission items by their parent container. @@ -8141,6 +8225,19 @@ export interface BoardSubmissionApiInterface { * @extends {BaseAPI} */ export class BoardSubmissionApi extends BaseAPI implements BoardSubmissionApiInterface { + /** + * + * @summary Create a new element in a submission item. + * @param {string} submissionItemId The id of the submission item. + * @param {CreateContentElementBodyParams} createContentElementBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardSubmissionApi + */ + public boardSubmissionControllerCreateElement(submissionItemId: string, createContentElementBodyParams: CreateContentElementBodyParams, options?: any) { + return BoardSubmissionApiFp(this.configuration).boardSubmissionControllerCreateElement(submissionItemId, createContentElementBodyParams, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @summary Get a list of submission items by their parent container. diff --git a/tests/test-utils/factory/submissionItemResponseFactory.ts b/tests/test-utils/factory/submissionItemResponseFactory.ts index 0a8a212ec5..2674efebeb 100644 --- a/tests/test-utils/factory/submissionItemResponseFactory.ts +++ b/tests/test-utils/factory/submissionItemResponseFactory.ts @@ -8,4 +8,5 @@ export const submissionItemResponseFactory = userId: `userId${sequence}`, completed: true, timestamps: timestampsResponseFactory.build(), + elements: [], })); From 2a6b16192e5c092c071d050190b51f508c5c352b Mon Sep 17 00:00:00 2001 From: Igor Richter <93926487+IgorCapCoder@users.noreply.github.com> Date: Sun, 5 Nov 2023 02:03:13 +0100 Subject: [PATCH 2/4] N21-1318 add schoolyear dependent tabs and a column to new class page (#2879) * add studentCount column to table * add tabs, api and classFilter call * schoolYear for tabnames, locales, landing tab * replace old Classes sidebar item with new one --------- Co-authored-by: Arne Gnisa --- src/locales/de.json | 3 +- src/locales/en.json | 3 +- src/locales/es.json | 3 +- src/locales/uk.json | 3 +- .../administration/ClassOverview.page.unit.ts | 201 +++++++++++++++++- .../administration/ClassOverview.page.vue | 121 ++++++++++- src/router/routes.ts | 3 + src/serverApi/v3/api.ts | 48 ++++- src/store/group.ts | 17 +- src/store/group.unit.ts | 25 ++- src/store/group/group.mapper.ts | 1 + src/store/schools.ts | 32 ++- src/store/types/class-info.ts | 1 + src/store/types/schools.ts | 8 +- src/utils/sidebar-base-items.ts | 61 +++--- tests/test-utils/factory/classInfoFactory.ts | 1 + .../factory/classInfoResponseFactory.ts | 1 + .../userLoginMigrationResponse.factory.ts | 1 + tests/test-utils/mockObjects.ts | 40 +++- 19 files changed, 509 insertions(+), 64 deletions(-) diff --git a/src/locales/de.json b/src/locales/de.json index b08050093c..b7057a4308 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -293,7 +293,6 @@ "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", @@ -755,6 +754,8 @@ "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.administration.classes.label.archive": "Archiv", + "pages.administration.classes.hint": "Mit allen Änderungen und Einstellungen im Verwaltungsbereich wird bestätigt, dass diese durch einen weisungsberechtigten Schul-Admin mit Befugnis zu Anpassungen der Schule in der Cloud durchgeführt werden. Anpassungen durch den Schul-Admin gelten insofern als Weisung der Schule gegenüber dem Cloudbetreiber {institute_title}.", "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 a1bc1f41e3..17d64e0441 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -292,7 +292,6 @@ "global.sidebar.addons": "Add-ons", "global.sidebar.calendar": "calendar", "global.sidebar.classes": "Classes", - "global.sidebar.classes.new": "Classes (new)", "global.sidebar.courses": "Courses", "global.sidebar.files-old": "My Files", "global.sidebar.files": "Files", @@ -753,6 +752,8 @@ "pages.administration.classes.edit": "Edit class", "pages.administration.classes.delete": "Delete class", "pages.administration.classes.createSuccessor": "Move class to the next school year", + "pages.administration.classes.label.archive": "Archive", + "pages.administration.classes.hint": "With all changes and settings in the administration area, it is confirmed that these are carried out by a school admin with authority to make adjustments to the school in the cloud. Adjustments made by the school admin are deemed to be instructions from the school to the cloud operator {institute_title}.", "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 9213ce0f9c..53c35a0f11 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -292,7 +292,6 @@ "global.sidebar.addons": "Complementos", "global.sidebar.calendar": "Calendario", "global.sidebar.classes": "Clases", - "global.sidebar.classes.new": "Clases (nuevas)", "global.sidebar.courses": "Cursos", "global.sidebar.files-old": "Mis archivos", "global.sidebar.files": "Archivos", @@ -742,6 +741,8 @@ "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.administration.classes.label.archive": "Archivo", + "pages.administration.classes.hint": "Con todos los cambios y ajustes en el área de administración, se confirma que estos son llevados a cabo por un administrador de la escuela autorizado para hacer ajustes en la escuela en la nube. Los ajustes realizados por el administrador de la escuela se consideran instrucciones de la escuela al operador de la nube {institute_title}.", "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 8cc8e72189..617c2788f5 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -471,7 +471,6 @@ "global.sidebar.addons": "Додаткові компоненти", "global.sidebar.calendar": "календар", "global.sidebar.classes": "Класи", - "global.sidebar.classes.new": "Класи (новий)", "global.sidebar.courses": "Курси", "global.sidebar.files-old": "Мої файли", "global.sidebar.files": "файли", @@ -833,6 +832,8 @@ "pages.administration.classes.edit": "Редагувати клас", "pages.administration.classes.delete": "Видалити клас", "pages.administration.classes.createSuccessor": "Перенести клас на наступний навчальний рік", + "pages.administration.classes.label.archive": "Архів", + "pages.administration.classes.hint": "Усі зміни та налаштування в області адміністрування підтверджують, що вони внесені авторизованим адміністратором школи з повноваженнями вносити зміни до школи в хмарі. Коригування, внесені адміністратором школи, вважаються вказівками школи оператору хмари {institute_title}.", "pages.content._id.addToTopic": "Для додавання в", "pages.content._id.collection.selectElements": "Виберіть елементи, які треба додати до теми", "pages.content._id.metadata.author": "Автор", diff --git a/src/pages/administration/ClassOverview.page.unit.ts b/src/pages/administration/ClassOverview.page.unit.ts index 690f712554..5b6e03f671 100644 --- a/src/pages/administration/ClassOverview.page.unit.ts +++ b/src/pages/administration/ClassOverview.page.unit.ts @@ -3,16 +3,30 @@ 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 { + AUTH_MODULE_KEY, + GROUP_MODULE_KEY, + I18N_KEY, + SCHOOLS_MODULE_KEY, +} from "@/utils/inject"; import { createModuleMocks } from "@/utils/mock-store-module"; import { classInfoFactory, i18nMock } from "@@/tests/test-utils"; import createComponentMocks from "@@/tests/test-utils/componentMocks"; import { mount, MountOptions, Wrapper } from "@vue/test-utils"; import Vue from "vue"; import ClassOverview from "./ClassOverview.page.vue"; +import SchoolsModule from "@/store/schools"; +import { School, Year } from "@/store/types/schools"; +import { createMock } from "@golevelup/ts-jest"; +import VueRouter from "vue-router"; + +const $router = createMock(); describe("ClassOverview", () => { - const getWrapper = (getters: Partial = {}) => { + const getWrapper = ( + getters: Partial = {}, + propsData: { tab: string } = { tab: "current" } + ) => { document.body.setAttribute("data-app", "true"); const groupModule = createModuleMocks(GroupModule, { @@ -36,6 +50,23 @@ describe("ClassOverview", () => { getUserPermissions: ["CLASS_EDIT".toLowerCase()], }); + const schoolModule = createModuleMocks(SchoolsModule, { + getSchool: { + years: { + schoolYears: [], + nextYear: { + name: "2024/25", + } as Year, + activeYear: { + name: "2023/24", + } as Year, + lastYear: {} as Year, + defaultYear: {} as Year, + }, + } as unknown as School, + ...getters, + }); + const wrapper: Wrapper = mount(ClassOverview as MountOptions, { ...createComponentMocks({ i18n: true, @@ -43,13 +74,17 @@ describe("ClassOverview", () => { provide: { [I18N_KEY.valueOf()]: i18nMock, [GROUP_MODULE_KEY.valueOf()]: groupModule, + [SCHOOLS_MODULE_KEY.valueOf()]: schoolModule, [AUTH_MODULE_KEY.valueOf()]: authModule, }, + mocks: { $router }, + propsData, }); return { wrapper, groupModule, + schoolModule, }; }; @@ -520,4 +555,166 @@ describe("ClassOverview", () => { }); }); }); + + describe("tabs", () => { + describe("when loading page", () => { + const setup = () => { + const { wrapper } = getWrapper(); + + return { + wrapper, + }; + }; + + it("should show 3 tabs", () => { + const { wrapper } = setup(); + + const nextYearTab = wrapper.find( + '[data-testid="admin-class-next-year-tab"]' + ); + + const currentYearTab = wrapper.find( + '[data-testid="admin-class-current-year-tab"]' + ); + + const previousYearTab = wrapper.find( + '[data-testid="admin-class-previous-years-tab"]' + ); + + expect(nextYearTab.exists()).toBeTruthy(); + expect(currentYearTab.exists()).toBeTruthy(); + expect(previousYearTab.exists()).toBeTruthy(); + }); + + it("should have current year tab active", () => { + const { wrapper } = setup(); + + const currentYearTab = wrapper.find( + '[data-testid="admin-class-current-year-tab"]' + ); + + expect(currentYearTab.classes()).toContain("v-tab--active"); + }); + }); + + describe("when clicking on a tab", () => { + const setup = () => { + const { wrapper, groupModule } = getWrapper(); + + return { + wrapper, + groupModule, + }; + }; + + it("should replace the route to the given tab ", async () => { + const { wrapper } = setup(); + + await wrapper + .find('[data-testid="admin-class-next-year-tab"]') + .trigger("click"); + + expect($router.replace).toHaveBeenCalledWith({ + query: { tab: "next" }, + }); + }); + }); + + describe("when clicking on next year tab", () => { + const setup = () => { + const { wrapper, groupModule } = getWrapper({}, { tab: "next" }); + + return { + wrapper, + groupModule, + }; + }; + + it("should call store to load classes of next year", async () => { + const { wrapper, groupModule } = setup(); + + await wrapper + .find('[data-testid="admin-class-next-year-tab"]') + .trigger("click"); + + expect(groupModule.loadClassesForSchool).toHaveBeenCalledWith( + "nextYear" + ); + }); + }); + + describe("when clicking on previous years tab", () => { + const setup = () => { + const { wrapper, groupModule } = getWrapper({}, { tab: "archive" }); + + return { + wrapper, + groupModule, + }; + }; + + it("should call store to load classes of previous years", async () => { + const { wrapper, groupModule } = setup(); + + await wrapper + .find('[data-testid="admin-class-previous-years-tab"]') + .trigger("click"); + + expect(groupModule.loadClassesForSchool).toHaveBeenCalledWith( + "previousYears" + ); + }); + }); + + describe("when clicking on current year tab", () => { + const setup = () => { + const { wrapper, groupModule } = getWrapper(); + + return { + wrapper, + groupModule, + }; + }; + + it("should call store to load groups and classes of current year", async () => { + const { wrapper, groupModule } = setup(); + + await wrapper + .find('[data-testid="admin-class-next-year-tab"]') + .trigger("click"); + + await wrapper + .find('[data-testid="admin-class-current-year-tab"]') + .trigger("click"); + + expect(groupModule.loadClassesForSchool).toHaveBeenCalledWith( + "currentYear" + ); + }); + }); + }); + + describe("addClass", () => { + describe("when clicking on add class buttton", () => { + const setup = () => { + const { wrapper } = getWrapper(); + + return { + wrapper, + }; + }; + + it("should redirect to legacy create class page", () => { + const { wrapper } = setup(); + + const addClassBtn = wrapper.find( + '[data-testid="admin-class-add-button"]' + ); + + expect(addClassBtn.attributes().href).toStrictEqual( + "/administration/classes/create" + ); + }); + }); + }); }); diff --git a/src/pages/administration/ClassOverview.page.vue b/src/pages/administration/ClassOverview.page.vue index e9654a09c4..6aaf6d94bf 100644 --- a/src/pages/administration/ClassOverview.page.vue +++ b/src/pages/administration/ClassOverview.page.vue @@ -5,6 +5,23 @@ :full-width="true" data-testid="admin-class-title" > + + + {{ nextYear }} + + + {{ currentYear }} + + + {{ t("pages.administration.classes.label.archive") }} + + + {{ t("pages.administration.classes.index.add") }} + +

+ {{ + t("pages.administration.classes.hint", { + institute_title: getInstituteTitle, + }) + }} +

@@ -147,6 +173,7 @@ import { AUTH_MODULE_KEY, GROUP_MODULE_KEY, injectStrict, + SCHOOLS_MODULE_KEY, } from "@/utils/inject"; import { RenderHTML } from "@feature-render-html"; import { @@ -160,20 +187,41 @@ import { ComputedRef, defineComponent, onMounted, + PropType, ref, Ref, + WritableComputedRef, } from "vue"; import VCustomDialog from "@/components/organisms/vCustomDialog.vue"; import AuthModule from "@/store/auth"; +import SchoolsModule from "@/store/schools"; +import { useRouter } from "vue-router/composables"; +import { SchoolYearQueryType } from "@/serverApi/v3"; + +type Tab = "current" | "next" | "archive"; export default defineComponent({ components: { DefaultWireframe, RenderHTML, VCustomDialog }, - setup() { + props: { + tab: { + type: String as PropType, + default: "current", + }, + }, + setup(props) { const groupModule: GroupModule = injectStrict(GROUP_MODULE_KEY); const authModule: AuthModule = injectStrict(AUTH_MODULE_KEY); + const schoolsModule: SchoolsModule = injectStrict(SCHOOLS_MODULE_KEY); + + const router = useRouter(); const { t } = useI18n(); + const activeTab: WritableComputedRef = computed({ + get: () => props.tab, + set: () => ({}), + }); + const footerProps = { itemsPerPageText: t("components.organisms.Pagination.recordsPerPage"), itemsPerPageOptions: [5, 10, 25, 50, 100], @@ -190,6 +238,35 @@ export default defineComponent({ }, ]; + const schoolYearQueryType: ComputedRef = computed( + () => { + switch (props.tab) { + case "next": + return SchoolYearQueryType.NextYear; + case "current": + return SchoolYearQueryType.CurrentYear; + case "archive": + return SchoolYearQueryType.PreviousYears; + default: + return SchoolYearQueryType.CurrentYear; + } + } + ); + + const nextYear: ComputedRef = computed( + () => schoolsModule.getSchool.years.nextYear.name + ); + + const currentYear: ComputedRef = computed( + () => schoolsModule.getSchool.years.activeYear.name + ); + + const onTabsChange = async (tab: string) => { + await groupModule.loadClassesForSchool(schoolYearQueryType.value); + + await router.replace({ query: { ...router.currentRoute.query, tab } }); + }; + const classes: ComputedRef = computed( () => groupModule.getClasses ); @@ -248,6 +325,11 @@ export default defineComponent({ text: t("common.labels.teacher"), sortable: true, }, + { + value: "studentCount", + text: "Schüler:innen", + sortable: true, + }, { value: "actions", text: "", @@ -257,38 +339,61 @@ export default defineComponent({ const onConfirmClassDeletion = async () => { if (selectedItem.value) { - await groupModule.deleteClass(selectedItem.value.id); + await groupModule.deleteClass({ + classId: selectedItem.value.id, + query: schoolYearQueryType.value, + }); } }; const onUpdateSortBy = async (sortBy: string) => { groupModule.setSortBy(sortBy); - await groupModule.loadClassesForSchool(); + + await groupModule.loadClassesForSchool(schoolYearQueryType.value); }; const updateSortOrder = async (sortDesc: boolean) => { const sortOrder = sortDesc ? SortOrder.DESC : SortOrder.ASC; groupModule.setSortOrder(sortOrder); - await groupModule.loadClassesForSchool(); + + await groupModule.loadClassesForSchool(schoolYearQueryType.value); }; const onUpdateCurrentPage = async (currentPage: number) => { groupModule.setPage(currentPage); const skip = (currentPage - 1) * groupModule.getPagination.limit; groupModule.setPagination({ ...pagination.value, skip }); - await groupModule.loadClassesForSchool(); + + await groupModule.loadClassesForSchool(schoolYearQueryType.value); }; const onUpdateItemsPerPage = async (itemsPerPage: number) => { groupModule.setPagination({ ...pagination.value, limit: itemsPerPage }); - await groupModule.loadClassesForSchool(); + + await groupModule.loadClassesForSchool(schoolYearQueryType.value); }; onMounted(() => { - groupModule.loadClassesForSchool(); + onTabsChange(activeTab.value); + }); + + const getInstituteTitle: ComputedRef = computed(() => { + switch (process.env.SC_THEME) { + case "n21": + return "Landesinitiative n-21: Schulen in Niedersachsen online e.V."; + case "thr": + return "Thüringer Institut für Lehrerfortbildung, Lehrplanentwicklung und Medien"; + case "brb": + return "Dataport"; + default: + return "Dataport"; + } }); return { t, footerProps, breadcrumbs, + nextYear, + currentYear, + onTabsChange, headers, classes, hasPermission, @@ -312,6 +417,8 @@ export default defineComponent({ mdiPencilOutline, mdiTrashCanOutline, mdiArrowUp, + getInstituteTitle, + activeTab, }; }, }); diff --git a/src/router/routes.ts b/src/router/routes.ts index 1d0e8bb747..32ab247311 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -100,6 +100,9 @@ export const routes: Array = [ component: () => import("@/pages/administration/ClassOverview.page.vue"), name: "administration-groups-classes", beforeEnter: createPermissionGuard(["class_list", "group_list"]), + props: (route: Route) => ({ + tab: route.query.tab, + }), }, { path: `/administration/groups/classes/:groupId(${REGEX_ID})`, diff --git a/src/serverApi/v3/api.ts b/src/serverApi/v3/api.ts index efdc2f78c3..c88495053f 100644 --- a/src/serverApi/v3/api.ts +++ b/src/serverApi/v3/api.ts @@ -407,6 +407,12 @@ export interface ClassInfoResponse { * @memberof ClassInfoResponse */ isUpgradable?: boolean; + /** + * + * @type {number} + * @memberof ClassInfoResponse + */ + studentCount: number; } /** @@ -4020,6 +4026,17 @@ export interface SchoolInfoResponse { */ name: string; } +/** + * + * @export + * @enum {string} + */ +export enum SchoolYearQueryType { + NextYear = 'nextYear', + CurrentYear = 'currentYear', + PreviousYears = 'previousYears' +} + /** * * @export @@ -4940,6 +4957,12 @@ export interface UserLoginMigrationMandatoryParams { * @interface UserLoginMigrationResponse */ export interface UserLoginMigrationResponse { + /** + * + * @type {string} + * @memberof UserLoginMigrationResponse + */ + id: string; /** * Id of the system which is the origin of the migration * @type {string} @@ -9055,10 +9078,11 @@ export const GroupApiAxiosParamCreator = function (configuration?: Configuration * @param {number} [limit] Page limit, defaults to 10. * @param {'asc' | 'desc'} [sortOrder] * @param {'name' | 'externalSourceName'} [sortBy] + * @param {SchoolYearQueryType} [type] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - groupControllerFindClasses: async (skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', options: any = {}): Promise => { + groupControllerFindClasses: async (skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', type?: SchoolYearQueryType, 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); @@ -9091,6 +9115,10 @@ export const GroupApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['sortBy'] = sortBy; } + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); @@ -9157,11 +9185,12 @@ export const GroupApiFp = function(configuration?: Configuration) { * @param {number} [limit] Page limit, defaults to 10. * @param {'asc' | 'desc'} [sortOrder] * @param {'name' | 'externalSourceName'} [sortBy] + * @param {SchoolYearQueryType} [type] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async groupControllerFindClasses(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.groupControllerFindClasses(skip, limit, sortOrder, sortBy, options); + async groupControllerFindClasses(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', type?: SchoolYearQueryType, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.groupControllerFindClasses(skip, limit, sortOrder, sortBy, type, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -9192,11 +9221,12 @@ export const GroupApiFactory = function (configuration?: Configuration, basePath * @param {number} [limit] Page limit, defaults to 10. * @param {'asc' | 'desc'} [sortOrder] * @param {'name' | 'externalSourceName'} [sortBy] + * @param {SchoolYearQueryType} [type] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - groupControllerFindClasses(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', options?: any): AxiosPromise { - return localVarFp.groupControllerFindClasses(skip, limit, sortOrder, sortBy, options).then((request) => request(axios, basePath)); + groupControllerFindClasses(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', type?: SchoolYearQueryType, options?: any): AxiosPromise { + return localVarFp.groupControllerFindClasses(skip, limit, sortOrder, sortBy, type, options).then((request) => request(axios, basePath)); }, /** * @@ -9224,11 +9254,12 @@ export interface GroupApiInterface { * @param {number} [limit] Page limit, defaults to 10. * @param {'asc' | 'desc'} [sortOrder] * @param {'name' | 'externalSourceName'} [sortBy] + * @param {SchoolYearQueryType} [type] * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof GroupApiInterface */ - groupControllerFindClasses(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', options?: any): AxiosPromise; + groupControllerFindClasses(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', type?: SchoolYearQueryType, options?: any): AxiosPromise; /** * @@ -9256,12 +9287,13 @@ export class GroupApi extends BaseAPI implements GroupApiInterface { * @param {number} [limit] Page limit, defaults to 10. * @param {'asc' | 'desc'} [sortOrder] * @param {'name' | 'externalSourceName'} [sortBy] + * @param {SchoolYearQueryType} [type] * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof GroupApi */ - public groupControllerFindClasses(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', options?: any) { - return GroupApiFp(this.configuration).groupControllerFindClasses(skip, limit, sortOrder, sortBy, options).then((request) => request(this.axios, this.basePath)); + public groupControllerFindClasses(skip?: number, limit?: number, sortOrder?: 'asc' | 'desc', sortBy?: 'name' | 'externalSourceName', type?: SchoolYearQueryType, options?: any) { + return GroupApiFp(this.configuration).groupControllerFindClasses(skip, limit, sortOrder, sortBy, type, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/src/store/group.ts b/src/store/group.ts index d3c1e49f91..7ff44559db 100644 --- a/src/store/group.ts +++ b/src/store/group.ts @@ -2,6 +2,7 @@ import { ClassInfoSearchListResponse, GroupApiFactory, GroupApiInterface, + SchoolYearQueryType, } from "@/serverApi/v3"; import { $axios, mapAxiosErrorToResponseError } from "@/utils/api"; import { AxiosResponse } from "axios"; @@ -105,13 +106,16 @@ export default class GroupModule extends VuexModule { } @Action - async deleteClass(classId: string): Promise { + async deleteClass(deleteQuery: { + classId: string; + query?: SchoolYearQueryType; + }): Promise { this.setLoading(true); try { - await $axios.delete(`/v1/classes/${classId}`); + await $axios.delete(`/v1/classes/${deleteQuery.classId}`); - await this.loadClassesForSchool(); + await this.loadClassesForSchool(deleteQuery.query); } catch (error) { const apiError = mapAxiosErrorToResponseError(error); @@ -128,7 +132,9 @@ export default class GroupModule extends VuexModule { } @Action - async loadClassesForSchool(): Promise { + async loadClassesForSchool( + schoolYearQuery?: SchoolYearQueryType + ): Promise { this.setLoading(true); try { const sortBy = @@ -141,7 +147,8 @@ export default class GroupModule extends VuexModule { this.pagination.skip, this.pagination.limit, this.getSortOrder, - sortBy + sortBy, + schoolYearQuery ); const mappedClasses: ClassInfo[] = GroupMapper.mapToClassInfo( response.data.data diff --git a/src/store/group.unit.ts b/src/store/group.unit.ts index 64263609e2..dbe12f29cd 100644 --- a/src/store/group.unit.ts +++ b/src/store/group.unit.ts @@ -2,6 +2,7 @@ import { ClassInfoResponse, ClassInfoSearchListResponse, GroupApiInterface, + SchoolYearQueryType, } from "@/serverApi/v3"; import * as serverApi from "@/serverApi/v3/api"; import { initializeAxios, mapAxiosErrorToResponseError } from "@/utils/api"; @@ -97,6 +98,7 @@ describe("GroupModule", () => { teachers: ["Carlie"], type: ClassRootType.Class, id: "id", + studentCount: 0, }, ]; @@ -221,7 +223,8 @@ describe("GroupModule", () => { pagination.skip, pagination.limit, sortOrder, - sortBy + sortBy, + undefined ); }); @@ -276,7 +279,10 @@ describe("GroupModule", () => { it("should delete the class", async () => { const { class1 } = setup(); - await module.deleteClass(class1.id); + await module.deleteClass({ + classId: class1.id, + query: SchoolYearQueryType.CurrentYear, + }); expect(axiosMock.delete).toHaveBeenCalled(); }); @@ -284,7 +290,10 @@ describe("GroupModule", () => { it("should load classes for school", async () => { const { class1 } = setup(); - await module.deleteClass(class1.id); + await module.deleteClass({ + classId: class1.id, + query: SchoolYearQueryType.CurrentYear, + }); expect(apiMock.groupControllerFindClasses).toHaveBeenCalled(); }); @@ -310,7 +319,10 @@ describe("GroupModule", () => { it("should update the stores error", async () => { const { apiError, class1 } = setup(); - await module.deleteClass(class1.id); + await module.deleteClass({ + classId: class1.id, + query: SchoolYearQueryType.CurrentYear, + }); expect(module.getBusinessError).toEqual({ error: apiError, @@ -322,7 +334,10 @@ describe("GroupModule", () => { it("should not remove the class from the store", async () => { const { class1, class2 } = setup(); - await module.deleteClass(class1.id); + await module.deleteClass({ + classId: class1.id, + query: SchoolYearQueryType.CurrentYear, + }); expect(module.getClasses).toEqual([class1, class2]); }); diff --git a/src/store/group/group.mapper.ts b/src/store/group/group.mapper.ts index 602b759f63..366cd39e8a 100644 --- a/src/store/group/group.mapper.ts +++ b/src/store/group/group.mapper.ts @@ -19,6 +19,7 @@ export class GroupMapper { type: ClassRootTypeMapping[classInfoResponse.type], id: classInfoResponse.id, isUpgradable: classInfoResponse.isUpgradable, + studentCount: classInfoResponse.studentCount, }) ); diff --git a/src/store/schools.ts b/src/store/schools.ts index 19d1bd2e7f..02d758ff51 100644 --- a/src/store/schools.ts +++ b/src/store/schools.ts @@ -106,7 +106,37 @@ export default class SchoolsModule extends VuexModule { documentBaseDir: "", isExternal: false, id: "", - years: {}, + years: { + nextYear: { + _id: "", + name: "", + startDate: "", + endDate: "", + __v: 0, + }, + lastYear: { + _id: "", + name: "", + startDate: "", + endDate: "", + __v: 0, + }, + activeYear: { + _id: "", + name: "", + startDate: "", + endDate: "", + __v: 0, + }, + defaultYear: { + _id: "", + name: "", + startDate: "", + endDate: "", + __v: 0, + }, + schoolYears: [], + }, isTeamCreationByStudentsEnabled: false, }; federalState: FederalState = { diff --git a/src/store/types/class-info.ts b/src/store/types/class-info.ts index bb36fe2ac2..8e1d6fdfe1 100644 --- a/src/store/types/class-info.ts +++ b/src/store/types/class-info.ts @@ -5,6 +5,7 @@ export type ClassInfo = { type: ClassRootType; id: string; isUpgradable?: boolean; + studentCount: number; }; export enum ClassRootType { diff --git a/src/store/types/schools.ts b/src/store/types/schools.ts index 5018b35630..bcdb65bf3a 100644 --- a/src/store/types/schools.ts +++ b/src/store/types/schools.ts @@ -55,7 +55,13 @@ export type School = { isExternal: boolean; id: string; officialSchoolNumber?: string; - years: unknown; + years: { + schoolYears: Year[]; + activeYear: Year; + defaultYear: Year; + nextYear: Year; + lastYear: Year; + }; language?: string; isTeamCreationByStudentsEnabled: boolean; }; diff --git a/src/utils/sidebar-base-items.ts b/src/utils/sidebar-base-items.ts index 1eb9d34607..246a05161e 100644 --- a/src/utils/sidebar-base-items.ts +++ b/src/utils/sidebar-base-items.ts @@ -1,4 +1,5 @@ import { Envs } from "@/store/types/env-config"; +import { envConfigModule } from "@/store"; export type SidebarItemBase = { title: string; @@ -173,21 +174,21 @@ const getSidebarItems = ( testId: "Lehrkräfte", activeForUrls: ["^/administration/teachers($|/.*)"], }, - { - title: "global.sidebar.classes", - icon: "$class", - href: "/administration/classes", - 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", - }, + envConfigModule.getShowNewClassViewEnabled + ? { + title: "global.sidebar.classes", + icon: "$class", + href: "/administration/groups/classes", + testId: "Klassen", + activeForUrls: ["^/administration/groups/classes($|/.*)"], + } + : { + title: "global.sidebar.classes", + icon: "$class", + href: "/administration/classes", + testId: "Klassen", + activeForUrls: ["^/administration/classes($|/.*)"], + }, ], }, { @@ -219,21 +220,21 @@ const getSidebarItems = ( testId: "Kurse", activeForUrls: ["^/administration/courses($|/.*)"], }, - { - title: "global.sidebar.classes", - icon: "$class", - href: "/administration/classes", - 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", - }, + envConfigModule.getShowNewClassViewEnabled + ? { + title: "global.sidebar.classes", + icon: "$class", + href: "/administration/groups/classes", + testId: "Klassen", + activeForUrls: ["^/administration/groups/classes($|/.*)"], + } + : { + title: "global.sidebar.classes", + icon: "$class", + href: "/administration/classes", + testId: "Klassen", + activeForUrls: ["^/administration/classes($|/.*)"], + }, { title: "global.sidebar.teams", icon: "$mdiAccountGroupOutline", diff --git a/tests/test-utils/factory/classInfoFactory.ts b/tests/test-utils/factory/classInfoFactory.ts index 3d44a9797c..53a449e281 100644 --- a/tests/test-utils/factory/classInfoFactory.ts +++ b/tests/test-utils/factory/classInfoFactory.ts @@ -7,4 +7,5 @@ export const classInfoFactory = Factory.define(({ sequence }) => ({ teachers: ["TestTeacher"], type: ClassRootType.Group, id: `id-${sequence}`, + studentCount: 2, })); diff --git a/tests/test-utils/factory/classInfoResponseFactory.ts b/tests/test-utils/factory/classInfoResponseFactory.ts index 5a18efe7e8..a1f0e48931 100644 --- a/tests/test-utils/factory/classInfoResponseFactory.ts +++ b/tests/test-utils/factory/classInfoResponseFactory.ts @@ -8,5 +8,6 @@ export const classInfoResponseFactory = Factory.define( teachers: ["TestTeacher"], type: ClassInfoResponseTypeEnum.Class, id: `id-${sequence}`, + studentCount: 2, }) ); diff --git a/tests/test-utils/factory/userLoginMigrationResponse.factory.ts b/tests/test-utils/factory/userLoginMigrationResponse.factory.ts index a27471e690..14527c8024 100644 --- a/tests/test-utils/factory/userLoginMigrationResponse.factory.ts +++ b/tests/test-utils/factory/userLoginMigrationResponse.factory.ts @@ -3,6 +3,7 @@ import { UserLoginMigrationResponse } from "@/serverApi/v3"; export const userLoginMigrationResponseFactory = Factory.define(() => ({ + id: "id", sourceSystemId: `sourceSystemId`, targetSystemId: `targetSystemId`, startedAt: new Date(2000, 1, 1, 0, 0).toString(), diff --git a/tests/test-utils/mockObjects.ts b/tests/test-utils/mockObjects.ts index 92230b6788..7956c93309 100644 --- a/tests/test-utils/mockObjects.ts +++ b/tests/test-utils/mockObjects.ts @@ -93,6 +93,44 @@ export const mockSchool: School = { documentBaseDir: "", isExternal: false, id: "mockSchoolId", - years: {}, + years: { + nextYear: { + _id: "", + name: "", + startDate: "", + endDate: "", + __v: 0, + }, + lastYear: { + _id: "", + name: "", + startDate: "", + endDate: "", + __v: 0, + }, + activeYear: { + _id: "", + name: "", + startDate: "", + endDate: "", + __v: 0, + }, + defaultYear: { + _id: "", + name: "", + startDate: "", + endDate: "", + __v: 0, + }, + schoolYears: [ + { + _id: "", + name: "", + startDate: "", + endDate: "", + __v: 0, + }, + ], + }, isTeamCreationByStudentsEnabled: false, }; From b01b94269bbc104d336e7e8e5b38ea345ec1f762 Mon Sep 17 00:00:00 2001 From: davwas Date: Mon, 6 Nov 2023 10:59:15 +0100 Subject: [PATCH 3/4] BC-4550 - Browser back on page Tools does not lead to previous page (#2889) * fix bug by setting active tab again if page was served from cache --- src/pages/rooms/RoomDetails.page.vue | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/pages/rooms/RoomDetails.page.vue b/src/pages/rooms/RoomDetails.page.vue index 27b78c7596..3aeb27f6e7 100644 --- a/src/pages/rooms/RoomDetails.page.vue +++ b/src/pages/rooms/RoomDetails.page.vue @@ -407,7 +407,7 @@ export default defineComponent({ }, }, async created() { - if (this.$route.query && this.$route.query.tab) { + if (this.$route.query?.tab) { this.setActiveTab(this.$route.query.tab); } @@ -419,7 +419,22 @@ export default defineComponent({ document.title = buildPageTitle(this.roomData.title); }, + mounted() { + window.addEventListener("pageshow", this.setActiveTabIfPageCached); + }, + beforeDestroy() { + window.removeEventListener("pageshow", this.setActiveTabIfPageCached); + }, methods: { + setActiveTabIfPageCached(event) { + if (event.persisted) { + if (this.$route.query?.tab) { + this.setActiveTab(this.$route.query.tab); + } else { + this.setActiveTab("learn-content"); + } + } + }, setActiveTab(tabName) { const index = this.tabItems.findIndex( (tabItem) => tabItem.name === tabName @@ -494,7 +509,7 @@ export default defineComponent({ watch: { tabIndex(newIndex) { if (newIndex >= 0 && newIndex < this.tabItems.length) { - this.$router.replace({ + this.$router.push({ query: { ...this.$route.query, tab: this.tabItems[newIndex].name }, }); } From 62bc3f3c0cfa82033a85084915a8996bd8428211 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport <106819770+hoeppner-dataport@users.noreply.github.com> Date: Mon, 6 Nov 2023 13:02:18 +0100 Subject: [PATCH 4/4] BC-5434 - meta data endpoint (#2876) refactored implementation of the meta-data-extraction for link-elements with preview. --- .../data-board/BoardApi.composable.ts | 47 ++-- .../composables/FileStorageApi.composable.ts | 26 +++ .../FileStorageApi.composable.unit.ts | 98 ++++++++ .../LinkContentElement.vue | 65 ------ .../LinkContentElementDisplay.vue | 83 ------- .../LinkContentElementEdit.vue | 70 ------ .../components/LinkContentElement.unit.ts | 212 ++++++++++++++++++ .../components/LinkContentElement.vue | 146 ++++++++++++ .../LinkContentElementCreate.unit.ts | 167 ++++++++++++++ .../components/LinkContentElementCreate.vue | 97 ++++++++ .../LinkContentElementDisplay.unit.ts | 71 ++++++ .../components/LinkContentElementDisplay.vue | 94 ++++++++ .../MetaTagExtractorApi.composable.ts | 33 +++ .../MetaTagExtractorApi.composable.unit.ts | 105 +++++++++ .../PreviewGenerator.composable.ts | 31 +++ .../PreviewGenerator.composable.unit.ts | 96 ++++++++ .../feature-board-link-element/index.ts | 2 +- .../util/url.util.ts | 6 + .../util/url.util.unit.ts | 21 ++ .../feature-board/card/ContentElement.vue | 16 +- .../ui-board/BoardMenuActionMoveDown.vue | 12 +- .../ui-board/BoardMenuActionMoveUp.vue | 12 +- .../ContentElementTitleIcon.vue | 8 +- .../util-board/board-injection-tokens.ts | 11 +- src/components/util-validators/validators.ts | 4 +- .../util-validators/validators.unit.ts | 1 + src/locales/de.json | 2 +- src/locales/en.json | 1 + src/locales/es.json | 1 + src/locales/uk.json | 3 +- src/serverApi/v3/api.ts | 191 ++++++++++++++++ .../api-mocks/fileStorageApiMock.ts | 5 +- .../factory/linkElementContentFactory.ts | 9 + .../factory/linkElementResponseFactory.ts | 13 ++ 34 files changed, 1485 insertions(+), 274 deletions(-) delete mode 100644 src/components/feature-board-link-element/LinkContentElement.vue delete mode 100644 src/components/feature-board-link-element/LinkContentElementDisplay.vue delete mode 100644 src/components/feature-board-link-element/LinkContentElementEdit.vue create mode 100644 src/components/feature-board-link-element/components/LinkContentElement.unit.ts create mode 100644 src/components/feature-board-link-element/components/LinkContentElement.vue create mode 100644 src/components/feature-board-link-element/components/LinkContentElementCreate.unit.ts create mode 100644 src/components/feature-board-link-element/components/LinkContentElementCreate.vue create mode 100644 src/components/feature-board-link-element/components/LinkContentElementDisplay.unit.ts create mode 100644 src/components/feature-board-link-element/components/LinkContentElementDisplay.vue create mode 100644 src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts create mode 100644 src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.unit.ts create mode 100644 src/components/feature-board-link-element/composables/PreviewGenerator.composable.ts create mode 100644 src/components/feature-board-link-element/composables/PreviewGenerator.composable.unit.ts create mode 100644 src/components/feature-board-link-element/util/url.util.ts create mode 100644 src/components/feature-board-link-element/util/url.util.unit.ts create mode 100644 tests/test-utils/factory/linkElementContentFactory.ts create mode 100644 tests/test-utils/factory/linkElementResponseFactory.ts diff --git a/src/components/data-board/BoardApi.composable.ts b/src/components/data-board/BoardApi.composable.ts index a6b93c4f1b..5e088a5339 100644 --- a/src/components/data-board/BoardApi.composable.ts +++ b/src/components/data-board/BoardApi.composable.ts @@ -9,12 +9,12 @@ import { ContentElementType, CreateCardBodyParamsRequiredEmptyElementsEnum, CreateContentElementBodyParams, - ExternalToolElementResponse, - FileElementContent, - LinkElementContent, - RichTextElementContent, + ExternalToolElementContentBody, + FileElementContentBody, + LinkElementContentBody, + RichTextElementContentBody, RoomsApiFactory, - SubmissionContainerElementContent, + SubmissionContainerElementContentBody, } from "@/serverApi/v3"; import { AnyContentElement } from "@/types/board/ContentElement"; import { $axios, mapAxiosErrorToResponseError } from "@/utils/api"; @@ -73,51 +73,42 @@ export const useBoardApi = () => { const generateDataProp = (element: AnyContentElement) => { if (element.type === ContentElementType.RichText) { return { - content: element.content as RichTextElementContent, + content: element.content, type: element.type, - }; + } as RichTextElementContentBody; } if (element.type === ContentElementType.File) { return { - content: element.content as FileElementContent, + content: element.content, type: ContentElementType.File, - }; + } as FileElementContentBody; } if (element.type === ContentElementType.SubmissionContainer) { return { - content: element.content as SubmissionContainerElementContent, + content: element.content, type: ContentElementType.SubmissionContainer, - }; + } as SubmissionContainerElementContentBody; } - if (isExternalToolElement(element)) { + if (element.type === ContentElementType.Link) { return { - content: { - contextExternalToolId: - element.content.contextExternalToolId ?? undefined, - }, - type: ContentElementType.ExternalTool, - }; + content: element.content, + type: ContentElementType.Link, + } as LinkElementContentBody; } - if (element.type === ContentElementType.Link) { + if (element.type === ContentElementType.ExternalTool) { return { - content: element.content as LinkElementContent, - type: ContentElementType.Link, - }; + content: element.content, + type: ContentElementType.ExternalTool, + } as ExternalToolElementContentBody; } throw new Error("element.type mapping is undefined for updateElementCall"); }; - const isExternalToolElement = ( - element: AnyContentElement - ): element is ExternalToolElementResponse => { - return element.type === ContentElementType.ExternalTool; - }; - const createElementCall = async ( cardId: string, params: CreateContentElementBodyParams diff --git a/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.ts b/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.ts index aeae0c93b8..5a2b242f2e 100644 --- a/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.ts +++ b/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.ts @@ -3,6 +3,7 @@ import { FileApiInterface, FileRecordParentType, FileRecordResponse, + FileUrlParams, RenameFileParams, } from "@/fileStorageApi/v3"; import { authModule } from "@/store/store-accessor"; @@ -65,6 +66,30 @@ export const useFileStorageApi = ( } }; + const uploadFromUrl = async (imageUrl: string): Promise => { + try { + const { pathname } = new URL(imageUrl); + const fileName = pathname.substring(pathname.lastIndexOf("/") + 1); + const schoolId = authModule.getUser?.schoolId as string; + const fileUrlParams: FileUrlParams = { + url: imageUrl, + fileName, + headers: `User-Agent: Embed Request User Agent`, + }; + const response = await fileApi.uploadFromUrl( + schoolId, + parentId, + parentType, + fileUrlParams + ); + + fileRecord.value = response.data; + } catch (error) { + showError(error); + throw error; + } + }; + const rename = async ( fileRecordId: FileRecordResponse["id"], params: RenameFileParams @@ -110,6 +135,7 @@ export const useFileStorageApi = ( fetchFile, rename, upload, + uploadFromUrl, fileRecord, }; }; diff --git a/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.unit.ts b/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.unit.ts index 5fd145e27b..867f6524c4 100644 --- a/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.unit.ts +++ b/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.unit.ts @@ -246,6 +246,104 @@ describe("FileStorageApi Composable", () => { }); }); + describe("uploadFromUrl", () => { + describe("when file api uploads file successfully", () => { + const setup = () => { + const fileName = "example-picture.jpg"; + const imageUrl = `https://www.example.com/${fileName}`; + const parentId = ObjectIdMock(); + const parentType = FileRecordParentType.BOARDNODES; + const fileRecordResponse = fileRecordResponseFactory.build({ + parentId, + parentType, + name: fileName, + }); + const response = { + data: fileRecordResponse, + }; + + const uploadFromUrlMock = jest.fn().mockResolvedValueOnce(response); + const { fileApiFactory } = setupFileStorageFactoryMock({ + uploadFromUrlMock, + }); + setupFileStorageNotifier(); + + return { + parentId, + parentType, + fileApiFactory, + fileRecordResponse, + fileName, + imageUrl, + }; + }; + + it("should call FileApiFactory.uploadFromUrl", async () => { + const { parentId, parentType, fileApiFactory, fileName, imageUrl } = + setup(); + const { uploadFromUrl } = useFileStorageApi(parentId, parentType); + + await uploadFromUrl(imageUrl); + + expect(fileApiFactory.uploadFromUrl).toBeCalledWith( + "schoolId", + parentId, + parentType, + expect.objectContaining({ + url: imageUrl, + fileName, + }) + ); + }); + + it("should set filerecord", async () => { + const { parentId, parentType, imageUrl, fileRecordResponse } = setup(); + const { uploadFromUrl, fileRecord } = useFileStorageApi( + parentId, + parentType + ); + + await uploadFromUrl(imageUrl); + + expect(fileRecord.value).toBe(fileRecordResponse); + }); + }); + + describe("when file api returns error", () => { + const setup = () => { + const parentId = ObjectIdMock(); + const parentType = FileRecordParentType.BOARDNODES; + const file = new File([""], "filename"); + + const { responseError, expectedPayload } = setupErrorResponse( + ErrorType.FILE_TOO_BIG + ); + + mockedMapAxiosErrorToResponseError.mockReturnValue(expectedPayload); + + const uploadFromUrlMock = jest.fn().mockRejectedValue(responseError); + setupFileStorageFactoryMock({ uploadFromUrlMock }); + setupFileStorageNotifier(); + + return { + parentId, + parentType, + file, + responseError, + }; + }; + + it("should call showFileTooBigError and pass error", async () => { + const { parentId, parentType, responseError } = setup(); + const { uploadFromUrl } = useFileStorageApi(parentId, parentType); + + await expect(uploadFromUrl("abc:/not-an-url")).rejects.toBe( + responseError + ); + }); + }); + }); + describe("rename", () => { describe("when file api rename file successfully", () => { const setup = () => { diff --git a/src/components/feature-board-link-element/LinkContentElement.vue b/src/components/feature-board-link-element/LinkContentElement.vue deleted file mode 100644 index 19ef62652b..0000000000 --- a/src/components/feature-board-link-element/LinkContentElement.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - diff --git a/src/components/feature-board-link-element/LinkContentElementDisplay.vue b/src/components/feature-board-link-element/LinkContentElementDisplay.vue deleted file mode 100644 index 9152f9cfed..0000000000 --- a/src/components/feature-board-link-element/LinkContentElementDisplay.vue +++ /dev/null @@ -1,83 +0,0 @@ - - - - - diff --git a/src/components/feature-board-link-element/LinkContentElementEdit.vue b/src/components/feature-board-link-element/LinkContentElementEdit.vue deleted file mode 100644 index aee3a0093f..0000000000 --- a/src/components/feature-board-link-element/LinkContentElementEdit.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - diff --git a/src/components/feature-board-link-element/components/LinkContentElement.unit.ts b/src/components/feature-board-link-element/components/LinkContentElement.unit.ts new file mode 100644 index 0000000000..e1dd367764 --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElement.unit.ts @@ -0,0 +1,212 @@ +import { AnyContentElement } from "@/types/board/ContentElement"; +import { + ENV_CONFIG_MODULE_KEY, + I18N_KEY, + NOTIFIER_MODULE_KEY, +} from "@/utils/inject"; +import { i18nMock } from "@@/tests/test-utils"; +import createComponentMocks from "@@/tests/test-utils/componentMocks"; +import { linkElementResponseFactory } from "@@/tests/test-utils/factory/linkElementResponseFactory"; +import { useBoardFocusHandler, useContentElementState } from "@data-board"; +import { LinkContentElement } from "@feature-board-link-element"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { MountOptions, shallowMount } from "@vue/test-utils"; +import { LinkElementContent, MetaTagExtractorResponse } from "@/serverApi/v3"; +import { useMetaTagExtractorApi } from "../composables/MetaTagExtractorApi.composable"; +import Vue, { computed, ref } from "vue"; +import NotifierModule from "@/store/notifier"; +import { createModuleMocks } from "@/utils/mock-store-module"; +import EnvConfigModule from "@/store/env-config"; +import { Envs } from "@/store/types/env-config"; +import LinkContentElementCreate from "./LinkContentElementCreate.vue"; +import { linkElementContentFactory } from "@@/tests/test-utils/factory/linkElementContentFactory"; +import { usePreviewGenerator } from "../composables/PreviewGenerator.composable"; + +jest.mock("@data-board/ContentElementState.composable"); + +jest.mock("@data-board/BoardFocusHandler.composable"); +jest.mock("../composables/MetaTagExtractorApi.composable"); +jest.mock("../composables/PreviewGenerator.composable"); +const mockedUseContentElementState = jest.mocked(useContentElementState); + +let defaultElement = linkElementResponseFactory.build(); +const mockedEnvConfigModule = createModuleMocks(EnvConfigModule, { + getEnv: createMock({ + FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED: true, + FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED: true, + FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED: true, + }), +}); + +describe("LinkContentElement", () => { + let useBoardFocusHandlerMock: DeepMocked< + ReturnType + >; + let useMetaTagExtractorApiMock: DeepMocked< + ReturnType + >; + let usePreviewGeneratorMock: DeepMocked< + ReturnType + >; + + beforeEach(() => { + useMetaTagExtractorApiMock = + createMock>(); + usePreviewGeneratorMock = + createMock>(); + + jest.mocked(useBoardFocusHandler).mockReturnValue(useBoardFocusHandlerMock); + jest + .mocked(useMetaTagExtractorApi) + .mockReturnValue(useMetaTagExtractorApiMock); + jest.mocked(usePreviewGenerator).mockReturnValue(usePreviewGeneratorMock); + + defaultElement = linkElementResponseFactory.build(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const getWrapper = (props: { + element: AnyContentElement; + isEditMode: boolean; + }) => { + const notifierModule = createModuleMocks(NotifierModule); + const wrapper = shallowMount(LinkContentElement as MountOptions, { + ...createComponentMocks({ i18n: true }), + provide: { + [I18N_KEY.valueOf()]: i18nMock, + [NOTIFIER_MODULE_KEY.valueOf()]: notifierModule, + [ENV_CONFIG_MODULE_KEY.valueOf()]: mockedEnvConfigModule, + }, + propsData: { ...props }, + }); + + return { wrapper }; + }; + + const setup = ( + options: { + content?: LinkElementContent; + isEditMode: boolean; + } = { content: undefined, isEditMode: true } + ) => { + const element = { + ...defaultElement, + content: + options.content ?? linkElementContentFactory.build({ url: undefined }), + }; + document.body.setAttribute("data-app", "true"); + + mockedUseContentElementState.mockReturnValue({ + modelValue: ref(element.content), + computedElement: computed(() => element), + isLoading: ref(false), + }); + + const { wrapper } = getWrapper({ + element, + isEditMode: options.isEditMode ?? false, + }); + + return { + element, + wrapper, + }; + }; + + describe("onCreateUrl", () => { + it("should request meta tags for the given url", async () => { + const { wrapper } = setup({ isEditMode: true }); + + const component = wrapper.getComponent(LinkContentElementCreate); + component.vm.$emit("create:url", "https://abc.de"); + + expect(useMetaTagExtractorApiMock.extractMetaTags).toHaveBeenCalled(); + }); + + describe("when no protocol was provided", () => { + it("should add https-protocol", async () => { + const { wrapper } = setup({ isEditMode: true }); + const url = "abc.de/my-article"; + + const component = wrapper.getComponent(LinkContentElementCreate); + component.vm.$emit("create:url", url); + + const expected = `https://${url}`; + expect(useMetaTagExtractorApiMock.extractMetaTags).toHaveBeenCalledWith( + expected + ); + }); + }); + + describe("when url was provided", () => { + describe("when imageUrl was in metaTags", () => { + it("should create a preview image", async () => { + const { wrapper } = setup({ isEditMode: true }); + const url = "https://abc.de/my-article"; + const fakeMetaTags: MetaTagExtractorResponse = { + url, + title: "my title", + description: "", + imageUrl: "https://abc.de/foto.png", + }; + + useMetaTagExtractorApiMock.extractMetaTags.mockResolvedValue( + fakeMetaTags + ); + + const component = wrapper.getComponent(LinkContentElementCreate); + component.vm.$emit("create:url", url); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + expect( + usePreviewGeneratorMock.createPreviewImage + ).toHaveBeenCalledWith(fakeMetaTags.imageUrl); + }); + }); + }); + }); + + describe("when arrow key up is pressed", () => { + describe("when component is in edit-mode", () => { + it("should NOT emit 'move-keyboard:edit'", async () => { + const { wrapper } = setup({ + isEditMode: true, + }); + + const card = wrapper.findComponent({ ref: "linkContentElement" }); + card.vm.$emit( + "keydown", + new KeyboardEvent("keydown", { + key: "ArrowUp", + keyCode: 38, + }) + ); + + expect(wrapper.emitted("move-keyboard:edit")).toBeUndefined(); + }); + }); + + describe("when component is in display-mode", () => { + it("should emit 'move-keyboard:edit'", async () => { + const { wrapper } = setup({ + isEditMode: false, + }); + + const card = wrapper.findComponent({ ref: "linkContentElement" }); + card.vm.$emit( + "keydown", + new KeyboardEvent("keydown", { + key: "ArrowUp", + keyCode: 38, + }) + ); + + expect(wrapper.emitted("move-keyboard:edit")).toHaveLength(1); + }); + }); + }); +}); diff --git a/src/components/feature-board-link-element/components/LinkContentElement.vue b/src/components/feature-board-link-element/components/LinkContentElement.vue new file mode 100644 index 0000000000..c71b7b834a --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElement.vue @@ -0,0 +1,146 @@ + + + diff --git a/src/components/feature-board-link-element/components/LinkContentElementCreate.unit.ts b/src/components/feature-board-link-element/components/LinkContentElementCreate.unit.ts new file mode 100644 index 0000000000..80265ed49a --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElementCreate.unit.ts @@ -0,0 +1,167 @@ +import { ENV_CONFIG_MODULE_KEY, I18N_KEY } from "@/utils/inject"; +import { i18nMock } from "@@/tests/test-utils"; +import createComponentMocks from "@@/tests/test-utils/componentMocks"; +import { createMock } from "@golevelup/ts-jest"; +import { mount, MountOptions } from "@vue/test-utils"; +import Vue, { nextTick } from "vue"; +import { createModuleMocks } from "@/utils/mock-store-module"; +import EnvConfigModule from "@/store/env-config"; +import { Envs } from "@/store/types/env-config"; +import LinkContentElementCreate from "./LinkContentElementCreate.vue"; + +const mockedEnvConfigModule = createModuleMocks(EnvConfigModule, { + getEnv: createMock({ + FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED: true, + FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED: true, + FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED: true, + }), +}); + +const VALID_URL = "https://www.abc.de/my-article"; +const INVALID_URL = "my-article"; + +describe("LinkContentElementCreate", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const wrapper = mount(LinkContentElementCreate as MountOptions, { + ...createComponentMocks({ i18n: true }), + provide: { + [I18N_KEY.valueOf()]: i18nMock, + [ENV_CONFIG_MODULE_KEY.valueOf()]: mockedEnvConfigModule, + }, + }); + + const insertUrl = (url: string) => { + const textAreaComponent = wrapper.findComponent({ name: "v-textarea" }); + textAreaComponent.vm.$emit("input", url); + }; + + const submitByClick = async () => { + const button = wrapper.find("button"); + await button.trigger("click"); + await nextTick(); + }; + + const submitByEnter = async () => { + const textAreaComponent = wrapper.findComponent({ name: "v-textarea" }); + textAreaComponent.vm.$emit( + "keydown", + new KeyboardEvent("keydown", { + key: "Enter", + keyCode: 13, + }) + ); + await nextTick(); + }; + + const hasEmitted = (eventName: string): string | false => { + const emitted = wrapper.emitted(eventName); + return emitted ? emitted[0][0] : false; + }; + + const areRulesActive = () => { + const rulesProperty = wrapper + .findComponent({ name: "v-textarea" }) + .props("rules"); + return typeof rulesProperty === "function"; + }; + + return { + wrapper, + insertUrl, + submitByClick, + submitByEnter, + hasEmitted, + areRulesActive, + }; + }; + + describe("when valid url was entered", () => { + describe("when enter is pressed", () => { + it("should not show error-message", async () => { + const { wrapper, insertUrl, submitByClick } = setup(); + + insertUrl(VALID_URL); + await submitByClick(); + + const alerts = wrapper.find('[role="alert"]'); + + expect(alerts.exists()).toBe(false); + }); + + it("should emit create:url event", async () => { + const { insertUrl, submitByEnter, hasEmitted } = setup(); + + insertUrl(VALID_URL); + await submitByEnter(); + + expect(hasEmitted("create:url")).toEqual(VALID_URL); + }); + }); + }); + + describe("when invalid url was entered", () => { + it("should not be validated during input", async () => { + const { wrapper, insertUrl } = setup(); + + insertUrl(INVALID_URL); + await nextTick(); + + const alerts = wrapper.find('[role="alert"]'); + + expect(alerts.exists()).toBe(false); + }); + + describe("when enter is pressed", () => { + it("should show invalid-url-error", async () => { + const { wrapper, insertUrl, submitByEnter } = setup(); + + insertUrl(INVALID_URL); + await submitByEnter(); + + const alerts = wrapper.find('[role="alert"]').text(); + + expect(alerts).toEqual( + expect.stringContaining("util-validators-invalid-url") + ); + }); + + it("should not emit create:url event", async () => { + const { wrapper, insertUrl, submitByEnter } = setup(); + + insertUrl(INVALID_URL); + await submitByEnter(); + + const emitted = wrapper.emitted("create:url"); + expect(emitted).toBeUndefined(); + }); + }); + }); + + describe("when url field is empty", () => { + describe("when submit button is clicked", () => { + it("should show required-error-message", async () => { + const { wrapper, submitByEnter } = setup(); + + await submitByEnter(); + + const alerts = wrapper.find('[role="alert"]').text(); + expect(alerts).toEqual( + expect.stringContaining("common.validation.required2") + ); + }); + + it("should not emit create:url event", async () => { + const { wrapper, submitByClick } = setup(); + + await submitByClick(); + + const emitted = wrapper.emitted("create:url"); + expect(emitted).toBeUndefined(); + }); + }); + }); +}); diff --git a/src/components/feature-board-link-element/components/LinkContentElementCreate.vue b/src/components/feature-board-link-element/components/LinkContentElementCreate.vue new file mode 100644 index 0000000000..b647dc1904 --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElementCreate.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/components/feature-board-link-element/components/LinkContentElementDisplay.unit.ts b/src/components/feature-board-link-element/components/LinkContentElementDisplay.unit.ts new file mode 100644 index 0000000000..bc9e61c01a --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElementDisplay.unit.ts @@ -0,0 +1,71 @@ +import LinkContentElementDisplayVue from "./LinkContentElementDisplay.vue"; +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"; + +type Props = { + url: string; + title: string; + imageUrl?: string; + isLoading: boolean; + isEditMode: boolean; +}; + +describe("LinkContentElementDisplay", () => { + const setup = (props: Props) => { + const wrapper = mount(LinkContentElementDisplayVue as MountOptions, { + ...createComponentMocks({ i18n: true }), + provide: { + [I18N_KEY.valueOf()]: i18nMock, + }, + propsData: { ...props }, + }); + + return { wrapper }; + }; + + describe("when valid url was given", () => { + it("should sanitize urls", async () => { + const VALID_UNSANITIZED_URL = + "https://example.com"; + const { wrapper } = setup({ + url: VALID_UNSANITIZED_URL, + title: "", + isLoading: false, + isEditMode: false, + }); + + const expectedUrl = "https://example.com"; + expect(wrapper.html()).toEqual(expect.stringContaining(expectedUrl)); + }); + + it("should sanitize javascript-urls", async () => { + const INVALID_UNSANITIZED_URL = + "javascript" + ":" + "alert(document.domain)"; + const { wrapper } = setup({ + url: INVALID_UNSANITIZED_URL, + title: "", + isLoading: false, + isEditMode: false, + }); + + const expectedUrl = "about:blank"; + expect(wrapper.html()).toEqual(expect.stringContaining(expectedUrl)); + }); + + it("should display the hostname ", async () => { + const INVALID_UNSANITIZED_URL = "https://de.wikipedia.org/dachs"; + const { wrapper } = setup({ + url: INVALID_UNSANITIZED_URL, + title: "", + isLoading: false, + isEditMode: false, + }); + + const expectedUrl = "de.wikipedia.org"; + expect(wrapper.html()).toEqual(expect.stringContaining(expectedUrl)); + }); + }); +}); diff --git a/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue b/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue new file mode 100644 index 0000000000..19f555e922 --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts b/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts new file mode 100644 index 0000000000..2a5c1d295a --- /dev/null +++ b/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts @@ -0,0 +1,33 @@ +import { MetaTagExtractorApiFactory } from "@/serverApi/v3"; +import { $axios } from "@/utils/api"; + +type MetaTagResult = { + url: string; + title: string; + description: string; + imageUrl?: string; +}; + +export const useMetaTagExtractorApi = () => { + const metaTagApi = MetaTagExtractorApiFactory(undefined, "/v3", $axios); + + const extractMetaTags = async (url: string): Promise => { + try { + const res = await metaTagApi.metaTagExtractorControllerGetData({ + url, + }); + + return res.data; + } catch (e) { + return { + url, + title: "", + description: "", + }; + } + }; + + return { + extractMetaTags, + }; +}; diff --git a/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.unit.ts b/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.unit.ts new file mode 100644 index 0000000000..3232e8a2bf --- /dev/null +++ b/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.unit.ts @@ -0,0 +1,105 @@ +import * as serverApi from "@/serverApi/v3/api"; +import { MetaTagExtractorResponse } from "@/serverApi/v3/api"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { mount } from "@vue/test-utils"; +import { defineComponent } from "vue"; +import { useMetaTagExtractorApi } from "./MetaTagExtractorApi.composable"; +import { mockApiResponse } from "@@/tests/test-utils"; + +describe("useMetaTagExtractorApi", () => { + let api: DeepMocked; + + beforeEach(() => { + api = createMock(); + + jest.spyOn(serverApi, "MetaTagExtractorApiFactory").mockReturnValue(api); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const getWrapper = () => { + let composable: ReturnType | undefined; + + const TestComponent = defineComponent({ + template: "
", + setup() { + composable = useMetaTagExtractorApi(); + }, + }); + + const wrapper = mount(TestComponent, {}); + return { wrapper, composable }; + }; + + describe("extractMetaTags", () => { + describe("when meta tags could be extracted", () => { + const setup = () => { + const mockedResponse: MetaTagExtractorResponse = { + url: "", + title: "", + description: "", + imageUrl: "", + }; + + api.metaTagExtractorControllerGetData.mockResolvedValue( + mockApiResponse({ data: mockedResponse }) + ); + + const { wrapper, composable } = getWrapper(); + + return { + wrapper, + mockedResponse, + composable, + }; + }; + + it("should be defined", () => { + const { composable } = setup(); + + expect(composable?.extractMetaTags).toBeDefined(); + }); + + it("should return the data", async () => { + const { composable, mockedResponse } = setup(); + + const url = "https://test.de/my-article"; + const data = await composable?.extractMetaTags(url); + + expect(data).toEqual(mockedResponse); + }); + }); + + describe("when meta tags extraction failed", () => { + const setup = () => { + const mockedResponse: MetaTagExtractorResponse = { + url: "", + title: "", + description: "", + imageUrl: "", + }; + + api.metaTagExtractorControllerGetData.mockRejectedValue(false); + + const { wrapper, composable } = getWrapper(); + + return { + wrapper, + mockedResponse, + composable, + }; + }; + + it("should return default values", async () => { + const { composable } = setup(); + + const url = "https://test.de/my-article"; + const data = await composable?.extractMetaTags(url); + + expect(data).toEqual({ url, title: "", description: "" }); + }); + }); + }); +}); diff --git a/src/components/feature-board-link-element/composables/PreviewGenerator.composable.ts b/src/components/feature-board-link-element/composables/PreviewGenerator.composable.ts new file mode 100644 index 0000000000..7217aa9d6d --- /dev/null +++ b/src/components/feature-board-link-element/composables/PreviewGenerator.composable.ts @@ -0,0 +1,31 @@ +import { FileRecordParentType } from "@/fileStorageApi/v3"; +import { + convertDownloadToPreviewUrl, + isPreviewPossible, +} from "@/utils/fileHelper"; +import { useFileStorageApi } from "@feature-board-file-element"; + +export const usePreviewGenerator = (elementId: string) => { + const { fileRecord, uploadFromUrl } = useFileStorageApi( + elementId, + FileRecordParentType.BOARDNODES + ); + + const createPreviewImage = async ( + externalImageUrl: string + ): Promise => { + await uploadFromUrl(externalImageUrl); + if ( + fileRecord.value?.previewStatus && + isPreviewPossible(fileRecord.value?.previewStatus) + ) { + const imageUrl = convertDownloadToPreviewUrl(fileRecord.value.url); + return imageUrl; + } + }; + + return { + createPreviewImage, + uploadFromUrl, + }; +}; diff --git a/src/components/feature-board-link-element/composables/PreviewGenerator.composable.unit.ts b/src/components/feature-board-link-element/composables/PreviewGenerator.composable.unit.ts new file mode 100644 index 0000000000..22f98a6e85 --- /dev/null +++ b/src/components/feature-board-link-element/composables/PreviewGenerator.composable.unit.ts @@ -0,0 +1,96 @@ +import { mount } from "@vue/test-utils"; +import { defineComponent } from "vue"; +import { usePreviewGenerator } from "./PreviewGenerator.composable"; +import { + fileRecordResponseFactory, + setupFileStorageApiMock, +} from "@@/tests/test-utils"; +import { PreviewStatus } from "@/fileStorageApi/v3"; + +jest.mock("@feature-board-file-element"); + +describe("usePreviewGenerator", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const getWrapper = (elementId: string) => { + let composable: ReturnType | undefined; + + const TestComponent = defineComponent({ + template: "
", + setup() { + composable = usePreviewGenerator(elementId); + }, + }); + + const wrapper = mount(TestComponent, {}); + return { wrapper, composable }; + }; + + describe("createPreviewImage", () => { + describe("when meta tags could be extracted", () => { + const setup = () => { + const elementId = "my-custom-mocked-id"; + const uploadFromUrlMock = jest.fn(); + const { fileRecord } = setupFileStorageApiMock({ uploadFromUrlMock }); + + const { wrapper, composable } = getWrapper(elementId); + + return { + wrapper, + composable, + fileRecord, + uploadFromUrlMock, + }; + }; + + it("should be defined", () => { + const { composable } = setup(); + + expect(composable?.createPreviewImage).toBeDefined(); + }); + + it("should upload the external image", async () => { + const { composable, uploadFromUrlMock } = setup(); + + const imageUrl = "https://test.de/my-article/image.jpg"; + await composable?.createPreviewImage(imageUrl); + + expect(uploadFromUrlMock).toHaveBeenCalledWith(imageUrl); + }); + + describe("when image can be uploaded", () => { + it("should return image url for the preview image", async () => { + const { composable, fileRecord } = setup(); + + fileRecord.value = fileRecordResponseFactory.build({ + previewStatus: PreviewStatus.PREVIEW_POSSIBLE, + }); + + const externalImageUrl = "https://test.de/my-article/image.jpg"; + const previewImageUrl = + await composable?.createPreviewImage(externalImageUrl); + + expect(previewImageUrl).toEqual( + expect.stringContaining(fileRecord.value.url) + ); + }); + }); + + describe("when image can not be uploaded", () => { + it("should return nothing", async () => { + const { composable, fileRecord } = setup(); + + fileRecord.value = undefined; + + const externalImageUrl = "https://test.de/my-article/image.jpg"; + const previewImageUrl = + await composable?.createPreviewImage(externalImageUrl); + + expect(previewImageUrl).toBeUndefined(); + }); + }); + }); + }); +}); diff --git a/src/components/feature-board-link-element/index.ts b/src/components/feature-board-link-element/index.ts index a71ecaa69a..d309839e04 100644 --- a/src/components/feature-board-link-element/index.ts +++ b/src/components/feature-board-link-element/index.ts @@ -1,3 +1,3 @@ -import LinkContentElement from "./LinkContentElement.vue"; +import LinkContentElement from "./components/LinkContentElement.vue"; export { LinkContentElement }; diff --git a/src/components/feature-board-link-element/util/url.util.ts b/src/components/feature-board-link-element/util/url.util.ts new file mode 100644 index 0000000000..d487fcb2eb --- /dev/null +++ b/src/components/feature-board-link-element/util/url.util.ts @@ -0,0 +1,6 @@ +export const ensureProtocolIncluded = (url: string) => { + if (!url.includes("://")) { + url = `https://${url}`; + } + return new URL(url).toString(); +}; diff --git a/src/components/feature-board-link-element/util/url.util.unit.ts b/src/components/feature-board-link-element/util/url.util.unit.ts new file mode 100644 index 0000000000..560209686e --- /dev/null +++ b/src/components/feature-board-link-element/util/url.util.unit.ts @@ -0,0 +1,21 @@ +import { ensureProtocolIncluded } from "./url.util"; + +describe("url.util", () => { + describe("ensureProtocolIncluded", () => { + describe("when a protocol is contained", () => { + it("should not change anything", async () => { + const url = "anyprotocol://abc.de/foto.png"; + const result = ensureProtocolIncluded(url); + expect(result).toEqual(url); + }); + }); + + describe("when no protocol is contained", () => { + it("should add https", async () => { + const url = "abc.de/foto.png"; + const result = ensureProtocolIncluded(url); + expect(result.indexOf("https://")).toEqual(0); + }); + }); + }); +}); diff --git a/src/components/feature-board/card/ContentElement.vue b/src/components/feature-board/card/ContentElement.vue index 0ebf0eac88..2e6e8a613d 100644 --- a/src/components/feature-board/card/ContentElement.vue +++ b/src/components/feature-board/card/ContentElement.vue @@ -10,7 +10,7 @@ import { BOARD_CARD_IS_FIRST_ELEMENT, BOARD_CARD_IS_LAST_ELEMENT, } from "@util-board"; -import { defineComponent, provide } from "vue"; +import { computed, defineComponent, provide } from "vue"; export default defineComponent({ name: "BoardMenuActionEdit", @@ -19,12 +19,16 @@ export default defineComponent({ index: { type: Number, required: false }, }, setup(props) { - const hasMultipleElements = props.elementCount > 0; - const isFirstElement = hasMultipleElements && props.index === 0; - const lastIndex = props.elementCount - 1; - const isLastElement = hasMultipleElements && props.index === lastIndex; + const hasManyElements = computed(() => props.elementCount > 0); + const isFirstElement = computed( + () => hasManyElements.value && props.index === 0 + ); + const lastIndex = computed(() => props.elementCount - 1); + const isLastElement = computed( + () => hasManyElements.value && props.index === lastIndex.value + ); - provide(BOARD_CARD_HAS_MULTIPLE_ELEMENTS, hasMultipleElements); + provide(BOARD_CARD_HAS_MULTIPLE_ELEMENTS, hasManyElements); provide(BOARD_CARD_IS_FIRST_ELEMENT, isFirstElement); provide(BOARD_CARD_IS_LAST_ELEMENT, isLastElement); diff --git a/src/components/ui-board/BoardMenuActionMoveDown.vue b/src/components/ui-board/BoardMenuActionMoveDown.vue index f11aedec9b..6d22ee9879 100644 --- a/src/components/ui-board/BoardMenuActionMoveDown.vue +++ b/src/components/ui-board/BoardMenuActionMoveDown.vue @@ -16,7 +16,7 @@ import { BOARD_CARD_HAS_MULTIPLE_ELEMENTS, BOARD_CARD_IS_LAST_ELEMENT, } from "@util-board"; -import { computed, defineComponent } from "vue"; +import { computed, defineComponent, ref } from "vue"; export default defineComponent({ name: "BoardMenuActionMoveDown", @@ -25,9 +25,13 @@ export default defineComponent({ }, emits: ["click"], setup(props, { emit }) { - const hasMultipleElements = injectStrict(BOARD_CARD_HAS_MULTIPLE_ELEMENTS); - const isLastElement = injectStrict(BOARD_CARD_IS_LAST_ELEMENT); - const isVisible = computed(() => hasMultipleElements && !isLastElement); + const hasMultipleElements = ref( + injectStrict(BOARD_CARD_HAS_MULTIPLE_ELEMENTS) + ); + const isLastElement = ref(injectStrict(BOARD_CARD_IS_LAST_ELEMENT)); + const isVisible = computed( + () => hasMultipleElements.value && !isLastElement.value + ); const onClick = ($event: Event) => emit("click", $event); diff --git a/src/components/ui-board/BoardMenuActionMoveUp.vue b/src/components/ui-board/BoardMenuActionMoveUp.vue index c608519c89..d626ca69ec 100644 --- a/src/components/ui-board/BoardMenuActionMoveUp.vue +++ b/src/components/ui-board/BoardMenuActionMoveUp.vue @@ -12,7 +12,7 @@ import { BOARD_CARD_HAS_MULTIPLE_ELEMENTS, BOARD_CARD_IS_FIRST_ELEMENT, } from "@util-board"; -import { computed, defineComponent } from "vue"; +import { computed, defineComponent, ref } from "vue"; export default defineComponent({ name: "BoardMenuActionMoveUp", @@ -21,9 +21,13 @@ export default defineComponent({ }, emits: ["click"], setup(_, { emit }) { - const hasMultipleElements = injectStrict(BOARD_CARD_HAS_MULTIPLE_ELEMENTS); - const isFirstElement = injectStrict(BOARD_CARD_IS_FIRST_ELEMENT); - const isVisible = computed(() => hasMultipleElements && !isFirstElement); + const hasMultipleElements = ref( + injectStrict(BOARD_CARD_HAS_MULTIPLE_ELEMENTS) + ); + const isFirstElement = ref(injectStrict(BOARD_CARD_IS_FIRST_ELEMENT)); + const isVisible = computed( + () => hasMultipleElements.value && !isFirstElement.value + ); const onClick = ($event: Event) => emit("click", $event); diff --git a/src/components/ui-board/content-element/ContentElementTitleIcon.vue b/src/components/ui-board/content-element/ContentElementTitleIcon.vue index 3e0a9890db..c2dab8ca88 100644 --- a/src/components/ui-board/content-element/ContentElementTitleIcon.vue +++ b/src/components/ui-board/content-element/ContentElementTitleIcon.vue @@ -1,7 +1,9 @@