From cf440b590b5ca56caf1f14d002cb4a82e98ee67d Mon Sep 17 00:00:00 2001 From: Kim Lan Phan Hoang Date: Tue, 12 Nov 2024 14:43:26 +0100 Subject: [PATCH] feat: factor out create link modal (#1566) * feat: factor out create link modal * test: improve test * refactor: fix tests --- cypress/e2e/item/create/createLink.cy.ts | 83 +++-- cypress/fixtures/links.ts | 38 --- cypress/support/commands/item.ts | 22 +- cypress/support/createUtils.ts | 15 - cypress/support/index.ts | 5 - src/components/item/form/ItemNameField.tsx | 2 +- .../item/form/link/LinkDescriptionField.tsx | 67 ++-- src/components/item/form/link/LinkForm.tsx | 315 ++++++++++-------- .../item/form/link/LinkUrlField.tsx | 58 ++-- src/components/main/NewItemModal.tsx | 54 +-- src/langs/constants.ts | 1 + src/langs/en.json | 3 +- src/langs/fr.json | 3 +- src/utils/item.ts | 38 +-- 14 files changed, 357 insertions(+), 347 deletions(-) diff --git a/cypress/e2e/item/create/createLink.cy.ts b/cypress/e2e/item/create/createLink.cy.ts index 288eec87c..d627bfc24 100644 --- a/cypress/e2e/item/create/createLink.cy.ts +++ b/cypress/e2e/item/create/createLink.cy.ts @@ -1,15 +1,27 @@ import { PackedFolderItemFactory } from '@graasp/sdk'; import { HOME_PATH, buildItemPath } from '../../../../src/config/paths'; -import { ITEM_FORM_CONFIRM_BUTTON_ID } from '../../../../src/config/selectors'; import { - GRAASP_LINK_ITEM, - GRAASP_LINK_ITEM_NO_PROTOCOL, - INVALID_LINK_ITEM, - LINK_ITEM_WITH_BLANK_NAME, -} from '../../../fixtures/links'; + CREATE_ITEM_BUTTON_ID, + CREATE_ITEM_LINK_ID, + ITEM_FORM_CONFIRM_BUTTON_ID, + ITEM_FORM_LINK_INPUT_ID, + ITEM_FORM_NAME_INPUT_ID, +} from '../../../../src/config/selectors'; import { CREATE_ITEM_PAUSE } from '../../../support/constants'; -import { createLink } from '../../../support/createUtils'; + +const openLinkModal = () => { + cy.get(`#${CREATE_ITEM_BUTTON_ID}`).click(); + cy.get(`#${CREATE_ITEM_LINK_ID}`).click(); +}; + +const createLink = ({ url }: { url: string }): void => { + openLinkModal(); + + cy.get(`#${ITEM_FORM_LINK_INPUT_ID}`).clear().type(url); + // wait for iframely to fill fields + cy.get(`[role=dialog]`).should('contain', 'Page title'); +}; describe('Create Link', () => { it('create link on Home', () => { @@ -17,7 +29,8 @@ describe('Create Link', () => { cy.visit(HOME_PATH); // create - createLink(GRAASP_LINK_ITEM); + createLink({ url: 'https://graasp.org' }); + cy.get(`#${ITEM_FORM_CONFIRM_BUTTON_ID}`).click(); cy.wait('@postItem').then(() => { // check item is created and displayed @@ -33,7 +46,8 @@ describe('Create Link', () => { cy.visit(HOME_PATH); // create - createLink(GRAASP_LINK_ITEM_NO_PROTOCOL); + createLink({ url: 'graasp.org' }); + cy.get(`#${ITEM_FORM_CONFIRM_BUTTON_ID}`).click(); cy.wait('@postItem').then(() => { // check item is created and displayed @@ -44,6 +58,21 @@ describe('Create Link', () => { }); }); + it('enter valid link, then reset link', () => { + cy.setUpApi(); + cy.visit(HOME_PATH); + + // enter valid data + createLink({ url: 'graasp.org' }); + cy.get(`#${ITEM_FORM_NAME_INPUT_ID} input`).should('not.be.empty'); + + // type a wrong link and cannot save + cy.get(`#${ITEM_FORM_LINK_INPUT_ID}`).clear().type('something'); + cy.get(`#${ITEM_FORM_NAME_INPUT_ID} input`).should('not.be.empty'); + cy.get(`#${ITEM_FORM_CONFIRM_BUTTON_ID}`).click(); + cy.get(`#${ITEM_FORM_CONFIRM_BUTTON_ID}`).should('be.disabled'); + }); + it('create link in item', () => { const FOLDER = PackedFolderItemFactory(); const CHILD = PackedFolderItemFactory({ parentItem: FOLDER }); @@ -55,7 +84,8 @@ describe('Create Link', () => { cy.visit(buildItemPath(id)); // create - createLink(GRAASP_LINK_ITEM); + createLink({ url: 'https://graasp.org' }); + cy.get(`#${ITEM_FORM_CONFIRM_BUTTON_ID}`).click(); cy.wait('@postItem').then(({ request: { url } }) => { expect(url).to.contain(FOLDER.id); @@ -69,17 +99,14 @@ describe('Create Link', () => { describe('Error handling', () => { it('cannot add an invalid link', () => { - const FOLDER = PackedFolderItemFactory(); - cy.setUpApi({ items: [FOLDER] }); - const { id } = FOLDER; + cy.setUpApi(); + cy.visit(HOME_PATH); - // go to children item - cy.visit(buildItemPath(id)); - - // create - createLink(INVALID_LINK_ITEM, { - confirm: false, - }); + // fill link and name + openLinkModal(); + cy.get(`#${ITEM_FORM_LINK_INPUT_ID}`).type('invalid'); + cy.get(`#${ITEM_FORM_NAME_INPUT_ID}`).type('name'); + cy.get(`#${ITEM_FORM_CONFIRM_BUTTON_ID}`).click(); cy.get(`#${ITEM_FORM_CONFIRM_BUTTON_ID}`).should( 'have.prop', @@ -89,17 +116,13 @@ describe('Create Link', () => { }); it('cannot have an empty name', () => { - const FOLDER = PackedFolderItemFactory(); - cy.setUpApi({ items: [FOLDER] }); - const { id } = FOLDER; - - // go to children item - cy.visit(buildItemPath(id)); + cy.setUpApi(); + cy.visit(HOME_PATH); - // create - createLink(LINK_ITEM_WITH_BLANK_NAME, { - confirm: false, - }); + // fill link and clear name + createLink({ url: 'https://graasp.org' }); + cy.get(`#${ITEM_FORM_NAME_INPUT_ID}`).clear(); + cy.get(`#${ITEM_FORM_CONFIRM_BUTTON_ID}`).click(); cy.get(`#${ITEM_FORM_CONFIRM_BUTTON_ID}`).should( 'have.prop', diff --git a/cypress/fixtures/links.ts b/cypress/fixtures/links.ts index e34f60245..4fff11d9d 100644 --- a/cypress/fixtures/links.ts +++ b/cypress/fixtures/links.ts @@ -19,20 +19,6 @@ export const GRAASP_LINK_ITEM: LinkItemType = PackedLinkItemFactory({ }), }); -export const GRAASP_LINK_ITEM_NO_PROTOCOL: LinkItemType = PackedLinkItemFactory( - { - creator: CURRENT_USER, - extra: buildLinkExtra({ - url: 'graasp.eu', - html: '', - thumbnails: ['https://graasp.eu/img/epfl/logo-tile.png'], - icons: [ - 'https://graasp.eu/cdn/img/epfl/favicons/favicon-32x32.png?v=yyxJ380oWY', - ], - }), - }, -); - export const GRAASP_LINK_ITEM_IFRAME_ONLY: LinkItemType = PackedLinkItemFactory( { ...GRAASP_LINK_ITEM, @@ -59,27 +45,3 @@ export const YOUTUBE_LINK_ITEM: LinkItemType = PackedLinkItemFactory({ showLinkIframe: true, }, }); - -export const INVALID_LINK_ITEM: LinkItemType = PackedLinkItemFactory({ - creator: CURRENT_USER, - name: 'graasp youtube link', - description: 'a description for graasp youtube link', - extra: buildLinkExtra({ - url: 'wrong link', - html: '', - thumbnails: [], - icons: [], - }), -}); - -export const LINK_ITEM_WITH_BLANK_NAME: LinkItemType = PackedLinkItemFactory({ - creator: CURRENT_USER, - name: '', - description: 'a description for graasp youtube link', - extra: buildLinkExtra({ - url: 'https://www.youtube.com/watch?v=FmiEgBMTPLo', - html: '', - thumbnails: [], - icons: [], - }), -}); diff --git a/cypress/support/commands/item.ts b/cypress/support/commands/item.ts index 6cfad0991..72696e25e 100644 --- a/cypress/support/commands/item.ts +++ b/cypress/support/commands/item.ts @@ -1,9 +1,4 @@ -import { - DiscriminatedItem, - ItemType, - getAppExtra, - getDocumentExtra, -} from '@graasp/sdk'; +import { DiscriminatedItem, getAppExtra, getDocumentExtra } from '@graasp/sdk'; import { CUSTOM_APP_CYPRESS_ID, @@ -13,7 +8,6 @@ import { ITEM_FORM_APP_URL_ID, ITEM_FORM_CONFIRM_BUTTON_ID, ITEM_FORM_DOCUMENT_TEXT_SELECTOR, - ITEM_FORM_LINK_INPUT_ID, ITEM_FORM_NAME_INPUT_ID, ITEM_MEMBERSHIP_PERMISSION_SELECT_CLASS, MY_GRAASP_ITEM_PATH, @@ -122,20 +116,6 @@ Cypress.Commands.add( }, ); -Cypress.Commands.add( - 'fillLinkModal', - ({ extra = {} }, { confirm = true } = {}) => { - cy.get(`#${ITEM_FORM_LINK_INPUT_ID}`).type( - // first select all the text and then remove it to have a clear field, then type new text - `{selectall}{backspace}${extra?.[ItemType.LINK]?.url}`, - ); - - if (confirm) { - cy.get(`#${ITEM_FORM_CONFIRM_BUTTON_ID}`).click(); - } - }, -); - Cypress.Commands.add( 'fillDocumentModal', ({ name = '', extra }, { confirm = true } = {}) => { diff --git a/cypress/support/createUtils.ts b/cypress/support/createUtils.ts index d8e512f54..f8f14f545 100644 --- a/cypress/support/createUtils.ts +++ b/cypress/support/createUtils.ts @@ -3,7 +3,6 @@ import { DiscriminatedItem, DocumentItemType, ItemType, - LinkItemType, } from '@graasp/sdk'; import { @@ -13,7 +12,6 @@ import { CREATE_ITEM_DOCUMENT_ID, CREATE_ITEM_FILE_ID, CREATE_ITEM_H5P_ID, - CREATE_ITEM_LINK_ID, CREATE_ITEM_ZIP_ID, DASHBOARD_UPLOADER_ID, H5P_DASHBOARD_UPLOADER_ID, @@ -49,15 +47,6 @@ export const createFolder = ( cy.fillFolderModal(payload, options); }; -export const createLink = ( - payload: LinkItemType, - options?: { confirm?: boolean }, -): void => { - cy.get(`#${CREATE_ITEM_BUTTON_ID}`).click(); - cy.get(`#${CREATE_ITEM_LINK_ID}`).click(); - cy.fillLinkModal(payload, options); -}; - export const createFile = ( payload: FileItemForTest, options?: { confirm?: boolean }, @@ -91,10 +80,6 @@ export const createItem = ( cy.get(`#${CREATE_ITEM_BUTTON_ID}`).click(); switch (payload.type) { - case ItemType.LINK: - cy.get(`#${CREATE_ITEM_LINK_ID}`).click(); - cy.fillLinkModal(payload, options); - break; case ItemType.S3_FILE: case ItemType.LOCAL_FILE: { const { confirm = true } = options; diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 9e7efd45a..3ec43005e 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -2,7 +2,6 @@ import { AppItemExtra, DiscriminatedItem, DocumentItemExtra, - ItemType, PermissionLevel, } from '@graasp/sdk'; @@ -53,10 +52,6 @@ declare global { goToHome(): void; - fillLinkModal( - payload?: { extra?: { [ItemType.LINK]: { url?: string } } }, - options?: { confirm?: boolean }, - ): void; fillDocumentModal( payload: { name: string; diff --git a/src/components/item/form/ItemNameField.tsx b/src/components/item/form/ItemNameField.tsx index d67dbb57e..e39befafb 100644 --- a/src/components/item/form/ItemNameField.tsx +++ b/src/components/item/form/ItemNameField.tsx @@ -35,13 +35,13 @@ export const ItemNameField = ({ const handleClearClick = () => { reset({ name: '' }); }; + return ( void; - onClear: () => void; - showRestoreButton?: boolean; - form: UseFormRegisterReturn; - showClearButton?: boolean; + showRestoreButton: boolean; }; const LinkDescriptionField = ({ - form, onRestore, - onClear, showRestoreButton, - showClearButton, }: LinkDescriptionFieldProps): JSX.Element => { + const { + register, + setValue, + getValues, + formState: { errors }, + } = useFormContext<{ description: string }>(); const { t } = useBuilderTranslation(); + const { description } = getValues(); return ( - - - + slotProps={{ + inputLabel: { shrink: true }, + input: { + endAdornment: ( + <> + + + - - - - - ), + setValue('description', '')} + sx={{ + visibility: description ? 'visible' : 'hidden', + }} + > + + + + ), + }, }} + error={Boolean(errors.description)} + helperText={errors.description?.message} + {...register('description')} /> ); }; diff --git a/src/components/item/form/link/LinkForm.tsx b/src/components/item/form/link/LinkForm.tsx index 30a2fb712..47291598d 100644 --- a/src/components/item/form/link/LinkForm.tsx +++ b/src/components/item/form/link/LinkForm.tsx @@ -2,6 +2,10 @@ import { useEffect, useMemo } from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import { + Box, + DialogActions, + DialogContent, + DialogTitle, FormControl, FormControlLabel, FormLabel, @@ -15,16 +19,23 @@ import { import { DiscriminatedItem, + ItemGeolocation, ItemType, UnionOfConst, buildLinkExtra, getLinkThumbnailUrl, } from '@graasp/sdk'; -import { LinkCard, LinkItem } from '@graasp/ui'; +import { COMMON } from '@graasp/translations'; +import { Button, LinkCard, LinkItem } from '@graasp/ui'; -import { hooks } from '@/config/queryClient'; +import CancelButton from '@/components/common/CancelButton'; +import { hooks, mutations } from '@/config/queryClient'; +import { ITEM_FORM_CONFIRM_BUTTON_ID } from '@/config/selectors'; -import { useBuilderTranslation } from '../../../../config/i18n'; +import { + useBuilderTranslation, + useCommonTranslation, +} from '../../../../config/i18n'; import { BUILDER } from '../../../../langs/constants'; import { isUrlValid } from '../../../../utils/item'; import { ItemNameField } from '../ItemNameField'; @@ -32,10 +43,6 @@ import LinkDescriptionField from './LinkDescriptionField'; import LinkUrlField from './LinkUrlField'; import { LinkType, getSettingsFromLinkType, normalizeURL } from './linkUtils'; -type Props = { - onChange: (item: Partial) => void; -}; - const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({ // remove weird default margins on label margin: 0, @@ -55,6 +62,13 @@ const StyledDiv = styled('div')(() => ({ }, })); +type Props = { + onClose: () => void; + parentId?: DiscriminatedItem['id']; + geolocation?: Pick; + previousItemId?: DiscriminatedItem['id']; +}; + type Inputs = { name: string; linkType: UnionOfConst; @@ -62,23 +76,37 @@ type Inputs = { url: string; }; -const LinkForm = ({ onChange }: Props): JSX.Element => { +export const LinkForm = ({ + onClose, + parentId, + geolocation, + previousItemId, +}: Props): JSX.Element => { const { t: translateBuilder } = useBuilderTranslation(); + const { t: translateCommon } = useCommonTranslation(); + const { mutateAsync: createItem } = mutations.usePostItem(); const methods = useForm(); - const { register, watch, control, setValue, reset } = methods; - const url = watch('url'); + const { + setValue, + watch, + handleSubmit, + control, + getValues, + formState: { isValid, isSubmitted }, + } = methods; - const name = watch('name'); - const linkType = watch('linkType'); const description = watch('description'); + const url = watch('url'); + const isValidUrl = isUrlValid(normalizeURL(url)); const { data: linkData } = hooks.useLinkMetadata( - isUrlValid(normalizeURL(url)) ? normalizeURL(url) : '', + isValidUrl ? normalizeURL(url) : '', ); // apply the description from the api to the field // this is only run once. useEffect( () => { + const { name } = getValues(); if (!description && linkData?.description) { setValue('description', linkData.description); } @@ -90,21 +118,28 @@ const LinkForm = ({ onChange }: Props): JSX.Element => { [linkData], ); - // synchronize upper state after async local state change - useEffect(() => { - onChange({ - name, - description, - extra: buildLinkExtra({ - url, - description: linkData?.description, - thumbnails: linkData?.thumbnails, - icons: linkData?.icons, - }), - settings: getSettingsFromLinkType(linkType), - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [description, linkData, linkType, name, url]); + async function onSubmit(data: Inputs) { + try { + await createItem({ + name: data.name, + type: ItemType.LINK, + description: data.description, + extra: buildLinkExtra({ + url: data.url, + description: linkData?.description, + thumbnails: linkData?.thumbnails, + icons: linkData?.icons, + }), + settings: getSettingsFromLinkType(data.linkType), + parentId, + geolocation, + previousItemId, + }); + onClose(); + } catch (e) { + console.error(e); + } + } const embeddedLinkPreview = useMemo( () => ( @@ -129,113 +164,129 @@ const LinkForm = ({ onChange }: Props): JSX.Element => { : undefined; return ( - + + + {translateBuilder(BUILDER.CREATE_ITEM_NEW_FOLDER_TITLE)} + - reset({ url: '' })} - showClearButton={Boolean(url)} - isValid={isUrlValid(normalizeURL(url))} - /> - - setValue('description', linkData?.description ?? '')} - form={register('description')} - onClear={() => reset({ description: '' })} - showRestoreButton={ - Boolean(description) && description !== linkData?.description - } - showClearButton={Boolean(description)} - /> - - - - {translateBuilder(BUILDER.CREATE_ITEM_LINK_TYPE_TITLE)} - - {url ? ( - ( - - {url}} - control={} - /> - - } - control={} - slotProps={{ - typography: { width: '100%', minWidth: '0px' }, - }} - sx={{ minWidth: '0px', width: '100%' }} - /> - {linkData?.html && linkData.html !== '' && ( - + + + + + setValue('description', linkData?.description ?? '') + } + showRestoreButton={ + Boolean(linkData?.description) && + linkData?.description !== description + } + /> + {isValidUrl && ( + + + + {translateBuilder(BUILDER.CREATE_ITEM_LINK_TYPE_TITLE)} + + {url ? ( + ( + + {url}} + control={} /> - } - control={} - slotProps={{ - typography: { - width: '100%', - minWidth: '0px', - }, - }} - /> - )} - { - // only show this options when embedding is allowed and there is no html code - // as the html will take precedence over showing the site as an iframe - // and some sites like daily motion actually allow both, we want to allow show the html setting - linkData?.isEmbeddingAllowed && !linkData?.html && ( - } - slotProps={{ - typography: { - width: '100%', - minWidth: '0px', - }, - }} - sx={{ - // this ensure the iframe takes up all horizontal space - '& iframe': { - width: '100%', - }, - }} - /> - ) - } - - )} - /> - ) : ( - - {translateBuilder(BUILDER.CREATE_ITEM_LINK_TYPE_HELPER_TEXT)} - + + } + control={} + slotProps={{ + typography: { width: '100%', minWidth: '0px' }, + }} + sx={{ minWidth: '0px', width: '100%' }} + /> + {linkData?.html && linkData.html !== '' && ( + + } + control={} + slotProps={{ + typography: { + width: '100%', + minWidth: '0px', + }, + }} + /> + )} + { + // only show this options when embedding is allowed and there is no html code + // as the html will take precedence over showing the site as an iframe + // and some sites like daily motion actually allow both, we want to allow show the html setting + linkData?.isEmbeddingAllowed && !linkData?.html && ( + } + slotProps={{ + typography: { + width: '100%', + minWidth: '0px', + }, + }} + sx={{ + // this ensure the iframe takes up all horizontal space + '& iframe': { + width: '100%', + }, + }} + /> + ) + } + + )} + /> + ) : ( + + {translateBuilder( + BUILDER.CREATE_ITEM_LINK_TYPE_HELPER_TEXT, + )} + + )} + + )} - - + + + + + + - + ); }; - -export default LinkForm; diff --git a/src/components/item/form/link/LinkUrlField.tsx b/src/components/item/form/link/LinkUrlField.tsx index 95b1f4689..cfd3a8ac6 100644 --- a/src/components/item/form/link/LinkUrlField.tsx +++ b/src/components/item/form/link/LinkUrlField.tsx @@ -1,4 +1,4 @@ -import { UseFormRegisterReturn } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; import { IconButton, TextField } from '@mui/material'; @@ -7,19 +7,15 @@ import { XIcon } from 'lucide-react'; import { useBuilderTranslation } from '@/config/i18n'; import { ITEM_FORM_LINK_INPUT_ID } from '@/config/selectors'; import { BUILDER } from '@/langs/constants'; +import { LINK_REGEX } from '@/utils/item'; -type LinkUrlFieldProps = { - onClear: () => void; - form: UseFormRegisterReturn; - showClearButton?: boolean; - isValid: boolean; -}; -const LinkUrlField = ({ - onClear, - form, - showClearButton = false, - isValid, -}: LinkUrlFieldProps): JSX.Element => { +const LinkUrlField = (): JSX.Element => { + const { + register, + reset, + getValues, + formState: { errors }, + } = useFormContext<{ url: string }>(); const { t } = useBuilderTranslation(); return ( - - - ), + slotProps={{ + inputLabel: { shrink: true }, + input: { + endAdornment: ( + reset({ url: '' })} + sx={{ + visibility: getValues().url ? 'visible' : 'hidden', + }} + > + + + ), + }, }} fullWidth required + error={Boolean(errors.url)} + helperText={errors.url?.message} + {...register('url', { + pattern: { + value: LINK_REGEX, + message: t(BUILDER.LINK_INVALID_MESSAGE), + }, + })} /> ); }; diff --git a/src/components/main/NewItemModal.tsx b/src/components/main/NewItemModal.tsx index 91008a0b5..1645b66bd 100644 --- a/src/components/main/NewItemModal.tsx +++ b/src/components/main/NewItemModal.tsx @@ -38,7 +38,7 @@ import AppForm from '../item/form/AppForm'; import useEtherpadForm from '../item/form/EtherpadForm'; import DocumentForm from '../item/form/document/DocumentForm'; import { FolderCreateForm } from '../item/form/folder/FolderCreateForm'; -import LinkForm from '../item/form/link/LinkForm'; +import { LinkForm } from '../item/form/link/LinkForm'; import ImportH5P from './ImportH5P'; import ImportZip from './ImportZip'; import ItemTypeTabs from './ItemTypeTabs'; @@ -162,7 +162,7 @@ const NewItemModal = ({ }); }; - // folders are handled beforehand + // folders and links are handled beforehand const renderContent = () => { switch (selectedItemType) { case ItemType.S3_FILE: @@ -217,15 +217,6 @@ const NewItemModal = ({ ); - case ItemType.LINK: - return ( - <> - - {translateBuilder(BUILDER.CREATE_ITEM_LINK_TITLE)} - - - - ); case ItemType.DOCUMENT: return ( <> @@ -240,7 +231,7 @@ const NewItemModal = ({ } }; - // folder is handled before + // folders and links are handled before const renderActions = () => { switch (selectedItemType) { case ItemType.ETHERPAD: @@ -258,7 +249,6 @@ const NewItemModal = ({ ); case ItemType.APP: - case ItemType.LINK: case ItemType.DOCUMENT: return ( <> @@ -293,18 +283,34 @@ const NewItemModal = ({ // temporary solution to wrap content and actions const renderContentWithWrapper = () => { let content = null; - if (selectedItemType === ItemType.FOLDER) { - content = ( - - ); - } else { - content = renderContent(); + switch (selectedItemType) { + case ItemType.FOLDER: { + content = ( + + ); + break; + } + case ItemType.LINK: { + content = ( + + ); + break; + } + default: { + content = renderContent(); + } } + return ( <> diff --git a/src/langs/constants.ts b/src/langs/constants.ts index b8d75aa80..ee31e99dc 100644 --- a/src/langs/constants.ts +++ b/src/langs/constants.ts @@ -605,4 +605,5 @@ export const BUILDER = { ITEM_LOGIN_SCHEMA_DISABLED_GUEST_ACCESS_MESSAGE: 'ITEM_LOGIN_SCHEMA_DISABLED_GUEST_ACCESS_MESSAGE', TRASH_COUNT: 'TRASH_COUNT', + LINK_INVALID_MESSAGE: 'LINK_INVALID_MESSAGE', }; diff --git a/src/langs/en.json b/src/langs/en.json index 20661aef0..8f17f906b 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -500,5 +500,6 @@ "ACCESS_MANAGEMENT_TITLE": "Access Management", "ITEM_LOGIN_SCHEMA_DISABLED_GUEST_ACCESS_MESSAGE": "This guest cannot login because pseudonymized access is disabled.", "TRASH_COUNT_one": "You have one item in your trash.", - "TRASH_COUNT_other": "You have {{count}} items in your trash." + "TRASH_COUNT_other": "You have {{count}} items in your trash.", + "LINK_INVALID_MESSAGE": "The link is invalid." } diff --git a/src/langs/fr.json b/src/langs/fr.json index 4f39fd1c1..76c156f77 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -500,5 +500,6 @@ "ACCESS_MANAGEMENT_TITLE": "Gestion des accès", "ITEM_LOGIN_SCHEMA_DISABLED_GUEST_ACCESS_MESSAGE": "Cet invité ne peut pas se connecter car l'accès pseudonymisé est désactivé.", "TRASH_COUNT_one": "Il y a un élément dans la poubelle.", - "TRASH_COUNT_other": "Il y a {{count}} éléments dans la poubelle." + "TRASH_COUNT_other": "Il y a {{count}} éléments dans la poubelle.", + "LINK_INVALID_MESSAGE": "Le lien est invalide." } diff --git a/src/utils/item.ts b/src/utils/item.ts index 596f59f0d..564a04d1e 100644 --- a/src/utils/item.ts +++ b/src/utils/item.ts @@ -6,7 +6,6 @@ import { ItemMembership, ItemType, getAppExtra, - getLinkExtra, } from '@graasp/sdk'; export const getParentsIdsFromPath = ( @@ -42,22 +41,27 @@ export const getDirectParentId = (path: string): string | null => { return ids[parentIdx]; }; +export const LINK_REGEX = new RegExp( + '^(https?:\\/\\/)?' + // protocol is optional + '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name + '((\\d{1,3}\\.){3}\\d{1,3})|' + // OR ip (v4) address + 'localhost)' + // OR localhost alias + '(\\:\\d+)?' + // post (optional) + '(\\/\\S*?)*' + // path (lazy: takes as few as possible) + '(\\?\\S*?)?' + // query string (lazy) + '(\\#\\S*)?$', + 'i', +); // fragment locator + export const isUrlValid = (str?: string | null): boolean => { if (!str) { return false; } - const pattern = new RegExp( - '^(https?:\\/\\/)?' + // protocol is optional - '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name - '((\\d{1,3}\\.){3}\\d{1,3})|' + // OR ip (v4) address - 'localhost)' + // OR localhost alias - '(\\:\\d+)?' + // post (optional) - '(\\/\\S*?)*' + // path (lazy: takes as few as possible) - '(\\?\\S*?)?' + // query string (lazy) - '(\\#\\S*)?$', // fragment locator - 'i', - ); - return pattern.test(str); + const pattern = LINK_REGEX; + if (pattern.test(str)) { + return true; + } + return false; }; /** @@ -75,14 +79,6 @@ export const isItemValid = (item: Partial): boolean => { let hasValidTypeProperties = item.type && Object.values(ItemType).includes(item.type); switch (item.type) { - case ItemType.LINK: { - let url; - if (item.extra) { - ({ url } = getLinkExtra(item.extra) || {}); - } - hasValidTypeProperties = isUrlValid(url); - break; - } case ItemType.APP: { let url; if (item.extra) {