WorkflowEditForm.tsx 18.4 KB
Newer Older
1
2
3
import { useButton } from '@react-aria/button'
import type { AriaButtonProps } from '@react-types/button'
import cx from 'clsx'
4
import type { Config as FormConfig } from 'final-form'
5
6
import get from 'lodash.get'
import set from 'lodash.set'
Stefan Probst's avatar
Stefan Probst committed
7
import { useRouter } from 'next/router'
8
import type { FC } from 'react'
9
import { Fragment, useEffect, useRef, useState } from 'react'
Stefan Probst's avatar
Stefan Probst committed
10
import { useQueryClient } from 'react-query'
Stefan Probst's avatar
Stefan Probst committed
11
12

import type { StepCore, WorkflowCore, WorkflowDto } from '@/api/sshoc'
13
import {
14
  useCreateStep,
15
  useGetLoggedInUser,
Stefan Probst's avatar
Stefan Probst committed
16
  useUpdateStep,
17
18
  useUpdateWorkflow,
} from '@/api/sshoc'
19
import type { ItemCategory } from '@/api/sshoc/types'
Stefan Probst's avatar
Stefan Probst committed
20
21
22
23
24
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
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'
25
import { WorkflowStepsFormSection } from '@/components/item/WorkflowStepsFormSection/WorkflowStepsFormSection'
Stefan Probst's avatar
Stefan Probst committed
26
27
import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast'
Stefan Probst's avatar
Stefan Probst committed
28
import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues'
29
import { useValidateCommonFormFields } from '@/lib/sshoc/validateCommonFormFields'
Stefan Probst's avatar
Stefan Probst committed
30
import { useAuth } from '@/modules/auth/AuthContext'
31
import { useErrorHandlers } from '@/modules/error/useErrorHandlers'
Stefan Probst's avatar
Stefan Probst committed
32
import { Form } from '@/modules/form/Form'
33
import { Title } from '@/modules/ui/typography/Title'
34
import { getSingularItemCategoryLabel } from '@/utils/getSingularItemCategoryLabel'
Stefan Probst's avatar
Stefan Probst committed
35

36
37
export type PageKey = 'workflow' | 'steps' | 'step'

Stefan Probst's avatar
Stefan Probst committed
38
39
export interface ItemFormValues extends WorkflowCore {
  draft?: boolean
Stefan Probst's avatar
Stefan Probst committed
40
  composedOf?: Array<StepCore & { persistentId?: string; dirty?: boolean }>
Stefan Probst's avatar
Stefan Probst committed
41
}
Stefan Probst's avatar
Stefan Probst committed
42
43
44
45
46

export interface ItemFormProps<T> {
  id: string
  category: ItemCategory
  initialValues?: Partial<T>
47
  item?: WorkflowDto
Stefan Probst's avatar
Stefan Probst committed
48
49
50
51
52
53
54
55
}

/**
 * Item edit form.
 */
export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
  const { id, category, initialValues } = props

56
  const categoryLabel = getSingularItemCategoryLabel(category)
Stefan Probst's avatar
Stefan Probst committed
57
  const stepLabel = getSingularItemCategoryLabel('step')
Stefan Probst's avatar
Stefan Probst committed
58

Stefan Probst's avatar
Stefan Probst committed
59
60
61
62
63
  const useItemMutation = useUpdateWorkflow

  const toast = useToast()
  const router = useRouter()
  const auth = useAuth()
64
  const user = useGetLoggedInUser()
65
  const handleErrors = useErrorHandlers()
66
  const validateCommonFormFields = useValidateCommonFormFields()
67
68
69
70
  const isAllowedToPublish =
    user.data?.role !== undefined
      ? ['administrator', 'moderator'].includes(user.data.role)
      : false
Stefan Probst's avatar
Stefan Probst committed
71
  const queryClient = useQueryClient()
72
  const createStep = useCreateStep({
73
    onError(error) {
74
75
76
      toast.error(
        `Failed to ${isAllowedToPublish ? 'publish' : 'submit'} ${stepLabel}.`,
      )
77
78
79
80

      if (error instanceof Error) {
        handleErrors(error)
      }
Stefan Probst's avatar
Stefan Probst committed
81
82
    },
  })
