diff --git a/src/components/FileInput/index.tsx b/src/components/FileInput/index.tsx new file mode 100644 index 00000000..f0177fd6 --- /dev/null +++ b/src/components/FileInput/index.tsx @@ -0,0 +1,224 @@ +import { + useCallback, + useState, +} from 'react'; +import { + _cs, + isDefined, +} from '@togglecorp/fujs'; +import { + InputContainer, + type InputContainerProps, + useButtonFeatures, +} from '@togglecorp/toggle-ui'; + +import Header from '#components/Header'; +import RawFileInput, { type RawFileInputProps } from '#components/RawFileInput'; +import useDropHandler from '#hooks/useDropHandler'; +import isValidFile, { ErrorType } from '#utils/common'; + +import styles from './styles.module.css'; + +type NameType = string | number | undefined; + +type InheritedProps = (Omit & Omit, 'onChange' | 'value'>); +export type Props = InheritedProps & { + inputElementRef?: React.RefObject; + inputClassName?: string; + labelClassName?: string; + + status?: string; + maxFileSize?: number; // NOTE: maxFileSize is in MB. +} & ({ + multiple: true; + value: File[] | undefined | null; + onChange?: (files: File[], name: T) => void; +} | { + multiple?: false; + value: File | undefined | null; + onChange?: (files: File | undefined, name: T) => void; +}); + +function FileInput(props: Props) { + const { + className, + disabled, + error, + errorContainerClassName, + inputSectionClassName, + inputContainerClassName, + label, + labelContainerClassName, + readOnly, + uiMode, + inputElementRef, + containerRef, + inputSectionRef, + inputClassName, + value, // eslint-disable-line @typescript-eslint/no-unused-vars + onChange, // eslint-disable-line @typescript-eslint/no-unused-vars + name: nameFromProps, + multiple, + accept, + labelClassName, + children, + maxFileSize = 10, // 10MB is default max file size + ...fileInputProps + } = props; + + const [inputKey, setInputKey] = useState(0); + const [internalError, setInternalError] = useState(); + + const handleFiles = useCallback( + (files: FileList | null) => { + setInternalError(undefined); + // eslint-disable-next-line react/destructuring-assignment + if (!files || !props.onChange) { + return; + } + + const fileList = Array.from(files); + let numberOfFilesExceedSize = 0; + let numberOfInvalidFiles = 0; + + const validFiles = fileList.filter((f) => { + const validity = isValidFile(f, maxFileSize, accept); + if (!validity.isValid) { + if (validity.errorType === ErrorType.invalidFileType) { + numberOfInvalidFiles += 1; + } else { + numberOfFilesExceedSize += 1; + } + } + return validity.isValid; + }); + + if (numberOfFilesExceedSize > 0 && numberOfInvalidFiles > 0) { + const isSingularFileSizeError = numberOfFilesExceedSize === 1; + const isSingularInvalidFileError = numberOfInvalidFiles === 1; + setInternalError(`${numberOfFilesExceedSize} ${isSingularFileSizeError ? 'file exceeds' : 'files exceed'} file size limit of ${maxFileSize} MB. + ${numberOfFilesExceedSize} ${isSingularInvalidFileError ? 'file is' : 'files are'} invalid. They are removed from selection.`); + } else if (numberOfFilesExceedSize > 0) { + const isSingularError = numberOfFilesExceedSize === 1; + setInternalError(`${numberOfFilesExceedSize} ${isSingularError ? 'file exceeds' : 'files exceed'} file size limit of ${maxFileSize} MB. + ${isSingularError ? 'It is' : 'They are'} removed from selection.`); + } else if (numberOfInvalidFiles > 0) { + const isSingularError = numberOfInvalidFiles === 1; + setInternalError(`${numberOfFilesExceedSize} ${isSingularError ? 'file is' : 'files are'} invalid. + ${isSingularError ? 'It is' : 'They are'} removed from selection.`); + } + + if (validFiles.length <= 0) { + return; + } + + if (!multiple) { + const [firstFile] = validFiles; + // eslint-disable-next-line react/destructuring-assignment + const onChangeFromProps = props.onChange; + onChangeFromProps(firstFile, nameFromProps); + } else { + // eslint-disable-next-line react/destructuring-assignment + const onChangeFromProps = props.onChange; + onChangeFromProps(validFiles, nameFromProps); + } + }, + // eslint-disable-next-line react/destructuring-assignment + [accept, multiple, props.onChange, nameFromProps, maxFileSize], + ); + + const handleChange = useCallback(( + files: File[] | undefined, + ) => { + if (isDefined(files)) { + handleFiles(files as unknown as FileList); + setInputKey((val) => val + 1); + } + }, [handleFiles]); + + const handleDrop: React.DragEventHandler = useCallback((e) => { + e.preventDefault(); + handleFiles(e.dataTransfer.files); + e.dataTransfer.clearData(); + }, [handleFiles]); + + const { + dropping, + onDragOver, + onDragEnter, + onDragLeave, + onDrop, + } = useDropHandler(handleDrop); + + const { + className: buttonLabelClassName, + children: buttonLabelChildren, + } = useButtonFeatures({ + className: labelClassName, + disabled, + variant: 'primary', + children: ( + <> + {children} + + key={inputKey} + className={styles.input} + inputRef={inputElementRef} + readOnly={readOnly} + uiMode={uiMode} + disabled={disabled} + value={undefined} + name={nameFromProps} + onChange={handleChange} + multiple={multiple} + accept={accept} + {...fileInputProps} // eslint-disable-line react/jsx-props-no-spreading + /> + + ), + }); + + return ( + +
+
+ + Upto 5 MB +
+ + )} + /> + ); +} + +export default FileInput; diff --git a/src/components/FileInput/styles.module.css b/src/components/FileInput/styles.module.css new file mode 100644 index 00000000..48ccfb1d --- /dev/null +++ b/src/components/FileInput/styles.module.css @@ -0,0 +1,31 @@ +.input-container { + .label { + color: var(--cms-ui-color-gray-500); + font-size: var(--cms-ui-font-size-xl); + } + + .input-section { + border: 2px dashed var(--cms-ui-color-gray-50); + background-color: var(--cms-ui-color-gray-20); + + .input-children { + display: flex; + align-items: center; + flex-direction: column; + gap: var(--cms-ui-spacing-lg); + + .header-children { + align-items: center; + } + + .browse-button { + display: flex; + align-items: center; + flex-direction: column; + } + } + .input { + visibility: hidden; + } + } +} diff --git a/src/components/Heading/styles.module.css b/src/components/Heading/styles.module.css index c2b4a1ce..75686ea4 100644 --- a/src/components/Heading/styles.module.css +++ b/src/components/Heading/styles.module.css @@ -26,6 +26,6 @@ } &.level-six { - --font-size: var(--cms-ui-font-size-lg); + --font-size: var(--cms-ui-font-size-md); } } diff --git a/src/components/RawFileInput/index.tsx b/src/components/RawFileInput/index.tsx new file mode 100644 index 00000000..3c0f72f0 --- /dev/null +++ b/src/components/RawFileInput/index.tsx @@ -0,0 +1,89 @@ +import { + useCallback, + useState, +} from 'react'; +import { + _cs, + randomString, +} from '@togglecorp/fujs'; +import type { ButtonProps } from '@togglecorp/toggle-ui'; +import { useButtonFeatures } from '@togglecorp/toggle-ui'; + +import styles from './styles.module.css'; + +export type RawFileInputProps = ButtonProps & { + accept?: string; + disabled?: boolean; + inputProps?: React.ComponentPropsWithoutRef<'input'>; + inputRef?: React.RefObject; + name: NAME; + readOnly?: boolean; +} & ({ + multiple: true; + onChange: (files: File[] | undefined, name: NAME) => void; +} | { + multiple?: false; + onChange: (files: File | undefined, name: NAME) => void; +}); + +function RawFileInput(props: RawFileInputProps) { + const { + accept, + disabled, + inputProps, + inputRef, + multiple, + name, + onChange, + readOnly, + ...buttonFeatureProps + } = props; + + const [inputId] = useState(randomString); + + const handleChange = useCallback((event: React.ChangeEvent) => { + if (multiple) { + const values = event.currentTarget.files + ? Array.from(event.currentTarget.files) : undefined; + onChange(values, name); + } else { + onChange(event.currentTarget.files?.[0] ?? undefined, name); + } + + if (event.currentTarget.value) { + event.currentTarget.value = ''; // eslint-disable-line no-param-reassign + } + }, [multiple, name, onChange]); + + const { + children, + className, + } = useButtonFeatures({ + ...buttonFeatureProps, + disabled, + }); + + return ( + + ); +} + +export default RawFileInput; diff --git a/src/components/RawFileInput/styles.module.css b/src/components/RawFileInput/styles.module.css new file mode 100644 index 00000000..29f734a8 --- /dev/null +++ b/src/components/RawFileInput/styles.module.css @@ -0,0 +1,7 @@ +.file-input { + .input { + visibility: hidden; + width: 0; + height: 0; + } +} diff --git a/src/components/domain/UploadFiles/index.tsx b/src/components/domain/UploadFiles/index.tsx new file mode 100644 index 00000000..248b7e4a --- /dev/null +++ b/src/components/domain/UploadFiles/index.tsx @@ -0,0 +1,55 @@ +import { useCallback } from 'react'; +import { + _cs, + randomString, +} from '@togglecorp/fujs'; + +import FileInput from '#components/FileInput'; + +import styles from './styles.module.css'; + +export type FileLike = { + key: string; + id: string; + name: string; + fileType: string; + file: File; +}; + +interface Props { + className?: string; + onAdd: (v: FileLike[]) => void; + accept: string +} + +function UploadFiles(props: Props) { + const { onAdd, className, accept } = props; + const handleFileInputChange = useCallback((values: File[] | null | undefined) => { + const basicFiles = values + ? values.map((file) => ({ + key: randomString(), + id: file.name, + name: file.name, + fileType: file.type, + file, + })) + : []; + onAdd(basicFiles); + }, [onAdd]); + + return ( + + Browse Files + + ); +} + +export default UploadFiles; diff --git a/src/components/domain/UploadFiles/styles.module.css b/src/components/domain/UploadFiles/styles.module.css new file mode 100644 index 00000000..82136e9d --- /dev/null +++ b/src/components/domain/UploadFiles/styles.module.css @@ -0,0 +1,4 @@ +.upload-file { + background-color: var(--cms-ui-color-white); + padding: var(--cms-ui-spacing-lg); +} diff --git a/src/hooks/useDropHandler.tsx b/src/hooks/useDropHandler.tsx new file mode 100644 index 00000000..613a9fa7 --- /dev/null +++ b/src/hooks/useDropHandler.tsx @@ -0,0 +1,67 @@ +import React, { useCallback } from 'react'; + +interface DragHandler { + (e: React.DragEvent): void; +} + +function useDropHandler( + dropHandler: DragHandler, + dragStartHandler?: DragHandler, +) { + const [dropping, setDropping] = React.useState(false); + const dragEnterCount = React.useRef(0); + + const onDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + }, + [], + ); + + const onDragEnter = useCallback( + (e: React.DragEvent) => { + if (dragEnterCount.current === 0) { + setDropping(true); + + if (dragStartHandler) { + dragStartHandler(e); + } + } + dragEnterCount.current += 1; + }, + [dragStartHandler], + ); + + const onDragLeave = useCallback( + () => { + dragEnterCount.current -= 1; + if (dragEnterCount.current === 0) { + setDropping(false); + } + }, + [], + ); + + const onDrop = useCallback( + (e: React.DragEvent) => { + dragEnterCount.current = 0; + setDropping(false); + + dropHandler(e); + + e.preventDefault(); + }, + [dropHandler], + ); + + return { + dropping, + + onDragOver, + onDragEnter, + onDragLeave, + onDrop, + }; +} + +export default useDropHandler; diff --git a/src/utils/common.ts b/src/utils/common.ts new file mode 100644 index 00000000..88ce3d21 --- /dev/null +++ b/src/utils/common.ts @@ -0,0 +1,62 @@ +export enum ErrorType { + maxFileSizeExceeded = 'MAX_FILE_SIZE_EXCEEDED', + invalidFileType = 'INVALID_FILE_TYPE', +} + +export type ValidityStatus = { + isValid: true; +} | { + isValid: false; + errorType: ErrorType; +} + +type DeepNonNullable = T extends object ? ( + T extends (infer K)[] ? ( + DeepNonNullable[] + ) : ( + { [P in keyof T]-?: DeepNonNullable } + ) +) : NonNullable; + +export type DeepReplace = ( + DeepNonNullable extends DeepNonNullable + ? B + : ( + T extends (infer Z)[] + ? DeepReplace[] + : ( + T extends object + ? { [K in keyof T]: DeepReplace } + : T + ) + ) +) + +export default function isValidFile( + file: File, + maxFileSize: number, + acceptString?: string, +): ValidityStatus { + if (file.size > (maxFileSize * 1024 * 1024)) { + return { isValid: false, errorType: ErrorType.maxFileSizeExceeded }; + } + // if there is no accept string, anything is valid + if (!acceptString) { + return { isValid: true }; + } + const extensionMatch = /\.\w+$/.exec(file.name); + const mimeMatch = /^.+\//.exec(file.type); + + const fileTypeList = acceptString.split(/,\s+/); + const isFileValid = fileTypeList.some((fileType) => { + // check mimeType such as image/png or image/* + if (file.type === fileType || (!!mimeMatch && `${mimeMatch[0]}*` === fileType)) { + return { isValid: true }; + } + return !!extensionMatch && extensionMatch[0].toLowerCase() === fileType.toLowerCase(); + }); + if (!isFileValid) { + return { isValid: false, errorType: ErrorType.invalidFileType }; + } + return { isValid: true }; +} diff --git a/src/views/ContentManagement/AddContentModal/FormPreviewSection/index.tsx b/src/views/ContentManagement/AddContentModal/FormPreviewSection/index.tsx new file mode 100644 index 00000000..8e49ea94 --- /dev/null +++ b/src/views/ContentManagement/AddContentModal/FormPreviewSection/index.tsx @@ -0,0 +1,147 @@ +import { + useEffect, + useState, +} from 'react'; +import { + gql, + useQuery, +} from '@apollo/client'; +import { isNotDefined } from '@togglecorp/fujs'; +import { + ArrayError, + getErrorObject, + SetValueArg, + useFormObject, +} from '@togglecorp/toggle-form'; +import { + MultiSelectInput, + TextInput, +} from '@togglecorp/toggle-ui'; + +import Container from '#components/Container'; +import Heading from '#components/Heading'; +import { + TagsQuery, + TagsQueryVariables, +} from '#generated/types/graphql'; + +import { PartialContentType } from '../schema'; + +import styles from './styles.module.css'; + +const TAGS = gql` + query Tags { + private { + tags { + items { + name + id + description + } + } + } + } +`; + +type TagsOptionsList = NonNullable['private']>['tags']>['items']>[number]; + +const tagsKeySelector = (option: TagsOptionsList) => option.id; +const tagsLabelSelector = (option: TagsOptionsList) => option.name; + +const defaultValue: PartialContentType = { + clientId: '-1', +}; + +interface Props { + value: PartialContentType | undefined; + error: ArrayError | undefined; + index: number; + onChange: ( + value: SetValueArg, + index: number, + ) => void; +} + +function FormPreviewSection(props: Props) { + const { + value, + error: errorFromProps, + index, + onChange, + } = props; + + const [previewText, setPreviewText] = useState(); + + const { + data: tagsResult, + } = useQuery( + TAGS, + ); + + useEffect(() => { + const textPreview = async () => { + if (isNotDefined(value?.documentFile)) { + return; + } + const textUrl = URL.createObjectURL(value?.documentFile); + + try { + const response = await fetch(textUrl); + const text = await response.text(); + setPreviewText(text); + } catch (error) { + setPreviewText("Couldn't preview the File"); + } finally { + URL.revokeObjectURL(textUrl); + } + }; + + textPreview(); + }, [value?.documentFile]); + + const onUploadFormChange = useFormObject(index, onChange, defaultValue); + + const error = (value && value.clientId && errorFromProps) + ? getErrorObject(errorFromProps?.[value.clientId]) + : undefined; + + return ( + +
+ + File Details + + + +
+
+ + Preview + +
{previewText}
+
+
+ ); +} + +export default FormPreviewSection; diff --git a/src/views/ContentManagement/AddContentModal/FormPreviewSection/styles.module.css b/src/views/ContentManagement/AddContentModal/FormPreviewSection/styles.module.css new file mode 100644 index 00000000..ad835fdc --- /dev/null +++ b/src/views/ContentManagement/AddContentModal/FormPreviewSection/styles.module.css @@ -0,0 +1,5 @@ +.preview-section { + .preview-text { + white-space: pre-wrap; + } +} diff --git a/src/views/ContentManagement/AddContentModal/index.tsx b/src/views/ContentManagement/AddContentModal/index.tsx new file mode 100644 index 00000000..cf836e6a --- /dev/null +++ b/src/views/ContentManagement/AddContentModal/index.tsx @@ -0,0 +1,348 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + gql, + useMutation, +} from '@apollo/client'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import { + createSubmitHandler, + getErrorObject, + nonFieldError, + removeNull, + useForm, + useFormArray, +} from '@togglecorp/toggle-form'; +import { + Button, + Modal, + RawButton, +} from '@togglecorp/toggle-ui'; + +import Container from '#components/Container'; +import UploadFiles, { type FileLike } from '#components/domain/UploadFiles'; +import Heading from '#components/Heading'; +import { + ContentCreateInput, + CreateContentMutation, + CreateContentMutationVariables, +} from '#generated/types/graphql'; +import useAlert from '#hooks/useAlert'; +import { transformToFormError } from '#utils/errorTransform'; + +import FormPreviewSection from './FormPreviewSection'; +import createContentFormSchema, { + defaultFormValues, + type PartialContentType, + type PartialFormType, +} from './schema'; + +import styles from './styles.module.css'; + +type Status = 'pending' | 'success' | 'failure'; + +interface FilesStatusKeyValue { + [clientId: string]: Status +} + +const CREATE_CONTENT = gql` + mutation CreateContent($input: ContentCreateInput!) { + private { + createContent(data: $input) { + ok + errors + result { + createdAt + documentType + documentStatus + id + tag { + name + } + title + } + } + } + } +`; + +interface Props { + onClose: () => void; + contentRefetch: () => void; +} + +function AddContentModal(props: Props) { + const { + onClose, + contentRefetch, + } = props; + + const [fileSelectedName, setFileSelectedName] = useState(); + const [filesStatusKeyValue, setFilesStatusKeyValue] = useState(); + const [submissionFileClientId, setSubmissionFileClientId] = useState(); + const alert = useAlert(); + + const { + pristine, + value, + error: formError, + setFieldValue, + validate, + setError, + } = useForm( + createContentFormSchema, + { value: defaultFormValues }, + ); + + const handleAddFiles = useCallback((values: FileLike[]) => { + const clientId = values[0].key; + + const newFile: PartialContentType = { + clientId, + documentFile: values[0].file, + }; + setFilesStatusKeyValue((oldVal) => ({ + ...oldVal, + [clientId]: 'pending', + })); + + setFieldValue( + (oldValue: PartialContentType[] | undefined) => ( + [...(oldValue ?? []), newFile] + ), + 'contents', + ); + }, [setFieldValue]); + + const error = getErrorObject(formError); + + const { + setValue: onContentFormChange, + } = useFormArray<'contents', PartialContentType>( + 'contents', + setFieldValue, + ); + + const [ + createContent, + { loading }, + ] = useMutation( + CREATE_CONTENT, + { + onCompleted: (response) => { + const { private: privateRes } = response; + if (!privateRes) return; + const { createContent: createContentRes } = privateRes; + if (!createContentRes) return; + const { errors, ok } = createContentRes; + + if (errors) { + const formErrors = transformToFormError(removeNull(errors)); + setError(formErrors); + const errorMessages = errors + ?.map((message: { messages: string; }) => message.messages) + .filter((msg: string) => msg) + .join(', '); + alert.show(errorMessages); + } else if (ok) { + if (isDefined(submissionFileClientId)) { + setFilesStatusKeyValue((oldVal) => ({ + ...oldVal, + [submissionFileClientId]: 'success', + })); + } + + const valueIndexOf = value.contents?.findIndex( + (content) => content.clientId === submissionFileClientId, + ); + + if ( + isNotDefined(value) + || isNotDefined(value.contents) + || isNotDefined(valueIndexOf) + ) { + return; + } + + if (isDefined(value.contents[valueIndexOf + 1])) { + const { + clientId, + ...inputWithoutClientId + } = value.contents[valueIndexOf + 1]; + + setSubmissionFileClientId(clientId); + + const variables: CreateContentMutationVariables = { + input: { + ...inputWithoutClientId, + } as ContentCreateInput, + }; + createContent({ + variables, + context: { + hasUpload: true, + }, + }); + } + + alert.show( + 'Content addition successfully', + { variant: 'success' }, + ); + } + }, + onError: (emailError) => { + setError({ [nonFieldError]: emailError.message }); + if (isDefined(submissionFileClientId)) { + setFilesStatusKeyValue((oldVal) => ({ + ...oldVal, + [submissionFileClientId]: 'failure', + })); + } + alert.show( + 'Content addition failed', + { variant: 'danger' }, + ); + }, + }, + ); + + if (isDefined(filesStatusKeyValue)) { + const isAllContentSuccessful = Object.values( + filesStatusKeyValue, + ).every((content) => content === 'success'); + + if (isAllContentSuccessful) { + contentRefetch(); + onClose(); + } + } + + const handleFileClick = useCallback((name: string) => { + setFileSelectedName(name); + }, []); + + const formContent = useMemo(() => { + if (isNotDefined(value.contents)) { + return undefined; + } + + const indexValue = value.contents?.findIndex(((ctn) => ctn.clientId === fileSelectedName)); + + const valueContent = value.contents[indexValue ?? 0]; + + return { + formValue: valueContent, + mainIndex: indexValue ?? 0, + name: valueContent?.documentFile?.name, + }; + }, [fileSelectedName, value.contents]); + + const handleCreateContentSubmit = useCallback((finalValue: PartialFormType) => { + if ( + isNotDefined(finalValue) + || isNotDefined(finalValue.contents) + ) { + return; + } + const { clientId, ...inputWithoutClientId } = finalValue.contents[0]; + + setSubmissionFileClientId(clientId); + + const variables: CreateContentMutationVariables = { + input: { + ...inputWithoutClientId, + } as ContentCreateInput, + }; + + createContent({ + variables, + context: { + hasUpload: true, + }, + }); + }, [createContent]); + + const handleSubmit = useCallback(() => { + createSubmitHandler(validate, setError, handleCreateContentSubmit)(); + }, [handleCreateContentSubmit, validate, setError]); + + return ( + + + + + )} + > + + +
+ + Uploads + +
+ {(isNotDefined(value.contents) || value.contents.length <= 0) ? ( +
No uploads
+ ) + : value.contents.map((file) => ( + + {file.documentFile.name} + {filesStatusKeyValue?.[file.clientId]} + + ))} +
+
+
+ {isNotDefined(formContent?.formValue) + ?
Please select the file
+ : ( + + )} +
+ ); +} + +export default AddContentModal; diff --git a/src/views/ContentManagement/AddContentModal/schema.ts b/src/views/ContentManagement/AddContentModal/schema.ts new file mode 100644 index 00000000..8dc01364 --- /dev/null +++ b/src/views/ContentManagement/AddContentModal/schema.ts @@ -0,0 +1,58 @@ +import { + ArraySchema, + ObjectSchema, + PartialForm, + PurgeNull, + requiredStringCondition, +} from '@togglecorp/toggle-form'; + +import { ContentCreateInput } from '#generated/types/graphql'; +import { DeepReplace } from '#utils/common'; + +type ContentFormFields = ContentCreateInput & { clientId: string, status: string }; + +type FormType = { + contents: ContentFormFields[]; +} +type FormFields = DeepReplace + +export type PartialFormType = PartialForm< + PurgeNull, + 'clientId' +>; + +export type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType; + +export type PartialContentType = NonNullable[number] + +export type ContentsSchema = ArraySchema; +type ContentsSchemaMember = ReturnType; + +export type ContentFormSchema = ObjectSchema; +export type ContentFormSchemaFields = ReturnType; + +const createContentFormSchema: FormSchema = { + fields: (): FormSchemaFields => ({ + contents: { + keySelector: (content) => content.clientId, + member: (): ContentsSchemaMember => ({ + fields: (): ContentFormSchemaFields => ({ + clientId: {}, + title: { + required: true, + requiredValidation: requiredStringCondition, + }, + tag: {}, + documentFile: {}, + }), + }), + }, + }), +}; + +export const defaultFormValues: PartialFormType = { + contents: [], +}; + +export default createContentFormSchema; diff --git a/src/views/ContentManagement/AddContentModal/styles.module.css b/src/views/ContentManagement/AddContentModal/styles.module.css new file mode 100644 index 00000000..b71d5536 --- /dev/null +++ b/src/views/ContentManagement/AddContentModal/styles.module.css @@ -0,0 +1,38 @@ +.body-modal{ + display: flex; + gap: var(--cms-ui-spacing-md); + + .upload-section { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--cms-ui-spacing-md); + + .uploads-container { + background-color: var(--cms-ui-color-white); + padding: var(--cms-ui-spacing-md); + + .file-card-container { + display: flex; + flex-direction: column; + gap: var(--cms-ui-spacing-sm); + + .file-card { + background-color: var(--cms-ui-color-blue-20); + padding: var(--cms-ui-spacing-md); + } + } + } + } + + .preview-section { + flex: 2; + background-color: var(--cms-ui-color-white); + } +} + +.footer-content{ + display: flex; + justify-content: flex-end; + gap: var(--cms-ui-spacing-md); +} diff --git a/src/views/ContentManagement/index.tsx b/src/views/ContentManagement/index.tsx index f452f8d6..c7f01ed8 100644 --- a/src/views/ContentManagement/index.tsx +++ b/src/views/ContentManagement/index.tsx @@ -20,13 +20,15 @@ import { ContentListQuery, ContentListQueryVariables, } from '#generated/types/graphql'; +import useBooleanState from '#hooks/useBooleanState'; import useFilterState from '#hooks/useFilterState'; +import AddContentModal from './AddContentModal'; import ContentActions from './ContentActions'; import styles from './styles.module.css'; -type ContentListTable = NonNullable['contents']>['items']>[number] & {serialNumber: string; }; +type ContentListTable = NonNullable['contents']>['items']>[number] & { serialNumber: string; }; const contentKeySelector = (option: ContentListTable) => option.id; @@ -91,14 +93,24 @@ export function Component() { pageSize: PAGE_SIZE, filter: {}, }); + + const [ + showAddModal, + { + setTrue: setShowAddModalTrue, + setFalse: setShowAddModalFalse, + }, + ] = useBooleanState(false); + const { data: contentResult, + refetch: contentRefetch, } = useQuery( CREATE_CONTENT_QUERY, { variables: { pagination: { - limit: 10, + limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, }, @@ -156,28 +168,28 @@ export function Component() { { columnClassName: styles.actions }, ), createElementColumn( - 'documentStatusDisplay', - 'Status', - ({ status, variant }) => ( - + { status: string | undefined; variant: string }>( + 'documentStatusDisplay', + 'Status', + ({ status, variant }) => ( + + ), + (_key, item) => { + const statusLabel = documentStatus?.find( + (status: { key: string; }) => status.key === item.documentStatus, + )?.label; + const variant = statusLabel ? statusVariant[statusLabel] : ''; + return { + status: statusLabel, + variant, + }; + }, + { columnClassName: styles.actions }, ), - (_key, item) => { - const statusLabel = documentStatus?.find( - (status: { key: string; }) => status.key === item.documentStatus, - )?.label; - const variant = statusLabel ? statusVariant[statusLabel] : ''; - return { - status: statusLabel, - variant, - }; - }, - { columnClassName: styles.actions }, - ), - createElementColumn( + createElementColumn( 'actions', 'Actions', ContentActions, @@ -189,40 +201,47 @@ export function Component() { ]), [documentType, documentStatus]); return ( - { }} - disabled - > - Add - - )} - footerActions={( - + + Add + + )} + footerActions={( + + )} + > + + + {showAddModal && ( + )} - > -
- + ); }