Skip to content

Commit

Permalink
fix: remote markdown url validation and suggestions
Browse files Browse the repository at this point in the history
docs: update documentation to changed approach
  • Loading branch information
dmijatovic committed Oct 24, 2024
1 parent 9652fb5 commit 4c22cd4
Show file tree
Hide file tree
Showing 9 changed files with 285 additions and 134 deletions.
4 changes: 2 additions & 2 deletions documentation/docs/01-users/05-adding-software.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,12 @@ You need to use the full URL of the image and the image needs to send CORS heade

```

### 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
Expand Down
18 changes: 14 additions & 4 deletions frontend/components/layout/PageErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ContentInTheMiddle>
<section className="flex justify-center items-center text-secondary">
<h1 className="border-r-2 px-4 font-medium">{status}</h1>
<p className="px-4 tracking-wider">{message}</p>
{children || <p className="px-4 tracking-wider">{message}</p>}
</section>
</ContentInTheMiddle>
)
Expand Down
82 changes: 13 additions & 69 deletions frontend/components/software/edit/editSoftwareConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
//
// SPDX-License-Identifier: Apache-2.0

import {isProperUrl} from '~/utils/fetchHelpers'

export const softwareInformation = {
slug: {
label: 'RSD path (admin only)',
Expand Down Expand Up @@ -62,14 +64,19 @@ export const softwareInformation = {
description_url: {
label: 'URL of (raw) Markdown file',
help: <>
Read <u><a href="/documentation/users/adding-software/#document-url" target="_blank">here</a></u> how to properly link to a raw Markdown URL
Read <u><a href="/documentation/users/adding-software/#markdown-url" target="_blank">here</a></u> 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
}
}
},
Expand Down Expand Up @@ -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'
// }
// }
// }
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<EditSoftwareItem>,
onSaveField: ({name,value}: OnSaveProps<EditSoftwareItem>) => 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,
Expand All @@ -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) {
Expand All @@ -112,7 +119,28 @@ export default function AutosaveRemoteMarkdown({control,rules,options,onSaveFiel
<PageErrorMessage
status={state.error?.status ?? undefined}
message={state.error?.message ?? 'Server error'}
/>
>
<div className="px-4 ">
<div className="text-error py-4">{state.error?.message ?? 'Incorrect link'}</div>
{ state.error?.rawUrl ?
<>
<div>
<span className="font-medium">Suggestion: </span>
<a href={state.error?.rawUrl} target="_blank">{state.error?.rawUrl}</a>
</div>
<div className="py-4">
<Button
variant="contained"
disabled = {!state.error?.rawUrl}
onClick={useSuggestedUrl}>
Use suggestion
</Button>
</div>
</>
: config.description_url.help
}
</div>
</PageErrorMessage>
)
}
if (state.markdown) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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(
<WithAppContext options={{session: mockSession}}>
Expand All @@ -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
Expand All @@ -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() => {
Expand Down Expand Up @@ -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()
Expand Down
Loading

0 comments on commit 4c22cd4

Please sign in to comment.