83
  const updateStep = useUpdateStep({
84
    onError(error) {
85
86
      toast.error(
        `Failed to ${isAllowedToPublish ? 'publish' : 'submit'} ${stepLabel}.`,
87
      )
88
89
90
91

      if (error instanceof Error) {
        handleErrors(error)
      }
Stefan Probst's avatar
Stefan Probst committed
92
    },
93
94
  })
  const updateWorkflow = useItemMutation({
95
    onError(error) {
96
97
98
99
100
      toast.error(
        `Failed to ${
          isAllowedToPublish ? 'publish' : 'submit'
        } ${categoryLabel}.`,
      )
101
102
103
104

      if (error instanceof Error) {
        handleErrors(error)
      }
Stefan Probst's avatar
Stefan Probst committed
105
106
107
    },
  })

108
109
110
111
112
113
114
  /**
   * We cannot invalidate cache and redirect in a useMutation onSuccess callback,
   * since the whole operation is not atomic, but consists of multiple requests.
   */
  function onSuccess(data: WorkflowDto) {
    toast.success(
      `Successfully ${
115
116
117
118
119
        isAllowedToPublish
          ? 'published'
          : data.status === 'draft'
          ? 'saved as draft'
          : 'submitted'
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
      } ${categoryLabel}.`,
    )

    queryClient.invalidateQueries({
      queryKey: ['itemSearch'],
    })
    queryClient.invalidateQueries({
      queryKey: ['getWorkflows'],
    })
    queryClient.invalidateQueries({
      queryKey: ['getWorkflow', { workflowId: data.persistentId }],
    })

    /**
     * if the item is published (i.e. submitted as admin), redirect to details page.
     */
    if (data.status === 'approved') {
      router.push({ pathname: `/${data.category}/${data.persistentId}` })
138
    } else if (data.status === 'draft') {
139
140
      /** Stay on page and don't clear form when saving as draft. */
      // router.push({ pathname: '/' })
141
      window.scroll(0, 0)
142
    } else {
143
      router.push({ pathname: '/success' })
144
145
146
    }
  }

Stefan Probst's avatar
Stefan Probst committed
147
  async function onSubmit({ draft, ...unsanitized }: ItemFormValues) {
Stefan Probst's avatar
Stefan Probst committed
148
149
150
151
152
    if (auth.session?.accessToken == null) {
      toast.error('Authentication required.')
      return Promise.reject()
    }

Stefan Probst's avatar
Stefan Probst committed
153
154
    const values = sanitizeFormValues(unsanitized)

Stefan Probst's avatar
Stefan Probst committed
155
    /**
Stefan Probst's avatar
Stefan Probst committed
156
     * Workflow steps need to be handled separately.
Stefan Probst's avatar
Stefan Probst committed
157
     */
Stefan Probst's avatar
Stefan Probst committed
158
159
    const { composedOf, ...workflow } = values

160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
    const updatedWorkflow = await updateWorkflow.mutateAsync([
      { workflowId: id },
      { draft },
      workflow,
      { token: auth.session.accessToken },
    ])

    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    const workflowId = updatedWorkflow.persistentId!

    /**
     * We cannot dispatch all at once with Promise,all because this crashes the backend.
     * create/update step operations must be run sequentially.
     */
    await (composedOf ?? []).reduce((operations, { dirty, ...data }, index) => {
      return operations.then(() => {
        const step = { ...data, stepNo: index + 1 }
Stefan Probst's avatar
Stefan Probst committed
177

178
        /** Step has an id, so it's an updated operation. */
Stefan Probst's avatar
Stefan Probst committed
179
        if (step.persistentId !== undefined) {
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
          const originalStep = props.item?.composedOf?.[index]

          /**
           * Step has either been edited (dirty), or has been re-ordered,
           * in which case the id at the index differs from the one in the
           * original workflow. Othewwise, there's nothing to do.
           */
          if (
            dirty === true ||
            step.persistentId !== originalStep?.persistentId
          ) {
            return updateStep.mutateAsync([
              { workflowId, stepId: step.persistentId },
              { draft },
              sanitizeFormValues(step),
              { token: auth.session?.accessToken },
            ])
Stefan Probst's avatar
Stefan Probst committed
197
          } else {
198
            return Promise.resolve()
Stefan Probst's avatar
Stefan Probst committed
199
200
          }
        }
Stefan Probst's avatar
Stefan Probst committed
201

202
203
204
205
206
207
208
209
210
211
212
        return createStep.mutateAsync([
          { workflowId },
          { draft },
          sanitizeFormValues(step),
          { token: auth.session?.accessToken },
        ])
      })
    }, Promise.resolve() as any)

    /** This will only get called when the above didn't throw. */
    onSuccess(updatedWorkflow)
213
214
215
216
217
218
219

    /**
     * If `onSubmit` resolves to `undefined` it's a successful submit.
     * If the promise resolves to something else the submit has failed.
     * If the promise rejects it's a network error (or similar).
     */
    return Promise.resolve()
Stefan Probst's avatar
Stefan Probst committed
220
221
  }

