From 75c879891305a9d9ab6034f8a8be04bc97eaa79d Mon Sep 17 00:00:00 2001 From: Oliver Happe <108657965+OliverHappe@users.noreply.github.com> Date: Wed, 11 Oct 2023 17:31:16 +0200 Subject: [PATCH] BC-4482 - add board link element (#2844) Adds link element and various cq improvements regarding the board feature --------- Co-authored-by: hoeppner-dataport --- .eslintrc.js | 2 +- package-lock.json | 11 + package.json | 1 + .../data-board/BoardApi.composable.ts | 10 +- .../ContentElementState.composable.ts | 42 +- .../error-handling/ErrorHandler.composable.ts | 2 +- .../ContentElementMenu.unit.ts | 386 ------------------ .../ContentElementMenu.vue | 91 ----- .../index.ts | 3 - .../ExternalToolElement.unit.ts | 43 -- .../ExternalToolElement.vue | 19 +- .../ExternalToolElementMenu.unit.ts | 123 +----- .../ExternalToolElementMenu.vue | 88 ++-- .../FileContentElement.unit.ts | 7 - .../FileContentElement.vue | 40 +- .../upload/FileUpload.vue | 7 +- .../LinkContentElement.vue | 66 +++ .../LinkContentElementDisplay.vue | 83 ++++ .../LinkContentElementEdit.vue | 70 ++++ .../feature-board-link-element/index.ts | 3 + .../SubmissionContentElement.unit.ts | 6 +- .../components/SubmissionContentElement.vue | 35 +- .../feature-board/board/BoardColumnHeader.vue | 43 +- .../feature-board/card/CardHost.unit.ts | 7 +- .../feature-board/card/CardHost.vue | 86 ++-- .../feature-board/card/ContentElement.vue | 34 ++ .../card/ContentElementList.unit.ts | 35 +- .../feature-board/card/ContentElementList.vue | 131 +++--- .../shared/AddElementDialog.composable.ts | 10 + .../shared/AddElementDialog.unit.ts | 6 +- src/components/ui-board/BoardMenu.vue | 10 +- src/components/ui-board/BoardMenuAction.vue | 9 +- .../ui-board/BoardMenuActionDelete.unit.ts | 72 ++++ .../ui-board/BoardMenuActionDelete.vue | 63 +++ .../ui-board/BoardMenuActionEdit.vue | 31 ++ .../ui-board/BoardMenuActionMoveDown.unit.ts | 58 +++ .../ui-board/BoardMenuActionMoveDown.vue | 41 ++ .../ui-board/BoardMenuActionMoveUp.unit.ts | 57 +++ .../ui-board/BoardMenuActionMoveUp.vue | 37 ++ src/components/ui-board/board-menu-scope.ts | 1 + src/components/ui-board/index.ts | 8 + src/components/ui-board/injection-tokens.ts | 1 + .../DeleteConfirmation.composable.ts | 4 +- .../DeleteConfirmation.composable.unit.ts | 42 +- .../util-board/board-injection-tokens.ts | 13 + src/components/util-board/index.ts | 14 +- src/components/util-validators/index.ts | 3 + src/components/util-validators/validators.ts | 30 ++ .../util-validators/validators.unit.ts | 55 +++ src/locales/de.json | 5 +- src/locales/en.json | 2 + src/locales/es.json | 2 + src/locales/uk.json | 2 + src/serverApi/v3/api.ts | 117 +++++- src/store/types/env-config.ts | 1 + src/types/board/ContentElement.ts | 2 + src/utils/api.ts | 1 - src/utils/inject/inject-strict.ts | 4 +- src/utils/inject/inject-strict.unit.ts | 19 +- tsconfig.json | 6 +- vue.config.js | 5 +- 61 files changed, 1216 insertions(+), 989 deletions(-) delete mode 100644 src/components/feature-board-content-element-menu/ContentElementMenu.unit.ts delete mode 100644 src/components/feature-board-content-element-menu/ContentElementMenu.vue delete mode 100644 src/components/feature-board-content-element-menu/index.ts create mode 100644 src/components/feature-board-link-element/LinkContentElement.vue create mode 100644 src/components/feature-board-link-element/LinkContentElementDisplay.vue create mode 100644 src/components/feature-board-link-element/LinkContentElementEdit.vue create mode 100644 src/components/feature-board-link-element/index.ts create mode 100644 src/components/feature-board/card/ContentElement.vue create mode 100644 src/components/ui-board/BoardMenuActionDelete.unit.ts create mode 100644 src/components/ui-board/BoardMenuActionDelete.vue create mode 100644 src/components/ui-board/BoardMenuActionEdit.vue create mode 100644 src/components/ui-board/BoardMenuActionMoveDown.unit.ts create mode 100644 src/components/ui-board/BoardMenuActionMoveDown.vue create mode 100644 src/components/ui-board/BoardMenuActionMoveUp.unit.ts create mode 100644 src/components/ui-board/BoardMenuActionMoveUp.vue create mode 100644 src/components/ui-board/board-menu-scope.ts create mode 100644 src/components/ui-board/injection-tokens.ts create mode 100644 src/components/util-board/board-injection-tokens.ts create mode 100644 src/components/util-validators/index.ts create mode 100644 src/components/util-validators/validators.ts create mode 100644 src/components/util-validators/validators.unit.ts diff --git a/.eslintrc.js b/.eslintrc.js index 782d9d6b5f..3e575e30eb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -66,7 +66,7 @@ module.exports = { { group: ["@components/page-*", "*/../page-*", "../**/page-*/*"], message: - "page-Modules have to be imported using the pattern '@page-'", + "Page-Modules have to be imported using the pattern '@page-'", }, { group: ["@components/ui-*", "*/../ui-*", "../**/ui-*/*"], diff --git a/package-lock.json b/package-lock.json index dbdf1d448f..c9a9d2fbc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "schulcloud-nuxt", "dependencies": { + "@braintree/sanitize-url": "^6.0.4", "@ckeditor/ckeditor5-vue2": "^3.0.1", "@hpi-schul-cloud/ckeditor": "0.4.0", "@mdi/js": "^7.2.96", @@ -1906,6 +1907,11 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@braintree/sanitize-url": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", + "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==" + }, "node_modules/@ckeditor/ckeditor5-vue2": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-vue2/-/ckeditor5-vue2-3.0.1.tgz", @@ -21836,6 +21842,11 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@braintree/sanitize-url": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", + "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==" + }, "@ckeditor/ckeditor5-vue2": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-vue2/-/ckeditor5-vue2-3.0.1.tgz", diff --git a/package.json b/package.json index bbdd4ffc51..65a757bbd4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "generate-client:h5p-editor": "node generate-client.js -u 'http://localhost:4448/api/v3/docs-json/' -p 'src/h5pEditorApi/v3' -c 'openapitools-for-h5p-editor.json'" }, "dependencies": { + "@braintree/sanitize-url": "^6.0.4", "@ckeditor/ckeditor5-vue2": "^3.0.1", "@hpi-schul-cloud/ckeditor": "0.4.0", "@mdi/js": "^7.2.96", diff --git a/src/components/data-board/BoardApi.composable.ts b/src/components/data-board/BoardApi.composable.ts index 33951e8215..4b9be20d65 100644 --- a/src/components/data-board/BoardApi.composable.ts +++ b/src/components/data-board/BoardApi.composable.ts @@ -11,6 +11,7 @@ import { CreateContentElementBodyParams, ExternalToolElementContent, FileElementContent, + LinkElementContent, RichTextElementContent, RoomsApiFactory, SubmissionContainerElementContent, @@ -73,7 +74,7 @@ export const useBoardApi = () => { if (element.type === ContentElementType.RichText) { return { content: element.content as RichTextElementContent, - type: ContentElementType.RichText, + type: element.type, }; } @@ -98,6 +99,13 @@ export const useBoardApi = () => { }; } + if (element.type === ContentElementType.Link) { + return { + content: element.content as LinkElementContent, + type: ContentElementType.Link, + }; + } + throw new Error("element.type mapping is undefined for updateElementCall"); }; diff --git a/src/components/data-board/ContentElementState.composable.ts b/src/components/data-board/ContentElementState.composable.ts index bc24cfa92a..89348bd4d9 100644 --- a/src/components/data-board/ContentElementState.composable.ts +++ b/src/components/data-board/ContentElementState.composable.ts @@ -1,5 +1,5 @@ import { watchDebounced } from "@vueuse/core"; -import { ref, toRef, unref } from "vue"; +import { computed, ComputedRef, Ref, ref, toRef, unref } from "vue"; import { useBoardApi } from "./BoardApi.composable"; import { AnyContentElement } from "@/types/board/ContentElement"; import { useErrorHandler } from "@/components/error-handling/ErrorHandler.composable"; @@ -11,28 +11,45 @@ export const useContentElementState = ( }, options: { autoSaveDebounce?: number } = { autoSaveDebounce: 300 } ) => { - const { handleError, notifyWithTemplate } = useErrorHandler(); - const elementRef = toRef(props, "element"); - const modelValue = ref(unref(elementRef).content); + const _elementRef: Ref = toRef(props, "element"); + const _responseValue = ref(unref(_elementRef)); + const { handleError, notifyWithTemplate } = useErrorHandler(); const { updateElementCall } = useBoardApi(); - watchDebounced( - elementRef.value, - async (elementRef) => { - await updateElement(unref(elementRef)); + const modelValue = ref(unref(_elementRef).content); + + const computedElement: ComputedRef = computed(() => ({ + ..._elementRef.value, + ..._responseValue.value, + })); + + const isLoading = ref(false); + + watchDebounced( + modelValue.value, + async (modelValue) => { + await updateElement(modelValue); }, { debounce: options.autoSaveDebounce, maxWait: 2500 } ); // TODO: refactor this to be properly typed - const updateElement = async (element: T) => { + const updateElement = async (content: T["content"]) => { + isLoading.value = true; + const payload = { + ...computedElement.value, + content: { ...content }, + }; try { - await updateElementCall(element); + const response = await updateElementCall(payload); + _responseValue.value = response.data; } catch (error) { handleError(error, { 404: notifyWithTemplate("notUpdated", "boardElement"), }); + } finally { + isLoading.value = false; } }; @@ -42,5 +59,10 @@ export const useContentElementState = ( * Will be saved automatically after a debounce */ modelValue, + /** + * Contains the whole element as it is currently known in the backend + */ + computedElement, + isLoading, }; }; diff --git a/src/components/error-handling/ErrorHandler.composable.ts b/src/components/error-handling/ErrorHandler.composable.ts index 8d9f44aef7..278d2144d4 100644 --- a/src/components/error-handling/ErrorHandler.composable.ts +++ b/src/components/error-handling/ErrorHandler.composable.ts @@ -65,7 +65,7 @@ export const useErrorHandler = () => { if (handlerFunction) { handlerFunction(responseError); } else { - console.error(responseError); + console.error(error); } }; diff --git a/src/components/feature-board-content-element-menu/ContentElementMenu.unit.ts b/src/components/feature-board-content-element-menu/ContentElementMenu.unit.ts deleted file mode 100644 index 72a4448d3b..0000000000 --- a/src/components/feature-board-content-element-menu/ContentElementMenu.unit.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { I18N_KEY } from "@/utils/inject"; -import createComponentMocks from "@@/tests/test-utils/componentMocks"; -import { createMock } from "@golevelup/ts-jest"; -import { BoardMenuAction } from "@ui-board"; -import { useDeleteConfirmationDialog } from "@ui-confirmation-dialog"; -import { shallowMount } from "@vue/test-utils"; -import FileContentElementMenu from "./ContentElementMenu.vue"; - -jest.mock("@/utils/fileHelper"); - -jest.mock("@ui-confirmation-dialog"); - -const useDeleteConfirmationDialogMock = jest.mocked( - useDeleteConfirmationDialog -); -const mockedUse = createMock>(); -useDeleteConfirmationDialogMock.mockReturnValue(mockedUse); - -describe("FileContentElementMenu", () => { - const name = "test-name"; - const setupProps = () => ({ - fileName: "file-record #1.txt", - url: "1/file-record #1.txt", - isFirstElement: false, - isLastElement: false, - hasMultipleElements: false, - name, - }); - - const setup = () => { - document.body.setAttribute("data-app", "true"); - - const propsData = setupProps(); - const wrapper = shallowMount(FileContentElementMenu, { - ...createComponentMocks({ i18n: true }), - propsData, - provide: { - [I18N_KEY as symbol]: { t: (key: string) => key }, - }, - }); - - return { - wrapper, - name, - }; - }; - - it("should be found in dom", () => { - const { wrapper } = setup(); - - const fileContentElementMenu = wrapper.findComponent( - FileContentElementMenu - ); - expect(fileContentElementMenu.exists()).toBe(true); - }); - - describe("move up and down board actions", () => { - const setup = () => { - document.body.setAttribute("data-app", "true"); - - const multipleElementsSetupProps = { - fileId: "file-id #1", - fileName: "file-record #1.txt", - url: "1/file-record #1.txt", - isFirstElement: false, - isLastElement: false, - hasMultipleElements: true, - }; - const wrapper = shallowMount(FileContentElementMenu, { - ...createComponentMocks({ i18n: true }), - propsData: multipleElementsSetupProps, - provide: { - [I18N_KEY as symbol]: { t: (key: string) => key }, - }, - }); - - return { - wrapper, - }; - }; - - describe("when move up menu action is clicked", () => { - it("should emit move-up:element event", async () => { - const { wrapper } = setup(); - - const moveUpTranslation = wrapper.vm - .$t("components.board.action.moveUp") - .toString(); - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(moveUpTranslation)) - .at(0); - childComponent.vm.$emit("click"); - - expect(wrapper.emitted("move-up:element")?.length).toBe(1); - }); - }); - - describe("when move down menu action is clicked", () => { - it("should emit move-down:element event", async () => { - const { wrapper } = setup(); - - const moveDownTranslation = wrapper.vm - .$t("components.board.action.moveDown") - .toString(); - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(moveDownTranslation)) - .at(0); - childComponent.vm.$emit("click"); - - expect(wrapper.emitted("move-down:element")?.length).toBe(1); - }); - }); - - describe("when multiple elements are present", () => { - it("should show the move up action", () => { - const { wrapper } = setup(); - - const moveUpTranslation = wrapper.vm - .$t("components.board.action.moveUp") - .toString(); - - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(moveUpTranslation)) - .at(0); - - expect(childComponent.exists()).toBe(true); - }); - - it("should show the move down action", () => { - const { wrapper } = setup(); - - const moveDownTranslation = wrapper.vm - .$t("components.board.action.moveDown") - .toString(); - - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(moveDownTranslation)) - .at(0); - - expect(childComponent.exists()).toBe(true); - }); - - describe("when element is at the beginning of the content elements list", () => { - const setup = () => { - document.body.setAttribute("data-app", "true"); - - const firstElementSetupProps = { - fileId: "file-id #1", - fileName: "file-record #1.txt", - url: "1/file-record #1.txt", - isFirstElement: true, - isLastElement: false, - hasMultipleElements: true, - }; - const wrapper = shallowMount(FileContentElementMenu, { - ...createComponentMocks({ i18n: true }), - propsData: firstElementSetupProps, - provide: { - [I18N_KEY as symbol]: { t: (key: string) => key }, - }, - }); - - return { - wrapper, - }; - }; - - it("should not show the move up action", () => { - const { wrapper } = setup(); - - const moveUpTranslation = wrapper.vm - .$t("components.board.action.moveUp") - .toString(); - - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(moveUpTranslation)); - - expect(childComponent.exists()).toBe(false); - }); - - it("should show the move down action", () => { - const { wrapper } = setup(); - - const moveDownTranslation = wrapper.vm - .$t("components.board.action.moveDown") - .toString(); - - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(moveDownTranslation)) - .at(0); - - expect(childComponent.exists()).toBe(true); - }); - }); - - describe("when element is at the end of the content elements list", () => { - const setup = () => { - document.body.setAttribute("data-app", "true"); - - const lastElementSetupProps = { - fileId: "file-id #1", - fileName: "file-record #1.txt", - url: "1/file-record #1.txt", - isFirstElement: false, - isLastElement: true, - hasMultipleElements: true, - }; - const wrapper = shallowMount(FileContentElementMenu, { - ...createComponentMocks({ i18n: true }), - propsData: lastElementSetupProps, - provide: { - [I18N_KEY as symbol]: { t: (key: string) => key }, - }, - }); - - return { - wrapper, - }; - }; - - it("should show the move up action", () => { - const { wrapper } = setup(); - - const moveUpTranslation = wrapper.vm - .$t("components.board.action.moveUp") - .toString(); - - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(moveUpTranslation)) - .at(0); - - expect(childComponent.exists()).toBe(true); - }); - - it("should not show the move down action", () => { - const { wrapper } = setup(); - - const moveDownTranslation = wrapper.vm - .$t("components.board.action.moveDown") - .toString(); - - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(moveDownTranslation)); - - expect(childComponent.exists()).toBe(false); - }); - }); - }); - - describe("when only a single element is present", () => { - const setup = () => { - document.body.setAttribute("data-app", "true"); - - const singleElementSetupProps = { - fileId: "file-id #1", - fileName: "file-record #1.txt", - url: "1/file-record #1.txt", - isFirstElement: false, - isLastElement: false, - hasMultipleElements: false, - }; - const wrapper = shallowMount(FileContentElementMenu, { - ...createComponentMocks({ i18n: true }), - propsData: singleElementSetupProps, - provide: { - [I18N_KEY as symbol]: { t: (key: string) => key }, - }, - }); - - return { - wrapper, - }; - }; - - it("should not show the move up action", () => { - const { wrapper } = setup(); - - const moveUpTranslation = wrapper.vm - .$t("components.board.action.moveUp") - .toString(); - - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(moveUpTranslation)); - - expect(childComponent.exists()).toBe(false); - }); - - it("should not show the move down action", () => { - const { wrapper } = setup(); - - const moveDownTranslation = wrapper.vm - .$t("components.board.action.moveDown") - .toString(); - - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(moveDownTranslation)); - - expect(childComponent.exists()).toBe(false); - }); - }); - }); - - describe("delete board action", () => { - it("should show delete board menu action", () => { - const { wrapper } = setup(); - - const deleteTranslation = wrapper.vm - .$t("components.board.action.delete") - .toString(); - - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(deleteTranslation)) - .at(0); - - expect(childComponent.exists()).toBe(true); - }); - - describe("when delete board menu action is clicked", () => { - describe("when delete confirmation dialog is confirmed", () => { - it("should emit delete:element event", async () => { - const { wrapper } = setup(); - - mockedUse.askDeleteConfirmation.mockReset(); - mockedUse.askDeleteConfirmation.mockResolvedValue(true); - useDeleteConfirmationDialogMock.mockReturnValue(mockedUse); - - const deleteTranslation = wrapper.vm - .$t("components.board.action.delete") - .toString(); - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(deleteTranslation)) - .at(0); - - childComponent.vm.$emit("click"); - - await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); - - expect(wrapper.emitted("delete:element")?.length).toBe(1); - }); - }); - - describe("when delete confirmation dialog is not confirmed", () => { - it("should emit delete:element event", async () => { - const { wrapper, name } = setup(); - - mockedUse.askDeleteConfirmation.mockReset(); - mockedUse.askDeleteConfirmation.mockResolvedValue(false); - useDeleteConfirmationDialogMock.mockReturnValue(mockedUse); - - const deleteTranslation = wrapper.vm - .$t("components.board.action.delete") - .toString(); - const childComponent = wrapper - .findAllComponents(BoardMenuAction) - .filter((c) => c.text().includes(deleteTranslation)) - .at(0); - - childComponent.vm.$emit("click"); - - await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); - - expect(mockedUse.askDeleteConfirmation).toHaveBeenCalledTimes(1); - expect(mockedUse.askDeleteConfirmation).toHaveBeenCalledWith( - name, - "boardElement" - ); - expect(wrapper.emitted("delete:element")?.length).toBeUndefined(); - }); - }); - }); - }); -}); diff --git a/src/components/feature-board-content-element-menu/ContentElementMenu.vue b/src/components/feature-board-content-element-menu/ContentElementMenu.vue deleted file mode 100644 index 2bbbf4997d..0000000000 --- a/src/components/feature-board-content-element-menu/ContentElementMenu.vue +++ /dev/null @@ -1,91 +0,0 @@ - - - diff --git a/src/components/feature-board-content-element-menu/index.ts b/src/components/feature-board-content-element-menu/index.ts deleted file mode 100644 index 3a341f3099..0000000000 --- a/src/components/feature-board-content-element-menu/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ContentElementMenu from "./ContentElementMenu.vue"; - -export default ContentElementMenu; diff --git a/src/components/feature-board-external-tool-element/ExternalToolElement.unit.ts b/src/components/feature-board-external-tool-element/ExternalToolElement.unit.ts index 82e7ecc6c5..34a389dd5e 100644 --- a/src/components/feature-board-external-tool-element/ExternalToolElement.unit.ts +++ b/src/components/feature-board-external-tool-element/ExternalToolElement.unit.ts @@ -401,48 +401,5 @@ describe("ExternalToolElement", () => { expect(menu.exists()).toEqual(false); }); }); - - describe("when deleting the element", () => { - const setup = () => { - const contextExternalToolId = "context-external-tool-id"; - const toolDisplayData = externalToolDisplayDataFactory.build({ - contextExternalToolId, - logoUrl: "logo-url", - }); - - const { wrapper, useDeleteConfirmationDialogReturnValue } = getWrapper( - { - element: { - ...TEST_ELEMENT, - content: { contextExternalToolId }, - }, - isEditMode: true, - }, - [toolDisplayData] - ); - - return { - wrapper, - useDeleteConfirmationDialogReturnValue, - toolDisplayData, - }; - }; - - it("should display a delete dialog", () => { - const { - wrapper, - useDeleteConfirmationDialogReturnValue, - toolDisplayData, - } = setup(); - - const menu = wrapper.findComponent({ ref: "externalToolElementMenu" }); - - menu.vm.$emit("delete:element"); - - expect( - useDeleteConfirmationDialogReturnValue.askDeleteConfirmation - ).toHaveBeenCalledWith(toolDisplayData.name, "boardElement"); - }); - }); }); }); diff --git a/src/components/feature-board-external-tool-element/ExternalToolElement.vue b/src/components/feature-board-external-tool-element/ExternalToolElement.vue index a966f4caa6..d1e43626f5 100644 --- a/src/components/feature-board-external-tool-element/ExternalToolElement.vue +++ b/src/components/feature-board-external-tool-element/ExternalToolElement.vue @@ -30,9 +30,6 @@ = ref(false); const element: Ref = toRef(props, "element"); @@ -132,16 +124,7 @@ export default defineComponent({ emit("move-up:edit"); }; - const onDeleteElement = async () => { - const shouldDelete = await askDeleteConfirmation( - toolDisplayData.value?.name, - "boardElement" - ); - - if (shouldDelete) { - emit("delete:element", element.value.id); - } - }; + const onDeleteElement = () => emit("delete:element", element.value.id); const onEditElement = () => { // TODO N21-1248: Edit dialog diff --git a/src/components/feature-board-external-tool-element/ExternalToolElementMenu.unit.ts b/src/components/feature-board-external-tool-element/ExternalToolElementMenu.unit.ts index c9120f8964..9ee96d5868 100644 --- a/src/components/feature-board-external-tool-element/ExternalToolElementMenu.unit.ts +++ b/src/components/feature-board-external-tool-element/ExternalToolElementMenu.unit.ts @@ -1,4 +1,5 @@ import createComponentMocks from "@@/tests/test-utils/componentMocks"; +import { BoardMenuAction, BoardMenuActionDelete } from "@ui-board"; import { MountOptions, shallowMount, Wrapper } from "@vue/test-utils"; import Vue from "vue"; import ExternalToolElementMenu from "./ExternalToolElementMenu.vue"; @@ -30,112 +31,6 @@ describe("ExternalToolElementMenu", () => { jest.resetAllMocks(); }); - describe("when the element can move up", () => { - const setup = () => { - const { wrapper } = getWrapper({ - hasMultipleElements: true, - isFirstElement: false, - isLastElement: false, - }); - - return { - wrapper, - }; - }; - - it("should have a menu option to move up", () => { - const { wrapper } = setup(); - - const menuItem = wrapper.find( - '[data-testid="board-external-tool-element-edit-menu-move-up"]' - ); - - expect(menuItem.exists()).toEqual(true); - }); - - it("should emit the move up event on click", () => { - const { wrapper } = setup(); - - const menuItem = wrapper.find( - '[data-testid="board-external-tool-element-edit-menu-move-up"]' - ); - - menuItem.vm.$emit("click"); - - expect(wrapper.emitted("move-up:element")).toBeDefined(); - }); - }); - - describe("when the element can move down", () => { - const setup = () => { - const { wrapper } = getWrapper({ - hasMultipleElements: true, - isFirstElement: false, - isLastElement: false, - }); - - return { - wrapper, - }; - }; - - it("should have a menu option to move down", () => { - const { wrapper } = setup(); - - const menuItem = wrapper.find( - '[data-testid="board-external-tool-element-edit-menu-move-down"]' - ); - - expect(menuItem.exists()).toEqual(true); - }); - - it("should emit the move down event on click", () => { - const { wrapper } = setup(); - - const menuItem = wrapper.find( - '[data-testid="board-external-tool-element-edit-menu-move-down"]' - ); - - menuItem.vm.$emit("click"); - - expect(wrapper.emitted("move-down:element")).toBeDefined(); - }); - }); - - describe("when the element cannot move up or down", () => { - const setup = () => { - const { wrapper } = getWrapper({ - hasMultipleElements: false, - isFirstElement: true, - isLastElement: true, - }); - - return { - wrapper, - }; - }; - - it("should not have a menu option to move up", () => { - const { wrapper } = setup(); - - const menuItem = wrapper.find( - '[data-testid="board-external-tool-element-edit-menu-move-up"]' - ); - - expect(menuItem.exists()).toEqual(false); - }); - - it("should not have a menu option to move down", () => { - const { wrapper } = setup(); - - const menuItem = wrapper.find( - '[data-testid="board-external-tool-element-edit-menu-move-down"]' - ); - - expect(menuItem.exists()).toEqual(false); - }); - }); - describe("Edit Button", () => { const setup = () => { const { wrapper } = getWrapper({ @@ -152,9 +47,7 @@ describe("ExternalToolElementMenu", () => { it("should have a menu option to edit", () => { const { wrapper } = setup(); - const menuItem = wrapper.find( - '[data-testid="board-external-tool-element-edit-menu-edit"]' - ); + const menuItem = wrapper.findComponent(BoardMenuAction); expect(menuItem.exists()).toEqual(true); }); @@ -162,9 +55,7 @@ describe("ExternalToolElementMenu", () => { it("should emit the edit event on click", () => { const { wrapper } = setup(); - const menuItem = wrapper.find( - '[data-testid="board-external-tool-element-edit-menu-edit"]' - ); + const menuItem = wrapper.findComponent(BoardMenuAction); menuItem.vm.$emit("click"); @@ -188,9 +79,7 @@ describe("ExternalToolElementMenu", () => { it("should have a menu option to delete", () => { const { wrapper } = setup(); - const menuItem = wrapper.find( - '[data-testid="board-external-tool-element-edit-menu-delete"]' - ); + const menuItem = wrapper.findComponent(BoardMenuActionDelete); expect(menuItem.exists()).toEqual(true); }); @@ -198,9 +87,7 @@ describe("ExternalToolElementMenu", () => { it("should emit the delete event on click", () => { const { wrapper } = setup(); - const menuItem = wrapper.find( - '[data-testid="board-external-tool-element-edit-menu-delete"]' - ); + const menuItem = wrapper.findComponent(BoardMenuActionDelete); menuItem.vm.$emit("click"); diff --git a/src/components/feature-board-external-tool-element/ExternalToolElementMenu.vue b/src/components/feature-board-external-tool-element/ExternalToolElementMenu.vue index 8a47f3d696..12740f8cf2 100644 --- a/src/components/feature-board-external-tool-element/ExternalToolElementMenu.vue +++ b/src/components/feature-board-external-tool-element/ExternalToolElementMenu.vue @@ -1,52 +1,11 @@ @@ -57,15 +16,22 @@ import { mdiCogOutline, mdiTrashCanOutline, } from "@mdi/js"; -import { BoardMenu, BoardMenuAction } from "@ui-board"; +import { + BoardMenu, + BoardMenuAction, + BoardMenuActionDelete, + BoardMenuActionMoveDown, + BoardMenuActionMoveUp, +} from "@ui-board"; import { defineComponent } from "vue"; export default defineComponent({ - components: { BoardMenu, BoardMenuAction }, - props: { - isFirstElement: { type: Boolean, required: true }, - isLastElement: { type: Boolean, required: true }, - hasMultipleElements: { type: Boolean, required: true }, + components: { + BoardMenu, + BoardMenuActionDelete, + BoardMenuAction, + BoardMenuActionMoveUp, + BoardMenuActionMoveDown, }, emits: [ "edit:element", @@ -74,21 +40,13 @@ export default defineComponent({ "move-up:element", ], setup(_, { emit }) { - const onEdit = () => { - emit("edit:element"); - }; + const onEdit = () => emit("edit:element"); - const onDelete = () => { - emit("delete:element"); - }; + const onDelete = () => emit("delete:element"); - const onMoveElementDown = () => { - emit("move-down:element"); - }; + const onMoveDown = () => emit("move-down:element"); - const onMoveElementUp = () => { - emit("move-up:element"); - }; + const onMoveUp = () => emit("move-up:element"); return { mdiArrowCollapseUp, @@ -97,8 +55,8 @@ export default defineComponent({ mdiCogOutline, onEdit, onDelete, - onMoveElementDown, - onMoveElementUp, + onMoveDown, + onMoveUp, }; }, }); diff --git a/src/components/feature-board-file-element/FileContentElement.unit.ts b/src/components/feature-board-file-element/FileContentElement.unit.ts index 80742e1dac..50d7b4e953 100644 --- a/src/components/feature-board-file-element/FileContentElement.unit.ts +++ b/src/components/feature-board-file-element/FileContentElement.unit.ts @@ -579,13 +579,6 @@ describe("FileContentElement", () => { const fileUpload = wrapper.findComponent(FileUpload); expect(fileUpload.exists()).toBe(false); }); - - it("should render slot menu component", async () => { - const { wrapper, menu } = setup(); - await wrapper.vm.$nextTick(); - - expect(wrapper.html()).toContain(menu); - }); }); describe("when a virus is detected", () => { diff --git a/src/components/feature-board-file-element/FileContentElement.vue b/src/components/feature-board-file-element/FileContentElement.vue index 7ce005a3c0..bc76bf5b16 100644 --- a/src/components/feature-board-file-element/FileContentElement.vue +++ b/src/components/feature-board-file-element/FileContentElement.vue @@ -18,18 +18,22 @@ @update:alternativeText="onUpdateAlternativeText" @update:caption="onUpdateCaption" > - + + + + + - + + + + + @@ -43,6 +47,12 @@ import { isPreviewPossible, } from "@/utils/fileHelper"; import { useBoardFocusHandler, useContentElementState } from "@data-board"; +import { + BoardMenu, + BoardMenuActionDelete, + BoardMenuActionMoveDown, + BoardMenuActionMoveUp, +} from "@ui-board"; import { computed, defineComponent, @@ -60,12 +70,21 @@ export default defineComponent({ components: { FileUpload, FileContent, + BoardMenu, + BoardMenuActionMoveUp, + BoardMenuActionMoveDown, + BoardMenuActionDelete, }, props: { element: { type: Object as PropType, required: true }, isEditMode: { type: Boolean, required: true }, }, - emits: ["move-keyboard:edit", "delete:element"], + emits: [ + "delete:element", + "move-down:edit", + "move-up:edit", + "move-keyboard:edit", + ], setup(props, { emit }) { const fileContentElement = ref(null); const isLoadingFileRecord = ref(true); @@ -142,6 +161,10 @@ export default defineComponent({ modelValue.value.caption = value; }; + const onDelete = () => emit("delete:element", element.value.id); + const onMoveUp = () => emit("move-up:edit"); + const onMoveDown = () => emit("move-down:edit"); + return { fileContentElement, fileProperties, @@ -154,6 +177,9 @@ export default defineComponent({ onFetchFile, onUpdateAlternativeText, onUpdateCaption, + onDelete, + onMoveUp, + onMoveDown, }; }, }); diff --git a/src/components/feature-board-file-element/upload/FileUpload.vue b/src/components/feature-board-file-element/upload/FileUpload.vue index 7d8666929c..d41a60a491 100644 --- a/src/components/feature-board-file-element/upload/FileUpload.vue +++ b/src/components/feature-board-file-element/upload/FileUpload.vue @@ -26,12 +26,7 @@ export default defineComponent({ elementId: { type: String, required: true }, }, components: { FilePicker }, - emits: [ - "delete:element", - "move-down:element", - "move-up:element", - "upload:file", - ], + emits: ["upload:file"], setup(props, { emit }) { const isFilePickerOpen = ref(false); const fileWasPicked = ref(false); diff --git a/src/components/feature-board-link-element/LinkContentElement.vue b/src/components/feature-board-link-element/LinkContentElement.vue new file mode 100644 index 0000000000..15f98f8144 --- /dev/null +++ b/src/components/feature-board-link-element/LinkContentElement.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/components/feature-board-link-element/LinkContentElementDisplay.vue b/src/components/feature-board-link-element/LinkContentElementDisplay.vue new file mode 100644 index 0000000000..0ca5fdf8b6 --- /dev/null +++ b/src/components/feature-board-link-element/LinkContentElementDisplay.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/src/components/feature-board-link-element/LinkContentElementEdit.vue b/src/components/feature-board-link-element/LinkContentElementEdit.vue new file mode 100644 index 0000000000..aee3a0093f --- /dev/null +++ b/src/components/feature-board-link-element/LinkContentElementEdit.vue @@ -0,0 +1,70 @@ + + + diff --git a/src/components/feature-board-link-element/index.ts b/src/components/feature-board-link-element/index.ts new file mode 100644 index 0000000000..a71ecaa69a --- /dev/null +++ b/src/components/feature-board-link-element/index.ts @@ -0,0 +1,3 @@ +import LinkContentElement from "./LinkContentElement.vue"; + +export { LinkContentElement }; diff --git a/src/components/feature-board-submission-element/components/SubmissionContentElement.unit.ts b/src/components/feature-board-submission-element/components/SubmissionContentElement.unit.ts index 1c5cfe1b20..8503f4a33c 100644 --- a/src/components/feature-board-submission-element/components/SubmissionContentElement.unit.ts +++ b/src/components/feature-board-submission-element/components/SubmissionContentElement.unit.ts @@ -7,7 +7,7 @@ import { submissionContainerElementResponseFactory } from "@@/tests/test-utils/f import { createMock } from "@golevelup/ts-jest"; import { useDeleteConfirmationDialog } from "@ui-confirmation-dialog"; import { MountOptions, shallowMount } from "@vue/test-utils"; -import Vue, { ref } from "vue"; +import Vue, { computed, ref } from "vue"; import SubmissionContentElement from "./SubmissionContentElement.vue"; import SubmissionContentElementDisplay from "./SubmissionContentElementDisplay.vue"; import SubmissionContentElementEdit from "./SubmissionContentElementEdit.vue"; @@ -79,6 +79,8 @@ describe("SubmissionContentElement", () => { modelValue: ref({ dueDate: element.content.dueDate, }), + computedElement: computed(() => element), + isLoading: ref(false), }); const { wrapper } = getWrapper({ @@ -176,6 +178,8 @@ describe("SubmissionContentElement", () => { modelValue: ref({ dueDate: element.content.dueDate, }), + computedElement: computed(() => element), + isLoading: ref(false), }); const { wrapper } = getWrapper({ diff --git a/src/components/feature-board-submission-element/components/SubmissionContentElement.vue b/src/components/feature-board-submission-element/components/SubmissionContentElement.vue index ab85d25c9d..0781ef636f 100644 --- a/src/components/feature-board-submission-element/components/SubmissionContentElement.vue +++ b/src/components/feature-board-submission-element/components/SubmissionContentElement.vue @@ -27,10 +27,11 @@ :editable="!isOverdue" @update:dueDate="($event) => (modelValue.dueDate = $event)" > - + + + + + @@ -44,10 +45,20 @@ import SubmissionContentElementEdit from "./SubmissionContentElementEdit.vue"; import { useSubmissionContentElementState } from "../composables/SubmissionContentElementState.composable"; import { useBoardFocusHandler, useContentElementState } from "@data-board"; import { useI18n } from "@/composables/i18n.composable"; +import { + BoardMenu, + BoardMenuActionDelete, + BoardMenuActionMoveDown, + BoardMenuActionMoveUp, +} from "@ui-board"; export default defineComponent({ name: "SubmissionContentElement", components: { + BoardMenu, + BoardMenuActionMoveUp, + BoardMenuActionMoveDown, + BoardMenuActionDelete, SubmissionContentElementDisplay, SubmissionContentElementEdit, }, @@ -58,7 +69,12 @@ export default defineComponent({ }, isEditMode: { type: Boolean, required: true }, }, - emits: ["move-keyboard:edit"], + emits: [ + "move-keyboard:edit", + "move-down:edit", + "move-up:edit", + "delete:element", + ], setup(props, { emit }) { const { t } = useI18n(); const submissionContentElement = ref(null); @@ -77,6 +93,12 @@ export default defineComponent({ } }; + const onMoveElementDown = () => emit("move-down:edit"); + + const onMoveElementUp = () => emit("move-up:edit"); + + const onDeleteElement = () => emit("delete:element", element.value.id); + const onUpdateCompleted = (completed: boolean) => { updateSubmissionItem(completed); }; @@ -88,6 +110,9 @@ export default defineComponent({ loading, isOverdue, onKeydownArrow, + onMoveElementDown, + onMoveElementUp, + onDeleteElement, onUpdateCompleted, t, }; diff --git a/src/components/feature-board/board/BoardColumnHeader.vue b/src/components/feature-board/board/BoardColumnHeader.vue index 56557f2f28..cd661c1b03 100644 --- a/src/components/feature-board/board/BoardColumnHeader.vue +++ b/src/components/feature-board/board/BoardColumnHeader.vue @@ -21,22 +21,9 @@ class="w-100" > - - - {{ $t("common.actions.edit") }} - - - - {{ $t("components.board.action.delete") }} - + + + @@ -51,8 +38,11 @@ import { useEditMode, } from "@data-board"; import { mdiPencilOutline, mdiTrashCanOutline } from "@mdi/js"; -import { BoardMenu, BoardMenuAction } from "@ui-board"; -import { useDeleteConfirmationDialog } from "@ui-confirmation-dialog"; +import { + BoardMenu, + BoardMenuActionEdit, + BoardMenuActionDelete, +} from "@ui-board"; import { defineComponent, ref, toRef } from "vue"; import BoardAnyTitleInput from "../shared/BoardAnyTitleInput.vue"; import BoardColumnInteractionHandler from "./BoardColumnInteractionHandler.vue"; @@ -62,8 +52,9 @@ export default defineComponent({ components: { BoardMenu, BoardAnyTitleInput, - BoardMenuAction, + BoardMenuActionEdit, BoardColumnInteractionHandler, + BoardMenuActionDelete, }, props: { columnId: { @@ -82,7 +73,6 @@ export default defineComponent({ emits: ["delete:column", "move:column-keyboard", "update:title"], setup(props, { emit }) { const columnId = toRef(props, "columnId"); - const { askDeleteConfirmation } = useDeleteConfirmationDialog(); const { isEditMode, startEditMode, stopEditMode } = useEditMode( columnId.value ); @@ -106,16 +96,7 @@ export default defineComponent({ stopEditMode(); }; - const onTryDelete = async () => { - const shouldDelete = await askDeleteConfirmation( - props.title, - "boardColumn" - ); - - if (shouldDelete) { - emit("delete:column", props.columnId); - } - }; + const onDelete = () => emit("delete:column", props.columnId); const onMoveColumnKeyboard = (event: KeyboardEvent) => { emit("move:column-keyboard", event.code); @@ -135,7 +116,7 @@ export default defineComponent({ mdiPencilOutline, onStartEditMode, onEndEditMode, - onTryDelete, + onDelete, onMoveColumnKeyboard, onUpdateTitle, }; diff --git a/src/components/feature-board/card/CardHost.unit.ts b/src/components/feature-board/card/CardHost.unit.ts index ad17f97855..a5e2e830fe 100644 --- a/src/components/feature-board/card/CardHost.unit.ts +++ b/src/components/feature-board/card/CardHost.unit.ts @@ -16,6 +16,7 @@ import { useCardState, useEditMode, } from "@data-board"; +import { BoardMenuActionDelete } from "@ui-board"; import { useDeleteConfirmationDialog } from "@ui-confirmation-dialog"; import { MountOptions, shallowMount, Wrapper } from "@vue/test-utils"; import Vue, { computed, ref } from "vue"; @@ -177,11 +178,9 @@ describe("CardHost", () => { it("should emit 'delete:card'", async () => { setup({ card: CARD_WITHOUT_ELEMENTS }); - const firstMenuItem = wrapper.findComponent({ - name: "BoardMenuAction", - }); + const deleteButton = wrapper.findComponent(BoardMenuActionDelete); - firstMenuItem.vm.$emit("click"); + deleteButton.vm.$emit("click"); await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick(); diff --git a/src/components/feature-board/card/CardHost.vue b/src/components/feature-board/card/CardHost.vue index 5162e3371b..8aa4fdaa15 100644 --- a/src/components/feature-board/card/CardHost.vue +++ b/src/components/feature-board/card/CardHost.vue @@ -33,25 +33,13 @@
- - - {{ $t("common.actions.edit") }} - - + - - {{ $t("components.board.action.delete") }} - +
@@ -73,7 +61,22 @@ diff --git a/src/components/feature-board/card/ContentElementList.unit.ts b/src/components/feature-board/card/ContentElementList.unit.ts index f9eaedc094..d62144dcb4 100644 --- a/src/components/feature-board/card/ContentElementList.unit.ts +++ b/src/components/feature-board/card/ContentElementList.unit.ts @@ -8,6 +8,7 @@ import { i18nMock } from "@@/tests/test-utils"; import createComponentMocks from "@@/tests/test-utils/componentMocks"; import { ExternalToolElement } from "@feature-board-external-tool-element"; import { FileContentElement } from "@feature-board-file-element"; +import { LinkContentElement } from "@feature-board-link-element"; import { SubmissionContentElement } from "@feature-board-submission-element"; import { RichTextContentElement } from "@feature-board-text-element"; import { createMock } from "@golevelup/ts-jest"; @@ -27,6 +28,7 @@ describe("ContentElementList", () => { 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, }), }); @@ -50,7 +52,7 @@ describe("ContentElementList", () => { expect(wrapper.findComponent(ContentElementList).exists()).toBe(true); }); - it.each([ + const elementComponents = [ { elementType: ContentElementType.RichText, component: RichTextContentElement, @@ -59,6 +61,10 @@ describe("ContentElementList", () => { elementType: ContentElementType.File, component: FileContentElement, }, + { + elementType: ContentElementType.Link, + component: LinkContentElement, + }, { elementType: ContentElementType.SubmissionContainer, component: SubmissionContentElement, @@ -67,8 +73,10 @@ describe("ContentElementList", () => { elementType: ContentElementType.ExternalTool, component: ExternalToolElement, }, - ])( - "should render elements based on type %s", + ]; + + it.each(elementComponents)( + "should render $elementType-elements", ({ elementType, component }) => { setup({ elements: [{ type: elementType } as AnyContentElement], @@ -78,25 +86,8 @@ describe("ContentElementList", () => { } ); - it.each([ - { - elementType: ContentElementType.RichText, - component: RichTextContentElement, - }, - { - elementType: ContentElementType.File, - component: FileContentElement, - }, - { - elementType: ContentElementType.SubmissionContainer, - component: SubmissionContentElement, - }, - { - elementType: ContentElementType.ExternalTool, - component: ExternalToolElement, - }, - ])( - "should propagate isEditMode to child elements", + it.each(elementComponents)( + "should propagate isEditMode to children of $elementType-elements", ({ elementType, component }) => { const isEditModeResult = true; diff --git a/src/components/feature-board/card/ContentElementList.vue b/src/components/feature-board/card/ContentElementList.vue index 493ef500f1..babf249f00 100644 --- a/src/components/feature-board/card/ContentElementList.vue +++ b/src/components/feature-board/card/ContentElementList.vue @@ -1,66 +1,57 @@ @@ -72,25 +63,28 @@ import { FileElementResponse, RichTextElementResponse, SubmissionContainerElementResponse, + LinkElementResponse, } from "@/serverApi/v3"; import { AnyContentElement } from "@/types/board/ContentElement"; import { ElementMove } from "@/types/board/DragAndDrop"; import { ENV_CONFIG_MODULE_KEY, injectStrict } from "@/utils/inject"; -import ContentElementMenu from "@feature-board-content-element-menu"; import { ExternalToolElement } from "@feature-board-external-tool-element"; import { FileContentElement } from "@feature-board-file-element"; import { SubmissionContentElement } from "@feature-board-submission-element"; import { RichTextContentElement } from "@feature-board-text-element"; import { computed, defineComponent, PropType } from "vue"; +import { LinkContentElement } from "@feature-board-link-element"; +import ContentElement from "./ContentElement.vue"; export default defineComponent({ name: "ContentElementList", components: { + ContentElement, ExternalToolElement, FileContentElement, RichTextContentElement, SubmissionContentElement, - ContentElementMenu, + LinkContentElement, }, props: { elements: { @@ -142,6 +136,21 @@ export default defineComponent({ ); }; + const isLinkElementResponse = ( + element: AnyContentElement + ): element is LinkElementResponse => { + return element.type === ContentElementType.Link; + }; + + const showLinkElement = ( + element: AnyContentElement + ): element is LinkElementResponse => { + return ( + !!envConfigModule.getEnv.FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED && + isLinkElementResponse(element) + ); + }; + const isExternalToolElementResponse = ( element: AnyContentElement ): element is ExternalToolElementResponse => { @@ -210,9 +219,11 @@ export default defineComponent({ isFileElementResponse, isRichTextElementResponse, isSubmissionContainerElementResponse, + isLinkElementResponse, showSubmissionContainerElement, isExternalToolElementResponse, showExternalToolElement, + showLinkElement, lastElementId, onDeleteElement, onMoveElementDown, diff --git a/src/components/feature-board/shared/AddElementDialog.composable.ts b/src/components/feature-board/shared/AddElementDialog.composable.ts index 8b3fa159a6..4082a5f2ce 100644 --- a/src/components/feature-board/shared/AddElementDialog.composable.ts +++ b/src/components/feature-board/shared/AddElementDialog.composable.ts @@ -4,6 +4,7 @@ import { ENV_CONFIG_MODULE_KEY, injectStrict } from "@/utils/inject"; import { mdiFormatText, mdiLightbulbOnOutline, + mdiLink, mdiPuzzleOutline, mdiTrayArrowUp, } from "@mdi/js"; @@ -62,6 +63,15 @@ export const useAddElementDialog = (addElementFunction: AddCardElement) => { }); } + if (envConfigModule.getEnv.FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED) { + options.push({ + icon: mdiLink, + label: "components.elementTypeSelection.elements.linkElement.subtitle", + action: () => onElementClick(ContentElementType.Link), + testId: "create-element-link", + }); + } + const askType = () => { elementTypeOptions.value = options; isDialogOpen.value = true; diff --git a/src/components/feature-board/shared/AddElementDialog.unit.ts b/src/components/feature-board/shared/AddElementDialog.unit.ts index 1fd6c4bd9d..cc22ca44c9 100644 --- a/src/components/feature-board/shared/AddElementDialog.unit.ts +++ b/src/components/feature-board/shared/AddElementDialog.unit.ts @@ -41,7 +41,11 @@ describe("ElementTypeSelection", () => { const envConfigModule: jest.Mocked = createModuleMocks( EnvConfigModule, { - getEnv: { ...mockEnvs, FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED: true }, + getEnv: { + ...mockEnvs, + FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED: true, + FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED: true, + }, } ); diff --git a/src/components/ui-board/BoardMenu.vue b/src/components/ui-board/BoardMenu.vue index 4316e9331f..9512e7f54a 100644 --- a/src/components/ui-board/BoardMenu.vue +++ b/src/components/ui-board/BoardMenu.vue @@ -31,24 +31,28 @@ - + diff --git a/src/components/ui-board/BoardMenuActionEdit.vue b/src/components/ui-board/BoardMenuActionEdit.vue new file mode 100644 index 0000000000..48d6eade2b --- /dev/null +++ b/src/components/ui-board/BoardMenuActionEdit.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/ui-board/BoardMenuActionMoveDown.unit.ts b/src/components/ui-board/BoardMenuActionMoveDown.unit.ts new file mode 100644 index 0000000000..fbc2fe9b68 --- /dev/null +++ b/src/components/ui-board/BoardMenuActionMoveDown.unit.ts @@ -0,0 +1,58 @@ +import createComponentMocks from "@@/tests/test-utils/componentMocks"; +import { BoardMenuActionMoveDown } from "@ui-board"; +import { + BOARD_CARD_HAS_MULTIPLE_ELEMENTS, + BOARD_CARD_IS_LAST_ELEMENT, +} from "@util-board"; +import { MountOptions, shallowMount, Wrapper } from "@vue/test-utils"; +import Vue from "vue"; +import BoardMenuAction from "./BoardMenuAction.vue"; + +describe("BoardMenuActionMoveDown Component", () => { + let wrapper: Wrapper; + + const setup = (options: { + isLastElement: boolean; + hasMultipleElements: boolean; + }) => { + document.body.setAttribute("data-app", "true"); + wrapper = shallowMount(BoardMenuActionMoveDown as MountOptions, { + ...createComponentMocks({}), + provide: { + [BOARD_CARD_HAS_MULTIPLE_ELEMENTS as symbol]: + options.hasMultipleElements, + [BOARD_CARD_IS_LAST_ELEMENT as symbol]: options.isLastElement, + }, + }); + }; + + describe("when component is mounted", () => { + it("should render if element not in last position and a list of elements exists", () => { + setup({ isLastElement: false, hasMultipleElements: true }); + const action = wrapper.findComponent(BoardMenuAction); + expect(action.exists()).toBeTruthy(); + }); + + it("should not be rendered if element is in last position and a list of elements exists", () => { + setup({ isLastElement: true, hasMultipleElements: true }); + const action = wrapper.findComponent(BoardMenuAction); + expect(action.exists()).toBeFalsy(); + }); + + it("should not be rendered if element is the only element", () => { + setup({ isLastElement: true, hasMultipleElements: false }); + const action = wrapper.findComponent(BoardMenuAction); + expect(action.exists()).toBeFalsy(); + }); + + it("should emit if is clicked", () => { + setup({ isLastElement: false, hasMultipleElements: true }); + const listItemComponent = wrapper.findComponent(BoardMenuAction); + + listItemComponent.vm.$emit("click", { preventDefault: jest.fn() }); + const emitted = wrapper.emitted("click"); + + expect(emitted).toBeDefined(); + }); + }); +}); diff --git a/src/components/ui-board/BoardMenuActionMoveDown.vue b/src/components/ui-board/BoardMenuActionMoveDown.vue new file mode 100644 index 0000000000..f11aedec9b --- /dev/null +++ b/src/components/ui-board/BoardMenuActionMoveDown.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/components/ui-board/BoardMenuActionMoveUp.unit.ts b/src/components/ui-board/BoardMenuActionMoveUp.unit.ts new file mode 100644 index 0000000000..abe7ea3704 --- /dev/null +++ b/src/components/ui-board/BoardMenuActionMoveUp.unit.ts @@ -0,0 +1,57 @@ +import createComponentMocks from "@@/tests/test-utils/componentMocks"; +import { BoardMenuActionMoveUp } from "@ui-board"; +import { + BOARD_CARD_HAS_MULTIPLE_ELEMENTS, + BOARD_CARD_IS_FIRST_ELEMENT, +} from "@util-board"; +import { MountOptions, shallowMount, Wrapper } from "@vue/test-utils"; +import Vue from "vue"; +import BoardMenuAction from "./BoardMenuAction.vue"; + +describe("BoardMenuActionMoveUp Component", () => { + let wrapper: Wrapper; + + const setup = (options: { + isFirstElement: boolean; + hasMultipleElements: boolean; + }) => { + document.body.setAttribute("data-app", "true"); + wrapper = shallowMount(BoardMenuActionMoveUp as MountOptions, { + ...createComponentMocks({}), + provide: { + [BOARD_CARD_HAS_MULTIPLE_ELEMENTS as symbol]: + options.hasMultipleElements, + [BOARD_CARD_IS_FIRST_ELEMENT as symbol]: options.isFirstElement, + }, + }); + }; + + describe("when component is mounted", () => { + it("should render if element not in first position and a list of elements exists", () => { + setup({ isFirstElement: false, hasMultipleElements: true }); + const action = wrapper.findComponent(BoardMenuAction); + expect(action.exists()).toBeTruthy(); + }); + + it("should not be rendered if element is in first position and a list of elements exists", () => { + setup({ isFirstElement: true, hasMultipleElements: true }); + const action = wrapper.findComponent(BoardMenuAction); + expect(action.exists()).toBeFalsy(); + }); + it("should not be rendered if element is the only element", () => { + setup({ isFirstElement: true, hasMultipleElements: false }); + const action = wrapper.findComponent(BoardMenuAction); + expect(action.exists()).toBeFalsy(); + }); + + it("should emit if is clicked", () => { + setup({ isFirstElement: false, hasMultipleElements: true }); + const listItemComponent = wrapper.findComponent(BoardMenuAction); + + listItemComponent.vm.$emit("click", { preventDefault: jest.fn() }); + const emitted = wrapper.emitted("click"); + + expect(emitted).toBeDefined(); + }); + }); +}); diff --git a/src/components/ui-board/BoardMenuActionMoveUp.vue b/src/components/ui-board/BoardMenuActionMoveUp.vue new file mode 100644 index 0000000000..c608519c89 --- /dev/null +++ b/src/components/ui-board/BoardMenuActionMoveUp.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/ui-board/board-menu-scope.ts b/src/components/ui-board/board-menu-scope.ts new file mode 100644 index 0000000000..6290c635a8 --- /dev/null +++ b/src/components/ui-board/board-menu-scope.ts @@ -0,0 +1 @@ +export type BoardMenuScope = "element" | "card" | "column" | "board"; diff --git a/src/components/ui-board/index.ts b/src/components/ui-board/index.ts index 708185500f..f8a6698910 100644 --- a/src/components/ui-board/index.ts +++ b/src/components/ui-board/index.ts @@ -1,5 +1,9 @@ import BoardMenu from "./BoardMenu.vue"; import BoardMenuAction from "./BoardMenuAction.vue"; +import BoardMenuActionEdit from "./BoardMenuActionEdit.vue"; +import BoardMenuActionDelete from "./BoardMenuActionDelete.vue"; +import BoardMenuActionMoveUp from "./BoardMenuActionMoveUp.vue"; +import BoardMenuActionMoveDown from "./BoardMenuActionMoveDown.vue"; import ContentElementBar from "./content-element/ContentElementBar.vue"; import ContentElementTitle from "./content-element/ContentElementTitle.vue"; import ContentElementTitleIcon from "./content-element/ContentElementTitleIcon.vue"; @@ -7,6 +11,10 @@ import ContentElementTitleIcon from "./content-element/ContentElementTitleIcon.v export { BoardMenu, BoardMenuAction, + BoardMenuActionEdit, + BoardMenuActionDelete, + BoardMenuActionMoveUp, + BoardMenuActionMoveDown, ContentElementBar, ContentElementTitle, ContentElementTitleIcon, diff --git a/src/components/ui-board/injection-tokens.ts b/src/components/ui-board/injection-tokens.ts new file mode 100644 index 0000000000..cd2d1a8758 --- /dev/null +++ b/src/components/ui-board/injection-tokens.ts @@ -0,0 +1 @@ +export const MENU_SCOPE = Symbol("MENU_SCOPE"); diff --git a/src/components/ui-confirmation-dialog/DeleteConfirmation.composable.ts b/src/components/ui-confirmation-dialog/DeleteConfirmation.composable.ts index cbc912199a..4e8cc7954c 100644 --- a/src/components/ui-confirmation-dialog/DeleteConfirmation.composable.ts +++ b/src/components/ui-confirmation-dialog/DeleteConfirmation.composable.ts @@ -7,10 +7,10 @@ export const useDeleteConfirmationDialog = () => { const askDeleteConfirmation = async ( title: string | undefined, - type: "boardColumn" | "boardCard" | "boardElement" + typeLanguageKey: string ): Promise => { const titleString = title ? `"${title}"` : ""; - const typeString = i18n.t(`components.${type}`).toString(); + const typeString = i18n.t(typeLanguageKey).toString(); const message = i18n .t("ui-confirmation-dialog.ask-delete", { diff --git a/src/components/ui-confirmation-dialog/DeleteConfirmation.composable.unit.ts b/src/components/ui-confirmation-dialog/DeleteConfirmation.composable.unit.ts index 66b467b23d..7b11021acc 100644 --- a/src/components/ui-confirmation-dialog/DeleteConfirmation.composable.unit.ts +++ b/src/components/ui-confirmation-dialog/DeleteConfirmation.composable.unit.ts @@ -9,8 +9,9 @@ describe("DeleteConfirmation composable", () => { const setup = (isConfirmed: boolean) => { const title = "title"; const titleString = `"${title}"`; - const type: "boardCard" | "boardElement" = "boardElement"; - const typeString = `components.${type}`; + const typeLanguageKey: + | "components.boardCard" + | "components.boardElement" = "components.boardElement"; const titleTranslationKey = "ui-confirmation-dialog.ask-delete"; const data = { elementId: "elementId", @@ -35,8 +36,7 @@ describe("DeleteConfirmation composable", () => { askConfirmation, data, translateMock, - type, - typeString, + typeLanguageKey, title, titleString, titleTranslationKey, @@ -52,19 +52,18 @@ describe("DeleteConfirmation composable", () => { const { askDeleteConfirmation, translateMock, - type, - typeString, + typeLanguageKey, title, titleString, titleTranslationKey, } = setup(true); - await askDeleteConfirmation(title, type); + await askDeleteConfirmation(title, typeLanguageKey); - expect(translateMock).toHaveBeenNthCalledWith(1, typeString); + expect(translateMock).toHaveBeenNthCalledWith(1, typeLanguageKey); expect(translateMock).toHaveBeenNthCalledWith(2, titleTranslationKey, { title: titleString, - type: typeString, + type: typeLanguageKey, }); }); @@ -73,11 +72,11 @@ describe("DeleteConfirmation composable", () => { askDeleteConfirmation, askConfirmation, title, - type, + typeLanguageKey, titleTranslationKey, } = setup(true); - await askDeleteConfirmation(title, type); + await askDeleteConfirmation(title, typeLanguageKey); expect(askConfirmation).toHaveBeenCalledWith({ message: titleTranslationKey, @@ -85,9 +84,9 @@ describe("DeleteConfirmation composable", () => { }); it("should return result", async () => { - const { askDeleteConfirmation, title, type } = setup(true); + const { askDeleteConfirmation, title, typeLanguageKey } = setup(true); - const result = await askDeleteConfirmation(title, type); + const result = await askDeleteConfirmation(title, typeLanguageKey); expect(result).toBe(true); }); @@ -98,17 +97,16 @@ describe("DeleteConfirmation composable", () => { const { askDeleteConfirmation, translateMock, - type, - typeString, + typeLanguageKey, titleTranslationKey, } = setup(true); - await askDeleteConfirmation("", type); + await askDeleteConfirmation("", typeLanguageKey); - expect(translateMock).toHaveBeenNthCalledWith(1, typeString); + expect(translateMock).toHaveBeenNthCalledWith(1, typeLanguageKey); expect(translateMock).toHaveBeenNthCalledWith(2, titleTranslationKey, { title: "", - type: typeString, + type: typeLanguageKey, }); }); @@ -116,11 +114,11 @@ describe("DeleteConfirmation composable", () => { const { askDeleteConfirmation, askConfirmation, - type, + typeLanguageKey, titleTranslationKey, } = setup(true); - await askDeleteConfirmation("", type); + await askDeleteConfirmation("", typeLanguageKey); expect(askConfirmation).toHaveBeenCalledWith({ message: titleTranslationKey, @@ -128,9 +126,9 @@ describe("DeleteConfirmation composable", () => { }); it("should return result", async () => { - const { askDeleteConfirmation, type } = setup(true); + const { askDeleteConfirmation, typeLanguageKey } = setup(true); - const result = await askDeleteConfirmation("", type); + const result = await askDeleteConfirmation("", typeLanguageKey); expect(result).toBe(true); }); diff --git a/src/components/util-board/board-injection-tokens.ts b/src/components/util-board/board-injection-tokens.ts new file mode 100644 index 0000000000..29901878a8 --- /dev/null +++ b/src/components/util-board/board-injection-tokens.ts @@ -0,0 +1,13 @@ +import { InjectionKey } from "vue"; + +export const BOARD_CARD_HAS_MULTIPLE_ELEMENTS: InjectionKey = Symbol( + "BoardCardHasMultipleElements" +); + +export const BOARD_CARD_IS_FIRST_ELEMENT: InjectionKey = Symbol( + "BoardCardIsFirstElement" +); + +export const BOARD_CARD_IS_LAST_ELEMENT: InjectionKey = Symbol( + "BoardCardIsLastElement" +); diff --git a/src/components/util-board/index.ts b/src/components/util-board/index.ts index fe11679c7d..013599f6d0 100644 --- a/src/components/util-board/index.ts +++ b/src/components/util-board/index.ts @@ -1,3 +1,15 @@ import { useBoardNotifier } from "./BoardNotifier.composable"; import { useSharedLastCreatedElement } from "./LastCreatedElement.composable"; -export { useBoardNotifier, useSharedLastCreatedElement }; +import { + BOARD_CARD_HAS_MULTIPLE_ELEMENTS, + BOARD_CARD_IS_FIRST_ELEMENT, + BOARD_CARD_IS_LAST_ELEMENT, +} from "./board-injection-tokens"; + +export { + useBoardNotifier, + useSharedLastCreatedElement, + BOARD_CARD_HAS_MULTIPLE_ELEMENTS, + BOARD_CARD_IS_FIRST_ELEMENT, + BOARD_CARD_IS_LAST_ELEMENT, +}; diff --git a/src/components/util-validators/index.ts b/src/components/util-validators/index.ts new file mode 100644 index 0000000000..9b071e141e --- /dev/null +++ b/src/components/util-validators/index.ts @@ -0,0 +1,3 @@ +import { isRequired, isValidUrl } from "./validators"; + +export { isRequired, isValidUrl }; diff --git a/src/components/util-validators/validators.ts b/src/components/util-validators/validators.ts new file mode 100644 index 0000000000..d123271e6c --- /dev/null +++ b/src/components/util-validators/validators.ts @@ -0,0 +1,30 @@ +type FormValidatorFn = (errMsg: string) => (value: T) => string | true; + +/** + * Checks if given value is not a nullish value + */ +export const isRequired: FormValidatorFn = (errMsg) => (value) => + !!value || errMsg; + +/** + * Checks if given value is a valid URL + */ +export const isValidUrl: FormValidatorFn = (errMsg) => (value) => { + try { + const urlWithProtocol = value.match(/:\/\//) ? value : `https://${value}`; + const urlObject = new URL(urlWithProtocol); + + if (!["http:", "https:"].includes(urlObject.protocol)) { + throw new Error("Wrong protocol"); + } + if (!urlObject.hostname.includes(".")) { + throw new Error("TopLevelDomain missing"); + } + if (/(^-)|(--)|(-$)/.test(urlObject.hostname)) { + throw new Error("IDN hyphen rules violated"); + } + } catch (e) { + return errMsg; + } + return true; +}; diff --git a/src/components/util-validators/validators.unit.ts b/src/components/util-validators/validators.unit.ts new file mode 100644 index 0000000000..a66bb9f9de --- /dev/null +++ b/src/components/util-validators/validators.unit.ts @@ -0,0 +1,55 @@ +import { isValidUrl } from "@util-validators"; + +describe("util-validators", () => { + describe("isValidUrl", () => { + const ERROR = "my error"; + const isValid = isValidUrl(ERROR); + + describe("when protocol is given", () => { + it("should accept true urls with http-protocol", () => { + expect(isValid("http://medium.com/how-to-write-great-tests")).toBe( + true + ); + }); + + it("should accept urls with https-protocol", () => { + expect(isValid("http://medium.com/my-article")).toBe(true); + }); + + it("should return ERROR when urls with other protocols than http and https", () => { + expect(isValid("ftp://medium.com/my-article")).toBe(ERROR); + }); + }); + + describe("when protocol is missing", () => { + it("should accept urls with hostname containing a dot", () => { + expect(isValid("medium.com/how-to-write-great-tests")).toBe(true); + }); + + it("should return ERROR when url's hostname does not contain a dot", () => { + expect(isValid("medium-com/how-to-write-great-tests")).toBe(ERROR); + }); + }); + + describe("when url is valid IDN (internationalized domain name)", () => { + test.each([ + "xn--huser-gra.tld", + "xn--grsse-lva.tld", + "xn--5eyx16c.tld", + "xn--90aqfi­dwgh3ei­.tld", + ])("should return ERROR for %s", (url) => { + expect(isValid(url)).toBe(ERROR); + }); + }); + + describe("when url is invalid IDN (internationalized domain name)", () => { + test.each([ + "-medium.com/how-to-write-test", + "me--dium.com/how-to--write-test", + "medium.com-/how-to--write-test", + ])("should return ERROR for %s", (url) => { + expect(isValid(url)).toBe(ERROR); + }); + }); + }); +}); diff --git a/src/locales/de.json b/src/locales/de.json index 5dc3162536..f6102bd1d8 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -182,6 +182,7 @@ "components.base.showPassword": "Passwort anzeigen", "components.elementTypeSelection.dialog.title": "Element hinzufügen", "components.elementTypeSelection.elements.fileElement.subtitle": "Datei", + "components.elementTypeSelection.elements.linkElement.subtitle": "Link", "components.elementTypeSelection.elements.submissionElement.subtitle": "Abgabe", "components.elementTypeSelection.elements.textElement.subtitle": "Text", "components.elementTypeSelection.elements.externalToolElement.subtitle": "Weitere Tools", @@ -822,6 +823,7 @@ "components.cardElement.submissionElement.until": "bis", "components.cardElement.submissionElement.open": "offen", "components.cardElement.submissionElement.expired": "abgelaufen", + "components.cardElement.LinkElement.label": "Link-Adresse einfügen", "components.cardElement.titleElement": "Titelelement", "components.cardElement.titleElement.placeholder": "Titel hinzufügen", "components.cardElement.titleElement.validation.required": "Bitte Titel angeben.", @@ -1008,5 +1010,6 @@ "pages.videoConference.action.refresh": "Status aktualisieren", "ui-confirmation-dialog.ask-delete": "{type} {title} wirklich löschen?", "feature-board-file-element.placeholder.uploadFile": "Datei hochladen", - "feature-board-external-tool-element.placeholder.selectTool": "Tool auswählen..." + "feature-board-external-tool-element.placeholder.selectTool": "Tool auswählen...", + "util-validators-invalid-url": "Bitte geben Sie eine gültige URL ein." } diff --git a/src/locales/en.json b/src/locales/en.json index 022e57571c..1b37e1c9c7 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -181,6 +181,7 @@ "components.base.showPassword": "Show password", "components.elementTypeSelection.dialog.title": "Add element", "components.elementTypeSelection.elements.fileElement.subtitle": "File", + "components.elementTypeSelection.elements.linkElement.subtitle": "Link", "components.elementTypeSelection.elements.submissionElement.subtitle": "Submission", "components.elementTypeSelection.elements.textElement.subtitle": "Text", "components.elementTypeSelection.elements.externalToolElement.subtitle": "More Tools", @@ -820,6 +821,7 @@ "components.cardElement.submissionElement.until": "until", "components.cardElement.submissionElement.open": "open", "components.cardElement.submissionElement.expired": "expired", + "components.cardElement.LinkElement.label": "Insert link address", "components.cardElement.titleElement": "Title element", "components.cardElement.titleElement.placeholder": "Add title", "components.cardElement.titleElement.validation.required": "Please enter a title.", diff --git a/src/locales/es.json b/src/locales/es.json index 1ea67e8f36..db6703d28d 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -181,6 +181,7 @@ "components.base.showPassword": "Mostrar contraseña", "components.elementTypeSelection.dialog.title": "Añadir elemento", "components.elementTypeSelection.elements.fileElement.subtitle": "Archivo", + "components.elementTypeSelection.elements.linkElement.subtitle": "Enlace", "components.elementTypeSelection.elements.submissionElement.subtitle": "Envíos", "components.elementTypeSelection.elements.textElement.subtitle": "Texto", "components.elementTypeSelection.elements.externalToolElement.subtitle": "Más herramientas", @@ -830,6 +831,7 @@ "components.cardElement.submissionElement.until": "hasta el", "components.cardElement.submissionElement.open": "abrir", "components.cardElement.submissionElement.expired": "expirado", + "components.cardElement.LinkElement.label": "Inserte la dirección del enlace", "components.cardElement.titleElement": "Elemento título", "components.cardElement.titleElement.placeholder": "Añadir título", "components.cardElement.titleElement.validation.required": "Por favor ingrese un título.", diff --git a/src/locales/uk.json b/src/locales/uk.json index e931df6349..78b3f670df 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -182,6 +182,7 @@ "components.base.showPassword": "Показати пароль", "components.elementTypeSelection.dialog.title": "Додати елемент", "components.elementTypeSelection.elements.fileElement.subtitle": "Файл", + "components.elementTypeSelection.elements.linkElement.subtitle": "посилання", "components.elementTypeSelection.elements.submissionElement.subtitle": "Подання", "components.elementTypeSelection.elements.textElement.subtitle": "Текст", "components.elementTypeSelection.elements.externalToolElement.subtitle": "Більше інструментів", @@ -333,6 +334,7 @@ "components.cardElement.submissionElement.until": "до", "components.cardElement.submissionElement.open": "Відкрити", "components.cardElement.submissionElement.expired": "Термін дії минув", + "components.cardElement.LinkElement.label": "Вставити адресу посилання", "components.cardElement.titleElement": "Елемент заголовка", "components.cardElement.titleElement.placeholder": "Додати назву", "components.cardElement.titleElement.validation.required": "Будь ласка, введіть назву.", diff --git a/src/serverApi/v3/api.ts b/src/serverApi/v3/api.ts index 43010f03a9..1c90b03ca0 100644 --- a/src/serverApi/v3/api.ts +++ b/src/serverApi/v3/api.ts @@ -298,10 +298,10 @@ export interface CardResponse { height: number; /** * - * @type {Array} + * @type {Array} * @memberof CardResponse */ - elements: Array; + elements: Array; /** * * @type {VisibilitySettingsResponse} @@ -618,6 +618,7 @@ export interface ConsentSessionResponse { */ export enum ContentElementType { File = 'file', + Link = 'link', RichText = 'richText', SubmissionContainer = 'submissionContainer', ExternalTool = 'externalTool' @@ -870,6 +871,7 @@ export enum CopyApiResponseTypeEnum { LessonContentText = 'LESSON_CONTENT_TEXT', LernstoreMaterial = 'LERNSTORE_MATERIAL', LernstoreMaterialGroup = 'LERNSTORE_MATERIAL_GROUP', + LinkElement = 'LINK_ELEMENT', LtitoolGroup = 'LTITOOL_GROUP', Metadata = 'METADATA', RichtextElement = 'RICHTEXT_ELEMENT', @@ -993,6 +995,7 @@ export interface CreateCardBodyParams { */ export enum CreateCardBodyParamsRequiredEmptyElementsEnum { File = 'file', + Link = 'link', RichText = 'richText', SubmissionContainer = 'submissionContainer', ExternalTool = 'externalTool' @@ -1969,6 +1972,100 @@ export interface LessonCopyApiParams { */ courseId?: string; } +/** + * + * @export + * @interface LinkContentBody + */ +export interface LinkContentBody { + /** + * + * @type {string} + * @memberof LinkContentBody + */ + url: string; +} +/** + * + * @export + * @interface LinkElementContent + */ +export interface LinkElementContent { + /** + * + * @type {string} + * @memberof LinkElementContent + */ + url: string; + /** + * + * @type {string} + * @memberof LinkElementContent + */ + title: string; + /** + * + * @type {string} + * @memberof LinkElementContent + */ + description?: string; + /** + * + * @type {string} + * @memberof LinkElementContent + */ + imageUrl?: string; +} +/** + * + * @export + * @interface LinkElementContentBody + */ +export interface LinkElementContentBody { + /** + * + * @type {ContentElementType} + * @memberof LinkElementContentBody + */ + type: ContentElementType; + /** + * + * @type {LinkContentBody} + * @memberof LinkElementContentBody + */ + content: LinkContentBody; +} +/** + * + * @export + * @interface LinkElementResponse + */ +export interface LinkElementResponse { + /** + * + * @type {string} + * @memberof LinkElementResponse + */ + id: string; + /** + * + * @type {ContentElementType} + * @memberof LinkElementResponse + */ + type: ContentElementType; + /** + * + * @type {LinkElementContent} + * @memberof LinkElementResponse + */ + content: LinkElementContent; + /** + * + * @type {TimestampsResponse} + * @memberof LinkElementResponse + */ + timestamps: TimestampsResponse; +} /** * * @export @@ -4525,10 +4622,10 @@ export enum ToolReferenceResponseStatusEnum { export interface UpdateElementContentBodyParams { /** * - * @type {FileElementContentBody | RichTextElementContentBody | SubmissionContainerElementContentBody | ExternalToolElementContentBody} + * @type {FileElementContentBody | LinkElementContentBody | RichTextElementContentBody | SubmissionContainerElementContentBody | ExternalToolElementContentBody} * @memberof UpdateElementContentBodyParams */ - data: FileElementContentBody | RichTextElementContentBody | SubmissionContainerElementContentBody | ExternalToolElementContentBody; + data: FileElementContentBody | LinkElementContentBody | RichTextElementContentBody | SubmissionContainerElementContentBody | ExternalToolElementContentBody; } /** * @@ -6585,7 +6682,7 @@ export const BoardCardApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async cardControllerCreateElement(cardId: string, createContentElementBodyParams: CreateContentElementBodyParams, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async cardControllerCreateElement(cardId: string, createContentElementBodyParams: CreateContentElementBodyParams, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.cardControllerCreateElement(cardId, createContentElementBodyParams, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -6665,7 +6762,7 @@ export const BoardCardApiFactory = function (configuration?: Configuration, base * @param {*} [options] Override http request option. * @throws {RequiredError} */ - cardControllerCreateElement(cardId: string, createContentElementBodyParams: CreateContentElementBodyParams, options?: any): AxiosPromise { + cardControllerCreateElement(cardId: string, createContentElementBodyParams: CreateContentElementBodyParams, options?: any): AxiosPromise { return localVarFp.cardControllerCreateElement(cardId, createContentElementBodyParams, options).then((request) => request(axios, basePath)); }, /** @@ -6739,7 +6836,7 @@ export interface BoardCardApiInterface { * @throws {RequiredError} * @memberof BoardCardApiInterface */ - cardControllerCreateElement(cardId: string, createContentElementBodyParams: CreateContentElementBodyParams, options?: any): AxiosPromise; + cardControllerCreateElement(cardId: string, createContentElementBodyParams: CreateContentElementBodyParams, options?: any): AxiosPromise; /** * @@ -7508,7 +7605,7 @@ export const BoardElementApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async elementControllerUpdateElement(contentElementId: string, updateElementContentBodyParams: UpdateElementContentBodyParams, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async elementControllerUpdateElement(contentElementId: string, updateElementContentBodyParams: UpdateElementContentBodyParams, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.elementControllerUpdateElement(contentElementId, updateElementContentBodyParams, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -7562,7 +7659,7 @@ export const BoardElementApiFactory = function (configuration?: Configuration, b * @param {*} [options] Override http request option. * @throws {RequiredError} */ - elementControllerUpdateElement(contentElementId: string, updateElementContentBodyParams: UpdateElementContentBodyParams, options?: any): AxiosPromise { + elementControllerUpdateElement(contentElementId: string, updateElementContentBodyParams: UpdateElementContentBodyParams, options?: any): AxiosPromise { return localVarFp.elementControllerUpdateElement(contentElementId, updateElementContentBodyParams, options).then((request) => request(axios, basePath)); }, }; @@ -7615,7 +7712,7 @@ export interface BoardElementApiInterface { * @throws {RequiredError} * @memberof BoardElementApiInterface */ - elementControllerUpdateElement(contentElementId: string, updateElementContentBodyParams: UpdateElementContentBodyParams, options?: any): AxiosPromise; + elementControllerUpdateElement(contentElementId: string, updateElementContentBodyParams: UpdateElementContentBodyParams, options?: any): AxiosPromise; } diff --git a/src/store/types/env-config.ts b/src/store/types/env-config.ts index 517d303ce6..e4306a62e3 100644 --- a/src/store/types/env-config.ts +++ b/src/store/types/env-config.ts @@ -6,6 +6,7 @@ export type Envs = { FALLBACK_DISABLED?: boolean; FEATURE_ADMIN_TOGGLE_STUDENT_LERNSTORE_VIEW_ENABLED?: boolean; FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED?: boolean; + FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED?: boolean; FEATURE_CONSENT_NECESSARY: boolean; FEATURE_COPY_SERVICE_ENABLED?: boolean; FEATURE_COURSE_SHARE_NEW?: boolean; diff --git a/src/types/board/ContentElement.ts b/src/types/board/ContentElement.ts index 1abe1f8590..6d8af6e1b0 100644 --- a/src/types/board/ContentElement.ts +++ b/src/types/board/ContentElement.ts @@ -3,9 +3,11 @@ import { FileElementResponse, RichTextElementResponse, SubmissionContainerElementResponse, + LinkElementResponse, } from "@/serverApi/v3"; export type AnyContentElement = + | LinkElementResponse | RichTextElementResponse | FileElementResponse | SubmissionContainerElementResponse diff --git a/src/utils/api.ts b/src/utils/api.ts index d1c7a74991..884204ac56 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -36,7 +36,6 @@ export const mapAxiosErrorToResponseError = ( apiError.title = error.response?.statusText || apiError.title; } } - return apiError; }; diff --git a/src/utils/inject/inject-strict.ts b/src/utils/inject/inject-strict.ts index c7263345eb..adfabc0282 100644 --- a/src/utils/inject/inject-strict.ts +++ b/src/utils/inject/inject-strict.ts @@ -3,8 +3,8 @@ import { InjectionKey, inject } from "vue"; export const injectStrict = (key: InjectionKey, fallback?: T): T => { const resolved = inject(key, fallback); - if (!resolved) { - throw new Error(`Could not resolve ${key.description}`); + if (resolved === undefined) { + throw new Error(`InjectStrict: Could not resolve ${key.description}`); } return resolved; diff --git a/src/utils/inject/inject-strict.unit.ts b/src/utils/inject/inject-strict.unit.ts index 9daaf1320d..1a79873c44 100644 --- a/src/utils/inject/inject-strict.unit.ts +++ b/src/utils/inject/inject-strict.unit.ts @@ -3,9 +3,10 @@ import { injectStrict } from "./inject-strict"; import { mountComposable } from "@@/tests/test-utils/mountComposable"; const PROVIDER_KEY: InjectionKey = Symbol("provider"); +const BOOLEAN_PROVIDER_KEY: InjectionKey = Symbol("booleanProvider"); describe("injectStrict", () => { - describe("when resource is provided", () => { + describe("when string-resource is provided", () => { it("should return the provided resource", () => { const { provider } = mountComposable( () => { @@ -21,6 +22,22 @@ describe("injectStrict", () => { }); }); + describe("when boolean-false-resource is provided", () => { + it("should return the provided value: false", () => { + const { provider } = mountComposable( + () => { + const provider = injectStrict(BOOLEAN_PROVIDER_KEY); + return { provider }; + }, + { + [BOOLEAN_PROVIDER_KEY.valueOf()]: false, + } + ); + + expect(provider).toEqual(false); + }); + }); + describe("otherwise when resource is NOT provided", () => { it("should throw if no default is given", () => { expect(() => { diff --git a/tsconfig.json b/tsconfig.json index 33593fe8bc..7d76622a08 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,10 +27,9 @@ "@feature-board-text-element": [ "src/components/feature-board-text-element" ], - "@feature-board-content-element-menu": [ - "src/components/feature-board-content-element-menu" + "@feature-board-link-element": [ + "src/components/feature-board-link-element" ], - "@feature-board-external-tool-element": [ "src/components/feature-board-external-tool-element" ], @@ -42,6 +41,7 @@ "@ui-confirmation-dialog": ["src/components/ui-confirmation-dialog"], "@ui-light-box": ["src/components/ui-light-box"], "@util-board": ["src/components/util-board"], + "@util-validators": ["src/components/util-validators"], "@page-board": ["src/components/page-board"], "@/*": ["src/*"], "@@/*": ["*"] diff --git a/vue.config.js b/vue.config.js index 6c317a19d2..29c3e6378b 100644 --- a/vue.config.js +++ b/vue.config.js @@ -30,8 +30,8 @@ module.exports = defineConfig({ "@feature-board-text-element": getDir( "src/components/feature-board-text-element" ), - "@feature-board-content-element-menu": getDir( - "src/components/feature-board-content-element-menu" + "@feature-board-link-element": getDir( + "src/components/feature-board-link-element" ), "@feature-board-external-tool-element": getDir( "src/components/feature-board-external-tool-element" @@ -48,6 +48,7 @@ module.exports = defineConfig({ ), "@ui-light-box": getDir("src/components/ui-light-box"), "@util-board": getDir("src/components/util-board"), + "@util-validators": getDir("src/components/util-validators"), "@page-board": getDir("src/components/page-board"), }, extensions: [".js", ".ts", ".vue", ".json"],