diff --git a/package.json b/package.json index 0c62297e60..e00e8d4434 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,7 @@ "react-bem-helper": "^1.4.1", "react-dom": "^17.0.2", "react-helmet": "^5.2.1", + "react-hook-form": "^7.22.3", "react-i18next": "11.11.4", "react-image-crop": "^6.0.18", "react-query": "^3.25.1", diff --git a/src/components/Contributors/Contributors.tsx b/src/components/Contributors/Contributors.tsx index 593ba392a2..1e697827c9 100644 --- a/src/components/Contributors/Contributors.tsx +++ b/src/components/Contributors/Contributors.tsx @@ -16,6 +16,7 @@ import { FieldHeader } from '@ndla/forms'; import { useTranslation } from 'react-i18next'; import Contributor from './Contributor'; import { ContributorType, ContributorFieldName } from './types'; +import { ContributorType as ContributorTypeName } from '../../interfaces'; const StyledFormWarningText = styled.p` font-family: ${fonts.sans}; @@ -23,14 +24,8 @@ const StyledFormWarningText = styled.p` ${fonts.sizes(14, 1.1)}; `; -enum ContributorGroups { - CREATORS = 'creators', - PROCESSORS = 'processors', - RIGHTSHOLDERS = 'rightsholders', -} - interface Props { - name: ContributorGroups; + name: ContributorTypeName; label: string; onChange: (event: { target: { value: ContributorType[]; name: string } }) => void; errorMessages?: string[]; @@ -123,11 +118,7 @@ const Contributors = ({ }; Contributors.propTypes = { - name: PropTypes.oneOf([ - ContributorGroups.CREATORS, - ContributorGroups.PROCESSORS, - ContributorGroups.RIGHTSHOLDERS, - ]).isRequired, + name: PropTypes.string.isRequired, label: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, errorMessages: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, diff --git a/src/components/ControlledImageSearchAndUploader.tsx b/src/components/ControlledImageSearchAndUploader.tsx index 8ab2267d56..6b40c4b777 100644 --- a/src/components/ControlledImageSearchAndUploader.tsx +++ b/src/components/ControlledImageSearchAndUploader.tsx @@ -19,8 +19,6 @@ import { ImageSearchQuery, UpdatedImageMetadata, } from '../modules/image/imageApiInterfaces'; -import EditorErrorMessage from './SlateEditor/EditorErrorMessage'; -import { useLicenses } from '../modules/draft/draftQueries'; const StyledTitleDiv = styled.div` margin-bottom: ${spacing.small}; @@ -34,7 +32,7 @@ interface Props { searchImages: (queryObject: ImageSearchQuery) => void; fetchImage: (id: number) => Promise; image?: ImageApiType; - updateImage: (imageMetadata: UpdatedImageMetadata, image: string | Blob) => void; + updateImage: (imageMetadata: UpdatedImageMetadata, image: string | Blob) => Promise; inModal?: boolean; showCheckbox?: boolean; checkboxAction?: (image: ImageApiType) => void; @@ -55,7 +53,6 @@ const ImageSearchAndUploader = ({ }: Props) => { const { t } = useTranslation(); const [selectedTabIndex, setSelectedTabIndex] = useState(0); - const { data: licenses } = useLicenses({ placeholderData: [] }); const searchImagesWithParameters = (query: string, page: number) => { return searchImages({ query, page, 'page-size': 16 }); }; @@ -98,17 +95,14 @@ const ImageSearchAndUploader = ({ }, { title: t('form.visualElement.imageUpload'), - content: licenses ? ( + content: ( - ) : ( - ), }, ]} diff --git a/src/components/Form/FormEventProvider.tsx b/src/components/Form/FormEventProvider.tsx new file mode 100644 index 0000000000..10768d24be --- /dev/null +++ b/src/components/Form/FormEventProvider.tsx @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2021-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { createContext, Dispatch, ReactNode, SetStateAction, useContext, useState } from 'react'; + +type EventType = 'reset'; + +interface FormEvent { + id: number; + type: EventType; +} +interface EventState { + events: FormEvent[]; +} +const FormEventContext = createContext< + [EventState, Dispatch>] | undefined +>(undefined); + +interface Props { + children: ReactNode; +} + +export interface FormEventProps { + events: FormEvent[]; + dispatchEvent: (type: EventType) => void; +} + +export const FormEventProvider = ({ children }: Props) => { + const initialValues: EventState = { events: [] }; + const eventContext = useState(initialValues); + return {children}; +}; + +export const useFormEvents = (): FormEventProps => { + const eventContext = useContext(FormEventContext); + if (eventContext === undefined) { + throw new Error('useFormEvents must be used within the context of a FormEventProvider!'); + } + const [state, setState] = eventContext; + + const dispatch = (type: EventType) => { + const newEvent: FormEvent = { id: state.events.length, type }; + const newEvents = state.events.concat([newEvent]); + setState(prev => ({ ...prev, events: newEvents })); + }; + + return { + events: state.events, + dispatchEvent: dispatch, + }; +}; diff --git a/src/components/Form/FormField.tsx b/src/components/Form/FormField.tsx new file mode 100644 index 0000000000..0f01ee9a1a --- /dev/null +++ b/src/components/Form/FormField.tsx @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2021-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import styled from '@emotion/styled'; +import { ReactNode } from 'react'; +import { + ControllerFieldState, + FieldValues, + Noop, + RefCallBack, + useController, + UseFormStateReturn, +} from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { Node } from 'slate'; +import { classes } from './'; +import FormFieldDescription from './FormFieldDescription'; +import FormFieldHelp from './FormFieldHelp'; +import FormFieldLabel from './FormFieldLabel'; +import FormRemainingCharacters from './FormRemainingCharacters'; + +const StyledErrorPreLine = styled.span` + white-space: pre-line; +`; + +interface ControllerRenderProps< + TFieldValues extends FieldValues, + TName extends keyof TFieldValues & string +> { + onChange: (...event: any[]) => void; + onBlur: Noop; + value: TFieldValues[TName]; + name: TName; + ref: RefCallBack; +} + +interface Props { + noBorder?: boolean; + right?: boolean; + title?: boolean; + name: TName; + label?: string; + showError?: boolean; + obligatory?: boolean; + description?: string; + maxLength?: number; + showMaxLength?: boolean; + className?: string; + children: ( + props: ControllerRenderProps & + ControllerFieldState & + UseFormStateReturn, + ) => ReactNode; + placeholder?: string; +} + +const FormField = ({ + children, + className, + label, + name, + maxLength, + showMaxLength, + noBorder = false, + title = false, + right = false, + description, + obligatory, + showError = true, + ...rest +}: Props) => { + const { t } = useTranslation(); + + const { field, fieldState, formState } = useController({ + name: name, + }); + const isSlateValue = Node.isNodeList(field.value); + + return ( +
+ + + {children({ ...field, ...fieldState, ...formState })} + {showMaxLength && maxLength && ( + + t('form.remainingCharacters', { maxLength, remaining }) + } + value={isSlateValue ? Node.string(field.value[0]) : field.value} + /> + )} + {showError && !!fieldState.error && ( + + {fieldState.error.message} + + )} +
+ ); +}; + +export default FormField; diff --git a/src/components/Form/FormFieldDescription.tsx b/src/components/Form/FormFieldDescription.tsx new file mode 100644 index 0000000000..5d986c609b --- /dev/null +++ b/src/components/Form/FormFieldDescription.tsx @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2019-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import PropTypes from 'prop-types'; +import styled from '@emotion/styled'; +import { css } from '@emotion/core'; + +const StyledFormDescriptionBlock = styled.span` + display: flex; +`; + +const obligatoryDescriptionStyle = css` + background-color: rgba(230, 132, 154, 1); + padding: 0.2em 0.6em; +`; + +const StyledFormDescription = styled.p` + margin: 0.2em 0; + font-size: 0.75em; + ${(p: Props) => (p.obligatory ? obligatoryDescriptionStyle : '')}; +`; + +interface Props { + description?: string; + obligatory?: boolean; +} + +const FormFieldDescription = ({ description, obligatory }: Props) => { + if (!description) { + return null; + } + return ( + + {description} + + ); +}; + +FormFieldDescription.propTypes = { + obligatory: PropTypes.bool, + description: PropTypes.string, +}; + +export default FormFieldDescription; diff --git a/src/components/Form/FormFieldHelp.tsx b/src/components/Form/FormFieldHelp.tsx new file mode 100644 index 0000000000..6fb64dbf26 --- /dev/null +++ b/src/components/Form/FormFieldHelp.tsx @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2019-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { ReactNode } from 'react'; +import PropTypes from 'prop-types'; +import styled from '@emotion/styled'; +import { colors, fonts } from '@ndla/core'; + +interface Props { + error?: boolean; + float?: 'left' | 'right' | 'none' | 'inherit'; + children: ReactNode; +} + +export const StyledHelpMessage = styled.span` + display: block; + font-size: ${fonts.sizes(14, 1.2)}; + color: ${(p: Props) => (p.error ? colors.support.red : 'black')}; + float: ${(p: Props) => p.float || 'none'}; +`; + +const FormFieldHelp = ({ error, float, children }: Props) => ( + + {children} + +); + +FormFieldHelp.propTypes = { + error: PropTypes.bool, + float: PropTypes.oneOf(['left', 'right', 'none', 'inherit']), +}; + +export default FormFieldHelp; diff --git a/src/components/Form/FormFieldLabel.tsx b/src/components/Form/FormFieldLabel.tsx new file mode 100644 index 0000000000..914e9e850b --- /dev/null +++ b/src/components/Form/FormFieldLabel.tsx @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2019-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import PropTypes from 'prop-types'; + +interface Props { + name: string; + label?: string; + noBorder?: boolean; +} + +const FormFieldLabel = ({ label, noBorder, name }: Props) => { + if (!label) { + return null; + } + if (!noBorder) { + return ; + } + return ( + <> + + + ); +}; + +FormFieldLabel.propTypes = { + noBorder: PropTypes.bool, + name: PropTypes.string.isRequired, + label: PropTypes.string, +}; + +export default FormFieldLabel; diff --git a/src/components/Form/FormRemainingCharacters.tsx b/src/components/Form/FormRemainingCharacters.tsx new file mode 100644 index 0000000000..96072a3fbe --- /dev/null +++ b/src/components/Form/FormRemainingCharacters.tsx @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2019-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { ReactNode } from 'react'; +import PropTypes from 'prop-types'; +import FormFieldHelp from './FormFieldHelp'; + +interface Props { + value: string; + maxLength: number; + getRemainingLabel: (maxLength: number, remaining: number) => ReactNode; +} + +export const FormRemainingCharacters = ({ value, maxLength, getRemainingLabel }: Props) => { + const currentLength = value ? value.length : 0; + return ( + + {getRemainingLabel(maxLength, maxLength - currentLength)} + + ); +}; + +FormRemainingCharacters.propTypes = { + value: PropTypes.string.isRequired, + maxLength: PropTypes.number.isRequired, + getRemainingLabel: PropTypes.func.isRequired, +}; + +export default FormRemainingCharacters; diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts new file mode 100644 index 0000000000..fb0fcfff1a --- /dev/null +++ b/src/components/Form/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2019-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import BEMHelper from 'react-bem-helper'; +import FormField from './FormField'; +import FormFieldHelp from './FormFieldHelp'; + +export const classes = new BEMHelper({ + name: 'field', + prefix: 'c-', +}); + +export { FormFieldHelp }; + +export default FormField; diff --git a/src/components/Form/withFormEvents.tsx b/src/components/Form/withFormEvents.tsx new file mode 100644 index 0000000000..d643162644 --- /dev/null +++ b/src/components/Form/withFormEvents.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2021-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { ComponentType } from 'react'; +import { FormEventProvider } from './FormEventProvider'; + +function withFormEventsProvider

(Component: ComponentType

) { + return (props: P) => { + return ( + + + + ); + }; +} + +export default withFormEventsProvider; diff --git a/src/components/SlateEditor/PlainTextEditor.tsx b/src/components/SlateEditor/PlainTextEditor.tsx index d7102e586e..b8c1435df5 100644 --- a/src/components/SlateEditor/PlainTextEditor.tsx +++ b/src/components/SlateEditor/PlainTextEditor.tsx @@ -10,10 +10,10 @@ import { useMemo, FocusEvent, useEffect } from 'react'; import { createEditor, Descendant } from 'slate'; import { Slate, Editable, ReactEditor, withReact } from 'slate-react'; import { withHistory } from 'slate-history'; -import { FormikHandlers, useFormikContext } from 'formik'; +import { FormikHandlers } from 'formik'; import { SlatePlugin } from './interfaces'; import withPlugins from './utils/withPlugins'; -import { LearningResourceFormikType } from '../../containers/FormikForm/articleFormHooks'; +import { useFormEvents } from '../Form/FormEventProvider'; interface Props { id: string; @@ -38,17 +38,15 @@ const PlainTextEditor = ({ }: Props) => { // eslint-disable-next-line react-hooks/exhaustive-deps const editor = useMemo(() => withHistory(withReact(withPlugins(createEditor(), plugins))), []); - - const { status, setStatus } = useFormikContext(); + const { events } = useFormEvents(); useEffect(() => { - if (status === 'revertVersion') { + if (events.length > 1 && events[events.length - 1].type === 'reset') { ReactEditor.deselect(editor); editor.children = value; - setStatus(undefined); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status]); + }, [events]); return ( error ? `${error} \n ${newError}` : newError; @@ -102,20 +101,6 @@ const validateFormik = ( } } - if (rules[ruleKey].maxSize) { - const maxSize = rules[ruleKey].maxSize!; - const fileSize = get(ruleKey, values); - if (fileSize > maxSize) { - errors[ruleKey] = appendError( - errors[ruleKey], - t('validation.maxSizeExceeded', { - maxSize: bytesToSensibleFormat(maxSize), - fileSize: bytesToSensibleFormat(fileSize), - }), - ); - } - } - const ruleMinLength = rules[ruleKey].minLength; if (ruleMinLength && minLength(value, ruleMinLength)) { errors[ruleKey] = appendError( diff --git a/src/containers/ArticlePage/LearningResourcePage/components/LearningResourceForm.tsx b/src/containers/ArticlePage/LearningResourcePage/components/LearningResourceForm.tsx index b8c6c43c85..7678d2275f 100644 --- a/src/containers/ArticlePage/LearningResourcePage/components/LearningResourceForm.tsx +++ b/src/containers/ArticlePage/LearningResourcePage/components/LearningResourceForm.tsx @@ -44,6 +44,7 @@ import { } from '../../../../modules/draft/draftApiInterfaces'; import { ConvertedDraftType, RelatedContent } from '../../../../interfaces'; import { useLicenses } from '../../../../modules/draft/draftQueries'; +import withFormEventsProvider from '../../../../components/Form/withFormEvents'; export const getInitialValues = ( article: Partial = {}, @@ -310,4 +311,4 @@ const LearningResourceForm = ({ ); }; -export default LearningResourceForm; +export default withFormEventsProvider(LearningResourceForm); diff --git a/src/containers/ArticlePage/TopicArticlePage/components/TopicArticleForm.tsx b/src/containers/ArticlePage/TopicArticlePage/components/TopicArticleForm.tsx index c5cf62af6a..042714b02a 100644 --- a/src/containers/ArticlePage/TopicArticlePage/components/TopicArticleForm.tsx +++ b/src/containers/ArticlePage/TopicArticlePage/components/TopicArticleForm.tsx @@ -47,6 +47,7 @@ import { } from '../../../../modules/draft/draftApiInterfaces'; import { convertDraftOrRelated } from '../../LearningResourcePage/components/LearningResourceForm'; import { useLicenses } from '../../../../modules/draft/draftQueries'; +import withFormEventsProvider from '../../../../components/Form/withFormEvents'; export const getInitialValues = ( article: Partial = {}, @@ -300,4 +301,4 @@ const TopicArticleForm = (props: Props) => { ); }; -export default TopicArticleForm; +export default withFormEventsProvider(TopicArticleForm); diff --git a/src/containers/AudioUploader/components/AudioForm.tsx b/src/containers/AudioUploader/components/AudioForm.tsx index 76a6e16917..96da5ad691 100644 --- a/src/containers/AudioUploader/components/AudioForm.tsx +++ b/src/containers/AudioUploader/components/AudioForm.tsx @@ -34,6 +34,7 @@ import FormWrapper from '../../ConceptPage/ConceptForm/FormWrapper'; import { audioApiTypeToFormType } from '../../../util/audioHelpers'; import { MessageError, useMessages } from '../../Messages/MessagesProvider'; import { useLicenses } from '../../../modules/draft/draftQueries'; +import withFormEventsProvider from '../../../components/Form/withFormEvents'; export interface AudioFormikType extends FormikFormBaseType { id?: number; @@ -266,4 +267,4 @@ AudioForm.propTypes = { translateToNN: PropTypes.func, }; -export default AudioForm; +export default withFormEventsProvider(AudioForm); diff --git a/src/containers/ConceptPage/ConceptForm/ConceptForm.tsx b/src/containers/ConceptPage/ConceptForm/ConceptForm.tsx index b70b296490..f64e67f87a 100644 --- a/src/containers/ConceptPage/ConceptForm/ConceptForm.tsx +++ b/src/containers/ConceptPage/ConceptForm/ConceptForm.tsx @@ -37,6 +37,7 @@ import ConceptFormFooter from './ConceptFormFooter'; import { DraftApiType } from '../../../modules/draft/draftApiInterfaces'; import { MessageError, useMessages } from '../../Messages/MessagesProvider'; import { useLicenses } from '../../../modules/draft/draftQueries'; +import withFormEventsProvider from '../../../components/Form/withFormEvents'; interface Props { concept?: ConceptApiType; @@ -235,4 +236,4 @@ const ConceptForm = ({ ); }; -export default ConceptForm; +export default withFormEventsProvider(ConceptForm); diff --git a/src/containers/EditSubjectFrontpage/components/SubjectpageForm.tsx b/src/containers/EditSubjectFrontpage/components/SubjectpageForm.tsx index 51ded9f58b..4346538758 100644 --- a/src/containers/EditSubjectFrontpage/components/SubjectpageForm.tsx +++ b/src/containers/EditSubjectFrontpage/components/SubjectpageForm.tsx @@ -37,6 +37,7 @@ import { formatErrorMessage } from '../../../util/apiHelpers'; import { queryLearningPathResource, queryResources, queryTopics } from '../../../modules/taxonomy'; import { Resource, Topic } from '../../../modules/taxonomy/taxonomyApiInterfaces'; import { TYPE_EMBED } from '../../../components/SlateEditor/plugins/embed'; +import withFormEventsProvider from '../../../components/Form/withFormEvents'; interface Props { subjectpage?: ISubjectPageData; @@ -203,4 +204,4 @@ const SubjectpageForm = ({ ); }; -export default SubjectpageForm; +export default withFormEventsProvider(SubjectpageForm); diff --git a/src/containers/Form/AsyncSearchTags.tsx b/src/containers/Form/AsyncSearchTags.tsx new file mode 100644 index 0000000000..4be69bced6 --- /dev/null +++ b/src/containers/Form/AsyncSearchTags.tsx @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2020-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useState, useEffect } from 'react'; +import { DropdownInput } from '@ndla/forms'; +import { useTranslation } from 'react-i18next'; +import { SearchResultBase } from '../../interfaces'; +import AsyncDropdown from '../../components/Dropdown/asyncDropdown/AsyncDropdown'; + +interface Props { + language: string; + initialTags: string[]; + onChange: (values: string[]) => void; + fetchTags: (input: string, language: string) => Promise>; + updateValue?: (value: string[]) => void; +} + +interface TagWithTitle { + title: string; +} + +const AsyncSearchTags = ({ language, initialTags, onChange, fetchTags, updateValue }: Props) => { + const { t } = useTranslation(); + const convertToTagsWithTitle = (tagsWithoutTitle: string[]) => { + return tagsWithoutTitle.map(tag => ({ title: tag })); + }; + + const [tags, setTags] = useState(initialTags); + + useEffect(() => { + setTags(initialTags); + }, [initialTags]); + + const searchForTags = async (inp: string) => { + const response = await fetchTags(inp, language); + const tagsWithTitle = convertToTagsWithTitle(response.results); + return { ...response, results: tagsWithTitle }; + }; + + const updateField = (newData: string[]) => { + setTags(newData || []); + onChange(newData); + }; + + const addTag = (tag: TagWithTitle) => { + if (tag && !tags.includes(tag.title)) { + const temp = [...tags, tag.title]; + updateField(temp); + } + }; + + const createNewTag = (newTag: string) => { + if (newTag && !tags.includes(newTag.trim())) { + const temp = [...tags, newTag.trim()]; + updateField(temp); + } + }; + + const removeTag = (tag: string) => { + const reduced_array = tags.filter(t => t !== tag); + setTags(reduced_array); + updateField(reduced_array); + }; + + return ( + <> + + {({ selectedItems, value, removeItem, onBlur, onChange, onKeyDown }) => ( + + )} + + + ); +}; + +export default AsyncSearchTags; diff --git a/src/containers/Form/ContributorsField.tsx b/src/containers/Form/ContributorsField.tsx new file mode 100644 index 0000000000..a53590392e --- /dev/null +++ b/src/containers/Form/ContributorsField.tsx @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2021-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useTranslation } from 'react-i18next'; +import Contributors from '../../components/Contributors'; +import FormField from '../../components/Form'; +import { ContributorType } from '../../interfaces'; + +interface Props { + contributorTypes: ContributorType[]; + width?: number; +} + +const ContributorsField = ({ contributorTypes, width }: Props) => { + const { t } = useTranslation(); + return ( + <> + {contributorTypes.map(contributorType => { + const label = t(`form.${contributorType}.label`); + return ( + + {({ name, onChange, value, error }) => ( + + )} + + ); + })} + + ); +}; + +export default ContributorsField; diff --git a/src/containers/Form/TitleField.tsx b/src/containers/Form/TitleField.tsx new file mode 100644 index 0000000000..325d80ba6c --- /dev/null +++ b/src/containers/Form/TitleField.tsx @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2016-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useFormContext } from 'react-hook-form'; + +import PlainTextEditor from '../../components/SlateEditor/PlainTextEditor'; + +import { textTransformPlugin } from '../../components/SlateEditor/plugins/textTransform'; +import saveHotkeyPlugin from '../../components/SlateEditor/plugins/saveHotkey'; +import FormField from '../../components/Form/FormField'; +import { ImageFormType } from '../ImageUploader/imageTransformers'; + +interface Props { + maxLength?: number; + name?: string; + onSubmit: (values: ImageFormType) => Promise; + type?: string; +} + +const TitleField = ({ maxLength = 256, name = 'title', onSubmit }: Props) => { + const { t } = useTranslation(); + const { handleSubmit } = useFormContext(); + const handleSubmitRef = useRef(handleSubmit); + + useEffect(() => { + handleSubmitRef.current = handleSubmit; + }, [handleSubmit]); + const plugins = [textTransformPlugin, saveHotkeyPlugin(() => handleSubmitRef.current(onSubmit))]; + return ( + + {({ name, value, onChange, isSubmitting }) => ( + + )} + + ); +}; + +export default TitleField; diff --git a/src/containers/FormikForm/MetaImageSearch.tsx b/src/containers/FormikForm/MetaImageSearch.tsx index 8eeef9f7f2..5e65e777a0 100644 --- a/src/containers/FormikForm/MetaImageSearch.tsx +++ b/src/containers/FormikForm/MetaImageSearch.tsx @@ -93,10 +93,12 @@ const MetaImageSearch = ({ if (image.id) { const updatedImage = await updateImage(image); onImageSet(updatedImage); + return updatedImage; } else { const formData = await createFormData(file, image); const createdImage = await postImage(formData); onImageSet(createdImage); + return createdImage; } }; diff --git a/src/containers/FormikForm/VersionAndNotesPanel.tsx b/src/containers/FormikForm/VersionAndNotesPanel.tsx index 99bd4d6c03..4c2860397e 100644 --- a/src/containers/FormikForm/VersionAndNotesPanel.tsx +++ b/src/containers/FormikForm/VersionAndNotesPanel.tsx @@ -32,6 +32,7 @@ import { ConvertedDraftType, Note } from '../../interfaces'; import { DraftApiType, UpdatedDraftApiType } from '../../modules/draft/draftApiInterfaces'; import { LearningResourceFormikType, TopicArticleFormikType } from './articleFormHooks'; import { useMessages } from '../Messages/MessagesProvider'; +import { useFormEvents } from '../../components/Form/FormEventProvider'; const paddingPanelStyleInside = css` background: ${colors.brand.greyLightest}; @@ -70,6 +71,7 @@ const VersionAndNotesPanel = ({ const [loading, setLoading] = useState(false); const [users, setUsers] = useState([]); const { createMessage } = useMessages(); + const { dispatchEvent } = useFormEvents(); useEffect(() => { const getVersions = async () => { try { @@ -125,6 +127,7 @@ const VersionAndNotesPanel = ({ setValues(newValues); setStatus('revertVersion'); + dispatchEvent('reset'); createMessage({ message: t('form.resetToProd.success'), severity: 'success', diff --git a/src/containers/ImageUploader/CreateImage.tsx b/src/containers/ImageUploader/CreateImage.tsx index 554c27c68e..098e877378 100644 --- a/src/containers/ImageUploader/CreateImage.tsx +++ b/src/containers/ImageUploader/CreateImage.tsx @@ -12,8 +12,6 @@ import { createFormData } from '../../util/formDataHelper'; import * as imageApi from '../../modules/image/imageApi'; import { toEditImage } from '../../util/routeHelpers'; import { ImageApiType, NewImageMetadata } from '../../modules/image/imageApiInterfaces'; -import { useLicenses } from '../../modules/draft/draftQueries'; -import { draftLicensesToImageLicenses } from '../../modules/draft/draftApiUtils'; interface Props { isNewlyCreated?: boolean; @@ -32,8 +30,6 @@ const CreateImage = ({ }: Props) => { const { i18n } = useTranslation(); const locale = i18n.language; - const { data: licenses } = useLicenses({ placeholderData: [] }); - const imageLicenses = draftLicensesToImageLicenses(licenses!); const navigate = useNavigate(); const onCreateImage = async (imageMetadata: NewImageMetadata, image: string | Blob) => { @@ -43,6 +39,7 @@ const CreateImage = ({ if (!editingArticle && createdImage.id) { navigate(toEditImage(createdImage.id, imageMetadata.language)); } + return createdImage; }; return ( @@ -50,7 +47,6 @@ const CreateImage = ({ language={locale} inModal={inModal} isNewlyCreated={isNewlyCreated} - licenses={imageLicenses} onUpdate={onCreateImage} closeModal={closeModal} /> diff --git a/src/containers/ImageUploader/EditImage.tsx b/src/containers/ImageUploader/EditImage.tsx index 736859de74..ec798c1d36 100644 --- a/src/containers/ImageUploader/EditImage.tsx +++ b/src/containers/ImageUploader/EditImage.tsx @@ -11,7 +11,6 @@ import { useParams } from 'react-router-dom'; import ImageForm from './components/ImageForm'; import { ImageApiType, UpdatedImageMetadata } from '../../modules/image/imageApiInterfaces'; import { fetchImage, updateImage } from '../../modules/image/imageApi'; -import { useLicenses } from '../../modules/draft/draftQueries'; import { useMessages } from '../Messages/MessagesProvider'; import { createFormData } from '../../util/formDataHelper'; import NotFoundPage from '../NotFoundPage/NotFoundPage'; @@ -26,7 +25,6 @@ interface Props { const EditImage = ({ isNewlyCreated }: Props) => { const { i18n } = useTranslation(); const { id: imageId, selectedLanguage: imageLanguage } = useParams<'id' | 'selectedLanguage'>(); - const { data: licenses } = useLicenses({ placeholderData: [] }); const [loading, setLoading] = useState(false); const { applicationError, createMessage } = useMessages(); const [image, setImage] = useState(undefined); @@ -48,9 +46,11 @@ const EditImage = ({ isNewlyCreated }: Props) => { try { const res = await updateImage(updatedImage, formData); setImage(res); + return res; } catch (e) { applicationError(e); createMessage(e.messages); + return Promise.reject(e); } }; @@ -68,7 +68,6 @@ const EditImage = ({ isNewlyCreated }: Props) => { image={image} onUpdate={onUpdate} isNewlyCreated={isNewlyCreated} - licenses={licenses!} /> ); }; diff --git a/src/containers/ImageUploader/components/ImageContent.tsx b/src/containers/ImageUploader/components/ImageContent.tsx index 75ff869425..7c4c1f7fe9 100644 --- a/src/containers/ImageUploader/components/ImageContent.tsx +++ b/src/containers/ImageUploader/components/ImageContent.tsx @@ -6,112 +6,54 @@ * */ -import { connect, FieldProps, FormikContextType } from 'formik'; -import styled from '@emotion/styled'; import { useTranslation } from 'react-i18next'; -import { UploadDropZone, Input } from '@ndla/forms'; -import SafeLink from '@ndla/safelink'; -import Tooltip from '@ndla/tooltip'; -import { DeleteForever } from '@ndla/icons/editor'; -import { animations, spacing, colors } from '@ndla/core'; -import IconButton from '../../../components/IconButton'; -import FormikField from '../../../components/FormikField'; -import { ImageFormikType } from '../imageTransformers'; -import { ImageFormErrorFields } from './ImageForm'; -import { TitleField } from '../../FormikForm'; - -const StyledImage = styled.img` - margin: ${spacing.normal} 0; - border: 1px solid ${colors.brand.greyLight}; - ${animations.fadeInBottom()} -`; - -const StyledDeleteButtonContainer = styled.div` - position: absolute; - right: -${spacing.medium}; - transform: translateY(${spacing.normal}); - z-index: 1; - display: flex; - flex-direction: row; -`; +import { Input } from '@ndla/forms'; +import TitleField from '../../Form/TitleField'; +import FormField from '../../../components/Form/FormField'; +import ImageFormField from './ImageFormField'; +import { ImageFormType } from '../imageTransformers'; interface Props { - formik: FormikContextType; + onSubmit: (values: ImageFormType) => Promise; } - -const ImageContent = ({ formik }: Props) => { +const ImageContent = ({ onSubmit }: Props) => { const { t } = useTranslation(); - const { values, errors, setFieldValue, submitForm } = formik; return ( <> - - {!values.imageFile && ( - { - const target = evt.target as HTMLInputElement; - setFieldValue( - 'filepath', - target.files?.[0] ? URL.createObjectURL(target.files[0]) : undefined, - ); - setFieldValue('imageFile', target.files?.[0]); - }} - ariaLabel={t('form.image.dragdrop.ariaLabel')}> - {t('form.image.dragdrop.main')} - {t('form.image.dragdrop.sub')} - - )} - {values.imageFile && ( - - - { - setFieldValue('imageFile', undefined); - }} - tabIndex={-1}> - - - - - )} - {values.imageFile && ( - - - - )} - - {_ => <>} - - - {({ field }: FieldProps) => ( + + + name="caption" showError={false}> + {({ error, value, onChange, onBlur }) => ( )} - - - {({ field }: FieldProps) => ( + + name="alttext" showError={false}> + {({ error, value, onChange, onBlur }) => ( )} - + ); }; -export default connect(ImageContent); +export default ImageContent; diff --git a/src/containers/ImageUploader/components/ImageForm.tsx b/src/containers/ImageUploader/components/ImageForm.tsx index c072c31e74..65cac0eb33 100644 --- a/src/containers/ImageUploader/components/ImageForm.tsx +++ b/src/containers/ImageUploader/components/ImageForm.tsx @@ -5,13 +5,12 @@ * LICENSE file in the root directory of this source tree. * */ -import { ReactNode, useState } from 'react'; +import { ReactNode, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Formik, Form, FormikHelpers } from 'formik'; +import { FormProvider, useForm, useFormState } from 'react-hook-form'; import { Accordions, AccordionSection } from '@ndla/accordion'; import Field from '../../../components/Field'; import SaveButton from '../../../components/SaveButton'; -import { isFormikFormDirty } from '../../../util/formHelper'; import validateFormik, { RulesType } from '../../../components/formikValidationSchema'; import ImageMetaData from './ImageMetaData'; import ImageContent from './ImageContent'; @@ -29,12 +28,12 @@ import { UpdatedImageMetadata, } from '../../../modules/image/imageApiInterfaces'; import ImageVersionNotes from './ImageVersionNotes'; -import { MAX_IMAGE_UPLOAD_SIZE } from '../../../constants'; -import { imageApiTypeToFormType, ImageFormikType } from '../imageTransformers'; -import { License } from '../../../interfaces'; +import { imageApiTypeToFormType, ImageFormType } from '../imageTransformers'; import { editorValueToPlainText } from '../../../util/articleContentConverter'; +import { useLicenses } from '../../../modules/draft/draftQueries'; +import withFormEventsProvider from '../../../components/Form/withFormEvents'; -const imageRules: RulesType = { +const imageRules: RulesType = { title: { required: true, }, @@ -59,9 +58,6 @@ const imageRules: RulesType = { imageFile: { required: true, }, - 'imageFile.size': { - maxSize: MAX_IMAGE_UPLOAD_SIZE, - }, license: { required: true, }, @@ -71,20 +67,24 @@ const FormWrapper = ({ inModal, children }: { inModal?: boolean; children: React if (inModal) { return

{children}
; } - return
{children}
; + return <>{children}; }; -type OnUpdateFunc = (imageMetadata: UpdatedImageMetadata, image: string | Blob) => void; -type OnCreateFunc = (imageMetadata: NewImageMetadata, image: string | Blob) => void; +type OnUpdateFunc = ( + imageMetadata: UpdatedImageMetadata, + image: string | Blob, +) => Promise; +type OnCreateFunc = ( + imageMetadata: NewImageMetadata, + image: string | Blob, +) => Promise; interface Props { image?: ImageApiType; - licenses: License[]; onUpdate: OnCreateFunc | OnUpdateFunc; inModal?: boolean; isNewlyCreated?: boolean; closeModal?: () => void; - isSaving?: boolean; language: string; } @@ -99,42 +99,33 @@ export type ImageFormErrorFields = | 'tags' | 'title'; -const ImageForm = ({ - licenses, - onUpdate, - image, - inModal, - language, - closeModal, - isNewlyCreated, - isSaving, -}: Props) => { +const imageContentErrorFields: ImageFormErrorFields[] = [ + 'title', + 'imageFile', + 'caption', + 'alttext', +]; + +const imageMetaErrorFields: ImageFormErrorFields[] = [ + 'tags', + 'rightsholders', + 'creators', + 'processors', + 'license', +]; + +const ImageForm = ({ onUpdate, image, inModal, language, closeModal, isNewlyCreated }: Props) => { const { t } = useTranslation(); const [savedToServer, setSavedToServer] = useState(false); + const licensesQuery = useLicenses({ placeholderData: [] }); - const handleSubmit = async (values: ImageFormikType, actions: FormikHelpers) => { - const license = licenses.find(license => license.license === values.license); - - if ( - license === undefined || - values.title === undefined || - values.alttext === undefined || - values.caption === undefined || - values.language === undefined || - values.tags === undefined || - values.origin === undefined || - values.creators === undefined || - values.processors === undefined || - values.rightsholders === undefined || - values.imageFile === undefined || - values.modelReleased === undefined - ) { - actions.setSubmitting(false); + const handleSubmit = async (values: ImageFormType) => { + const license = licensesQuery.data?.find(license => license.license === values.license); + if (values.imageFile === undefined || license === undefined) { setSavedToServer(false); return; } - actions.setSubmitting(true); const imageMetaData: NewImageMetadata = { id: values.id, title: editorValueToPlainText(values.title), @@ -151,109 +142,150 @@ const ImageForm = ({ }, modelReleased: values.modelReleased, }; - await onUpdate(imageMetaData, values.imageFile); + const newImage = await onUpdate(imageMetaData, values.imageFile); setSavedToServer(true); - actions.resetForm(); + methods.reset(imageApiTypeToFormType(newImage, values.language)); }; - const initialValues = imageApiTypeToFormType(image, language); - const initialErrors = validateFormik(initialValues, imageRules, t); + const methods = useForm({ + mode: 'onChange', + defaultValues: initialValues, + criteriaMode: 'all', + resolver: (data, _) => { + const validationResult = validateFormik(data, imageRules, t); + if (Object.keys(validationResult).length === 0) return { values: data, errors: {} }; + + const resolveErrors = Object.entries(validationResult).reduce< + Record + >((acc, [key, message]) => { + acc[key] = { message }; + return acc; + }, {}); + return { values: {}, errors: resolveErrors }; + }, + }); + const values = methods.getValues(); + const errors = methods.formState.errors; + const imageContentHasError = imageContentErrorFields.some(f => !!errors[f]); + const imageMetaHasError = imageMetaErrorFields.some(f => !!errors[f]); + + //validate on initial load. + useEffect(() => { + methods.trigger(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const toImageUrl = (lang: string) => (image ? toEditImage(image.id, lang) : toCreateImage()); + + return ( + + +
+ + + + + + + + + + + + + + + +
+
+ ); +}; + +const AlertModal = () => { + const { isDirty, isSubmitting } = useFormState(); + const { t } = useTranslation(); + + return ( + + ); +}; +interface ButtonProps { + inModal?: boolean; + closeModal?: () => void; + handleSubmit: () => Promise; + isNewlyCreated?: boolean; + savedToServer?: boolean; +} +const ImageFormButtons = ({ + inModal, + closeModal, + isNewlyCreated, + handleSubmit, + savedToServer, +}: ButtonProps) => { + const { t } = useTranslation(); + const { isDirty, isValid, isSubmitting } = useFormState(); return ( - validateFormik(values, imageRules, t)}> - {({ values, dirty, errors, isSubmitting, submitForm, isValid }) => { - const formIsDirty = isFormikFormDirty({ - values, - initialValues, - dirty, - }); - const hasError = (errorFields: ImageFormErrorFields[]): boolean => - errorFields.some(field => !!errors[field]); - return ( - - { - if (values.id) return toEditImage(values.id, lang); - else return toCreateImage(); - }} - /> - - - - - - - - - - - - - {inModal ? ( - - {t('form.abort')} - - ) : ( - - {t('form.abort')} - - )} - { - if (inModal) { - evt.preventDefault(); - submitForm(); - } - }} - /> - - - - ); - }} - + + {inModal ? ( + + {t('form.abort')} + + ) : ( + + {t('form.abort')} + + )} + { + if (inModal) { + evt.preventDefault(); + handleSubmit(); + } + }} + /> + ); }; -export default ImageForm; +export default withFormEventsProvider(ImageForm); diff --git a/src/containers/ImageUploader/components/ImageFormField.tsx b/src/containers/ImageUploader/components/ImageFormField.tsx new file mode 100644 index 0000000000..5da3c61dcc --- /dev/null +++ b/src/containers/ImageUploader/components/ImageFormField.tsx @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2021-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import styled from '@emotion/styled'; +import { spacing, colors, animations } from '@ndla/core'; +import SafeLink from '@ndla/safelink'; +import Tooltip from '@ndla/tooltip'; +import { UploadDropZone } from '@ndla/forms'; +import { DeleteForever } from '@ndla/icons/editor'; +import { useTranslation } from 'react-i18next'; +import FormField from '../../../components/Form'; +import IconButton from '../../../components/IconButton'; +import { MAX_IMAGE_UPLOAD_SIZE } from '../../../constants'; +import { bytesToSensibleFormat } from '../../../util/fileSizeUtil'; +import { ImageFormType } from '../imageTransformers'; + +const StyledImage = styled.img` + margin: ${spacing.normal} 0; + border: 1px solid ${colors.brand.greyLight}; + ${animations.fadeInBottom()} +`; + +const StyledDeleteButtonContainer = styled.div` + position: absolute; + right: -${spacing.medium}; + transform: translateY(${spacing.normal}); + z-index: 1; + display: flex; + flex-direction: row; +`; + +const getImageUrl = (file: string | any): string => (typeof file === 'string' ? file : ''); + +const ImageFormField = () => { + const { t } = useTranslation(); + const [filePath, setFilePath] = useState(''); + const { setError } = useFormContext(); + return ( + name="imageFile"> + {({ value, onChange }) => { + if (!value) { + return ( + { + const target = evt.target as HTMLInputElement; + const image = target.files?.[0]; + if (!image) return; + setFilePath(URL.createObjectURL(image)); + if (image.size > MAX_IMAGE_UPLOAD_SIZE) { + setError('imageFile', { + message: t('validation.maxSizeExceeded', { + maxSize: bytesToSensibleFormat(MAX_IMAGE_UPLOAD_SIZE), + fileSize: bytesToSensibleFormat(image.size), + }), + }); + } else { + onChange(image); + } + }} + ariaLabel={t('form.image.dragdrop.ariaLabel')}> + {t('form.image.dragdrop.main')} + {t('form.image.dragdrop.sub')} + + ); + } + return ( + <> + + + { + onChange(undefined); + }} + tabIndex={-1}> + + + + + + + + + ); + }} + + ); +}; + +export default ImageFormField; diff --git a/src/containers/ImageUploader/components/ImageMetaData.tsx b/src/containers/ImageUploader/components/ImageMetaData.tsx index 743ef4b77a..5a608f2e78 100644 --- a/src/containers/ImageUploader/components/ImageMetaData.tsx +++ b/src/containers/ImageUploader/components/ImageMetaData.tsx @@ -6,90 +6,73 @@ * */ -import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; import { RadioButtonGroup } from '@ndla/ui'; -import { FieldInputProps, FieldProps } from 'formik'; import { fetchSearchTags } from '../../../modules/image/imageApi'; -import FormikField from '../../../components/FormikField'; -import { LicenseField, ContributorsField } from '../../FormikForm'; -import AsyncSearchTags from '../../../components/Dropdown/asyncDropdown/AsyncSearchTags'; -import { ImageApiLicense } from '../../../modules/image/imageApiInterfaces'; +import { LicenseField } from '../../FormikForm'; +import ContributorsField from '../../Form/ContributorsField'; +import FormField from '../../../components/Form'; +import { ImageFormType } from '../imageTransformers'; +import AsyncSearchTags from '../../Form/AsyncSearchTags'; +import { ContributorType } from '../../../interfaces'; -const contributorTypes = ['creators', 'rightsholders', 'processors']; +const contributorTypes: ContributorType[] = ['creators', 'rightsholders', 'processors']; interface Props { - imageTags: string[]; - licenses: ImageApiLicense[]; imageLanguage?: string; } -const ImageMetaData = ({ imageTags, licenses, imageLanguage }: Props) => { +const ImageMetaData = ({ imageLanguage }: Props) => { const { t } = useTranslation(); return ( <> - name="tags" label={t('form.tags.label')} obligatory description={t('form.tags.description')}> - {({ field, form }: FieldProps) => ( + {({ value, onChange }) => ( )} - - - {({ field }: { field: FieldInputProps }) => ( - + + name="license"> + {({ onChange, onBlur, name, value }) => ( + )} - - + + label={t('form.origin.label')} name="origin"> + {({ value, onChange, onBlur }) => ( + + )} + - name="modelReleased" label={t('form.modelReleased.label')} description={t('form.modelReleased.description')}> - {({ field }: { field: FieldInputProps }) => { + {({ value, onChange }) => { const options = ['yes', 'not-applicable', 'no', 'not-set']; const defaultValue = 'not-set'; return ( <> ({ title: t(`form.modelReleased.${value}`), value }))} - onChange={(value: string) => - field.onChange({ - target: { - name: field.name, - value: value, - }, - }) - } + onChange={(value: string) => onChange(value)} /> ); }} - + ); }; -ImageMetaData.propTypes = { - imageTags: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, - licenses: PropTypes.arrayOf( - PropTypes.shape({ - description: PropTypes.string.isRequired, - license: PropTypes.string.isRequired, - }).isRequired, - ).isRequired, - imageLanguage: PropTypes.string, -}; - export default ImageMetaData; diff --git a/src/containers/ImageUploader/imageTransformers.ts b/src/containers/ImageUploader/imageTransformers.ts index fe3ab4abdd..c2c2040480 100644 --- a/src/containers/ImageUploader/imageTransformers.ts +++ b/src/containers/ImageUploader/imageTransformers.ts @@ -1,16 +1,24 @@ +/** + * Copyright (c) 2021-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + import { Descendant } from 'slate'; import { Author } from '../../interfaces'; import { ImageApiType } from '../../modules/image/imageApiInterfaces'; import { plainTextToEditorValue } from '../../util/articleContentConverter'; -export interface ImageFormikType { +export interface ImageFormType { id?: number; language: string; supportedLanguages: string[]; title: Descendant[]; alttext: string; caption: string; - imageFile?: string; + imageFile?: File | string; tags: string[]; creators: Author[]; processors: Author[]; @@ -18,13 +26,12 @@ export interface ImageFormikType { origin: string; license?: string; modelReleased: string; - filepath?: string; } export const imageApiTypeToFormType = ( image: ImageApiType | undefined, language: string, -): ImageFormikType => { +): ImageFormType => { return { id: image?.id ? parseInt(image.id) : undefined, language, diff --git a/src/containers/NdlaFilm/components/NdlaFilmForm.tsx b/src/containers/NdlaFilm/components/NdlaFilmForm.tsx index 57f77edbf5..7de21cd032 100644 --- a/src/containers/NdlaFilm/components/NdlaFilmForm.tsx +++ b/src/containers/NdlaFilm/components/NdlaFilmForm.tsx @@ -20,6 +20,7 @@ import { toEditNdlaFilm } from '../../../util/routeHelpers'; import NdlaFilmAccordionPanels from './NdlaFilmAccordionPanels'; import SaveButton from '../../../components/SaveButton'; import { TYPE_EMBED } from '../../../components/SlateEditor/plugins/embed'; +import withFormEventsProvider from '../../../components/Form/withFormEvents'; interface Props { filmFrontpage: IFilmFrontPageData; @@ -121,4 +122,4 @@ const NdlaFilmForm = ({ filmFrontpage, selectedLanguage }: Props) => { ); }; -export default NdlaFilmForm; +export default withFormEventsProvider(NdlaFilmForm); diff --git a/src/containers/Podcast/components/PodcastForm.tsx b/src/containers/Podcast/components/PodcastForm.tsx index 7d091658a2..d5ae079e70 100644 --- a/src/containers/Podcast/components/PodcastForm.tsx +++ b/src/containers/Podcast/components/PodcastForm.tsx @@ -32,6 +32,7 @@ import PodcastSeriesInformation from './PodcastSeriesInformation'; import handleError from '../../../util/handleError'; import { audioApiTypeToPodcastFormType } from '../../../util/audioHelpers'; import { useLicenses } from '../../../modules/draft/draftQueries'; +import withFormEventsProvider from '../../../components/Form/withFormEvents'; const podcastRules: RulesType = { title: { @@ -300,4 +301,4 @@ const PodcastForm = ({ ); }; -export default PodcastForm; +export default withFormEventsProvider(PodcastForm); diff --git a/src/containers/PodcastSeries/components/PodcastSeriesForm.tsx b/src/containers/PodcastSeries/components/PodcastSeriesForm.tsx index bd176d76c2..c84268c2d7 100644 --- a/src/containers/PodcastSeries/components/PodcastSeriesForm.tsx +++ b/src/containers/PodcastSeries/components/PodcastSeriesForm.tsx @@ -27,6 +27,7 @@ import PodcastSeriesMetaData from './PodcastSeriesMetaData'; import PodcastEpisodes from './PodcastEpisodes'; import { ITUNES_STANDARD_MAXIMUM_WIDTH, ITUNES_STANDARD_MINIMUM_WIDTH } from '../../../constants'; import { podcastSeriesTypeToFormType } from '../../../util/audioHelpers'; +import withFormEventsProvider from '../../../components/Form/withFormEvents'; const podcastRules: RulesType = { title: { @@ -210,4 +211,4 @@ const PodcastSeriesForm = ({ ); }; -export default PodcastSeriesForm; +export default withFormEventsProvider(PodcastSeriesForm); diff --git a/src/interfaces.ts b/src/interfaces.ts index 5c84fc139e..a2a441edbb 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -23,6 +23,8 @@ export type PartialRecord = { export type AvailabilityType = 'everyone' | 'teacher' | 'student'; +export type ContributorType = 'creators' | 'processors' | 'rightsholders'; + export type EditMode = | 'changeSubjectName' | 'deleteTopic' diff --git a/src/modules/draft/draftApiUtils.ts b/src/modules/draft/draftApiUtils.ts deleted file mode 100644 index ddb88cb350..0000000000 --- a/src/modules/draft/draftApiUtils.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Copyright (c) 2021-present, NDLA. - * - * This source code is licensed under the GPLv3 license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import { License } from '../../interfaces'; -import { ImageApiLicense } from '../image/imageApiInterfaces'; - -export const draftLicensesToImageLicenses = (licenses: License[]): ImageApiLicense[] => - licenses.map(l => ({ license: l.license, description: l.description || '', url: l.url })); diff --git a/src/modules/image/imageApiInterfaces.ts b/src/modules/image/imageApiInterfaces.ts index 19dcf7cef5..38fa50ca8a 100644 --- a/src/modules/image/imageApiInterfaces.ts +++ b/src/modules/image/imageApiInterfaces.ts @@ -6,16 +6,10 @@ * */ -import { Author } from '../../interfaces'; - -export interface ImageApiLicense { - license: string; - description?: string; - url?: string; -} +import { Author, License } from '../../interfaces'; interface Copyright { - license: ImageApiLicense; + license: License; origin: string; processors: Author[]; rightsholders: Author[]; diff --git a/yarn.lock b/yarn.lock index 38ac7948b1..d9f1c90624 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13262,6 +13262,11 @@ react-helmet@^5.2.1: react-fast-compare "^2.0.2" react-side-effect "^1.1.0" +react-hook-form@^7.22.3: + version "7.22.3" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.22.3.tgz#a66da5e0eaf19c3625f03af8a15266e08b4d000c" + integrity sha512-VhWJ531WbyP25C0O0nNTe6GNHmw/zFrJAXJLGKEeseNHitjcQXZ7OsdHbFSiH2KEYmY9tDFkMz9UzWUHdyFswQ== + react-i18next@11.11.4: version "11.11.4" resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.11.4.tgz#f6f9a1c827e7a5271377de2bf14db04cb1c9e5ce"