222
223
224
225
226
227
228
  const [state, setState] = useState<{
    page: PageKey
    prefix?: string
    onReset?: () => void
    values?: Partial<ItemFormValues>
  }>({ page: 'workflow', prefix: '', values: initialValues })

229
  function onValidateWorkflow(values: Partial<ItemFormValues>) {
Stefan Probst's avatar
Stefan Probst committed
230
231
    const errors: Partial<Record<keyof typeof values, string>> = {}

Stefan Probst's avatar
Stefan Probst committed
232
    validateCommonFormFields(values, errors)
Stefan Probst's avatar
Stefan Probst committed
233
234
235
236

    return errors
  }

237
238
239
240
241
242
243
244
245
246
247
  function onValidateWorkflowStep(values: Partial<ItemFormValues>) {
    const errors: Partial<Record<keyof typeof values, string>> = {}

    const prefix = state.prefix?.slice(0, -1)
    if (prefix == null || prefix.length === 0) return errors

    const step = get(values, prefix)
    if (step == null) return errors
    validateCommonFormFields(step, errors)

    return set({}, prefix, errors)
Stefan Probst's avatar
Stefan Probst committed
248
249
  }

250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
  const pages: Record<
    PageKey,
    {
      Page: FC<FormPageProps>
      onValidate?: FormConfig<ItemFormValues>['validate']
    }
  > = {
    workflow: {
      Page: WorkflowPage,
      onValidate: onValidateWorkflow,
    },
    steps: {
      Page: WorkflowStepsPage,
    },
    step: {
      Page: WorkflowStepPage,
      onValidate: onValidateWorkflowStep,
    },
  }
269

270
271
272
273
274
  // const pageKeys = Object.keys(pages)
  // const pageCount = pageKeys.length
  const currentPageKey = state.page
  const currentPage = pages[currentPageKey]
  const { Page, onValidate } = currentPage
275

276
277
278
  useEffect(() => {
    window.scroll(0, 0)
  }, [currentPageKey])
279

280
  function onNextPage(values: Partial<ItemFormValues>) {
281
    setState((state) => ({
282
      ...state,
283
      values,
284
285
286
287
288
289
290
291
292
293
      page:
        state.page === 'workflow'
          ? 'steps'
          : state.page === 'step'
          ? 'steps'
          : state.page,
    }))
  }

  function onSetPage(page: PageKey, prefix = '', onReset?: () => void) {
294
    setState((state) => ({
295
296
297
298
      ...state,
      page,
      prefix,
      onReset,
299
300
301
    }))
  }

302
303
304
305
306
307
308
309
310
  function onCancel() {
    if (currentPageKey === 'step') {
      state.onReset?.()
      onSetPage('steps', '', undefined)
    } else {
      router.push('/')
    }
  }

311
  function handleSubmit(values: Partial<ItemFormValues>) {
312
    if (currentPageKey === 'steps') {
313
314
      return onSubmit(values)
    } else {
315
      onNextPage(values)
316
317
318
319
    }
  }

  function validate(values: Partial<ItemFormValues>) {
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
    return typeof onValidate === 'function' ? onValidate(values) : undefined
  }

  /**
   * Keep track if pristine state for all pages, because final-form will
   * (i) calculate `pristine` only for the currently registered (i.e. rendered)
   * fields, and (ii) recalculate when going back to a previously visited page
   * by comparing to the current initialValues.
   */
  const [pageStatus, setPageStatus] = useState<Record<PageKey, boolean>>({
    workflow: true,
    steps: true,
    step: true,
  })

  function updatePageStatus(pristine: boolean) {
    setPageStatus((status) => {
      return {
        ...status,
        [state.page]: status[state.page] && pristine,
      }
    })
  }

  function isFormPristine(isCurrentPagePristine: boolean) {
    return (
      isCurrentPagePristine &&
      Object.values(pageStatus).every((pristine) => pristine === true)
    )
349
350
  }

