Commit 8511c271 authored by Stefan Probst's avatar Stefan Probst
Browse files

feat: add item create forms

parent 6f2b7535
Pipeline #177674 passed with stages
in 10 minutes and 32 seconds
......@@ -49,4 +49,4 @@ public/robots.txt
public/sitemap.xml
# auto-generated api clients
src/api/sshoc/index.ts
# src/api/sshoc/index.ts
......@@ -51,4 +51,4 @@ public/robots.txt
public/sitemap.xml
# auto-generated api clients
src/api/sshoc/index.ts
# src/api/sshoc/index.ts
......@@ -75,7 +75,7 @@ CLARIN.
In the current governance scheme, 5 ERICs will ensure the continuity of the  SSH
Open Marketplace beyond the lifetime of the SSHOC project.
<div class="grid grid-cols-5">
<div className="grid grid-cols-5">
<a href="https://www.cessda.eu">
......
......@@ -14,7 +14,7 @@
"testPathIgnorePatterns": ["/node_modules/", "/.next/"],
"transform": {
"^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
"^.+\\.(css|jpg|png|svg)$": "<rootDir>/test/stub.ts"
"^.+\\.(css|jpg|png|svg)$": "<rootDir>/test/stub.js"
},
"transformIgnorePatterns": ["/node_modules/", "^.+\\.module\\.css$"]
}
......@@ -18,10 +18,10 @@
"lint": "eslint . --ignore-path .gitignore",
"lint:fix": "yarn lint --fix",
"postbuild": "yarn create:sitemap",
"prebuild": "rimraf .next && yarn create:sshoc-client && yarn create:favicons",
"prebuild": "rimraf .next && yarn create:favicons",
"preexport": "rimraf out",
"start": "next start",
"test": "jest",
"test": "jest --passWithNoTests",
"test:coverage": "yarn test --coverage"
},
"dependencies": {
......@@ -33,6 +33,40 @@
"@reach/dialog": "^0.11.2",
"@reach/disclosure": "^0.11.2",
"@reach/visually-hidden": "^0.11.1",
"@react-aria/breadcrumbs": "^3.1.2",
"@react-aria/button": "^3.3.1",
"@react-aria/checkbox": "^3.2.1",
"@react-aria/combobox": "^3.0.0-alpha.1",
"@react-aria/dialog": "^3.1.2",
"@react-aria/focus": "^3.2.3",
"@react-aria/i18n": "^3.3.0",
"@react-aria/interactions": "^3.3.3",
"@react-aria/link": "^3.1.2",
"@react-aria/listbox": "^3.2.4",
"@react-aria/menu": "^3.1.4",
"@react-aria/numberfield": "^3.0.0-alpha.0",
"@react-aria/overlays": "^3.6.1",
"@react-aria/progress": "^3.1.1",
"@react-aria/searchfield": "^3.1.1",
"@react-aria/select": "^3.3.0",
"@react-aria/separator": "^3.1.1",
"@react-aria/ssr": "^3.0.1",
"@react-aria/textfield": "^3.2.2",
"@react-aria/tooltip": "^3.1.1",
"@react-aria/utils": "^3.6.0",
"@react-aria/visually-hidden": "^3.2.1",
"@react-stately/checkbox": "^3.0.1",
"@react-stately/collections": "^3.3.0",
"@react-stately/combobox": "^3.0.0-alpha.1",
"@react-stately/data": "^3.2.0",
"@react-stately/menu": "^3.2.1",
"@react-stately/numberfield": "^3.0.0-alpha.0",
"@react-stately/overlays": "^3.1.1",
"@react-stately/searchfield": "^3.1.1",
"@react-stately/select": "^3.1.1",
"@react-stately/toggle": "^3.2.1",
"@react-stately/tooltip": "^3.0.2",
"@react-stately/tree": "^3.1.2",
"@stefanprobst/next-app-layout": "^1.0.1",
"@stefanprobst/next-error-boundary": "^1.0.5",
"@stefanprobst/next-page-metadata": "^1.0.7",
......@@ -41,7 +75,11 @@
"@tailwindcss/typography": "^0.3.1",
"autoprefixer": "^10.1.0",
"clsx": "^1.1.1",
"final-form": "^4.20.1",
"final-form-arrays": "^3.0.2",
"final-form-focus": "^1.1.2",
"focus-visible": "^5.2.0",
"highlight-words-core": "^1.2.2",
"jwt-decode": "^3.1.1",
"next": "^10.0.3",
"nodemailer": "^6.4.16",
......@@ -50,9 +88,11 @@
"postcss-focus-visible": "^5.0.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-final-form": "^6.5.2",
"react-final-form-arrays": "^3.1.3",
"react-google-recaptcha": "^2.1.0",
"react-hook-form": "^6.13.1",
"react-query": "^2.26.2",
"react-query": "^3.12.0",
"react-query-devtools": "^2.6.3",
"react-toastify": "^6.1.0",
"rehype-stringify": "^8.0.0",
......@@ -80,6 +120,8 @@
"@testing-library/jest-dom": "^5.11.5",
"@testing-library/react": "^11.1.1",
"@testing-library/user-event": "^12.2.0",
"@types/final-form-focus": "^1.1.1",
"@types/highlight-words-core": "^1.2.0",
"@types/jest": "^26.0.15",
"@types/jwt-decode": "^3.1.0",
"@types/mdx-js__react": "^1.5.3",
......
......@@ -4,7 +4,7 @@
/* eslint-disable @typescript-eslint/no-namespace */
import type { MutationConfig, MutationResultPair } from 'react-query'
import type { UseMutationOptions, UseMutationResult } from 'react-query'
import { useMutation } from 'react-query'
import type { ImplicitGrantTokenData, OAuthRegistrationDto } from '@/api/sshoc'
import { request } from '@/api/sshoc'
......@@ -51,13 +51,13 @@ export function signInUser([body]: [body: SignInUser.RequestBody]): Promise<
}
export function useSignInUser(
config?: MutationConfig<
config?: UseMutationOptions<
SignInUser.Response.Success,
SignInUser.Response.Error,
[SignInUser.RequestBody],
unknown
>,
): MutationResultPair<
): UseMutationResult<
SignInUser.Response.Success,
SignInUser.Response.Error,
[SignInUser.RequestBody],
......@@ -105,13 +105,13 @@ export async function validateImplicitGrantTokenWithoutRegistration([body]: [
}
export function useValidateImplicitGrantTokenWithoutRegistration(
config?: MutationConfig<
config?: UseMutationOptions<
ValidateImplicitGrantTokenWithoutRegistration.Response.Success,
ValidateImplicitGrantTokenWithoutRegistration.Response.Error,
[ValidateImplicitGrantTokenWithoutRegistration.RequestBody],
unknown
>,
): MutationResultPair<
): UseMutationResult<
ValidateImplicitGrantTokenWithoutRegistration.Response.Success,
ValidateImplicitGrantTokenWithoutRegistration.Response.Error,
[ValidateImplicitGrantTokenWithoutRegistration.RequestBody],
......@@ -160,13 +160,13 @@ export async function validateImplicitGrantTokenWithRegistration([body]: [
}
export function useValidateImplicitGrantTokenWithRegistration(
config?: MutationConfig<
config?: UseMutationOptions<
ValidateImplicitGrantTokenWithRegistration.Response.Success,
ValidateImplicitGrantTokenWithRegistration.Response.Error,
[ValidateImplicitGrantTokenWithRegistration.RequestBody],
unknown
>,
): MutationResultPair<
): UseMutationResult<
ValidateImplicitGrantTokenWithRegistration.Response.Success,
ValidateImplicitGrantTokenWithRegistration.Response.Error,
[ValidateImplicitGrantTokenWithRegistration.RequestBody],
......
import type {
DatasetCore,
DatasetDto,
PublicationCore,
PublicationDto,
StepCore,
StepDto,
ToolCore,
ToolDto,
TrainingMaterialCore,
TrainingMaterialDto,
WorkflowCore,
WorkflowDto,
} from '@/api/sshoc'
export function convertToInitialFormValues(item: DatasetDto): DatasetCore
export function convertToInitialFormValues(
item: PublicationDto,
): PublicationCore
export function convertToInitialFormValues(item: StepDto): StepCore
export function convertToInitialFormValues(item: ToolDto): ToolCore
export function convertToInitialFormValues(
item: TrainingMaterialDto,
): TrainingMaterialCore
export function convertToInitialFormValues(item: WorkflowDto): WorkflowCore
/**
* Converts item details into intitial form values,
* i.e. `${ItemCategory}Dto` into `${ItemCategory}Core`.
*/
export function convertToInitialFormValues(
item:
| DatasetDto
| PublicationDto
| StepDto
| ToolDto
| TrainingMaterialDto
| WorkflowDto,
):
| DatasetCore
| PublicationCore
| StepCore
| ToolCore
| TrainingMaterialCore
| WorkflowCore {
return {
...item,
/**
* Only ids needed.
*/
contributors: item.contributors?.map((contributor) => {
const id = contributor.actor?.id
const code = contributor.role?.code
return { actor: { id }, role: { code } }
}),
/**
* Only ids needed.
*/
properties: item.properties?.map((property) => {
return {
// id: property.id, // this is in ItemDto but not in ItemCore
type: { code: property.type?.code },
value: property.value,
concept: {
code: property.concept?.code,
vocabulary: { code: property.concept?.vocabulary?.code },
uri: property.concept?.uri,
},
}
}),
/**
* `persistentId` => `objectId`
*/
relatedItems: item.relatedItems?.map((relation) => {
return {
...relation,
objectId: relation.persistentId,
}
}),
/**
* FIXME: figure out what this is, and why it it incompatible
*/
externalIds: item.externalIds?.map((id) => {
return {
serviceIdentifier: id.identifierService?.code ?? '',
identifier: id.identifier ?? '',
}
}),
/**
* Only id needed.
*/
source: {
id: item.source?.id,
},
}
}
This diff is collapsed.
import { useState } from 'react'
import { useGetActors, useGetAllActorRoles } from '@/api/sshoc'
import { useDebouncedState } from '@/lib/hooks/useDebouncedState'
import { FormComboBox } from '@/modules/form/components/FormComboBox/FormComboBox'
import { FormFieldAddButton } from '@/modules/form/components/FormFieldAddButton/FormFieldAddButton'
import { FormFieldRemoveButton } from '@/modules/form/components/FormFieldRemoveButton/FormFieldRemoveButton'
import { FormRecord } from '@/modules/form/components/FormRecord/FormRecord'
import { FormRecords } from '@/modules/form/components/FormRecords/FormRecords'
import { FormSection } from '@/modules/form/components/FormSection/FormSection'
import { FormSelect } from '@/modules/form/components/FormSelect/FormSelect'
import { FormFieldArray } from '@/modules/form/FormFieldArray'
export interface ActorsFormSectionProps {
initialValues?: any
}
/**
* Form section for contributors.
*/
export function ActorsFormSection(props: ActorsFormSectionProps): JSX.Element {
return (
<FormSection title={'Actors'}>
<FormFieldArray name="contributors">
{({ fields }) => {
return (
<FormRecords>
{fields.map((name, index) => {
return (
<FormRecord key={name}>
<ActorRoleSelect
name={`${name}.role.code`}
label={'Actor role'}
/>
<ActorComboBox name={`${name}.actor.id`} label={'Name'} />
<FormFieldRemoveButton
onPress={() => fields.remove(index)}
aria-label={'Remove actor'}
/>
</FormRecord>
)
})}
<FormFieldAddButton onPress={() => fields.push(undefined)}>
{'Add actor'}
</FormFieldAddButton>
</FormRecords>
)
}}
</FormFieldArray>
</FormSection>
)
}
interface ActorRoleSelectProps {
name: string
label: string
}
/**
* Actor role.
*/
function ActorRoleSelect(props: ActorRoleSelectProps): JSX.Element {
const actorRoles = useGetAllActorRoles()
return (
<FormSelect
name={props.name}
label={props.label}
items={actorRoles.data ?? []}
isLoading={actorRoles.isLoading}
variant="form"
>
{(item) => (
<FormSelect.Item key={item.code}>{item.label}</FormSelect.Item>
)}
</FormSelect>
)
}
interface ActorComboBoxProps {
name: string
label: string
}
/**
* Actor.
*/
function ActorComboBox(props: ActorComboBoxProps): JSX.Element {
const [searchTerm, setSearchTerm] = useState('')
const debouncedsearchTerm = useDebouncedState(searchTerm, 150).trim()
const actors = useGetActors(
{ q: debouncedsearchTerm },
{
// enabled: debouncedsearchTerm.length > 2,
keepPreviousData: true,
},
)
return (
<FormComboBox
name={props.name}
label={props.label}
items={actors.data?.actors ?? []}
isLoading={actors.isLoading}
onInputChange={setSearchTerm}
variant="form"
style={{ flex: 1 }}
>
{(item) => <FormComboBox.Item>{item.name}</FormComboBox.Item>}
</FormComboBox>
)
}
import { useRouter } from 'next/router'
import { useQueryClient } from 'react-query'
import { DatasetCore, DatasetDto, useCreateDataset } from '@/api/sshoc'
import type { ItemCategory } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
import { DateFormSection } from '@/components/item/DateFormSection/DateFormSection'
import { MainFormSection } from '@/components/item/MainFormSection/MainFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { SourceFormSection } from '@/components/item/SourceFormSection/SourceFormSection'
import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast'
import { useAuth } from '@/modules/auth/AuthContext'
import { Form } from '@/modules/form/Form'
export type ItemFormValues = DatasetCore
export interface ItemFormProps<T> {
category: ItemCategory
initialValues?: Partial<T>
}
/**
* Item create form.
*/
export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
const { category, initialValues } = props
const useItemMutation = useCreateDataset
const toast = useToast()
const router = useRouter()
const auth = useAuth()
const queryClient = useQueryClient()
const create = useItemMutation({
onSuccess(data: DatasetDto) {
toast.success(`Successfully updated ${category}.`)
queryClient.invalidateQueries({
queryKey: ['itemSearch'],
})
queryClient.invalidateQueries({
queryKey: ['getDatasets'],
})
// queryClient.invalidateQueries({
// queryKey: ['getDataset', { id: data.persistentId }],
// })
router.push({ pathname: `/${data.category}/${data.persistentId}` })
},
onError() {
toast.error(`Failed to update ${category}.`)
},
})
function onSubmit(values: ItemFormValues) {
if (auth.session?.accessToken == null) {
toast.error('Authentication required.')
return Promise.reject()
}
/**
* Backend crashes with `source: {}`.
*/
if (values.source && values.source.id === undefined) {
delete values.source
}
return create.mutateAsync([{}, values, { token: auth.session.accessToken }])
}
function onValidate(values: Partial<ItemFormValues>) {
const errors: Partial<Record<keyof typeof values, string>> = {}
/** Required field `label`. */
if (values.label === undefined) {
errors.label = 'Label is required.'
}
/** Required field `description`. */
if (values.description === undefined) {
errors.description = 'Description is required.'
}
/** `sourceItemId` is required when `source` is set. */
if (values.source?.id != null && values.sourceItemId == null) {
errors.sourceItemId = 'Missing value in Source ID.'
}
/** `source` is required when `sourceItemId` is set. */
if (values.sourceItemId != null && values.source?.id == null) {
errors.sourceItemId = 'Missing value in Source.'
}
return errors
}
function onCancel() {
router.push('/')
}
return (
<Form
onSubmit={onSubmit}
validate={onValidate}
initialValues={initialValues}
>
{({ handleSubmit, pristine, invalid, submitting }) => {
return (
<form
onSubmit={handleSubmit}
noValidate
className="flex flex-col space-y-12"
>
<MainFormSection />
<DateFormSection />
<ActorsFormSection />
<PropertiesFormSection />
<RelatedItemsFormSection />
<SourceFormSection />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
Cancel
</Button>
<Button
type="submit"
isDisabled={
pristine || invalid || submitting || create.isLoading
}
>
Submit
</Button>
</div>
</form>
)
}}
</Form>
)
}
import { useRouter } from 'next/router'
import { useQueryClient } from 'react-query'
import type { DatasetCore, DatasetDto } from '@/api/sshoc'
import { useUpdateDataset } from '@/api/sshoc'
import type { ItemCategory } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
import { DateFormSection } from '@/components/item/DateFormSection/DateFormSection'
import { MainFormSection } from '@/components/item/MainFormSection/MainFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { SourceFormSection } from '@/components/item/SourceFormSection/SourceFormSection'
import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast'
import { useAuth } from '@/modules/auth/AuthContext'
import { Form } from '@/modules/form/Form'
export type ItemFormValues = DatasetCore
export interface ItemFormProps<T> {
id: string
category: ItemCategory
initialValues?: Partial<T>
}
/**
* Item edit form.
*/
export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
const { id, category, initialValues } = props
const useItemMutation = useUpdateDataset
const toast = useToast()
const router = useRouter()
const auth = useAuth()
const queryClient = useQueryClient()
const create = useItemMutation({
onSuccess(data: DatasetDto) {
toast.success(`Successfully updated ${category}.`)
queryClient.invalidateQueries({
queryKey: ['itemSearch'],
})
queryClient.invalidateQueries({
queryKey: ['getDatasets'],
})
queryClient.invalidateQueries({
queryKey: ['getDataset', { id: data.persistentId }],
})
router.push({ pathname: `/${data.category}/${data.persistentId}` })
},