diff --git a/documentation/docs/01-users/05-adding-software.md b/documentation/docs/01-users/05-adding-software.md index f5932bbc0..9dead3f05 100644 --- a/documentation/docs/01-users/05-adding-software.md +++ b/documentation/docs/01-users/05-adding-software.md @@ -64,12 +64,12 @@ You need to use the full URL of the image and the image needs to send CORS heade ![Mozilla](https://cdn.glitch.me/4c9ebeb9-8b9a-4adc-ad0a-238d9ae00bb5%2Fmdn_logo-only_color.svg) ``` -### Document URL +### Markdown URL You can link to a remote Markdown file which will be dynamically loaded by the RSD. An often used approach is to link to a readme file on the GitHub repository. In this case you need to link to the `raw` version of the readme file. For example, to link to the readme file of the RSD repository, we used the link https://raw.githubusercontent.com/research-software-directory/RSD-as-a-service/main/README.md. **Note that the URL domain is different** `https://raw.githubusercontent.com/` from the default GitHub domain (https://github.com). :::warning -When using a Document URL to point to a remote Markdown file on the GitHub, you need to provide a URL to a raw Markdown file (see the animation below). In addition, all links used in the Markdown document, like **images**, need to use the **full URL** (e.g. use `https://user-images.githubusercontent.com/4195550/136156498-736f915f-7623-43d2-8678-f30b06563a38.png`, not `/4195550/136156498-736f915f-7623-43d2-8678-f30b06563a38.png`). This is required, because the Markdown content is loaded from the GitHub domain into the RSD website. +When using a Markdown URL to point to a remote Markdown file all links used in the Markdown document, like **images**, need to use the **full URL** (e.g. use `https://user-images.githubusercontent.com/4195550/136156498-736f915f-7623-43d2-8678-f30b06563a38.png`, not `/4195550/136156498-736f915f-7623-43d2-8678-f30b06563a38.png`). This is required, because the Markdown content is loaded into the RSD website. ::: ### Logo diff --git a/frontend/components/layout/PageErrorMessage.tsx b/frontend/components/layout/PageErrorMessage.tsx index c143df463..e57e7586c 100644 --- a/frontend/components/layout/PageErrorMessage.tsx +++ b/frontend/components/layout/PageErrorMessage.tsx @@ -1,18 +1,28 @@ // SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 dv4all +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 import ContentInTheMiddle from './ContentInTheMiddle' -export default function PageErrorMessage({status = 500, message = 'Failed to process you request'}: { - message:string, status?:number -}) { +type PageErrorMessageProps=Readonly<{ + message:string, + status?:number, + children?:any +}> + +export default function PageErrorMessage({ + message = 'Failed to process you request', + status = 500, + children +}:PageErrorMessageProps) { return (

{status}

-

{message}

+ {children ||

{message}

}
) diff --git a/frontend/components/software/edit/editSoftwareConfig.tsx b/frontend/components/software/edit/editSoftwareConfig.tsx index f993f419c..1cb9ec196 100644 --- a/frontend/components/software/edit/editSoftwareConfig.tsx +++ b/frontend/components/software/edit/editSoftwareConfig.tsx @@ -11,6 +11,8 @@ // // SPDX-License-Identifier: Apache-2.0 +import {isProperUrl} from '~/utils/fetchHelpers' + export const softwareInformation = { slug: { label: 'RSD path (admin only)', @@ -62,14 +64,19 @@ export const softwareInformation = { description_url: { label: 'URL of (raw) Markdown file', help: <> - Read here how to properly link to a raw Markdown URL + Read here how to properly link to a raw Markdown URL , validation: { - required: 'Valid markdown URL must be provided', + required: 'Valid markdown URL is required', maxLength: {value: 200, message: 'Maximum length is 200'}, - pattern: { - value: /^https?:\/\/.+\..+\.md$/, - message: 'URL should start with http(s):// have at least one dot (.) and end with (.md)' + // custom validation function for correct url syntax + validate: (url:string)=>{ + // return error message if not correct url syntax + if (isProperUrl(url)===false){ + return 'Invalid url. Please improve your input' + } + // has correct url syntax + return true } } }, @@ -100,70 +107,7 @@ export const contributorInformation = { infoLink: '/documentation/users/adding-software/#contributors', label: 'Import contributors', message: (doi: string) => `Import contributors from datacite.org using DOI ${doi}` - }, - // We use default config of AggregatedPersonModal - // Dusan, 2024-09-12 - // is_contact_person: { - // label: 'Contact person', - // help: 'Is this contributor the main contact person?' - // }, - // given_names: { - // label: 'First name / Given name(s)', - // help: 'One or more given names', - // validation: { - // required: 'First name is required', - // minLength: {value: 1, message: 'Minimum length is 1'}, - // maxLength: {value: 200, message: 'Maximum length is 200'}, - // } - // }, - // family_names: { - // label: 'Last name / Family name(s)', - // help: 'Include "de/van/van den ...etc."', - // validation: { - // required: 'Family name is required', - // minLength: {value: 2, message: 'Minimum length is 2'}, - // maxLength: {value: 200, message: 'Maximum length is 200'}, - // } - // }, - // email_address: { - // label: 'Email', - // help: 'Contact person should have an email', - // validation: (required:boolean) => ({ - // required: required ? 'Contact person should have an email' : false, - // minLength: {value: 5, message: 'Minimum length is 5'}, - // maxLength: {value: 200, message: 'Maximum length is 200'}, - // pattern: { - // value: /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, - // message: 'Invalid email address' - // } - // }) - // }, - // affiliation: { - // label: 'Affiliation', - // help: 'Select or type in the current affiliation?', - // validation: { - // minLength: {value: 2, message: 'Minimum length is 2'}, - // maxLength: {value: 200, message: 'Maximum length is 200'}, - // } - // }, - // role: { - // label: 'Role', - // help: 'For this software', - // validation: { - // minLength: {value: 2, message: 'Minimum length is 2'}, - // maxLength: {value: 200, message: 'Maximum length is 200'}, - // } - // }, - // orcid: { - // label: 'ORCID', - // help: '16 digits, pattern 0000-0000-0000-0000', - // validation: { - // pattern: { - // value: /^\d{4}-\d{4}-\d{4}-\d{3}[0-9X]$/, - // message: 'Invalid pattern, not a 0000-0000-0000-0000' - // } - // } - // } + } } diff --git a/frontend/components/software/edit/information/AutosaveRemoteMarkdown.tsx b/frontend/components/software/edit/information/AutosaveRemoteMarkdown.tsx index 2572dec04..e9fbfbc35 100644 --- a/frontend/components/software/edit/information/AutosaveRemoteMarkdown.tsx +++ b/frontend/components/software/edit/information/AutosaveRemoteMarkdown.tsx @@ -8,35 +8,37 @@ // SPDX-License-Identifier: Apache-2.0 import {useState,useEffect} from 'react' -import {useWatch,useFormState} from 'react-hook-form' +import Button from '@mui/material/Button' +import {useWatch,useFormState, useFormContext} from 'react-hook-form' import {EditSoftwareItem} from '~/types/SoftwareTypes' -import {getRemoteMarkdown} from '~/utils/getSoftware' import {useDebounceValid} from '~/utils/useDebounce' +import {apiRemoteMarkdown} from '~/utils/getSoftware' import ReactMarkdownWithSettings from '~/components/layout/ReactMarkdownWithSettings' import PageErrorMessage from '~/components/layout/PageErrorMessage' import ContentLoader from '~/components/layout/ContentLoader' import AutosaveControlledTextField, {OnSaveProps} from '~/components/form/AutosaveControlledTextField' import {ControlledTextFieldOptions} from '~/components/form/ControlledTextField' +import {softwareInformation as config} from '../editSoftwareConfig' -type AutosaveRemoteMarkdownProps = { - control: any, +type AutosaveRemoteMarkdownProps = Readonly<{ rules: any, options: ControlledTextFieldOptions, onSaveField: ({name,value}: OnSaveProps) => void -} +}> -export default function AutosaveRemoteMarkdown({control,rules,options,onSaveField}: AutosaveRemoteMarkdownProps) { - // watch for change - const markdownUrl = useWatch({control, name: options.name}) +export default function AutosaveRemoteMarkdown({rules,options,onSaveField}: AutosaveRemoteMarkdownProps) { + const {control, setError, setValue} = useFormContext() const {errors} = useFormState({control}) - const debouncedUrl = useDebounceValid(markdownUrl,errors[options.name],1000) + const markdownUrl = useWatch({control, name: options.name}) + const debouncedUrl = useDebounceValid(markdownUrl,errors[options.name],700) const [loading, setLoading] = useState(false) const [state, setState] = useState<{ markdown: string | null, error: { status: number | null, - message: string | null + message: string | null, + rawUrl?: string } }>({ markdown: null, @@ -45,63 +47,68 @@ export default function AutosaveRemoteMarkdown({control,rules,options,onSaveFiel message: null } }) - // listen for error - let error:any - if (errors.hasOwnProperty(options.name) === true) { - error = errors[options.name] - } // console.group(`AutosaveRemoteMarkdownProps...${options.name}`) // console.log('markdownUrl...',markdownUrl) // console.log('debouncedUrl...',debouncedUrl) // console.log('errors...', errors) - // console.log('error...', error) + // console.log('state...', state) // console.groupEnd() useEffect(() => { let abort = false const getMarkdown=async(url:string)=>{ setLoading(true) - const markdown = await getRemoteMarkdown(url) + const resp = await apiRemoteMarkdown(url) // exit on abort if (abort) return - if (typeof markdown === 'string') { + if (resp.status===200) { + // debugger setState({ - markdown, + markdown: resp.message, error: { status: null, message: null } }) - } else if (typeof markdown === 'object'){ + } else { // create error setState({ markdown: null, error: { - ...markdown + ...resp } }) + // set error to input form + setError(options.name,{ + type:'custom', + message: resp.message + }) } setLoading(false) } // if there is markdownUrl value - if (debouncedUrl && - debouncedUrl.length > 9) { + if (debouncedUrl && debouncedUrl.length > 9) { if (abort) return getMarkdown(debouncedUrl) - } else if (!debouncedUrl) { - if (abort) return - setLoading(false) - setState({ - markdown: '', - error: { - status: 200, - message: 'Waiting for input' - } - }) } return ()=>{abort=true} - }, [debouncedUrl]) + }, [debouncedUrl,options.name,setError]) + + function useSuggestedUrl(){ + if (state.error?.rawUrl){ + // change input value + setValue(options.name,state.error?.rawUrl,{ + shouldValidate:true, + shouldDirty:true + }) + // save change + onSaveField({ + name: options.name, + value: state.error?.rawUrl + }) + } + } function renderContent() { if (loading) { @@ -112,7 +119,28 @@ export default function AutosaveRemoteMarkdown({control,rules,options,onSaveFiel + > +
+
{state.error?.message ?? 'Incorrect link'}
+ { state.error?.rawUrl ? + <> +
+ Suggestion: + {state.error?.rawUrl} +
+
+ +
+ + : config.description_url.help + } +
+
) } if (state.markdown) { diff --git a/frontend/components/software/edit/information/AutosaveSoftwareMarkdown.test.tsx b/frontend/components/software/edit/information/AutosaveSoftwareMarkdown.test.tsx index f164c33fb..2f2329dbd 100644 --- a/frontend/components/software/edit/information/AutosaveSoftwareMarkdown.test.tsx +++ b/frontend/components/software/edit/information/AutosaveSoftwareMarkdown.test.tsx @@ -1,11 +1,11 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) -// SPDX-FileCopyrightText: 2023 Netherlands eScience Center // SPDX-FileCopyrightText: 2023 dv4all // // SPDX-License-Identifier: Apache-2.0 -import {fireEvent, render, screen, waitFor, waitForElementToBeRemoved,act} from '@testing-library/react' +import {fireEvent, render, screen, waitFor, waitForElementToBeRemoved} from '@testing-library/react' import {WithAppContext, mockSession} from '~/utils/jest/WithAppContext' import {WithFormContext} from '~/utils/jest/WithFormContext' @@ -20,8 +20,13 @@ jest.mock('./patchSoftwareTable', () => ({ })) const mockGetRemoteMarkdown = jest.fn(props => Promise.resolve('Remote markdown')) +const mockApiRemoteMarkdown = jest.fn(props => Promise.resolve({ + status:200, + message: 'Remote markdown' +})) jest.mock('~/utils/getSoftware', () => ({ - getRemoteMarkdown: jest.fn(props=>mockGetRemoteMarkdown(props)) + getRemoteMarkdown: jest.fn(props=>mockGetRemoteMarkdown(props)), + apiRemoteMarkdown: jest.fn(props=>mockApiRemoteMarkdown(props)) })) beforeEach(() => { @@ -79,8 +84,12 @@ it('shows loaded description_url', async() => { description_url: 'https://github.com/project/README.md' } // mock remote api response - const expectedMarkdown = 'Remote markdown for testing' - mockGetRemoteMarkdown.mockResolvedValueOnce(expectedMarkdown) + const expectedMarkdown = { + status:200, + message:'Remote markdown for testing' + } + + mockApiRemoteMarkdown.mockResolvedValueOnce(expectedMarkdown) render( @@ -94,7 +103,7 @@ it('shows loaded description_url', async() => { // expect document URL const documentUrl = screen.getByRole('radio', { - name: 'Document URL' + name: 'Markdown URL' }) expect(documentUrl).toBeChecked() // select document_url @@ -103,7 +112,7 @@ it('shows loaded description_url', async() => { // wait loader to be removed await waitForElementToBeRemoved(screen.getByRole('progressbar')) // validate remote markdown response - screen.getByText(expectedMarkdown) + screen.getByText(expectedMarkdown.message) }) it('saves custom markdown', async() => { @@ -194,7 +203,7 @@ it('saves remote markdown', async() => { // check remote markdown const remoteUrl = screen.getByRole('radio', { - name: 'Document URL' + name: 'Markdown URL' }) fireEvent.click(remoteUrl) expect(remoteUrl).toBeChecked() diff --git a/frontend/components/software/edit/information/AutosaveSoftwareMarkdown.tsx b/frontend/components/software/edit/information/AutosaveSoftwareMarkdown.tsx index 993b51b74..5cc9b2108 100644 --- a/frontend/components/software/edit/information/AutosaveSoftwareMarkdown.tsx +++ b/frontend/components/software/edit/information/AutosaveSoftwareMarkdown.tsx @@ -7,19 +7,19 @@ // // SPDX-License-Identifier: Apache-2.0 +import {FocusEventHandler} from 'react' import Radio from '@mui/material/Radio' import RadioGroup from '@mui/material/RadioGroup' import FormControlLabel from '@mui/material/FormControlLabel' +import {useController, useFormContext} from 'react-hook-form' -import MarkdownInputWithPreview from '../../../form/MarkdownInputWithPreview' -import EditSectionTitle from '../../../layout/EditSectionTitle' -import {softwareInformation as config} from '../editSoftwareConfig' import {useSession} from '~/auth' +import MarkdownInputWithPreview from '~/components/form/MarkdownInputWithPreview' +import EditSectionTitle from '~/components/layout/EditSectionTitle' import useSnackbar from '~/components/snackbar/useSnackbar' -import {useController, useFormContext} from 'react-hook-form' +import {softwareInformation as config} from '../editSoftwareConfig' import {patchSoftwareTable} from './patchSoftwareTable' import AutosaveRemoteMarkdown from './AutosaveRemoteMarkdown' -import {FocusEventHandler} from 'react' type SaveInfo = { name: string, @@ -98,12 +98,6 @@ export default function AutosaveSoftwareMarkdown() { token }) - // console.group('AutosaveSoftwareMarkdown.saveSoftwareInfo') - // console.log('saved...', name) - // console.log('data...', data) - // console.log('status...', resp?.status) - // console.groupEnd() - if (resp?.status !== 200) { showErrorMessage(`Failed to save ${name}. ${resp?.message}`) } else { @@ -132,6 +126,7 @@ export default function AutosaveSoftwareMarkdown() { @@ -194,7 +188,7 @@ export default function AutosaveSoftwareMarkdown() { />
} diff --git a/frontend/pages/api/fe/markdown/raw.ts b/frontend/pages/api/fe/markdown/raw.ts new file mode 100644 index 000000000..551925710 --- /dev/null +++ b/frontend/pages/api/fe/markdown/raw.ts @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2024 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {NextApiRequest, NextApiResponse} from 'next' +import {isProperUrl} from '~/utils/fetchHelpers' +import {getRemoteMarkdown} from '~/utils/getSoftware' +import logger from '~/utils/logger' + +const GITHUB = new RegExp(/^http?s:\/\/github.com\//g) +const HTML = new RegExp(/]*>([\s\S]*?)<\/html>/i) +const BITBUCKET = new RegExp(/^http?s:\/\/bitbucket(.org|.com)\//) + +function suggestRawMarkdownUrl(url:string){ + + // GitHub + if (GITHUB.test(url)){ + const rawUrl = url + .replaceAll(GITHUB,'https://raw.githubusercontent.com/') + .replace(/\/blob\//g,'/refs/heads/') + + return rawUrl + } + + // BITBUCKET + if (BITBUCKET.test(url)){ + const rawUrl = url.replace(/\/src\//,'/raw/') + return rawUrl + } + + // Gitlab & on premise ? + const rawUrl = url.replace(/\/blob\//g,'/raw/') + + if (rawUrl!==url){ + return rawUrl + } +} + +/** + * Endpoint to retrieve raw markdown. + * It checks if the provided url returns raw markdown content or HTML page. + * For github.com and gitlab com provides suggestion for correct raw markdown url. * + * Examples + * https://github.com/research-software-directory/RSD-as-a-service/blob/main/README.md + * https://raw.githubusercontent.com/research-software-directory/RSD-as-a-service/refs/heads/main/README.md + * https://gitlab.com/dmijatovic/nexter-demo/-/blob/master/README.md?ref_type=heads + * https://gitlab.com/dmijatovic/nexter-demo/-/raw/master/README.md?ref_type=heads + * https://bitbucket.org/dmijatovic/publictest/src/main/README.md + * https://bitbucket.org/dmijatovic/publictest/raw/85e8651f290d058ad7832d09efa2fd63873b648b/README.md + * @param req + * @param res + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + const url = req.query.url as string + + // validation 1: url should be string + if (url === undefined || Array.isArray(url)) { + // we return 200 to api call + // and the error as json body + res.status(200) + res.json({ + status: 400, + message: 'Missing url as query parameter' + }) + } + + // validation 2: check if url syntax is correct + if (isProperUrl(url)===false){ + // we return 200 to api call + // and the error as json body + res.status(200) + res.json({ + status: 400, + message: 'Not a valid url. Please validate your input.' + }) + } + + // get markdown from URL + const rawMarkdown = await getRemoteMarkdown(url) + + if (typeof rawMarkdown === 'string'){ + // if HTML returned + if (HTML.test(rawMarkdown)){ + const rawUrl = suggestRawMarkdownUrl(url) + // we return 200 to api call + // and the error as json body + res.status(200) + // we send 400 error and raw url suggestion + res.json({ + status:400, + message:'HTML page, not a raw text/markdown content.', + rawUrl + }) + }else{ + // assuming text/markdown returned + res.status(200) + res.json({status:200,message:rawMarkdown}) + } + }else{ + // we return 200 to api call + // and the error as json body + res.status(200) + // pass error returned from fetch + res.json({ + status: rawMarkdown.status, + message: rawMarkdown.message + }) + } + } catch (e: any) { + // we return 200 to api call + // and the error as json body + res.status(200) + res.json({ + status: 500, + message: e.message ?? 'Unknown server error' + }) + logger(`api/fe/markdown/raw: ${e.message}`) + } +} diff --git a/frontend/utils/fetchHelpers.ts b/frontend/utils/fetchHelpers.ts index 4151ea4d0..1f7115854 100644 --- a/frontend/utils/fetchHelpers.ts +++ b/frontend/utils/fetchHelpers.ts @@ -138,3 +138,13 @@ export async function promiseWithTimeout( // console.log('promiseWithTimeout...', resp) return resp } + +// Validate url string is syntactically correct +export function isProperUrl(url:string){ + try{ + new URL(url) + return true + }catch(e){ + return false + } +} diff --git a/frontend/utils/getSoftware.ts b/frontend/utils/getSoftware.ts index 2194d0a84..dc51f8592 100644 --- a/frontend/utils/getSoftware.ts +++ b/frontend/utils/getSoftware.ts @@ -300,7 +300,7 @@ export async function getLicenseForSoftware(uuid:string,token?:string){ } /** - * REMOTE MARKDOWN FILE + * REMOTE MARKDOWN FILE called server side */ export async function getRemoteMarkdown(url: string) { try { @@ -326,7 +326,39 @@ export async function getRemoteMarkdown(url: string) { logger(`getRemoteMarkdown: ${e?.message}`, 'error') return { status: 404, - message: e?.message + message: 'Markdown file not found. Validate url.' + } + } +} + +/** + * Get remote markdown using RSD api. + * Validates the url returns text/markdown and not html page. + * If html page it returns suggested rawUrl for Github/Gitlab + * @param url + * @returns + */ +export async function apiRemoteMarkdown(url:string){ + try{ + const api = `/api/fe/markdown/raw?url=${encodeURI(url)}` + const resp = await fetch(api) + if (resp.ok){ + const data:{ + status:number, + message:string, + rawUrl?:string + } = await resp.json() + return data + }else{ + return { + status: resp.status, + message: resp.statusText + } + } + }catch(e:any){ + return { + status:500, + message: e?.message as string ?? 'Unknown server error' } } }