Stefan Probst's avatar
Stefan Probst committed
351
352
  return (
    <Form
353
354
355
      onSubmit={handleSubmit}
      validate={validate}
      initialValues={state.values}
Stefan Probst's avatar
Stefan Probst committed
356
    >
357
      {({ handleSubmit, form, pristine, invalid, submitting, values }) => {
Stefan Probst's avatar
Stefan Probst committed
358
359
360
361
362
363
        return (
          <form
            onSubmit={handleSubmit}
            noValidate
            className="flex flex-col space-y-12"
          >
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
            <div className="flex items-center justify-between space-x-12">
              <Title>
                {currentPageKey === 'step' ? 'Add step' : 'Edit workflow'}
              </Title>
              {currentPageKey !== 'step' ? (
                <div className="flex items-center space-x-12">
                  <ActionButton
                    onPress={() => {
                      updatePageStatus(pristine)
                      onSetPage('workflow')
                    }}
                    isDisabled={currentPageKey === 'workflow'}
                    className={cx(
                      'group inline-flex items-center space-x-4 font-body focus:outline-none',
                      currentPageKey === 'workflow'
                        ? 'pointer-events-none'
                        : '',
                    )}
                  >
                    <span
                      className={cx(
                        'w-8 h-8 transition flex items-center justify-center flex-shrink-0 border rounded-full text-sm ',
                        currentPageKey === 'workflow'
                          ? 'text-white bg-secondary-600 border-secondary-600'
                          : 'text-gray-600 bg-gray-50 border-gray-300 group-hover:text-white group-hover:bg-secondary-600 group-hover:border-secondary-600 group-focus:text-white group-focus:bg-secondary-600 group-focus:border-secondary-600',
                      )}
                    >
                      1
                    </span>
                    <span
                      className={cx(
                        currentPageKey === 'workflow'
                          ? 'text-gray-800'
                          : 'text-primary-750',
                        'transition group-hover:text-secondary-600',
                      )}
                    >
                      Workflow details
                    </span>
                  </ActionButton>
                  <ActionButton
                    type="submit"
                    onPress={() => {
                      updatePageStatus(pristine)
                    }}
                    isDisabled={currentPageKey === 'steps' || invalid}
                    className={cx(
                      'group inline-flex items-center space-x-4 font-body focus:outline-none',
                      currentPageKey === 'steps' || invalid
                        ? 'pointer-events-none'
                        : '',
                    )}
                  >
                    <span
                      className={cx(
                        'w-8 h-8 transition flex items-center justify-center flex-shrink-0 border rounded-full text-sm',
                        currentPageKey === 'steps'
                          ? 'text-white bg-secondary-600 border-secondary-600'
                          : 'text-gray-600 bg-gray-50 border-gray-300 group-hover:text-white group-hover:bg-secondary-600 group-hover:border-secondary-600 group-focus:text-white group-focus:bg-secondary-600 group-focus:border-secondary-600',
                      )}
                    >
                      2
                    </span>
                    <span
                      className={cx(
                        currentPageKey === 'steps'
                          ? 'text-gray-800'
                          : invalid
                          ? 'text-gray-800'
                          : 'text-primary-750',
                        'transition group-hover:text-secondary-600',
                      )}
                    >
                      Workflow steps
                    </span>
                  </ActionButton>
                </div>
              ) : null}
            </div>
443
444
445
446
447
            <Page
              onSetPage={onSetPage}
              prefix={state.prefix}
              item={props.item}
            />
Stefan Probst's avatar
Stefan Probst committed
448
449
450
451
            <div className="flex items-center justify-end space-x-6">
              <Button onPress={onCancel} variant="link">
                Cancel
              </Button>
452
              {currentPageKey === 'steps' ? (
453
454
455
456
457
458
459
                <Fragment>
                  <Button
                    type="submit"
                    onPress={() => {
                      form.change('draft', true)
                    }}
                    isDisabled={
460
                      isFormPristine(pristine) ||
Stefan Probst's avatar
Stefan Probst committed
461
462
                      invalid ||
                      submitting ||
463
464
465
                      updateWorkflow.isLoading ||
                      updateStep.isLoading ||
                      createStep.isLoading
466
467
468
469
470
471
472
473
474
475
476
                    }
                    variant="link"
                  >
                    Save as draft
                  </Button>
                  <Button
                    type="submit"
                    onPress={() => {
                      form.change('draft', undefined)
                    }}
                    isDisabled={
477
                      isFormPristine(pristine) ||
Stefan Probst's avatar
Stefan Probst committed
478
479
                      invalid ||
                      submitting ||
480
481
482
                      updateWorkflow.isLoading ||
                      updateStep.isLoading ||
                      createStep.isLoading
483
484
485
486
487
                    }
                  >
                    {isAllowedToPublish ? 'Publish' : 'Submit'}
                  </Button>
                </Fragment>
488
              ) : currentPageKey === 'step' ? (
Stefan Probst's avatar
Stefan Probst committed
489
490
491
                <Button
                  type="submit"
                  onPress={() => {
492
                    /** mark step as dirty on submit. yuck! */
493
494
495
496
497
498
                    const prefix = state.prefix?.slice(0, -1)
                    if (prefix != null && prefix.length > 0) {
                      const step = get(values, prefix)
                      if (step != null) {
                        console.log('Setting step to dirty')
                        step.dirty = !pristine
499
500
501
502
                      }
                    }

                    updatePageStatus(pristine)
Stefan Probst's avatar
Stefan Probst committed
503
504
                  }}
                  isDisabled={invalid}
505
506
507
508
509
510
511
512
513
514
                >
                  Save
                </Button>
              ) : (
                <Button
                  type="submit"
                  onPress={() => {
                    updatePageStatus(pristine)
                  }}
                  isDisabled={invalid}
Stefan Probst's avatar
Stefan Probst committed
515
                >
516
517
518
                  Next
                </Button>
              )}
Stefan Probst's avatar
Stefan Probst committed
519
520
521
522
523
524
525
            </div>
          </form>
        )
      }}
    </Form>
  )
}
526
527

