diff --git a/app/Root/config/routes.ts b/app/Root/config/routes.ts index 0353e2f..28c9299 100644 --- a/app/Root/config/routes.ts +++ b/app/Root/config/routes.ts @@ -178,6 +178,16 @@ const documents: RouteConfig = { load: () => import('#views/Documents'), visibility: 'is-authenticated', }; +const createDocument: RouteConfig = { + path: '/documents/new', + load: () => import('#views/Documents/DocumentsForm'), + visibility: 'is-authenticated', +}; +const editDocument: RouteConfig = { + path: '/documents/:id/edit', + load: () => import('#views/Documents/DocumentsForm'), + visibility: 'is-authenticated', +}; const onlineInteractive: RouteConfig = { index: true, path: '/online-interactive', @@ -250,6 +260,8 @@ const routes = { createResourceDashboard, editResourceDashboard, documents, + createDocument, + editDocument, onlineInteractive, createOnlineInteractive, editOnlineInteractive, diff --git a/app/Root/index.tsx b/app/Root/index.tsx index 920f5af..ac33520 100644 --- a/app/Root/index.tsx +++ b/app/Root/index.tsx @@ -66,6 +66,7 @@ function RootContent() { const globalEnumsContext: GlobalEnumsContextInterface = useMemo(() => ({ linkType: globalEnumsData?.enums.LinkType, + reportType: globalEnumsData?.enums.ReportType, userRole: globalEnumsData?.enums.UserRole, teamMemberSex: globalEnumsData?.enums.TeamMemberSex, dashboardPage: globalEnumsData?.enums.DashboardPage, diff --git a/app/Root/query.ts b/app/Root/query.ts index 48ae983..abfd42f 100644 --- a/app/Root/query.ts +++ b/app/Root/query.ts @@ -8,6 +8,10 @@ export const GLOBAL_ENUMS = gql` key label } + ReportType { + key + label + } UserRole { key label diff --git a/app/components/FileInput/index.tsx b/app/components/FileInput/index.tsx index c470362..2cbe258 100644 --- a/app/components/FileInput/index.tsx +++ b/app/components/FileInput/index.tsx @@ -1,21 +1,24 @@ -import { useCallback } from 'react'; +import { + useCallback, + useState, +} from 'react'; import { Description, InlineLayout, + InputError, RawFileInput, } from '@ifrc-go/ui'; import { isDefined } from '@togglecorp/fujs'; import { type Error } from '@togglecorp/toggle-form'; import NonFieldError from '#components/NonFieldError'; -import useAlert from '#hooks/useAlert'; +import { MAX_REPORT_FILE_SIZE } from '#utils/common'; interface Props { name: N; fileName?: string; onChange: (file: File | undefined, name: N) => void; error?: Error; - maxSize?: number; accept?: string; disabled?: boolean; placeholder?: string; @@ -27,26 +30,24 @@ function FileInput(props: Props) { fileName, onChange, error, - maxSize, accept, disabled, placeholder = 'Please upload a file', } = props; - const alert = useAlert(); + const [sizeError, setSizeError] = useState(); const handleChange = useCallback( (file: File | undefined, inputName: N) => { - if (isDefined(file) && isDefined(maxSize) && file.size > maxSize) { - alert.show( - `File must be ${Math.round(maxSize / (1024 * 1024))}MB or smaller`, - { variant: 'danger' }, - ); + if (isDefined(file) && file.size > MAX_REPORT_FILE_SIZE) { + setSizeError(`File must be less than ${MAX_REPORT_FILE_SIZE / (1024 * 1024)}MB`); + onChange(undefined, inputName); return; } + setSizeError(undefined); onChange(file, inputName); }, - [alert, maxSize, onChange], + [onChange], ); return ( @@ -70,6 +71,7 @@ function FileInput(props: Props) { {isDefined(fileName) ? fileName : placeholder} + {isDefined(sizeError) && {sizeError}} ); diff --git a/app/contexts/GlobalEnumsContext.ts b/app/contexts/GlobalEnumsContext.ts index 2d0c352..313bd08 100644 --- a/app/contexts/GlobalEnumsContext.ts +++ b/app/contexts/GlobalEnumsContext.ts @@ -4,12 +4,14 @@ import type { AppEnumCollectionDashboardPage, AppEnumCollectionLinkType, AppEnumCollectionReportContentType, + AppEnumCollectionReportType, AppEnumCollectionTeamMemberSex, AppEnumCollectionUserRole, } from '#generated/types/graphql'; export interface GlobalEnumsContextInterface { linkType: AppEnumCollectionLinkType[] | undefined; + reportType: AppEnumCollectionReportType[] | undefined; userRole: AppEnumCollectionUserRole[] | undefined; teamMemberSex: AppEnumCollectionTeamMemberSex[] | undefined; dashboardPage: AppEnumCollectionDashboardPage[] | undefined; @@ -18,6 +20,7 @@ export interface GlobalEnumsContextInterface { const GlobalEnumsContext = createContext({ linkType: undefined, + reportType: undefined, userRole: undefined, teamMemberSex: undefined, dashboardPage: undefined, diff --git a/app/utils/common.ts b/app/utils/common.ts index c46b8b1..08c9013 100644 --- a/app/utils/common.ts +++ b/app/utils/common.ts @@ -3,6 +3,9 @@ import { nonFieldError } from '@togglecorp/toggle-form'; import type { AdminAreaLevel } from '#generated/types/graphql'; +export const MAX_REPORT_FILE_SIZE = 5 * 1024 * 1024; // 5MB +export const MAX_IMAGE_FILE_SIZE = 2 * 1024 * 1024; // 2MB + export function labelSelector(item: { label: T }) { return item.label; } diff --git a/app/views/DataAndReports/DataAndReportsForm/index.tsx b/app/views/DataAndReports/DataAndReportsForm/index.tsx index a648f75..0b2a8d8 100644 --- a/app/views/DataAndReports/DataAndReportsForm/index.tsx +++ b/app/views/DataAndReports/DataAndReportsForm/index.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, + useState, } from 'react'; import { useParams } from 'react-router'; import { @@ -10,6 +11,7 @@ import { Container, DateInput, Image, + InputError, InputSection, ListView, RadioInput, @@ -55,6 +57,7 @@ import { idSelector, keySelector, labelSelector, + MAX_IMAGE_FILE_SIZE, nameSelector, omitKeys, safeUrlCondition, @@ -123,8 +126,6 @@ const defaultFormValue: PartialFormType = { visibility: ReportVisibility.Public, }; -const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB - function getFileFields( file: File | null | undefined, coverImage: File | null | undefined, @@ -194,8 +195,16 @@ function DataAndReportsForm() { } }, [coverImagePreview, value.coverImage]); + const [coverImageSizeError, setCoverImageSizeError] = useState(); + const handleCoverImageChange = useCallback( (file: File | undefined, name: 'coverImage') => { + if (isDefined(file) && file.size > MAX_IMAGE_FILE_SIZE) { + setCoverImageSizeError(`Image must be less than ${MAX_IMAGE_FILE_SIZE / (1024 * 1024)}MB`); + setFieldValue(undefined, name); + return; + } + setCoverImageSizeError(undefined); setFieldValue(file, name); }, [setFieldValue], @@ -390,6 +399,9 @@ function DataAndReportsForm() { alt="Cover image preview" /> )} + {isDefined(coverImageSizeError) && ( + {coverImageSizeError} + )} diff --git a/app/views/Documents/DocumentsFilters/index.tsx b/app/views/Documents/DocumentsFilters/index.tsx new file mode 100644 index 0000000..8daadd7 --- /dev/null +++ b/app/views/Documents/DocumentsFilters/index.tsx @@ -0,0 +1,39 @@ +import { + DateInput, + TextInput, +} from '@ifrc-go/ui'; +import { type EntriesAsList } from '@togglecorp/toggle-form'; + +import type { DocumentFilterType } from '..'; + +export interface Props { + value: DocumentFilterType; + onChange: (...args: EntriesAsList) => void; +} + +function DocumentsFilter({ value, onChange }: Props) { + return ( + <> + + + + + ); +} + +export default DocumentsFilter; diff --git a/app/views/Documents/DocumentsForm/index.tsx b/app/views/Documents/DocumentsForm/index.tsx new file mode 100644 index 0000000..4ebd113 --- /dev/null +++ b/app/views/Documents/DocumentsForm/index.tsx @@ -0,0 +1,325 @@ +import { + useCallback, + useEffect, + useMemo, +} from 'react'; +import { + createSearchParams, + useNavigate, + useParams, + useSearchParams, +} from 'react-router'; +import { + BlockLoading, + Button, + Container, + InputSection, + ListView, + SelectInput, + TextInput, +} from '@ifrc-go/ui'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import { + createSubmitHandler, + getErrorObject, + type ObjectSchema, + type PartialForm, + removeNull, + requiredStringCondition, + useForm, +} from '@togglecorp/toggle-form'; + +import FileInput from '#components/FileInput'; +import NonFieldError from '#components/NonFieldError'; +import { + ReportContentType, + type ReportCreateInput, + ReportTypeEnum, + type ReportUpdateInput, + useCreateDocumentMutation, + useDocumentDetailQuery, + useUpdateDocumentMutation, +} from '#generated/types/graphql'; +import useAlert from '#hooks/useAlert'; +import useGlobalEnums from '#hooks/useGlobalEnums'; +import routes from '#root/config/routes'; +import { + errorMessage, + keySelector, + labelSelector, + omitKeys, + transformToFormError, +} from '#utils/common'; + +type PartialFormType = PartialForm; +type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType; + +const allowedReportTypes = [ + ReportTypeEnum.Manual, + ReportTypeEnum.Policy, + ReportTypeEnum.Guideline, +]; + +function isAllowedReportType(value: string | null | undefined): value is ReportTypeEnum { + return allowedReportTypes.some((type) => type === value); +} + +function getDocumentSchema(isEditing: boolean): FormSchema { + return { + fields: (): FormSchemaFields => ({ + reportType: { + required: true, + }, + contentType: { + required: true, + }, + title: { + required: true, + requiredValidation: requiredStringCondition, + }, + file: { + required: !isEditing, + }, + }), + }; +} + +const defaultFormValue: PartialFormType = { + contentType: ReportContentType.File, +}; + +function getFileFields(file: File | null | undefined) { + return { + ...(isDefined(file) ? { file } : {}), + }; +} + +function DocumentsForm() { + const { id } = useParams(); + const navigate = useNavigate(); + const alert = useAlert(); + const [searchParams] = useSearchParams(); + + const isEditing = isDefined(id); + + const documentSchema = useMemo(() => getDocumentSchema(isEditing), [isEditing]); + + const seedType = searchParams.get('type'); + const initialValue = useMemo(() => ({ + ...defaultFormValue, + reportType: isAllowedReportType(seedType) ? seedType : undefined, + }), [seedType]); + + const { + setFieldValue, + error: formError, + value, + validate, + setError, + setValue, + } = useForm(documentSchema, { value: initialValue }); + + const [{ data, fetching: documentDetailFetch }] = useDocumentDetailQuery({ + variables: { id: id ?? '' }, + pause: isNotDefined(id), + }); + + const { reportType: reportTypeEnumOptions } = useGlobalEnums(); + + const [{ fetching: createPending }, createDocumentMutate] = useCreateDocumentMutation(); + const [{ fetching: updatePending }, updateDocumentMutate] = useUpdateDocumentMutation(); + + const reportTypeOptions = useMemo(() => ( + (reportTypeEnumOptions ?? []).filter( + (option) => allowedReportTypes.includes(option.key), + ) + ), [reportTypeEnumOptions]); + + const navigateToDocuments = useCallback((reportType?: ReportTypeEnum | null) => { + navigate({ + pathname: routes.documents.path, + search: isDefined(reportType) + ? createSearchParams({ tab: reportType }).toString() + : undefined, + }); + }, [navigate]); + + const handleResult = useCallback(( + result: { + ok?: boolean | null; + errors?: Parameters[0] | null; + } | undefined | null, + successMessage: string, + reportType?: ReportTypeEnum | null, + ) => { + if (isDefined(result) && result.ok) { + navigateToDocuments(reportType); + alert.show(successMessage, { variant: 'success' }); + } else if (isDefined(result) && isDefined(result.errors)) { + setError(transformToFormError(result.errors)); + alert.show(errorMessage, { variant: 'danger' }); + } else { + alert.show(errorMessage, { variant: 'danger' }); + } + }, [navigateToDocuments, alert, setError]); + + const handleCreate = useCallback(async (formValues: PartialFormType) => { + const { file, ...rest } = formValues; + + const res = await createDocumentMutate({ + data: { + ...removeNull(rest), + ...getFileFields(file), + } as ReportCreateInput, + }); + + handleResult(res.data?.createReport, 'Document created successfully', formValues.reportType); + }, [createDocumentMutate, handleResult]); + + const handleUpdate = useCallback(async (formValues: PartialFormType) => { + if (isNotDefined(id)) { + return; + } + const { file, ...rest } = formValues; + + const res = await updateDocumentMutate({ + id, + data: { + ...omitKeys(removeNull(rest), ['contentType']), + ...getFileFields(file), + } as ReportUpdateInput, + }); + + handleResult(res.data?.updateReport, 'Document updated successfully', formValues.reportType); + }, [id, updateDocumentMutate, handleResult]); + + const handleFileChange = useCallback( + (file: File | undefined, name: 'file') => { + setFieldValue(file, name); + }, + [setFieldValue], + ); + + const handleFormSubmit = useCallback( + () => createSubmitHandler( + validate, + setError, + isDefined(id) ? handleUpdate : handleCreate, + )(), + [validate, setError, id, handleUpdate, handleCreate], + ); + + const error = getErrorObject(formError); + + const documentData = data?.report; + + const handleCancelClick = useCallback(() => { + navigateToDocuments(value.reportType); + }, [navigateToDocuments, value.reportType]); + + useEffect(() => { + if (!documentDetailFetch && isDefined(documentData)) { + setValue(omitKeys(removeNull(documentData), ['file'])); + } + }, [documentDetailFetch, documentData, setValue]); + + const fileName = value.file instanceof File + ? value.file.name + : documentData?.file?.name?.split('/').pop(); + + const pending = createPending || updatePending || documentDetailFetch; + + if (documentDetailFetch) { + return ( + + ); + } + + return ( + + + + + )} + > + + + + + + + + + + + + + + ); +} + +export default DocumentsForm; diff --git a/app/views/Documents/index.tsx b/app/views/Documents/index.tsx index fc8899c..416a640 100644 --- a/app/views/Documents/index.tsx +++ b/app/views/Documents/index.tsx @@ -1,8 +1,256 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + createSearchParams, + useNavigate, +} from 'react-router'; +import { AddFillIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + Description, + InlineLayout, + ListView, + Pager, + Tab, + Table, + TabList, + TabPanel, + Tabs, +} from '@ifrc-go/ui'; +import { + createDateColumn, + createElementColumn, + createNumberColumn, + createStringColumn, +} from '@ifrc-go/ui/utils'; + +import EditDeleteActions, { type Props as EditDeleteActionsProps } from '#components/EditDeleteActions'; +import { + type DocumentsQuery, + type ReportFilter, + ReportTypeEnum, + useDeleteDocumentMutation, + useDocumentsQuery, +} from '#generated/types/graphql'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useUrlSearchState from '#hooks/useUrlSearchState'; +import routes from '#root/config/routes'; +import { + errorMessage, + idSelector, +} from '#utils/common'; + +import DocumentsFilter from './DocumentsFilters'; + +type ReportsListItem = NonNullable['results'][number]> & { no: number }; + +export type DocumentFilterType = Pick & { + createdAtGte: string | undefined; + createdAtLte: string | undefined; +}; + +const defaultFilter: DocumentFilterType = { + search: undefined, + createdAtGte: undefined, + createdAtLte: undefined, +}; + +const tabKeys = [ + { key: ReportTypeEnum.Manual, label: 'Manuals' }, + { key: ReportTypeEnum.Policy, label: 'Policies' }, + { key: ReportTypeEnum.Guideline, label: 'Guidelines' }, +]; + function Documents() { + const { + rawFilter, + filter, + filtered, + setFilterField, + page, + setPage, + limit, + offset, + } = useFilterState({ + filter: defaultFilter, + }); + + const alert = useAlert(); + const navigate = useNavigate(); + + const [activeTab, setActiveTab] = useUrlSearchState( + 'tab', + (tab) => (tab as ReportTypeEnum) ?? ReportTypeEnum.Manual, + (tab) => tab, + ); + + const queryVariables = useMemo(() => ({ + pagination: { + limit, + offset, + }, + filters: { + reportType: activeTab, + search: filter.search || undefined, + createdAt: (filter.createdAtGte || filter.createdAtLte) ? { + gte: filter.createdAtGte, + lte: filter.createdAtLte, + } : undefined, + }, + }), [limit, offset, filter, activeTab]); + + const [{ fetching, data }, reExecuteQuery] = useDocumentsQuery({ variables: queryVariables }); + const [, deleteDocument] = useDeleteDocumentMutation(); + + const tableData: ReportsListItem[] = useMemo(() => ( + (data?.reports?.results ?? []).map((report, index) => ({ + ...report, + no: (page - 1) * limit + index + 1, + })) + ), [page, data, limit]); + + const onDeleteClick = useCallback( + (id: string) => { + deleteDocument({ id }).then((resp) => { + const result = resp.data?.deleteReport; + if (result?.ok) { + reExecuteQuery(); + alert.show('Document deleted successfully', { variant: 'success' }); + } else { + alert.show(errorMessage, { variant: 'danger' }); + } + }).catch(() => { + alert.show(errorMessage, { variant: 'danger' }); + }); + }, + [deleteDocument, reExecuteQuery, alert], + ); + + const columns = useMemo(() => [ + createNumberColumn( + 'no', + 'No.', + (item) => item.no, + ), + createDateColumn( + 'createdAt', + 'Created At', + (item) => item.createdAt, + ), + createStringColumn( + 'title', + 'Title', + (item) => item.title, + ), + createElementColumn( + 'actions', + '', + EditDeleteActions, + (_, datum) => ({ + id: datum.id, + onDelete: onDeleteClick, + itemTitle: datum.title, + to: 'editDocument', + }), + { columnWidth: 150 }, + ), + ], [onDeleteClick]); + + const handleTabChange = useCallback((tab: ReportTypeEnum) => { + setActiveTab(tab); + setPage(1); + }, [setActiveTab, setPage]); + + const handleCreateClick = useCallback(() => { + navigate({ + pathname: routes.createDocument.path, + search: createSearchParams({ type: activeTab }).toString(), + }); + }, [navigate, activeTab]); + return ( -
- Documents -
+ + + + )} + styleVariant="filled" + > + Create + + )} + footerActions={( + + )} + filters={( + + + )} + headerDescription={( + + + Upload, store, and organize documents for easy access across the system + + + {tabKeys.map((tab) => ( + + {tab.label} + + ))} + + )} + /> + + + )} + > + {tabKeys.map((tab) => ( + + + + ))} + + + + ); } diff --git a/app/views/Documents/query.ts b/app/views/Documents/query.ts new file mode 100644 index 0000000..995b8ef --- /dev/null +++ b/app/views/Documents/query.ts @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { gql } from 'urql'; + +const DOCUMENTS = gql` + query Documents($pagination: OffsetPaginationInput, $filters: ReportFilter) { + reports(pagination: $pagination, filters: $filters) { + results { + id + createdAt + title + reportType + reportTypeDisplay + } + totalCount + } + } +`; + +const DELETE_DOCUMENT = gql` + mutation DeleteDocument($id: ID!) { + deleteReport(id: $id) { + ... on ReportTypeMutationResponseType { + errors + ok + result { + id + } + } + } + } +`; + +const DOCUMENT_DETAIL = gql` + query DocumentDetail($id: ID!) { + report(id: $id) { + id + title + contentType + reportType + file { + url + name + } + } + } +`; + +const CREATE_DOCUMENT = gql` + mutation CreateDocument($data: ReportCreateInput!) { + createReport(data: $data) { + ... on ReportTypeMutationResponseType { + errors + ok + result { + id + } + } + } + } +`; + +const UPDATE_DOCUMENT = gql` + mutation UpdateDocument($id: ID!, $data: ReportUpdateInput!) { + updateReport(id: $id, data: $data) { + ... on ReportTypeMutationResponseType { + errors + ok + result { + id + } + } + } + } +`; diff --git a/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx b/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx index 3019d65..2f4456a 100644 --- a/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx +++ b/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx @@ -8,11 +8,8 @@ import { BlockLoading, Button, Container, - Description, - InlineLayout, InputSection, ListView, - RawFileInput, TextInput, } from '@ifrc-go/ui'; import { @@ -28,6 +25,7 @@ import { useForm, } from '@togglecorp/toggle-form'; +import FileInput from '#components/FileInput'; import NonFieldError from '#components/NonFieldError'; import { ReportContentType, @@ -75,8 +73,6 @@ const defaultFormValue: PartialFormType = { reportType: ReportTypeEnum.OnlineInteractive, }; -const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB - function OnlineInteractiveForm() { const { id } = useParams(); const navigate = useRouting(); @@ -109,15 +105,11 @@ function OnlineInteractiveForm() { ? value.file.name : detailData?.report?.file?.name?.split('/').pop(); - const handleFileInputChange = useCallback( + const handleFileChange = useCallback( (file: File | undefined, name: 'file') => { - if (isDefined(file) && file.size > MAX_FILE_SIZE) { - alert.show('File must be 5MB or smaller', { variant: 'danger' }); - return; - } setFieldValue(file, name); }, - [alert, setFieldValue], + [setFieldValue], ); const handleResult = useCallback(( @@ -252,25 +244,13 @@ function OnlineInteractiveForm() { description="Upload the online interactive file (max 5MB)" withAsteriskOnTitle > - - {isDefined(fileName) ? 'Change file' : 'Upload file'} - - )} - > - - {isDefined(fileName) ? fileName : 'Please upload a file'} - - - +