From 80e979434dcda7f9a988ab1f9c74ab9ed306db0c Mon Sep 17 00:00:00 2001 From: amrit Date: Mon, 29 Jun 2026 10:47:40 +0545 Subject: [PATCH 1/4] feat(documents): add document table and queries --- app/Root/config/routes.ts | 12 + .../Documents/DocumentsFilters/index.tsx | 39 ++ app/views/Documents/DocumentsForm/index.tsx | 356 ++++++++++++++++++ app/views/Documents/index.tsx | 260 ++++++++++++- app/views/Documents/query.ts | 93 +++++ 5 files changed, 757 insertions(+), 3 deletions(-) create mode 100644 app/views/Documents/DocumentsFilters/index.tsx create mode 100644 app/views/Documents/DocumentsForm/index.tsx create mode 100644 app/views/Documents/query.ts 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/views/Documents/DocumentsFilters/index.tsx b/app/views/Documents/DocumentsFilters/index.tsx new file mode 100644 index 0000000..380ea76 --- /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 '../index'; + +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..3022760 --- /dev/null +++ b/app/views/Documents/DocumentsForm/index.tsx @@ -0,0 +1,356 @@ +import { + useCallback, + useEffect, + useMemo, +} from 'react'; +import { + createSearchParams, + useNavigate, + useParams, + useSearchParams, +} from 'react-router'; +import { + BlockLoading, + Button, + Container, + Description, + InlineLayout, + InputError, + InputSection, + ListView, + RawFileInput, + SelectInput, + TextInput, +} from '@ifrc-go/ui'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import { + createSubmitHandler, + getErrorObject, + getErrorString, + type ObjectSchema, + type PartialForm, + removeNull, + requiredStringCondition, + useForm, +} from '@togglecorp/toggle-form'; + +import NonFieldError from '#components/NonFieldError'; +import { + ReportContentType, + type ReportCreateInput, + ReportTypeEnum, + type ReportUpdateInput, + useCreateDocumentMutation, + useDocumentDetailQuery, + useDocumentEnumsQuery, + useUpdateDocumentMutation, +} from '#generated/types/graphql'; +import useAlert from '#hooks/useAlert'; +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 isDefined(value) && (allowedReportTypes as string[]).includes(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, +}; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +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: isDefined(id) ? id : '' }, + pause: isNotDefined(id), + }); + + const [{ data: enumsData }] = useDocumentEnumsQuery(); + + const [{ fetching: createPending }, createDocumentMutate] = useCreateDocumentMutation(); + const [{ fetching: updatePending }, updateDocumentMutate] = useUpdateDocumentMutation(); + + const reportTypeOptions = useMemo(() => ( + (enumsData?.enums?.ReportType ?? []).filter( + (option) => allowedReportTypes.includes(option.key), + ) + ), [enumsData]); + + 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) => { + if (isDefined(file) && file.size > MAX_FILE_SIZE) { + alert.show('File must be 5MB or smaller', { variant: 'danger' }); + return; + } + setFieldValue(file, 'file'); + }, [alert, 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)) { + const { + ...otherValues + } = removeNull(documentData); + + delete (otherValues as { file?: unknown }).file; + setValue({ + ...otherValues, + }); + } + }, [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 ( + + + + + )} + > + + + + + + + + {isDefined(fileName) ? 'Change file' : 'Upload file'} + + )} + > + + {isDefined(fileName) ? fileName : 'Please upload a file'} + + + {isDefined(error?.file) && ( + + {getErrorString(error.file)} + + )} + + + + + + + ); +} + +export default DocumentsForm; diff --git a/app/views/Documents/index.tsx b/app/views/Documents/index.tsx index fc8899c..2b93e73 100644 --- a/app/views/Documents/index.tsx +++ b/app/views/Documents/index.tsx @@ -1,8 +1,262 @@ +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, + 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: string }; + +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 deserializeTab(value: string | null | undefined): ReportTypeEnum { + if (value === ReportTypeEnum.Policy || value === ReportTypeEnum.Guideline) { + return value; + } + return ReportTypeEnum.Manual; +} + 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', + deserializeTab, + (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: String((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(() => [ + createStringColumn( + '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..9411a6a --- /dev/null +++ b/app/views/Documents/query.ts @@ -0,0 +1,93 @@ +/* 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_ENUMS = gql` + query DocumentEnums { + enums { + ReportType { + key + label + } + ReportVisibility { + key + label + } + ReportContentType { + key + label + } + } + } +`; + +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 + } + } + } + } +`; From be600ca519d7b7bfa6243ef3791cfd7f8f87bdd1 Mon Sep 17 00:00:00 2001 From: amrit Date: Thu, 9 Jul 2026 11:20:19 +0545 Subject: [PATCH 2/4] fixup! feat(documents): add document table and queries --- app/views/Documents/DocumentsForm/index.tsx | 50 +++++++-------------- app/views/Documents/index.tsx | 4 +- 2 files changed, 17 insertions(+), 37 deletions(-) diff --git a/app/views/Documents/DocumentsForm/index.tsx b/app/views/Documents/DocumentsForm/index.tsx index 3022760..0479f75 100644 --- a/app/views/Documents/DocumentsForm/index.tsx +++ b/app/views/Documents/DocumentsForm/index.tsx @@ -13,12 +13,8 @@ import { BlockLoading, Button, Container, - Description, - InlineLayout, - InputError, InputSection, ListView, - RawFileInput, SelectInput, TextInput, } from '@ifrc-go/ui'; @@ -29,7 +25,6 @@ import { import { createSubmitHandler, getErrorObject, - getErrorString, type ObjectSchema, type PartialForm, removeNull, @@ -37,6 +32,7 @@ import { useForm, } from '@togglecorp/toggle-form'; +import FileInput from '#components/FileInput'; import NonFieldError from '#components/NonFieldError'; import { ReportContentType, @@ -203,13 +199,12 @@ function DocumentsForm() { handleResult(res.data?.updateReport, 'Document updated successfully', formValues.reportType); }, [id, updateDocumentMutate, handleResult]); - const handleFileChange = useCallback((file: File | undefined) => { - if (isDefined(file) && file.size > MAX_FILE_SIZE) { - alert.show('File must be 5MB or smaller', { variant: 'danger' }); - return; - } - setFieldValue(file, 'file'); - }, [alert, setFieldValue]); + const handleFileChange = useCallback( + (file: File | undefined, name: 'file') => { + setFieldValue(file, name); + }, + [setFieldValue], + ); const handleFormSubmit = useCallback( () => createSubmitHandler( @@ -311,29 +306,14 @@ function DocumentsForm() { withAsteriskOnTitle > - - {isDefined(fileName) ? 'Change file' : 'Upload file'} - - )} - > - - {isDefined(fileName) ? fileName : 'Please upload a file'} - - - {isDefined(error?.file) && ( - - {getErrorString(error.file)} - - )} + ( 'tab', - deserializeTab, + persistTab, (tab) => tab, ); From 2052edaf35c7092a9a3cdc8dbab4de0bcb65b82e Mon Sep 17 00:00:00 2001 From: amrit Date: Thu, 9 Jul 2026 14:57:48 +0545 Subject: [PATCH 3/4] fixup! feat(documents): add document table and queries --- app/components/FileInput/index.tsx | 19 +++++---- app/utils/common.ts | 2 + .../DataAndReportsForm/index.tsx | 3 +- app/views/Documents/DocumentsForm/index.tsx | 3 +- .../OnlineInteractiveForm/index.tsx | 42 ++++++------------- 5 files changed, 27 insertions(+), 42 deletions(-) diff --git a/app/components/FileInput/index.tsx b/app/components/FileInput/index.tsx index c470362..1cc8aa4 100644 --- a/app/components/FileInput/index.tsx +++ b/app/components/FileInput/index.tsx @@ -1,14 +1,17 @@ -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'; interface Props { name: N; @@ -33,20 +36,19 @@ function FileInput(props: Props) { 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' }, - ); + setSizeError(`File must be ${Math.round(maxSize / (1024 * 1024))}MB or smaller`); + onChange(undefined, inputName); return; } + setSizeError(undefined); onChange(file, inputName); }, - [alert, maxSize, onChange], + [maxSize, onChange], ); return ( @@ -70,6 +72,7 @@ function FileInput(props: Props) { {isDefined(fileName) ? fileName : placeholder} + {isDefined(sizeError) && {sizeError}} ); diff --git a/app/utils/common.ts b/app/utils/common.ts index c46b8b1..161f90f 100644 --- a/app/utils/common.ts +++ b/app/utils/common.ts @@ -3,6 +3,8 @@ import { nonFieldError } from '@togglecorp/toggle-form'; import type { AdminAreaLevel } from '#generated/types/graphql'; +export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + 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..ca6a9cf 100644 --- a/app/views/DataAndReports/DataAndReportsForm/index.tsx +++ b/app/views/DataAndReports/DataAndReportsForm/index.tsx @@ -55,6 +55,7 @@ import { idSelector, keySelector, labelSelector, + MAX_FILE_SIZE, nameSelector, omitKeys, safeUrlCondition, @@ -123,8 +124,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, diff --git a/app/views/Documents/DocumentsForm/index.tsx b/app/views/Documents/DocumentsForm/index.tsx index 0479f75..0999389 100644 --- a/app/views/Documents/DocumentsForm/index.tsx +++ b/app/views/Documents/DocumentsForm/index.tsx @@ -50,6 +50,7 @@ import { errorMessage, keySelector, labelSelector, + MAX_FILE_SIZE, omitKeys, transformToFormError, } from '#utils/common'; @@ -92,8 +93,6 @@ const defaultFormValue: PartialFormType = { contentType: ReportContentType.File, }; -const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB - function getFileFields(file: File | null | undefined) { return { ...(isDefined(file) ? { file } : {}), diff --git a/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx b/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx index 3019d65..74cd793 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, @@ -42,6 +40,7 @@ import useAlert from '#hooks/useAlert'; import useRouting from '#hooks/useRouting'; import { errorMessage, + MAX_FILE_SIZE, transformToFormError, } from '#utils/common'; @@ -75,8 +74,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 +106,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 +245,14 @@ function OnlineInteractiveForm() { description="Upload the online interactive file (max 5MB)" withAsteriskOnTitle > - - {isDefined(fileName) ? 'Change file' : 'Upload file'} - - )} - > - - {isDefined(fileName) ? fileName : 'Please upload a file'} - - - + From cf1d0f3f8f188685e9d947a8f3bad6e011b12757 Mon Sep 17 00:00:00 2001 From: amrit Date: Fri, 10 Jul 2026 09:52:11 +0545 Subject: [PATCH 4/4] fixup! feat(documents): add document table and queries --- app/Root/index.tsx | 1 + app/Root/query.ts | 4 ++++ app/components/FileInput/index.tsx | 9 ++++--- app/contexts/GlobalEnumsContext.ts | 3 +++ app/utils/common.ts | 3 ++- .../DataAndReportsForm/index.tsx | 16 +++++++++++-- .../Documents/DocumentsFilters/index.tsx | 2 +- app/views/Documents/DocumentsForm/index.tsx | 24 ++++++------------- app/views/Documents/index.tsx | 16 ++++--------- app/views/Documents/query.ts | 19 --------------- .../OnlineInteractiveForm/index.tsx | 2 -- 11 files changed, 41 insertions(+), 58 deletions(-) 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 1cc8aa4..2cbe258 100644 --- a/app/components/FileInput/index.tsx +++ b/app/components/FileInput/index.tsx @@ -12,13 +12,13 @@ import { isDefined } from '@togglecorp/fujs'; import { type Error } from '@togglecorp/toggle-form'; import NonFieldError from '#components/NonFieldError'; +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; @@ -30,7 +30,6 @@ function FileInput(props: Props) { fileName, onChange, error, - maxSize, accept, disabled, placeholder = 'Please upload a file', @@ -40,15 +39,15 @@ function FileInput(props: Props) { const handleChange = useCallback( (file: File | undefined, inputName: N) => { - if (isDefined(file) && isDefined(maxSize) && file.size > maxSize) { - setSizeError(`File must be ${Math.round(maxSize / (1024 * 1024))}MB or smaller`); + 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); }, - [maxSize, onChange], + [onChange], ); return ( 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 161f90f..08c9013 100644 --- a/app/utils/common.ts +++ b/app/utils/common.ts @@ -3,7 +3,8 @@ import { nonFieldError } from '@togglecorp/toggle-form'; import type { AdminAreaLevel } from '#generated/types/graphql'; -export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +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 ca6a9cf..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,7 +57,7 @@ import { idSelector, keySelector, labelSelector, - MAX_FILE_SIZE, + MAX_IMAGE_FILE_SIZE, nameSelector, omitKeys, safeUrlCondition, @@ -193,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], @@ -389,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 index 380ea76..8daadd7 100644 --- a/app/views/Documents/DocumentsFilters/index.tsx +++ b/app/views/Documents/DocumentsFilters/index.tsx @@ -4,7 +4,7 @@ import { } from '@ifrc-go/ui'; import { type EntriesAsList } from '@togglecorp/toggle-form'; -import type { DocumentFilterType } from '../index'; +import type { DocumentFilterType } from '..'; export interface Props { value: DocumentFilterType; diff --git a/app/views/Documents/DocumentsForm/index.tsx b/app/views/Documents/DocumentsForm/index.tsx index 0999389..4ebd113 100644 --- a/app/views/Documents/DocumentsForm/index.tsx +++ b/app/views/Documents/DocumentsForm/index.tsx @@ -41,16 +41,15 @@ import { type ReportUpdateInput, useCreateDocumentMutation, useDocumentDetailQuery, - useDocumentEnumsQuery, 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, - MAX_FILE_SIZE, omitKeys, transformToFormError, } from '#utils/common'; @@ -66,7 +65,7 @@ const allowedReportTypes = [ ]; function isAllowedReportType(value: string | null | undefined): value is ReportTypeEnum { - return isDefined(value) && (allowedReportTypes as string[]).includes(value); + return allowedReportTypes.some((type) => type === value); } function getDocumentSchema(isEditing: boolean): FormSchema { @@ -125,20 +124,20 @@ function DocumentsForm() { } = useForm(documentSchema, { value: initialValue }); const [{ data, fetching: documentDetailFetch }] = useDocumentDetailQuery({ - variables: { id: isDefined(id) ? id : '' }, + variables: { id: id ?? '' }, pause: isNotDefined(id), }); - const [{ data: enumsData }] = useDocumentEnumsQuery(); + const { reportType: reportTypeEnumOptions } = useGlobalEnums(); const [{ fetching: createPending }, createDocumentMutate] = useCreateDocumentMutation(); const [{ fetching: updatePending }, updateDocumentMutate] = useUpdateDocumentMutation(); const reportTypeOptions = useMemo(() => ( - (enumsData?.enums?.ReportType ?? []).filter( + (reportTypeEnumOptions ?? []).filter( (option) => allowedReportTypes.includes(option.key), ) - ), [enumsData]); + ), [reportTypeEnumOptions]); const navigateToDocuments = useCallback((reportType?: ReportTypeEnum | null) => { navigate({ @@ -224,14 +223,7 @@ function DocumentsForm() { useEffect(() => { if (!documentDetailFetch && isDefined(documentData)) { - const { - ...otherValues - } = removeNull(documentData); - - delete (otherValues as { file?: unknown }).file; - setValue({ - ...otherValues, - }); + setValue(omitKeys(removeNull(documentData), ['file'])); } }, [documentDetailFetch, documentData, setValue]); @@ -303,14 +295,12 @@ function DocumentsForm() { title="File Upload" description="Upload the document file" withAsteriskOnTitle - > diff --git a/app/views/Documents/index.tsx b/app/views/Documents/index.tsx index e6ace2d..416a640 100644 --- a/app/views/Documents/index.tsx +++ b/app/views/Documents/index.tsx @@ -23,6 +23,7 @@ import { import { createDateColumn, createElementColumn, + createNumberColumn, createStringColumn, } from '@ifrc-go/ui/utils'; @@ -45,7 +46,7 @@ import { import DocumentsFilter from './DocumentsFilters'; -type ReportsListItem = NonNullable['results'][number]> & { no: string }; +type ReportsListItem = NonNullable['results'][number]> & { no: number }; export type DocumentFilterType = Pick & { createdAtGte: string | undefined; @@ -64,13 +65,6 @@ const tabKeys = [ { key: ReportTypeEnum.Guideline, label: 'Guidelines' }, ]; -function persistTab(value: string | null | undefined): ReportTypeEnum { - if (value === ReportTypeEnum.Policy || value === ReportTypeEnum.Guideline) { - return value; - } - return ReportTypeEnum.Manual; -} - function Documents() { const { rawFilter, @@ -90,7 +84,7 @@ function Documents() { const [activeTab, setActiveTab] = useUrlSearchState( 'tab', - persistTab, + (tab) => (tab as ReportTypeEnum) ?? ReportTypeEnum.Manual, (tab) => tab, ); @@ -115,7 +109,7 @@ function Documents() { const tableData: ReportsListItem[] = useMemo(() => ( (data?.reports?.results ?? []).map((report, index) => ({ ...report, - no: String((page - 1) * limit + index + 1), + no: (page - 1) * limit + index + 1, })) ), [page, data, limit]); @@ -137,7 +131,7 @@ function Documents() { ); const columns = useMemo(() => [ - createStringColumn( + createNumberColumn( 'no', 'No.', (item) => item.no, diff --git a/app/views/Documents/query.ts b/app/views/Documents/query.ts index 9411a6a..995b8ef 100644 --- a/app/views/Documents/query.ts +++ b/app/views/Documents/query.ts @@ -30,25 +30,6 @@ const DELETE_DOCUMENT = gql` } `; -const DOCUMENT_ENUMS = gql` - query DocumentEnums { - enums { - ReportType { - key - label - } - ReportVisibility { - key - label - } - ReportContentType { - key - label - } - } - } -`; - const DOCUMENT_DETAIL = gql` query DocumentDetail($id: ID!) { report(id: $id) { diff --git a/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx b/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx index 74cd793..2f4456a 100644 --- a/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx +++ b/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx @@ -40,7 +40,6 @@ import useAlert from '#hooks/useAlert'; import useRouting from '#hooks/useRouting'; import { errorMessage, - MAX_FILE_SIZE, transformToFormError, } from '#utils/common'; @@ -250,7 +249,6 @@ function OnlineInteractiveForm() { fileName={fileName} onChange={handleFileChange} error={error?.file} - maxSize={MAX_FILE_SIZE} disabled={pending} />