diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index e91fad7ec..c1c684609 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -43,7 +43,7 @@ jobs: VITE_GRAASP_LIBRARY_HOST: ${{ vars.VITE_GRAASP_LIBRARY_HOST }} VITE_GRAASP_ANALYZER_HOST: ${{ vars.VITE_GRAASP_ANALYZER_HOST }} VITE_SHOW_NOTIFICATIONS: ${{ vars.VITE_SHOW_NOTIFICATIONS }} - VITE_GRAASP_REDIRECTION_HOST: "http://localhost:3030" + VITE_GRAASP_REDIRECTION_HOST: ${{ vars.VITE_GRAASP_REDIRECTION_HOST }} # use the Cypress GitHub Action to run Cypress tests within the chrome browser - name: Cypress run @@ -66,8 +66,7 @@ jobs: VITE_GRAASP_LIBRARY_HOST: ${{ vars.VITE_GRAASP_LIBRARY_HOST }} VITE_GRAASP_ANALYZER_HOST: ${{ vars.VITE_GRAASP_ANALYZER_HOST }} VITE_SHOW_NOTIFICATIONS: ${{ vars.VITE_SHOW_NOTIFICATIONS }} - # TODO: add REDIRECTION_HOST ! - VITE_GRAASP_REDIRECTION_HOST: "http://localhost:3030" + VITE_GRAASP_REDIRECTION_HOST: ${{ vars.VITE_GRAASP_REDIRECTION_HOST }} # after the test run completes # store any screenshots diff --git a/README.md b/README.md index 8765e1b5d..aae7e1dda 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ VITE_PORT=3111 VITE_GRAASP_API_HOST=http://localhost:3000 VITE_SHOW_NOTIFICATIONS=true VITE_GRAASP_AUTH_HOST=http://localhost:3001 -VITE_GRAASP_REDIRECTION_HOST=http://localhost:3000 +# in prod, it is https://go.graasp.org +VITE_GRAASP_REDIRECTION_HOST=http://localhost:3000/items/short-links VITE_H5P_INTEGRATION_URL= VITE_VERSION=latest-dev ``` @@ -56,7 +57,8 @@ VITE_GRAASP_AUTH_HOST=http://localhost:3001 VITE_GRAASP_PLAYER_HOST=http://localhost:3112 VITE_GRAASP_LIBRARY_HOST=http://localhost:3005 VITE_GRAASP_ANALYZER_HOST=http://localhost:3113 -VITE_GRAASP_REDIRECTION_HOST=http://localhost:3000 +# in prod, it is https://go.graasp.org who will redirect to the backend +VITE_GRAASP_REDIRECTION_HOST=http://localhost:3000/items/short-links VITE_H5P_INTEGRATION_URL= VITE_VERSION=cypress-tests VITE_SHOW_NOTIFICATIONS=true diff --git a/cypress.config.ts b/cypress.config.ts index b5fc2c9ee..586968d7b 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -13,6 +13,8 @@ export default defineConfig({ BUILDER_HOST: `http://localhost:${process.env.VITE_PORT}`, PLAYER_HOST: process.env.VITE_GRAASP_PLAYER_HOST, ANALYZER_HOST: process.env.VITE_GRAASP_ANALYZER_HOST, + LIBRARY_HOST: process.env.VITE_GRAASP_LIBRARY_HOST, + REDIRECTION_HOST: process.env.VITE_GRAASP_REDIRECTION_HOST, }, // We've imported your old cypress plugins here. // You may want to clean this up later by importing these. diff --git a/cypress/e2e/item/share/changeVisibility.cy.ts b/cypress/e2e/item/share/changeVisibility.cy.ts new file mode 100644 index 000000000..3f09eaf6e --- /dev/null +++ b/cypress/e2e/item/share/changeVisibility.cy.ts @@ -0,0 +1,119 @@ +import { ItemLoginSchemaType, ItemTagType } from '@graasp/sdk'; + +import { buildItemPath } from '@/config/paths'; + +import { SETTINGS } from '../../../../src/config/constants'; +import { + SHARE_ITEM_PSEUDONYMIZED_SCHEMA_ID, + SHARE_ITEM_VISIBILITY_SELECT_ID, + buildShareButtonId, +} from '../../../../src/config/selectors'; +import { + ITEM_LOGIN_ITEMS, + SAMPLE_ITEMS, + SAMPLE_PUBLIC_ITEMS, +} from '../../../fixtures/items'; + +const changeVisibility = (value: string): void => { + cy.get(`#${SHARE_ITEM_VISIBILITY_SELECT_ID}`).click(); + cy.get(`li[data-value="${value}"]`, { timeout: 1000 }).click(); +}; + +describe('Visibility of an Item', () => { + it('Change Private Item to Public', () => { + const item = SAMPLE_ITEMS.items[0]; + cy.setUpApi({ ...SAMPLE_ITEMS }); + cy.visit(buildItemPath(item.id)); + cy.get(`#${buildShareButtonId(item.id)}`).click(); + + const visiblitySelect = cy.get( + `#${SHARE_ITEM_VISIBILITY_SELECT_ID} + input`, + ); + + // visibility select default value + visiblitySelect.should('have.value', SETTINGS.ITEM_PRIVATE.name); + + // change private -> public + changeVisibility(SETTINGS.ITEM_PUBLIC.name); + cy.wait(`@postItemTag-${ItemTagType.Public}`).then( + ({ request: { url } }) => { + expect(url).to.contain(item.id); + }, + ); + }); + + it('Change Public Item to Private', () => { + const item = SAMPLE_PUBLIC_ITEMS.items[0]; + cy.setUpApi({ ...SAMPLE_PUBLIC_ITEMS }); + cy.visit(buildItemPath(item.id)); + cy.get(`#${buildShareButtonId(item.id)}`).click(); + cy.wait(1000); + const visiblitySelect = cy.get( + `#${SHARE_ITEM_VISIBILITY_SELECT_ID} + input`, + ); + + // visibility select default value + visiblitySelect.should('have.value', SETTINGS.ITEM_PUBLIC.name); + + // change public -> private + changeVisibility(SETTINGS.ITEM_PRIVATE.name); + cy.wait(`@deleteItemTag-${ItemTagType.Public}`).then( + ({ request: { url } }) => { + expect(url).to.contain(item.id); + }, + ); + }); + + it('Change Public Item to Item Login', () => { + const item = SAMPLE_PUBLIC_ITEMS.items[0]; + cy.setUpApi({ ...SAMPLE_PUBLIC_ITEMS }); + cy.visit(buildItemPath(item.id)); + cy.get(`#${buildShareButtonId(item.id)}`).click(); + cy.wait(1000); + const visiblitySelect = cy.get( + `#${SHARE_ITEM_VISIBILITY_SELECT_ID} + input`, + ); + + // visibility select default value + visiblitySelect.should('have.value', SETTINGS.ITEM_PUBLIC.name); + + // change public -> item login + changeVisibility(SETTINGS.ITEM_LOGIN.name); + cy.wait([ + `@deleteItemTag-${ItemTagType.Public}`, + '@putItemLoginSchema', + ]).then((data) => { + const { + request: { url }, + } = data[0]; + expect(url).to.contain(item.id); + expect(url).to.contain(ItemTagType.Public); // originally item login + }); + }); + + it('Change Pseudonymized Item to Private Item', () => { + const item = ITEM_LOGIN_ITEMS.items[0]; + cy.setUpApi({ items: [item] }); + cy.visit(buildItemPath(item.id)); + cy.get(`#${buildShareButtonId(item.id)}`).click(); + + // visibility select default value + cy.get(`#${SHARE_ITEM_VISIBILITY_SELECT_ID} + input`).should( + 'have.value', + SETTINGS.ITEM_LOGIN.name, + ); + + // change item login schema + cy.get(`#${SHARE_ITEM_PSEUDONYMIZED_SCHEMA_ID} + input`).should( + 'have.value', + ItemLoginSchemaType.Username, + ); + // item login edition is done in itemLogin.cy.js + + // change pseudonymized -> private + changeVisibility(SETTINGS.ITEM_PRIVATE.name); + cy.wait(`@deleteItemLoginSchema`).then(({ request: { url } }) => { + expect(url).to.include(item.id); + }); + }); +}); diff --git a/cypress/e2e/item/share/shareItem.cy.ts b/cypress/e2e/item/share/shareItem.cy.ts new file mode 100644 index 000000000..20f868ca9 --- /dev/null +++ b/cypress/e2e/item/share/shareItem.cy.ts @@ -0,0 +1,194 @@ +import { Context, ShortLink, appendPathToUrl } from '@graasp/sdk'; + +import { buildItemPath } from '@/config/paths'; +import { ShortLinkPlatform } from '@/utils/shortLink'; + +import { + SHARE_ITEM_QR_BTN_ID, + SHARE_ITEM_QR_DIALOG_ID, + SHORT_LINK_COMPONENT, + buildShareButtonId, + buildShortLinkPlatformTextId, + buildShortLinkUrlTextId, +} from '../../../../src/config/selectors'; +import { PUBLISHED_ITEM } from '../../../fixtures/items'; +import { + GRAASP_REDIRECTION_HOST, + buildGraaspBuilderView, + buildGraaspLibraryLink, + buildGraaspPlayerView, +} from '../../../support/paths'; + +const checkContainPlatformText = (platform: ShortLinkPlatform) => + cy + .get(`#${buildShortLinkPlatformTextId(platform)}`) + .should('contain', platform); + +const checkContainUrlText = (platform: ShortLinkPlatform, itemId: string) => { + let expectedUrl; + + // The client host manager can't be used here because + // cypress run this before the main.tsx, where the manager is init. + switch (platform) { + case 'builder': + expectedUrl = buildGraaspBuilderView(itemId); + break; + case 'player': + expectedUrl = buildGraaspPlayerView(itemId); + break; + case 'library': + expectedUrl = buildGraaspLibraryLink(itemId); + break; + default: + throw new Error(`The given platform ${platform} is unknown.`); + } + + cy.get(`#${buildShortLinkUrlTextId(platform)}`).should( + 'contain', + expectedUrl, + ); +}; + +const checkContainShortLinkText = ( + platform: ShortLinkPlatform, + alias: string, +) => { + const expectedUrl = appendPathToUrl({ + baseURL: GRAASP_REDIRECTION_HOST, + pathname: alias, + }).toString(); + + cy.get(`#${buildShortLinkUrlTextId(platform)}`).should( + 'contain', + expectedUrl, + ); +}; + +describe('Share Item Link', () => { + describe('Without short links', () => { + const item = PUBLISHED_ITEM; + + beforeEach(() => { + cy.setUpApi({ items: [PUBLISHED_ITEM] }); + }); + + it('Builder link is correctly displayed', () => { + cy.visit(buildItemPath(item.id)); + cy.get(`#${buildShareButtonId(item.id)}`).click(); + + cy.get(`.${SHORT_LINK_COMPONENT}`).should('have.length', 3); + + const context = Context.Builder; + checkContainPlatformText(context); + checkContainUrlText(context, item.id); + }); + + it('Player link is correctly displayed', () => { + cy.visit(buildItemPath(item.id)); + cy.get(`#${buildShareButtonId(item.id)}`).click(); + + cy.get(`.${SHORT_LINK_COMPONENT}`).should('have.length', 3); + + const context = Context.Player; + checkContainPlatformText(context); + checkContainUrlText(context, item.id); + }); + + it('Library link is correctly displayed', () => { + cy.visit(buildItemPath(item.id)); + cy.get(`#${buildShareButtonId(item.id)}`).click(); + + cy.get(`.${SHORT_LINK_COMPONENT}`).should('have.length', 3); + + const context = Context.Library; + checkContainPlatformText(context); + checkContainUrlText(context, item.id); + }); + + it('Share Item with QR Code', () => { + cy.visit(buildItemPath(item.id)); + cy.get(`#${buildShareButtonId(item.id)}`).click(); + + cy.get(`.${SHORT_LINK_COMPONENT}`).should('have.length', 3); + + cy.get(`#${SHARE_ITEM_QR_BTN_ID}`).click(); + cy.get(`#${SHARE_ITEM_QR_DIALOG_ID}`).should('exist'); + }); + }); + + describe('With short links', () => { + const item = PUBLISHED_ITEM; + + const shortLinks: ShortLink[] = [ + { + alias: 'test-1', + platform: Context.Builder, + item: { id: item.id }, + createdAt: new Date().toISOString(), + }, + { + alias: 'test-2', + platform: Context.Player, + item: { id: item.id }, + createdAt: new Date().toISOString(), + }, + { + alias: 'test-3', + platform: Context.Library, + item: { id: item.id }, + createdAt: new Date().toISOString(), + }, + ]; + + beforeEach(() => { + cy.setUpApi({ items: [PUBLISHED_ITEM], shortLinks, itemId: item.id }); + }); + + it('Builder link is correctly displayed', () => { + cy.visit(buildItemPath(item.id)); + cy.get(`#${buildShareButtonId(item.id)}`).click(); + + cy.wait('@getShortLinksItem').its('response.body.length').should('eq', 3); + cy.get(`.${SHORT_LINK_COMPONENT}`).should('have.length', 3); + + const context = Context.Builder; + checkContainPlatformText(context); + checkContainShortLinkText(context, shortLinks[0].alias); + }); + + it('Player link is correctly displayed', () => { + cy.visit(buildItemPath(item.id)); + cy.get(`#${buildShareButtonId(item.id)}`).click(); + + cy.wait('@getShortLinksItem').its('response.body.length').should('eq', 3); + cy.get(`.${SHORT_LINK_COMPONENT}`).should('have.length', 3); + + const context = Context.Player; + checkContainPlatformText(context); + checkContainShortLinkText(context, shortLinks[1].alias); + }); + + it('Library link is correctly displayed', () => { + cy.visit(buildItemPath(item.id)); + cy.get(`#${buildShareButtonId(item.id)}`).click(); + + cy.wait('@getShortLinksItem').its('response.body.length').should('eq', 3); + cy.get(`.${SHORT_LINK_COMPONENT}`).should('have.length', 3); + + const context = Context.Library; + checkContainPlatformText(context); + checkContainShortLinkText(context, shortLinks[2].alias); + }); + + it('Share Item with QR Code', () => { + cy.visit(buildItemPath(item.id)); + cy.get(`#${buildShareButtonId(item.id)}`).click(); + + cy.wait('@getShortLinksItem').its('response.body.length').should('eq', 3); + cy.get(`.${SHORT_LINK_COMPONENT}`).should('have.length', 3); + + cy.get(`#${SHARE_ITEM_QR_BTN_ID}`).click(); + cy.get(`#${SHARE_ITEM_QR_DIALOG_ID}`).should('exist'); + }); + }); +}); diff --git a/cypress/e2e/item/shortLink/shortLink.cy.ts b/cypress/e2e/item/share/shortLink.cy.ts similarity index 99% rename from cypress/e2e/item/shortLink/shortLink.cy.ts rename to cypress/e2e/item/share/shortLink.cy.ts index b154a2423..32545849e 100644 --- a/cypress/e2e/item/shortLink/shortLink.cy.ts +++ b/cypress/e2e/item/share/shortLink.cy.ts @@ -31,13 +31,14 @@ describe('Short links', () => { beforeEach(() => { const shortLinks: ShortLink[] = []; + itemId = SAMPLE_ITEMS.items[1].id; + cy.setUpApi({ ...SAMPLE_ITEMS, getShortLinkAvailable: true, // indicates that the short link is available shortLinks, + itemId, }); - - itemId = SAMPLE_ITEMS.items[1].id; }); it('Add default short link', () => { @@ -147,6 +148,7 @@ describe('Short links', () => { ...SAMPLE_ITEMS, getShortLinkAvailable: true, // indicates that the short link is available shortLinks, + itemId, }); }); @@ -294,6 +296,7 @@ describe('Short links', () => { ...SAMPLE_ITEMS, getShortLinkAvailable: false, // indicates that the short link is not available shortLinks, + itemId, }); }); @@ -373,13 +376,14 @@ describe('Short links', () => { beforeEach(() => { const shortLinks: ShortLink[] = []; + itemId = PUBLISHED_ITEM.id; + cy.setUpApi({ items: [PUBLISHED_ITEM], getShortLinkAvailable: true, // indicates that the short link is available shortLinks, + itemId, }); - - itemId = PUBLISHED_ITEM.id; }); it('POST Library short link', () => { @@ -426,6 +430,8 @@ describe('Short links', () => { let itemId: string; let shortLinks: ShortLink[]; beforeEach(() => { + itemId = SAMPLE_READ_ITEMS.items[0].id; + shortLinks = [ { alias: 'test-1', @@ -439,9 +445,8 @@ describe('Short links', () => { ...SAMPLE_READ_ITEMS, getShortLinkAvailable: true, // indicates that the short link is available shortLinks, + itemId, }); - - itemId = SAMPLE_READ_ITEMS.items[0].id; }); it('Short links are read only', () => { diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 415f8d6cc..0da995cee 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -160,7 +160,7 @@ Cypress.Commands.add( getFavoriteError = false, addFavoriteError = false, deleteFavoriteError = false, - + itemId, shortLinks = [], getShortLinksItemError = false, getShortLinkAvailable = true, @@ -332,7 +332,7 @@ Cypress.Commands.add( mockDeleteFavorite(deleteFavoriteError); - mockGetShortLinksItem(cachedShortLinks, getShortLinksItemError); + mockGetShortLinksItem(itemId, cachedShortLinks, getShortLinksItemError); mockCheckShortLink(getShortLinkAvailable); diff --git a/cypress/support/paths.ts b/cypress/support/paths.ts index 38f8d395b..90e5bd9f0 100644 --- a/cypress/support/paths.ts +++ b/cypress/support/paths.ts @@ -1,10 +1,12 @@ -import { buildSignInPath } from '@graasp/sdk'; +import { LIBRARY_ITEMS_PREFIX, buildSignInPath } from '@graasp/sdk'; import { buildItemPath } from '@/config/paths'; const GRAASP_PLAYER_HOST = Cypress.env('PLAYER_HOST'); const GRAASP_BUILDER_HOST = Cypress.env('BUILDER_HOST'); const GRAASP_ANALYZER_HOST = Cypress.env('ANALYZER_HOST'); +const GRAASP_LIBRARY_HOST = Cypress.env('LIBRARY_HOST'); +export const GRAASP_REDIRECTION_HOST = Cypress.env('REDIRECTION_HOST'); export const SIGN_IN_PATH = buildSignInPath({ host: Cypress.env('AUTH_HOST') }); @@ -14,3 +16,5 @@ export const buildGraaspBuilderView = (id: string): string => `${GRAASP_BUILDER_HOST}${buildItemPath(id)}`; export const buildGraaspAnalyzerLink = (id: string): string => `${GRAASP_ANALYZER_HOST}/embedded/${id}`; +export const buildGraaspLibraryLink = (id: string): string => + `${GRAASP_LIBRARY_HOST}${LIBRARY_ITEMS_PREFIX}/${id}`; diff --git a/cypress/support/server.ts b/cypress/support/server.ts index 6ed8a355b..29a41faa1 100644 --- a/cypress/support/server.ts +++ b/cypress/support/server.ts @@ -16,6 +16,7 @@ import { PermissionLevel, RecycledItemData, ShortLink, + ShortLinkPayload, } from '@graasp/sdk'; import { FAILURE_MESSAGES } from '@graasp/translations'; @@ -2025,6 +2026,7 @@ export const mockDeleteFavorite = (shouldThrowError: boolean): void => { // Intercept ShortLinks calls export const mockGetShortLinksItem = ( + itemId: string, shortLinks: ShortLink[], shouldThrowError: boolean, ): void => { @@ -2038,7 +2040,7 @@ export const mockGetShortLinksItem = ( return reply({ statusCode: StatusCodes.BAD_REQUEST }); } - return reply(shortLinks); + return reply(shortLinks.filter(({ item }) => item?.id === itemId)); }, ).as('getShortLinksItem'); }; @@ -2061,6 +2063,21 @@ export const mockCheckShortLink = (shouldAliasBeAvailable: boolean): void => { ).as('checkShortLink'); }; +/** + * Convert short link payload to short link object to mock server response. + * @param payload The payload of the short link when posting new short link for example. + * @returns The short link object converted from the payload. + */ +function payloadToShortLink(payload: ShortLinkPayload): ShortLink { + const { itemId, ...restOfPayload } = payload; + + return { + ...restOfPayload, + item: { id: itemId }, + createdAt: new Date().toISOString(), + }; +} + export const mockPostShortLink = ( shortLinks: ShortLink[], shouldThrowError: boolean, @@ -2075,9 +2092,12 @@ export const mockPostShortLink = ( return reply({ statusCode: StatusCodes.BAD_REQUEST }); } - shortLinks.push(body); + // Because the payload contains itemId and short link object contains item: { id } + // it is necessary to transform the post request to short link to mock server response. + const shortLink = payloadToShortLink(body); + shortLinks.push(shortLink); - return reply(body); + return reply(shortLink); }, ).as('postShortLink'); }; @@ -2101,10 +2121,11 @@ export const mockPatchShortLink = ( const urlParams = url.split('/'); const patchedAlias = urlParams[urlParams.length - 1]; - const shortLink = shortLinks.filter( + const shortLink = shortLinks.find( (shortlink) => shortlink.alias === patchedAlias, - )[0]; + ); + // This works only because of JS referenced object. It is for a mocked db only. shortLink.alias = body.alias; shortLink.platform = body.platform; @@ -2136,6 +2157,7 @@ export const mockDeleteShortLink = ( (shortLink) => shortLink.alias === deletedAlias, ); const removed = shortLinks[idxToRemove]; + // This works only because of JS referenced object. It is for a mocked db only. shortLinks.splice(idxToRemove, 1); return reply(removed); diff --git a/package.json b/package.json index b8820a9f1..82949adc4 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,9 @@ "@emotion/styled": "11.11.0", "@graasp/chatbox": "3.0.0", "@graasp/query-client": "2.1.0", - "@graasp/sdk": "3.2.0", + "@graasp/sdk": "3.3.0", "@graasp/translations": "1.21.0", - "@graasp/ui": "4.0.0", + "@graasp/ui": "4.1.0", "@mui/icons-material": "5.14.16", "@mui/lab": "5.0.0-alpha.151", "@mui/material": "5.14.16", @@ -148,8 +148,5 @@ "vite-plugin-checker": "0.6.2", "vite-plugin-istanbul": "5.0.0" }, - "resolutions": { - "@graasp/sdk": "3.2.0" - }, "packageManager": "yarn@3.6.4" } diff --git a/src/components/item/sharing/shortLink/AliasInput.tsx b/src/components/item/sharing/shortLink/AliasInput.tsx index d143ec6d7..e58f9e651 100644 --- a/src/components/item/sharing/shortLink/AliasInput.tsx +++ b/src/components/item/sharing/shortLink/AliasInput.tsx @@ -29,7 +29,7 @@ const AliasInput = ({ alias, onChange, hasError }: Props): JSX.Element => { ({ type Props = { alias: string; - aliasUnchanged: boolean; + hasAliasChanged: boolean; isAvailableLoading: boolean; shortLinkAvailable?: ShortLinkAvailable; onValidAlias: (isValid: boolean) => void; @@ -25,7 +25,7 @@ type Props = { const AliasValidation = ({ alias, - aliasUnchanged, + hasAliasChanged, isAvailableLoading, shortLinkAvailable, onValidAlias, @@ -50,7 +50,7 @@ const AliasValidation = ({ if (!isValid) { const msgKey = messageKey ?? BUILDER.SHORT_LINK_UNKOWN_ERROR; setMessage(translateBuilder(msgKey, { data })); - } else if (aliasUnchanged) { + } else if (!hasAliasChanged) { setMessage(ALIAS_UNCHANGED_MSG); setAvailable(true); } else if (isAvailableLoading) { @@ -60,20 +60,24 @@ const AliasValidation = ({ setMessage(aliasAvailable ? ALIAS_VALID_MSG : ALIAS_ALREADY_EXIST); setAvailable(aliasAvailable); } + + onValidAlias(valid); + onError(hasError); }, [ alias, - aliasUnchanged, + hasAliasChanged, isAvailableLoading, shortLinkAvailable?.available, translateBuilder, ALIAS_UNCHANGED_MSG, ALIAS_VALID_MSG, ALIAS_ALREADY_EXIST, + onValidAlias, + valid, + onError, + hasError, ]); - useEffect(() => onValidAlias(valid), [valid, onValidAlias]); - useEffect(() => onError(hasError), [hasError, onError]); - return {message}; }; diff --git a/src/components/item/sharing/shortLink/PlatformIcon.tsx b/src/components/item/sharing/shortLink/PlatformIcon.tsx index 1394fa5a6..0b43d1ad1 100644 --- a/src/components/item/sharing/shortLink/PlatformIcon.tsx +++ b/src/components/item/sharing/shortLink/PlatformIcon.tsx @@ -1,5 +1,5 @@ import { Context } from '@graasp/sdk'; -import { BuildIcon, LibraryIcon, PlayIcon } from '@graasp/ui'; +import { BuildIcon, GraaspLogo, LibraryIcon, PlayIcon } from '@graasp/ui'; import { ShortLinkPlatform } from '@/utils/shortLink'; @@ -24,7 +24,8 @@ const PlatformIcon = ({ case Context.Library: return ; default: - return

