Commit 13771c01 authored by Stefan Probst's avatar Stefan Probst
Browse files

chore: wip

parent 1c48e39e
......@@ -36,13 +36,14 @@ export function convertToInitialFormValues(
| ToolDto
| TrainingMaterialDto
| WorkflowDto,
):
): (
| DatasetCore
| PublicationCore
| StepCore
| ToolCore
| TrainingMaterialCore
| WorkflowCore {
| WorkflowCore
) & { persistentId?: string } {
const initialValues = {
label: item.label,
version: item.version,
......@@ -99,6 +100,7 @@ export function convertToInitialFormValues(
sourceItemId: item.sourceItemId,
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (['dataset', 'publication'].includes(item.category!)) {
// @ts-expect-error items are not discriminated unions
initialValues.dateCreated = item.dateCreated
......@@ -113,5 +115,11 @@ export function convertToInitialFormValues(
)
}
if (item.category === 'step') {
/** Keep id, so we can easily identify if a workflow step is edited or newly created. */
// @ts-expect-error items are not discriminated unions
initialValues.persistentId = item.persistentId
}
return initialValues
}
......@@ -3,11 +3,12 @@ import { useRouter } from 'next/router'
import type { ReactNode } from 'react'
import { Fragment, useState } from 'react'
import { useQueryClient } from 'react-query'
import type { StepCore, WorkflowCore, WorkflowDto } from '@/api/sshoc'
import {
useCreateStep,
useCreateWorkflow,
useGetLoggedInUser,
WorkflowCore,
WorkflowDto,
} from '@/api/sshoc'
import type { ItemCategory, ItemSearchQuery } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
......@@ -18,6 +19,7 @@ import { SourceFormSection } from '@/components/item/SourceFormSection/SourceFor
import { WorkflowStepsFormSection } from '@/components/item/WorkflowStepsFormSection/WorkflowStepsFormSection'
import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast'
import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues'
import { validateCommonFormFields } from '@/lib/sshoc/validateCommonFormFields'
import { useAuth } from '@/modules/auth/AuthContext'
import { Form } from '@/modules/form/Form'
......@@ -25,6 +27,7 @@ import { getSingularItemCategoryLabel } from '@/utils/getSingularItemCategoryLab
export interface ItemFormValues extends WorkflowCore {
draft?: boolean
composedOf?: Array<StepCore & { persistentId?: string }>
}
export interface ItemFormProps<T> {
......@@ -39,6 +42,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
const { category, initialValues } = props
const categoryLabel = getSingularItemCategoryLabel(category)
const stepLabel = getSingularItemCategoryLabel('step')
const useItemMutation = useCreateWorkflow
......@@ -51,7 +55,14 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
? ['administrator', 'moderator'].includes(user.data.role)
: false
const queryClient = useQueryClient()
const create = useItemMutation({
const createStep = useCreateStep({
onError() {
toast.error(
`Failed to ${isAllowedToPublish ? 'publish' : 'submit'} ${stepLabel}.`,
)
},
})
const createWorkflow = useItemMutation({
onSuccess(data: WorkflowDto) {
toast.success(
`Successfully ${
......@@ -92,24 +103,40 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
},
})
function onSubmit({ draft, ...values }: ItemFormValues) {
async function onSubmit({ draft, ...unsanitized }: ItemFormValues) {
if (auth.session?.accessToken == null) {
toast.error('Authentication required.')
return Promise.reject()
}
const values = sanitizeFormValues(unsanitized)
/**
* Backend crashes with `source: {}`.
* Workflow steps need to be handled separately.
*/
if (values.source && values.source.id === undefined) {
delete values.source
}
const { composedOf, ...workflow } = values
return create.mutateAsync([
const createdWorkflow = await createWorkflow.mutateAsync([
{ draft },
values,
workflow,
{ token: auth.session.accessToken },
])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const workflowId = createdWorkflow.persistentId!
if (composedOf !== undefined) {
await Promise.all(
composedOf.map((step) => {
return createStep.mutateAsync([
{ workflowId },
{ draft },
sanitizeFormValues(step),
{ token: auth.session?.accessToken },
])
}),
)
// TODO: what should happen if any step creation fails? delete the whole workflow and show an error?
}
}
function onValidateWorkflow(values: Partial<ItemFormValues>) {
......@@ -128,6 +155,11 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
* Multi-page form.
* */
const [state, setState] = useState({ page: 0, values: initialValues })
/**
* final-form `pristine` only reflects registered fields, i.e. those currently
* rendered. for multipage forms we need to keep track of this ourselves.
*/
// const [isFormPristine, setFormPristine] = useState(true)
const pages = [
<FormPage key="workflow-page" onValidate={onValidateWorkflow}>
......@@ -138,12 +170,13 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<SourceFormSection />
</FormPage>,
<FormPage key="steps-page">
<WorkflowStepsFormSection />
<WorkflowStepsFormSection onPreviousPage={previousPage} />
</FormPage>,
]
const activePage = pages[state.page]
const isLastPage = state.page === pages.length - 1
const [pageStatus, setPageStatus] = useState(Array(pages.length).fill(true))
function nextPage(values: Partial<ItemFormValues>) {
setState((state) => ({
......@@ -188,7 +221,6 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
>
{pages[state.page]}
<div className="flex items-center justify-end space-x-6">
<pre>{JSON.stringify(pristine)}</pre>
<Button onPress={onCancel} variant="link">
Cancel
</Button>
......@@ -203,8 +235,10 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
form.change('draft', true)
}}
isDisabled={
/* FIXME: handle `pristine` for multi-step form || */
invalid || submitting || create.isLoading
pageStatus.some((pristine) => pristine === false) ||
invalid ||
submitting ||
createWorkflow.isLoading
}
variant="link"
>
......@@ -216,15 +250,27 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
form.change('draft', undefined)
}}
isDisabled={
/* FIXME: pristine || */
invalid || submitting || create.isLoading
pageStatus.some((pristine) => pristine === false) ||
invalid ||
submitting ||
createWorkflow.isLoading
}
>
{isAllowedToPublish ? 'Publish' : 'Submit'}
</Button>
</Fragment>
) : (
<Button type="submit" isDisabled={invalid}>
<Button
type="submit"
onPress={() => {
setPageStatus((status) => {
const newStatus = [...status]
newStatus[state.page] = pristine
return newStatus
})
}}
isDisabled={invalid}
>
Next
</Button>
)}
......
......@@ -3,11 +3,12 @@ import { useRouter } from 'next/router'
import type { ReactNode } from 'react'
import { Fragment, useState } from 'react'
import { useQueryClient } from 'react-query'
import type { StepCore, WorkflowCore, WorkflowDto } from '@/api/sshoc'
import {
useGetLoggedInUser,
useUpdateStep,
useUpdateWorkflow,
WorkflowCore,
WorkflowDto,
} from '@/api/sshoc'
import type { ItemCategory, ItemSearchQuery } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
......@@ -18,6 +19,7 @@ import { SourceFormSection } from '@/components/item/SourceFormSection/SourceFor
import { WorkflowStepsFormSection } from '@/components/item/WorkflowStepsFormSection/WorkflowStepsFormSection'
import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast'
import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues'
import { validateCommonFormFields } from '@/lib/sshoc/validateCommonFormFields'
import { useAuth } from '@/modules/auth/AuthContext'
import { Form } from '@/modules/form/Form'
......@@ -25,6 +27,7 @@ import { getSingularItemCategoryLabel } from '@/utils/getSingularItemCategoryLab
export interface ItemFormValues extends WorkflowCore {
draft?: boolean
composedOf?: Array<StepCore & { persistentId?: string; dirty?: boolean }>
}
export interface ItemFormProps<T> {
......@@ -41,6 +44,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
const { id, category, initialValues } = props
const categoryLabel = getSingularItemCategoryLabel(category)
const stepLabel = getSingularItemCategoryLabel('step')
const useItemMutation = useUpdateWorkflow
......@@ -53,7 +57,15 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
? ['administrator', 'moderator'].includes(user.data.role)
: false
const queryClient = useQueryClient()
const create = useItemMutation({
const updateStep = useUpdateStep({
onSuccess() {
toast.success('Updated step')
},
onError() {
toast.error('Failed to update step')
},
})
const createWorkflow = useItemMutation({
onSuccess(data: WorkflowDto) {
toast.success(
`Successfully ${
......@@ -94,23 +106,85 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
},
})
function onSubmit({ draft, ...values }: ItemFormValues) {
async function onSubmit({ draft, ...unsanitized }: ItemFormValues) {
if (auth.session?.accessToken == null) {
toast.error('Authentication required.')
return Promise.reject()
}
const values = sanitizeFormValues(unsanitized)
/**
* Backend crashes with `source: {}`.
* Workflow steps need to be handled separately.
*/
if (values.source && values.source.id === undefined) {
delete values.source
const { composedOf, ...workflow } = values
if (composedOf !== undefined) {
// await Promise.all(
// composedOf.map(({ dirty, ...rest }, index) => {
// const step = { ...rest, stepNo: index + 1 }
// /**
// * Backend crashes with `source: {}`.
// */
// if (step.source && step.source.id === undefined) {
// delete step.source
// }
// if (step.persistentId !== undefined) {
// /** Existing step was edited. */
// if (dirty) {
// console.log('Edit dirty step', step.persistentId, step.stepNo)
// } else {
// const original = props.item?.composedOf?.[index]
// /** Existing step was reorderd. */
// if (original.persistentId !== step.persistentId) {
// return updateStep.mutateAsync([
// { workflowId: id, stepId: step.persistentId },
// { draft },
// step,
// { token: auth.session?.accessToken },
// ])
// }
// }
// } else {
// /** Create new step. */
// console.log('Create step')
// }
// }),
// )
for (let index = 0; index < composedOf.length; index++) {
const { dirty, ...rest } = composedOf[index]
const step = { ...rest, stepNo: index + 1 }
if (step.persistentId !== undefined) {
/** Existing step was edited. */
if (dirty === true) {
console.log('Edit dirty step', step.persistentId, step.stepNo)
} else {
const original = props.item?.composedOf?.[index]
/** Existing step was reorderd. */
if (original.persistentId !== step.persistentId) {
return updateStep.mutateAsync([
{ workflowId: id, stepId: step.persistentId },
{ draft },
sanitizeFormValues(step),
{ token: auth.session.accessToken },
])
}
}
} else {
/** Create new step. */
console.log('Create step')
}
}
}
return create.mutateAsync([
return createWorkflow.mutateAsync([
{ workflowId: id },
{ draft },
values,
workflow,
{ token: auth.session.accessToken },
])
}
......@@ -141,12 +215,13 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<SourceFormSection />
</FormPage>,
<FormPage key="steps-page">
<WorkflowStepsFormSection />
<WorkflowStepsFormSection onPreviousPage={previousPage} />
</FormPage>,
]
const activePage = pages[state.page]
const isLastPage = state.page === pages.length - 1
const [pageStatus, setPageStatus] = useState(Array(pages.length).fill(true))
function nextPage(values: Partial<ItemFormValues>) {
setState((state) => ({
......@@ -205,8 +280,10 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
form.change('draft', true)
}}
isDisabled={
/* FIXME: handle `pristine` for multi-step form || */
invalid || submitting || create.isLoading
pageStatus.some((pristine) => pristine === false) ||
invalid ||
submitting ||
createWorkflow.isLoading
}
variant="link"
>
......@@ -218,15 +295,27 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
form.change('draft', undefined)
}}
isDisabled={
/* FIXME: pristine || */
invalid || submitting || create.isLoading
pageStatus.some((pristine) => pristine === false) ||
invalid ||
submitting ||
createWorkflow.isLoading
}
>
{isAllowedToPublish ? 'Publish' : 'Submit'}
</Button>
</Fragment>
) : (
<Button type="submit" isDisabled={invalid}>
<Button
type="submit"
onPress={() => {
setPageStatus((status) => {
const newStatus = [...status]
newStatus[state.page] = pristine
return newStatus
})
}}
isDisabled={invalid}
>
Next
</Button>
)}
......
import React from 'react'
import { WorkflowCore } from '@/api/sshoc'
import { Fragment } from 'react'
import type { StepCore, WorkflowCore } from '@/api/sshoc'
import { Button } from '@/elements/Button/Button'
import { Icon } from '@/elements/Icon/Icon'
import { Svg as CrossIcon } from '@/elements/icons/small/cross.svg'
......@@ -8,113 +9,160 @@ import { Svg as TriangleIcon } from '@/elements/icons/small/triangle.svg'
import { FormField } from '@/modules/form/FormField'
import { FormFieldArray } from '@/modules/form/FormFieldArray'
import { ActorsFormSection } from '../ActorsFormSection/ActorsFormSection'
import { MainFormSection } from '../MainFormSection/MainFormSection'
import { PropertiesFormSection } from '../PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '../RelatedItemsFormSection/RelatedItemsFormSection'
import { SourceFormSection } from '../SourceFormSection/SourceFormSection'
export interface ItemFormValues extends WorkflowCore {
draft?: boolean
composedOf?: Array<StepCore & { persistentId?: string }>
}
export interface WorkflowStepsFormSectionProps {
onPreviousPage: () => void
}
/**
* Form section for workflow steps.
*/
export function WorkflowStepsFormSection(): JSX.Element {
export function WorkflowStepsFormSection(
props: WorkflowStepsFormSectionProps,
): JSX.Element {
return (
<section className="flex flex-col space-y-2">
<strong className="mb-6 text-error-500">
Editing and creating workflow steps is not yet implemented.
</strong>
<FormField name="label" subscription={{ value: true }}>
{({ input }) => {
return (
<div className="flex flex-col px-4 py-3 border border-gray-200 rounded bg-gray-75">
<h2 className="text-base font-bold text-gray-800 font-body">
{input.value}
</h2>
<div className="flex self-end space-x-8 text-primary-750">
<Button variant="link">
<Icon icon={PlusIcon} className="w-2.5 h-2.5" />
<span>Add a step</span>
</Button>
<Button variant="link">Edit</Button>
</div>
</div>
)
}}
</FormField>
<FormFieldArray name="composedOf">
{({ fields }) => {
return (
<div className="flex flex-col ml-8 space-y-2">
{fields.map((name, index) => {
return (
<FormField
key={name}
name={`${name}.label`}
subscription={{ value: true }}
>
{({ input }) => {
return (
<div className="flex flex-col px-4 py-3 border border-gray-200 rounded bg-gray-75">
<h3 className="text-base font-medium text-gray-800 font-body">
{input.value}
</h3>
<div className="flex self-end space-x-8 text-primary-750">
<Button
onPress={() => {
fields.move(index, Math.max(0, index - 1))
}}
isDisabled={index === 0}
variant="link"
>
<Icon
icon={TriangleIcon}
className="w-2.5 h-2.5 transform rotate-180"
/>
<span>Move up</span>
</Button>
<Button
onPress={() => {
fields.move(
index,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Math.min(fields.length! - 1, index + 1),
)
}}
<Fragment>
<FormField name="label" subscription={{ value: true }}>
{({ input }) => {
return (
<div className="flex flex-col px-4 py-3 space-y-3 border border-gray-200 rounded bg-gray-75">
<h2 className="text-base font-bold text-gray-800 font-body">
{input.value}
</h2>
<div className="flex self-end space-x-8 text-primary-750">
<Button
onPress={() => {
fields.push(undefined)
}}
variant="link"
>
<Icon icon={PlusIcon} className="w-2.5 h-2.5" />
<span>Add a step</span>
</Button>
<Button onPress={props.onPreviousPage} variant="link">
Edit
</Button>
</div>
</div>
)
}}
</FormField>
<div className="flex flex-col ml-8 space-y-2">
{fields.map((name, index) => {
return (
<div
key={name}
className="flex flex-col px-4 py-3 space-y-3 border border-gray-200 rounded bg-gray-75"
>
<WorkflowStepForm name={name} />
<FormField
name={`${name}.label`}
subscription={{ value: true }}
>
{({ input }) => {
return (
<h3 className="text-base font-medium text-gray-800 font-body">
{input.value}
</h3>
)
}}
</FormField>
<div className="flex self-end space-x-8 text-primary-750">
<Button
onPress={() => {
fields.move(index, Math.max(0, index - 1))
}}
isDisabled={index === 0}
variant="link"
>
<Icon
icon={TriangleIcon}
className="w-2.5 h-2.5 transform rotate-180"
/>
<span>Move up</span>
</Button>
<Button
onPress={() => {
fields.move(
index,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
isDisabled={index === fields.length! - 1}
variant="link"
>
<Icon
icon={TriangleIcon}
className="w-2.5 h-2.5"
/>
<span>Move down</span>
</Button>