Skip to content

Commit

Permalink
BC-5642 - internal links in LinkElements on boards (#2907)
Browse files Browse the repository at this point in the history
The collection of meta tags for internal pages of the project can not be done via scraping. Therefore a set of rules for types of urls and implementations to gather the needed data was implementedv (in the backend).
On the frontendside the actual link-title is composed using entity-type-information based on the received information.
  • Loading branch information
hoeppner-dataport authored Nov 21, 2023
1 parent ffc3698 commit b24f8b8
Show file tree
Hide file tree
Showing 7 changed files with 225 additions and 78 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe("LinkContentElement", () => {
const component = wrapper.getComponent(LinkContentElementCreate);
component.vm.$emit("create:url", "https://abc.de");

expect(useMetaTagExtractorApiMock.extractMetaTags).toHaveBeenCalled();
expect(useMetaTagExtractorApiMock.getMetaTags).toHaveBeenCalled();
});

describe("when no protocol was provided", () => {
Expand All @@ -135,7 +135,7 @@ describe("LinkContentElement", () => {
component.vm.$emit("create:url", url);

const expected = `https://${url}`;
expect(useMetaTagExtractorApiMock.extractMetaTags).toHaveBeenCalledWith(
expect(useMetaTagExtractorApiMock.getMetaTags).toHaveBeenCalledWith(
expected
);
});
Expand All @@ -151,9 +151,12 @@ describe("LinkContentElement", () => {
title: "my title",
description: "",
imageUrl: "https://abc.de/foto.png",
type: "unknown",
parentTitle: "",
parentType: "unknown",
};

useMetaTagExtractorApiMock.extractMetaTags.mockResolvedValue(
useMetaTagExtractorApiMock.getMetaTags.mockResolvedValue(
fakeMetaTags
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export default defineComponent({
() => props.isEditMode && !computedElement.value.content.url
);
const { extractMetaTags } = useMetaTagExtractorApi();
const { getMetaTags } = useMetaTagExtractorApi();
const { createPreviewImage } = usePreviewGenerator(element.value.id);
Expand All @@ -101,8 +101,7 @@ export default defineComponent({
const validUrl = ensureProtocolIncluded(originalUrl);
modelValue.value.url = validUrl;
const { title, description, imageUrl } =
await extractMetaTags(validUrl);
const { title, description, imageUrl } = await getMetaTags(validUrl);
modelValue.value.title = title;
modelValue.value.description = description;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@
rows="1"
@keydown.enter.prevent.stop="onKeydownEnter"
class="text"
>
<template v-slot:append>
<button type="submit" ref="submit">
<v-icon aria-hidden="true"> {{ mdiCheck }}</v-icon>
<span class="d-sr-only">{{ $t("common.actions.save") }}</span>
</button>
</template>
</v-textarea>
/>

<div class="align-self-center pl-2">
<button type="submit" ref="submit">
<v-icon aria-hidden="true"> {{ mdiCheck }}</v-icon>
<span class="d-sr-only">{{ $t("common.actions.save") }}</span>
</button>
</div>

<div class="align-self-center menu">
<slot />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { MetaTagExtractorApiFactory } from "@/serverApi/v3";
import {
MetaTagExtractorApiFactory,
MetaTagExtractorResponse,
} from "@/serverApi/v3";
import { $axios } from "@/utils/api";
import { useI18n } from "@/composables/i18n.composable";

type MetaTagResult = {
url: string;
Expand All @@ -9,15 +13,53 @@ type MetaTagResult = {
};

export const useMetaTagExtractorApi = () => {
const { t } = useI18n();
const metaTagApi = MetaTagExtractorApiFactory(undefined, "/v3", $axios);

const extractMetaTags = async (url: string): Promise<MetaTagResult> => {
const mapMetaTagResponse = (
response: MetaTagExtractorResponse
): MetaTagResult => {
const titleParts = [
getPrefix(response.type),
getTitle(response.type, response.title),
getSuffix(response.type, response.parentTitle),
];
const title = titleParts.join(" ").trim();
return { ...response, title };
};

const getPrefix = (type: string): string => {
const typeToLanguageKeyMap: Record<string, string> = {
course: "common.labels.course",
lesson: "common.words.topic",
task: "common.words.task",
board: "components.board",
};

const prefixKey = typeToLanguageKeyMap[type];
return prefixKey ? `${t(prefixKey)}:` : "";
};

const getTitle = (type: string, title: string) => {
if (type === "board" && title == "") {
return t("pages.room.boardCard.label.courseBoard");
}
return title;
};

const getSuffix = (type: string, parentTitle: string): string => {
if (type === "board" && parentTitle !== "") {
return `(${parentTitle})`;
}
return "";
};

const getMetaTags = async (url: string): Promise<MetaTagResult> => {
try {
const res = await metaTagApi.metaTagExtractorControllerGetData({
const res = await metaTagApi.metaTagExtractorControllerGetMetaTags({
url,
});

return res.data;
return mapMetaTagResponse(res.data);
} catch (e) {
return {
url,
Expand All @@ -28,6 +70,6 @@ export const useMetaTagExtractorApi = () => {
};

return {
extractMetaTags,
getMetaTags,
};
};
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
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";
import {
i18nMock,
mockApiResponse,
mountComposable,
} from "@@/tests/test-utils";
import { I18N_KEY } from "@/utils/inject";

describe("useMetaTagExtractorApi", () => {
let api: DeepMocked<serverApi.MetaTagExtractorApi>;
Expand All @@ -19,56 +22,132 @@ describe("useMetaTagExtractorApi", () => {
jest.clearAllMocks();
});

const getWrapper = () => {
let composable: ReturnType<typeof useMetaTagExtractorApi> | undefined;

const TestComponent = defineComponent({
template: "<div/>",
setup() {
composable = useMetaTagExtractorApi();
},
});

const wrapper = mount(TestComponent, {});
return { wrapper, composable };
};

describe("extractMetaTags", () => {
describe("getMetaTags", () => {
describe("when meta tags could be extracted", () => {
const setup = () => {
const mockedResponse: MetaTagExtractorResponse = {
url: "",
title: "",
description: "",
imageUrl: "",
describe("when meta tags are of type unknown", () => {
const setup = () => {
const mockedResponse: MetaTagExtractorResponse = {
url: "",
title: "",
description: "",
imageUrl: "",
type: "unknown",
parentTitle: "",
parentType: "unknown",
};

api.metaTagExtractorControllerGetMetaTags.mockResolvedValue(
mockApiResponse({ data: mockedResponse })
);

const composable = mountComposable(() => useMetaTagExtractorApi(), {
[I18N_KEY.valueOf()]: i18nMock,
});

return {
mockedResponse,
composable,
};
};

api.metaTagExtractorControllerGetData.mockResolvedValue(
mockApiResponse({ data: mockedResponse })
);
it("should be defined", () => {
const { composable } = setup();

const { wrapper, composable } = getWrapper();
expect(composable?.getMetaTags).toBeDefined();
});

return {
wrapper,
mockedResponse,
composable,
};
};
it("should return the data", async () => {
const { composable, mockedResponse } = setup();

it("should be defined", () => {
const { composable } = setup();
const url = "https://test.de/my-article";
const data = await composable?.getMetaTags(url);

expect(composable?.extractMetaTags).toBeDefined();
expect(data).toEqual(mockedResponse);
});
});

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 metatags are of type board", () => {
describe("when board has explicit title", () => {
const setup = () => {
const mockedResponse: MetaTagExtractorResponse = {
url: "https://test.de/my-article",
title: "Shakespear",
description: "",
imageUrl: "",
type: "board",
parentTitle: "English",
parentType: "course",
};

api.metaTagExtractorControllerGetMetaTags.mockResolvedValue(
mockApiResponse({ data: mockedResponse })
);

const composable = mountComposable(() => useMetaTagExtractorApi(), {
[I18N_KEY.valueOf()]: i18nMock,
});

return {
mockedResponse,
composable,
};
};

it("should be defined", () => {
const { composable } = setup();

expect(composable?.getMetaTags).toBeDefined();
});

it("should return the correct composed title", async () => {
const { composable } = setup();

const url = "https://test.de/my-article";
const data = await composable?.getMetaTags(url);

expect(data.title).toEqual(
"components.board: Shakespear (English)"
);
});
});

describe("when board has no explicit title", () => {
const setup = () => {
const mockedResponse: MetaTagExtractorResponse = {
url: "https://test.de/my-article",
title: "",
description: "",
imageUrl: "",
type: "board",
parentTitle: "English",
parentType: "course",
};

api.metaTagExtractorControllerGetMetaTags.mockResolvedValue(
mockApiResponse({ data: mockedResponse })
);

const composable = mountComposable(() => useMetaTagExtractorApi(), {
[I18N_KEY.valueOf()]: i18nMock,
});

return {
mockedResponse,
composable,
};
};

it("should use default fallback title", async () => {
const { composable } = setup();

const url = "https://test.de/my-article";
const data = await composable?.getMetaTags(url);

expect(data.title).toEqual(
"components.board: pages.room.boardCard.label.courseBoard (English)"
);
});
});
});
});

Expand All @@ -79,14 +158,18 @@ describe("useMetaTagExtractorApi", () => {
title: "",
description: "",
imageUrl: "",
type: "unknown",
parentTitle: "",
parentType: "unknown",
};

api.metaTagExtractorControllerGetData.mockRejectedValue(false);
api.metaTagExtractorControllerGetMetaTags.mockRejectedValue(false);

const { wrapper, composable } = getWrapper();
const composable = mountComposable(() => useMetaTagExtractorApi(), {
[I18N_KEY.valueOf()]: { t: (key: string) => key },
});

return {
wrapper,
mockedResponse,
composable,
};
Expand All @@ -96,7 +179,7 @@ describe("useMetaTagExtractorApi", () => {
const { composable } = setup();

const url = "https://test.de/my-article";
const data = await composable?.extractMetaTags(url);
const data = await composable?.getMetaTags(url);

expect(data).toEqual({ url, title: "", description: "" });
});
Expand Down
4 changes: 3 additions & 1 deletion src/components/util-validators/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export const isValidUrl: FormValidatorFn<string> = (errMsg) => (value) => {
if (!["http:", "https:"].includes(urlObject.protocol)) {
throw new Error("Wrong protocol");
}
if (!urlObject.hostname.includes(".")) {
if (
!(urlObject.hostname.includes(".") || urlObject.hostname === "localhost")
) {
throw new Error("TopLevelDomain missing");
}
if (/(^-)|(--)|(-$)/.test(urlObject.hostname)) {
Expand Down
Loading

0 comments on commit b24f8b8

Please sign in to comment.