Undefined platform {platform}

; + console.error(`Undefined platform ${platform}.`); + return ; } }; diff --git a/src/components/item/sharing/shortLink/ShortLink.tsx b/src/components/item/sharing/shortLink/ShortLink.tsx index 38441f1c6..31290c467 100644 --- a/src/components/item/sharing/shortLink/ShortLink.tsx +++ b/src/components/item/sharing/shortLink/ShortLink.tsx @@ -11,7 +11,11 @@ import { SHORT_LINK_CONTAINER_BORDER_WIDTH, } from '@/config/constants'; import { mutations } from '@/config/queryClient'; -import { SHORT_LINK_COMPONENT } from '@/config/selectors'; +import { + SHORT_LINK_COMPONENT, + buildShortLinkPlatformTextId, + buildShortLinkUrlTextId, +} from '@/config/selectors'; import { ShortLinkPlatform } from '@/utils/shortLink'; import ConfirmDeleteLink from './ConfirmDeleteLink'; @@ -63,8 +67,11 @@ const ShortLink = ({ const [modalOpen, setModalOpen] = useState(false); const handleDeleteAlias = () => { - if (!canAdminShortLink) return; - deleteShortLink(alias); + if (canAdminShortLink) { + deleteShortLink(alias); + } else { + console.error('Only administrators can delete short link.'); + } }; const responsiveDirection = { @@ -101,9 +108,19 @@ const ShortLink = ({ platform={platform} accentColor={AccentColors[platform]} /> - {platform} + + {platform} + - {url} + + {url} +
(initialAlias); const [search, setSearch] = useState(); const [sendApi, setSendAPI] = useState(); - const [saved, setSaved] = useState(!isNew); + const [saved, setSaved] = useState(false); const [hasError, setHasError] = useState(false); const [isValidAlias, setIsValidAlias] = useState(false); - const [isDebounced, setIsDebounced] = useState(false); + const setSendAPICallBack = useCallback(() => { + // Avoid sending invalid alias format to the API + if (isValidAlias) { + setSendAPI(search); + } + }, [isValidAlias, search]); + const { isDebounced } = useDebouncedCallback( + setSendAPICallBack, + SHORT_LINK_API_CALL_DEBOUNCE_MS, + ); const { data: shortLinkAvailable, isFetching: isAvailableLoading } = useShortLinkAvailable(sendApi); const isLoading = isDebounced || isAvailableLoading; const mutateIsLoading = loadingDelete || loadingPost; - const aliasUnchanged = saved && initialAlias === alias; - const enableSaveBtn = - !hasError && !aliasUnchanged && !isLoading && !mutateIsLoading; - // this avoid to send each keydown to the API - const debounceSetSend = useMemo(() => { - setIsDebounced(true); - return debounce(() => { - // avoid to send invalid alias format to API - if (isValidAlias) { - setSendAPI(search); - } - setIsDebounced(false); - }, SHORT_LINK_API_CALL_DEBOUNCE_MS); - }, [isValidAlias, search]); + const hasAliasChanged = isNew || (!saved && initialAlias !== alias); - // Stop the invocation of the debounced function after unmounting - useEffect(() => () => debounceSetSend.cancel(), [debounceSetSend]); - // Call debounce to minimize API calls - useEffect(() => debounceSetSend(), [debounceSetSend, search]); + const enableSaveBtn = + !hasError && hasAliasChanged && !isLoading && !mutateIsLoading; const handleSaveAlias = async () => { if (isNew) { @@ -110,7 +103,7 @@ const ShortLinkDialogContent = ({ const onValidAlias = (isValid: boolean) => { setIsValidAlias(isValid); - if (isValid && !aliasUnchanged) { + if (isValid && hasAliasChanged) { setSearch(alias); } }; @@ -128,7 +121,7 @@ const ShortLinkDialogContent = ({ ([]); const [modalOpen, setOpen] = useState(false); const [isNew, setIsNew] = useState(false); - const [initialAlias, setInitAlias] = useState(''); const [initialPlatform, setInitPlatform] = useState( Context.Player, ); + const [itemPlatforms, setItemPlatforms] = useState( + [], + ); const addNewAlias = useCallback( ( @@ -75,37 +78,29 @@ const ShortLinksRenderer = ({ [setShortLinks], ); - const [itemPlatforms, setItemPlatforms] = useState( - [], - ); - useEffect(() => { setItemPlatforms( - Object.values(ShortLinkPlatformConst) - .map((platform) => - platform === Context.Library && !publishedEntry - ? undefined - : platform, - ) - .filter( - (platform): platform is ShortLinkPlatformType => - platform !== undefined, - ), + Object.values(ShortLinkPlatformConst).filter( + (platform) => publishedEntry || platform !== Context.Library, + ), ); }, [publishedEntry]); useEffect(() => { setShortLinks([]); apiLinks?.forEach((link) => { - const url = new URL(link.alias, GRAASP_REDIRECTION_HOST); + const url = appendPathToUrl({ + baseURL: GRAASP_REDIRECTION_HOST, + pathname: link.alias, + }); addNewAlias(link.platform, link.alias, url, true); }); itemPlatforms.forEach((platform) => { - const shortLinkPlatform = apiLinks?.filter( + const shortLinksPlatform = apiLinks?.filter( (shortLink) => shortLink.platform === platform, ); - if (!shortLinkPlatform || shortLinkPlatform.length === 0) { + if (!shortLinksPlatform?.length) { const clientHostManager = ClientHostManager.getInstance(); const url = clientHostManager.getItemAsURL(platform, itemId); addNewAlias(platform, itemId, url, false); diff --git a/src/config/notifier.ts b/src/config/notifier.ts index eac8aad1e..aa26a9afe 100644 --- a/src/config/notifier.ts +++ b/src/config/notifier.ts @@ -28,7 +28,6 @@ const i18n = buildI18n(); const getErrorMessageFromPayload = ( payload?: Parameters[0]['payload'], ) => { - // TODO: check if it the old code worked before if (payload?.error && axios.isAxiosError(payload.error)) { return ( payload.error.response?.data.message ?? FAILURE_MESSAGES.UNEXPECTED_ERROR diff --git a/src/config/selectors.ts b/src/config/selectors.ts index a6b69d270..d55a33035 100644 --- a/src/config/selectors.ts +++ b/src/config/selectors.ts @@ -331,3 +331,8 @@ export const buildShortLinkShortenBtnId = ( itemId: string, platform: ShortLinkPlatform, ): string => `${SHORT_LINK_SHORTEN_START_ID}-${platform}-${itemId}`; +export const buildShortLinkPlatformTextId = ( + platform: ShortLinkPlatform, +): string => `shortLinkPlatformText-${platform}`; +export const buildShortLinkUrlTextId = (platform: ShortLinkPlatform): string => + `shortLinkUrlText-${platform}`; diff --git a/src/langs/constants.ts b/src/langs/constants.ts index 79242e05a..9bad65487 100644 --- a/src/langs/constants.ts +++ b/src/langs/constants.ts @@ -347,6 +347,7 @@ export const BUILDER = { ALIAS_UNCHANGED_MSG: 'ALIAS_UNCHANGED_MSG', ALIAS_ALREADY_EXIST: 'ALIAS_ALREADY_EXIST', GENERATE_ALIAS_TOOLTIP: 'GENERATE_ALIAS_TOOLTIP', + ALIAS_INPUT: 'ALIAS_INPUT', SHORT_LINK_MIN_CHARS_ERROR: 'SHORT_LINK_MIN_CHARS_ERROR', SHORT_LINK_MAX_CHARS_ERROR: 'SHORT_LINK_MAX_CHARS_ERROR', diff --git a/src/langs/en.json b/src/langs/en.json index 462323a1a..f4225494c 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -281,14 +281,15 @@ "DELETE_SHORT_LINK_TITLE": "Delete the short link", "SHORTEN_LINK_TOOLTIP": "Shorten the link", - "ALIAS_VALID_MSG": "The alias is valid", + "ALIAS_VALID_MSG": "The short link is valid", "ALIAS_CHECKING_MSG": "Checking the availability...", - "ALIAS_UNCHANGED_MSG": "The alias is unchanged", - "ALIAS_ALREADY_EXIST": "The alias already exists", - "GENERATE_ALIAS_TOOLTIP": "Generate alias", + "ALIAS_UNCHANGED_MSG": "The short link is unchanged", + "ALIAS_ALREADY_EXIST": "The short link already exists", + "GENERATE_ALIAS_TOOLTIP": "Generate short link", + "ALIAS_INPUT": "Short link", - "SHORT_LINK_MIN_CHARS_ERROR": "The alias must at least have {{data}} chars.", - "SHORT_LINK_MAX_CHARS_ERROR": "The alias must not exceed {{data}} chars.", - "SHORT_LINK_INVALID_CHARS_ERROR": "The alias contains invalid chars ({{data}}).", + "SHORT_LINK_MIN_CHARS_ERROR": "The short link must at least have {{data}} chars.", + "SHORT_LINK_MAX_CHARS_ERROR": "The short link must not exceed {{data}} chars.", + "SHORT_LINK_INVALID_CHARS_ERROR": "The short link contains invalid chars ({{data}}).", "SHORT_LINK_UNKOWN_ERROR": "An unknown error occured." } diff --git a/src/langs/fr.json b/src/langs/fr.json index a77fccf4f..67a35a3eb 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -265,13 +265,14 @@ "EDIT_SHORT_LINK_TITLE": "Modifier le lien rapide", "DELETE_SHORT_LINK_TITLE": "Supprimer le lien rapide", "SHORTEN_LINK_TOOLTIP": "Raccourcir le lien", - "ALIAS_VALID_MSG": "L'alias est valide", + "ALIAS_VALID_MSG": "Le lien rapide est valide", "ALIAS_CHECKING_MSG": "Vérification de la disponibilité...", - "ALIAS_UNCHANGED_MSG": "L'alias est inchangé", - "ALIAS_ALREADY_EXIST": "L'alias existe déjà", - "GENERATE_ALIAS_TOOLTIP": "Générer un alias", - "SHORT_LINK_MIN_CHARS_ERROR": "L'alias doit au moins contenir {{data}} caractères.", - "SHORT_LINK_MAX_CHARS_ERROR": "L'alias ne doit pas dépasser les {{data}} caractères.", - "SHORT_LINK_INVALID_CHARS_ERROR": "L'alias contient des caractères non valides ({{data}}).", + "ALIAS_UNCHANGED_MSG": "Le lien rapide est inchangé", + "ALIAS_ALREADY_EXIST": "Le lien rapide existe déjà", + "GENERATE_ALIAS_TOOLTIP": "Générer un lien rapide", + "ALIAS_INPUT": "Lien rapide", + "SHORT_LINK_MIN_CHARS_ERROR": "Le lien rapide doit au moins contenir {{data}} caractères.", + "SHORT_LINK_MAX_CHARS_ERROR": "Le lien rapide ne doit pas dépasser les {{data}} caractères.", + "SHORT_LINK_INVALID_CHARS_ERROR": "Le lien rapide contient des caractères non valides ({{data}}).", "SHORT_LINK_UNKOWN_ERROR": "Il y a une erreur inconnue." } diff --git a/src/utils/shortLink.ts b/src/utils/shortLink.ts index 78ef48915..578c98728 100644 --- a/src/utils/shortLink.ts +++ b/src/utils/shortLink.ts @@ -2,7 +2,6 @@ import { ShortLinkPlatform as ShortLinkPlatformConst } from '@graasp/sdk'; import { BUILDER } from '@/langs/constants'; -// TODO: check where to store them export const MIN_SHORT_LINK_LENGTH = 6; export const MAX_SHORT_LINK_LENGTH = 255; diff --git a/src/utils/useDebounce.ts b/src/utils/useDebounce.ts new file mode 100644 index 000000000..22c5893af --- /dev/null +++ b/src/utils/useDebounce.ts @@ -0,0 +1,39 @@ +import { useEffect, useMemo, useState } from 'react'; + +import debounce from 'lodash.debounce'; + +/** + * This custom hooks create a debounced function that delays invoking func until after wait milliseconds have elapsed since the last time the debounced function was invoked. + * + * **Warning**: It is important to use useCallback for the callback params to ensure to not end in an infinite loop. + * + * @param callback The callback function to debounce, returned by a **useCallback**. + * @param debounceDelayMS The number of milliseconds to delay. + * @returns isDebounced Indicates if the callback is debounced or not. + */ +export const useDebouncedCallback = ( + callback: () => void, + debounceDelayMS: number, +): { isDebounced: boolean } => { + const [isDebounced, setIsDebounced] = useState(false); + + const debounceSetSend = useMemo(() => { + setIsDebounced(true); + return debounce(() => { + callback(); + setIsDebounced(false); + }, debounceDelayMS); + }, [callback, debounceDelayMS]); + + // Stop the invocation of the debounced function after unmounting + useEffect(() => () => debounceSetSend.cancel(), [debounceSetSend]); + + // Call debounce to minimize calls + useEffect(() => debounceSetSend(), [debounceSetSend, callback]); + + return { + isDebounced, + }; +}; + +export default useDebouncedCallback; diff --git a/yarn.lock b/yarn.lock index 34395d99a..4e41e6cdd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1071,6 +1071,19 @@ __metadata: languageName: node linkType: hard +"@graasp/sdk@npm:3.0.1": + version: 3.0.1 + resolution: "@graasp/sdk@npm:3.0.1" + dependencies: + "@graasp/etherpad-api": 2.1.1 + date-fns: 2.30.0 + js-cookie: 3.0.5 + uuid: 9.0.1 + validator: 13.11.0 + checksum: 8d202882470b80f8e6970fb8c1e44b7bd2c8763bbe94e70b5272d485fe08e071cf638492c2937d59c204018a0a665152b1b1261871b0747a1f36e68cf9bcdf7e + languageName: node + linkType: hard + "@graasp/sdk@npm:3.2.0": version: 3.2.0 resolution: "@graasp/sdk@npm:3.2.0" @@ -1084,6 +1097,19 @@ __metadata: languageName: node linkType: hard +"@graasp/sdk@npm:3.3.0": + version: 3.3.0 + resolution: "@graasp/sdk@npm:3.3.0" + dependencies: + "@graasp/etherpad-api": 2.1.1 + date-fns: 2.30.0 + js-cookie: 3.0.5 + uuid: 9.0.1 + validator: 13.11.0 + checksum: 7ed2ce8f30476b951d1d580dff7172916273ae4c29cc594723f51a4995d6981f6ef3bb6f953a8a026259a0c6a8d19c6d7d288bb9add1131ff1aa5c31652ebdda + languageName: node + linkType: hard + "@graasp/translations@npm:1.21.0": version: 1.21.0 resolution: "@graasp/translations@npm:1.21.0" @@ -1093,17 +1119,17 @@ __metadata: languageName: node linkType: hard -"@graasp/ui@npm:4.0.0": - version: 4.0.0 - resolution: "@graasp/ui@npm:4.0.0" +"@graasp/ui@npm:4.1.0": + version: 4.1.0 + resolution: "@graasp/ui@npm:4.1.0" dependencies: - "@graasp/sdk": 2.0.0 + "@graasp/sdk": 3.0.1 http-status-codes: 2.3.0 katex: 0.16.9 lodash.truncate: 4.4.2 qs: 6.11.2 quill-emoji: 0.2.0 - react-cookie-consent: 8.0.1 + react-cookie-consent: 9.0.0 react-quill: 2.0.0-beta.4 react-rnd: 10.4.1 react-text-mask: 5.5.0 @@ -1130,7 +1156,7 @@ __metadata: optional: true ag-grid-react: optional: true - checksum: 17b08f3b45e5292416b3ef9cafdf73931e302907da1f5d3ebc88fb6e460f711b21e2d436e37832591779a35484d1bf6ed48e192e280b72a2c2f381dff3854349 + checksum: ea47280d4db8313fb0ac2b271ac05f83370ff6406fa8032e89873344ba3dfc901ba3a1ef901847046d540fba3fe8f323318c5b7581a9cf45c6b00b8010a942c3 languageName: node linkType: hard @@ -5646,9 +5672,9 @@ __metadata: "@emotion/styled": 11.11.0 "@graasp/chatbox": 3.0.0 "@graasp/query-client": 2.1.0 - "@graasp/sdk": 3.2.0 + "@graasp/sdk": 3.3.0 "@graasp/translations": 1.21.0 - "@graasp/ui": 4.0.0 + "@graasp/ui": 4.1.0 "@mui/icons-material": 5.14.16 "@mui/lab": 5.0.0-alpha.151 "@mui/material": 5.14.16 @@ -8990,14 +9016,14 @@ __metadata: languageName: node linkType: hard -"react-cookie-consent@npm:8.0.1": - version: 8.0.1 - resolution: "react-cookie-consent@npm:8.0.1" +"react-cookie-consent@npm:9.0.0": + version: 9.0.0 + resolution: "react-cookie-consent@npm:9.0.0" dependencies: js-cookie: ^2.2.1 peerDependencies: react: ">=16" - checksum: c99f3e40e3091c439956498158b5b8906cce170763836d6fade98a06ce667eca25aaf3bc407e10a6fb72fc44e5178a2ebf14cbbe4e1b5684a74b7d7f484bd359 + checksum: 56a50f03e21c7345dc97159222fd93e920290653da52fc7bf405b757e84dac183753950c42ea1db6cf633fabd0eded8216986798935bbdeda44ddb2db4dc83e0 languageName: node linkType: hard