interface FormPageProps {
528
529
  onSetPage: (page: PageKey, prefix?: string, onReset?: () => void) => void
  prefix?: string
530
  item?: WorkflowDto
531
532
}

533
function WorkflowPage(props: FormPageProps) {
534
535
536
  return (
    <Fragment>
      <MainFormSection />
537
538
539
      <ActorsFormSection initialValues={props.item} />
      <PropertiesFormSection initialValues={props.item} />
      <RelatedItemsFormSection initialValues={props.item} />
540
      <SourceFormSection initialValues={props.item} />
541
542
543
544
545
546
547
548
549
550
551
552
553
554
    </Fragment>
  )
}

function WorkflowStepsPage(props: FormPageProps) {
  return (
    <Fragment>
      <WorkflowStepsFormSection onSetPage={props.onSetPage} />
    </Fragment>
  )
}

function WorkflowStepPage(props: FormPageProps) {
  const prefix = props.prefix ?? ''
555
556
557
  const initialValues = prefix
    ? get(props.item, prefix.slice(0, -1))
    : undefined
558
559
560
561

  return (
    <Fragment>
      <MainFormSection prefix={prefix} />
562
563
564
565
      <ActorsFormSection prefix={prefix} initialValues={initialValues} />
      <PropertiesFormSection prefix={prefix} initialValues={initialValues} />
      <RelatedItemsFormSection prefix={prefix} initialValues={initialValues} />
      <SourceFormSection prefix={prefix} initialValues={initialValues} />
566
567
    </Fragment>
  )
568
}
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583

interface ActionButtonProps extends AriaButtonProps {
  className?: string
}

function ActionButton({ className, ...props }: ActionButtonProps) {
  const ref = useRef<HTMLButtonElement>(null)
  const { buttonProps } = useButton(props, ref)

  return (
    <button className={className} {...buttonProps} ref={ref}>
      {props.children}
    </button>
  )
}