From 822ab0cf9329fa60ef4b85a7b2f3227e5d7f5ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= Date: Wed, 15 Jan 2025 15:00:33 +0100 Subject: [PATCH 1/5] implement share links for cards --- src/locales/de.ts | 5 + src/locales/en.ts | 4 + src/locales/es.ts | 4 + src/locales/uk.ts | 5 + src/modules/feature/board/board/Board.unit.ts | 33 +++++- src/modules/feature/board/board/Board.vue | 22 +++- .../feature/board/card/CardHost.unit.ts | 57 +++++++-- src/modules/feature/board/card/CardHost.vue | 37 ++++-- .../ui/board/BoardMenuActionShareLink.unit.ts | 41 +++++++ .../ui/board/BoardMenuActionShareLink.vue | 26 +++++ src/modules/ui/board/index.ts | 2 + src/modules/util/board/index.ts | 2 + .../util/board/shareBoardLink.composable.ts | 42 +++++++ .../board/shareBoardLink.composable.unit.ts | 110 ++++++++++++++++++ 14 files changed, 368 insertions(+), 22 deletions(-) create mode 100644 src/modules/ui/board/BoardMenuActionShareLink.unit.ts create mode 100644 src/modules/ui/board/BoardMenuActionShareLink.vue create mode 100644 src/modules/util/board/shareBoardLink.composable.ts create mode 100644 src/modules/util/board/shareBoardLink.composable.unit.ts diff --git a/src/locales/de.ts b/src/locales/de.ts index 3ed55fbdbb..78f90c9c26 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -169,6 +169,10 @@ export default { "common.words.color.turquoise": "Türkis", "common.words.color.yellow": "Gelb", "common.words.copiedToClipboard": "In die Zwischenablage kopiert", + "common.words.copyLinkToClipboard.success": + "Link in die Zwischenablage kopiert", + "common.words.copyLinkToClipboard.failure": + "Link konnte nicht in die Zwischenablage kopiert werden", "common.words.courseGroups": "Kursgruppen", "common.words.courses": "Kurse", "common.words.draft": "Entwurf", @@ -370,6 +374,7 @@ export default { "components.board.action.moveLeft": "Nach links verschieben", "components.board.action.moveRight": "Nach rechts verschieben", "components.board.action.moveUp": "Nach oben verschieben", + "components.board.action.shareLink.card": "Link zur Karte kopieren", "components.board.alert.info.teacher": "Dieser Bereich ist sichtbar für alle Kursteilnehmenden.", "components.board.alert.info.draft": diff --git a/src/locales/en.ts b/src/locales/en.ts index 481c80ba09..844de968aa 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -170,6 +170,9 @@ export default { "common.words.color.turquoise": "Turquoise", "common.words.color.yellow": "Yellow", "common.words.copiedToClipboard": "Copied to the clipboard", + "common.words.copyLinkToClipboard.success": "Link copied to clipboard", + "common.words.copyLinkToClipboard.failure": + "Link could not be copied to clipboard", "common.words.courseGroups": "Course Groups", "common.words.courses": "Courses", "common.words.draft": "draft", @@ -369,6 +372,7 @@ export default { "components.board.action.moveLeft": "Move left", "components.board.action.moveRight": "Move right", "components.board.action.moveUp": "Move up", + "components.board.action.shareLink.card": "Copy link to card", "components.board.alert.info.teacher": "This board is visible to all course participants.", "components.board.alert.info.draft": diff --git a/src/locales/es.ts b/src/locales/es.ts index c614ebff8c..6f752ef334 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -172,6 +172,9 @@ export default { "common.words.color.turquoise": "Turquesa", "common.words.color.yellow": "Amarillo", "common.words.copiedToClipboard": "Copiado en el portapapeles", + "common.words.copyLinkToClipboard.success": "Enlace copiado al portapapeles", + "common.words.copyLinkToClipboard.failure": + "El enlace no se pudo copiar al portapapeles", "common.words.courseGroups": "grupos de cursos", "common.words.courses": "Cursos", "common.words.draft": "borrador", @@ -374,6 +377,7 @@ export default { "components.board.action.moveLeft": "Mover a la izquierda", "components.board.action.moveRight": "Mover a la derecha", "components.board.action.moveUp": "Levantar", + "components.board.action.shareLink.card": "Enlace a la ficha", "components.board.alert.info.teacher": "Este tablero es visible para todos los participantes en el curso.", "components.board.alert.info.draft": diff --git a/src/locales/uk.ts b/src/locales/uk.ts index 21d5da9e3a..2835db633b 100644 --- a/src/locales/uk.ts +++ b/src/locales/uk.ts @@ -172,6 +172,10 @@ export default { "common.words.color.turquoise": "Бірюза", "common.words.color.yellow": "Жовтий", "common.words.copiedToClipboard": "Скопійовано в буфер обміну", + "common.words.copyLinkToClipboard.success": + "Посилання скопійовано в буфер обміну", + "common.words.copyLinkToClipboard.failure": + "Не вдалося скопіювати посилання в буфер обміну", "common.words.courseGroups": "курсові групи", "common.words.courses": "Мій курс", "common.words.draft": "чернетка", @@ -377,6 +381,7 @@ export default { "components.board.action.moveLeft": "Перемістіться вліво", "components.board.action.moveRight": "Перемістіться праворуч", "components.board.action.moveUp": "Рухатися вгору", + "components.board.action.shareLink.card": "Скопіювати посилання на Карту", "components.board.alert.info.teacher": "Цю дошку бачать усі учасники курсу.", "components.board.alert.info.draft": "Ця дошка невидима для учасників курсу.", "components.board.column.defaultTitle": "Нова колонка", diff --git a/src/modules/feature/board/board/Board.unit.ts b/src/modules/feature/board/board/Board.unit.ts index 03d68e7045..c245c240a7 100644 --- a/src/modules/feature/board/board/Board.unit.ts +++ b/src/modules/feature/board/board/Board.unit.ts @@ -167,7 +167,9 @@ describe("Board", () => { }); mockExtractDataAttribute.mockReturnValue("column-id"); - route = createMock>(); + route = createMock>({ + hash: "", + }); useRouteMock.mockReturnValue(route); router = createMock(); @@ -389,6 +391,35 @@ describe("Board", () => { expect(cardStore.loadPreferredTools).not.toHaveBeenCalled(); }); }); + + describe("when the url has a hash", () => { + const setup2 = () => { + setup(); + + const elementId = "card-12345"; + route.hash = `#${elementId}`; + + const domElementMock = createMock(); + const querySelectorSpy = jest.spyOn(document, "querySelector"); + querySelectorSpy.mockReturnValueOnce(domElementMock); + + return { + domElementMock, + }; + }; + + it("should scroll to and focus the element", async () => { + const { domElementMock } = setup2(); + + await nextTick(); + + expect(domElementMock.scrollIntoView).toHaveBeenCalledWith({ + block: "start", + inline: "center", + }); + expect(domElementMock.focus).toHaveBeenCalled(); + }); + }); }); describe("when component is unMounted", () => { diff --git a/src/modules/feature/board/board/Board.vue b/src/modules/feature/board/board/Board.vue index 05d59f0475..36270e3341 100644 --- a/src/modules/feature/board/board/Board.vue +++ b/src/modules/feature/board/board/Board.vue @@ -284,15 +284,33 @@ const onUpdateBoardTitle = async (newTitle: string) => { boardStore.updateBoardTitleRequest({ boardId: props.boardId, newTitle }); }; -onMounted(() => { +const scrollToNodeAndFocus = (scrollTargetId: string) => { + const targetElement: HTMLElement | null = document.querySelector( + `[data-scroll-target="${scrollTargetId}"]` + ); + + targetElement?.scrollIntoView({ block: "start", inline: "center" }); + targetElement?.focus(); +}; + +onMounted(async () => { resetPageInformation(); setAlert(); useBoardInactivity(); - boardStore.fetchBoardRequest({ boardId: props.boardId }); + const boardFetchPromise = boardStore.fetchBoardRequest({ + boardId: props.boardId, + }); if (hasCreateToolPermission) { cardStore.loadPreferredTools(ToolContextType.BoardElement); } + + await boardFetchPromise; + + if (route.hash) { + const scrollTargetId: string = route.hash.slice(1); + scrollToNodeAndFocus(scrollTargetId); + } }); onUnmounted(() => { diff --git a/src/modules/feature/board/card/CardHost.unit.ts b/src/modules/feature/board/card/CardHost.unit.ts index 7d4456f81f..16ac9b4ee1 100644 --- a/src/modules/feature/board/card/CardHost.unit.ts +++ b/src/modules/feature/board/card/CardHost.unit.ts @@ -24,11 +24,17 @@ import { } from "@data-board"; import { createMock, DeepMocked } from "@golevelup/ts-jest"; import { createTestingPinia } from "@pinia/testing"; -import { BoardMenuActionDelete } from "@ui-board"; +import { + BoardMenuActionDelete, + BoardMenuActionEdit, + BoardMenuActionShareLink, + BoardMenuScope, +} from "@ui-board"; import { useDeleteConfirmationDialog } from "@ui-confirmation-dialog"; import { useBoardNotifier, useCourseBoardEditMode, + useShareBoardLink, useSharedEditMode, useSharedLastCreatedElement, } from "@util-board"; @@ -43,6 +49,7 @@ const mockedUseBoardNotifier = jest.mocked(useBoardNotifier); const mockedSharedLastCreatedElement = jest.mocked(useSharedLastCreatedElement); const mockedEditMode = jest.mocked(useCourseBoardEditMode); const mockedUseSharedEditMode = jest.mocked(useSharedEditMode); +const mockedUseShareBoardLink = jest.mocked(useShareBoardLink); jest.mock("@data-board/BoardFocusHandler.composable"); const mockedBoardFocusHandler = jest.mocked(useBoardFocusHandler); @@ -65,6 +72,7 @@ describe("CardHost", () => { let mockedSharedLastCreatedElementCalls: DeepMocked< ReturnType >; + let useShareBoardLinkMock: DeepMocked>; beforeEach(() => { setupStores({ envConfigModule: EnvConfigModule }); @@ -83,6 +91,11 @@ describe("CardHost", () => { isInEditMode: computed(() => true), }); + useShareBoardLinkMock = createMock>({ + getShareLinkId: jest.fn().mockReturnValue("shareLinkId"), + }); + mockedUseShareBoardLink.mockReturnValue(useShareBoardLinkMock); + mockedBoardFocusHandler.mockReturnValue({ isFocusContained: computed(() => true), isFocused: computed(() => true), @@ -138,6 +151,8 @@ describe("CardHost", () => { }); } + const cardId = card?.id ?? "cardId"; + const wrapper = shallowMount(CardHost, { global: { plugins: [ @@ -154,7 +169,7 @@ describe("CardHost", () => { ], }, propsData: { - cardId: card?.id ?? "cardId", + cardId, height: card?.height ?? 0, columnIndex: 0, rowIndex: 1, @@ -163,7 +178,10 @@ describe("CardHost", () => { mockedPiniaStoreTyping(useCardStore); - return { wrapper }; + return { + wrapper, + cardId, + }; }; describe("when component is mounted", () => { @@ -203,20 +221,43 @@ describe("CardHost", () => { describe("user permissions", () => { describe("when user is not permitted to delete", () => { - it("should not be rendered on DOM", () => { + it("should not show an edit button", () => { + mockedBoardPermissions.hasDeletePermission = false; + const { wrapper } = setup(); + + const deleteButton = wrapper.findComponent(BoardMenuActionEdit); + + expect(deleteButton.exists()).toEqual(false); + }); + + it("should not show a delete button", () => { mockedBoardPermissions.hasDeletePermission = false; const { wrapper } = setup(); - const boardMenuComponent = wrapper.findAllComponents({ - name: "BoardMenu", - }); + const deleteButton = wrapper.findComponent(BoardMenuActionDelete); - expect(boardMenuComponent.length).toStrictEqual(0); + expect(deleteButton.exists()).toEqual(false); }); }); }); describe("card menus", () => { + describe("when users clicks share link menu", () => { + it("should copy a share link", async () => { + mockedBoardPermissions.hasDeletePermission = true; + const { wrapper, cardId } = setup(); + + const shareLinkButton = wrapper.findComponent(BoardMenuActionShareLink); + + await shareLinkButton.trigger("click"); + + expect(useShareBoardLinkMock.copyShareLink).toHaveBeenCalledWith( + cardId, + BoardMenuScope.CARD + ); + }); + }); + describe("when users click delete menu", () => { it("should emit 'delete:card'", async () => { mockedBoardPermissions.hasDeletePermission = true; diff --git a/src/modules/feature/board/card/CardHost.vue b/src/modules/feature/board/card/CardHost.vue index c904a95ad5..daadbb27e9 100644 --- a/src/modules/feature/board/card/CardHost.vue +++ b/src/modules/feature/board/card/CardHost.vue @@ -19,6 +19,7 @@ :ripple="false" :hover="isHovered" :data-testid="cardTestId" + :data-scroll-target="getShareLinkId(cardId, BoardMenuScope.CARD)" >