From 65d1048775546e06c819ac4230be6435fec8e941 Mon Sep 17 00:00:00 2001 From: amrit Date: Thu, 23 Jul 2026 14:25:09 +0545 Subject: [PATCH 1/6] fix(reports): fix reports, document issues --- app/Root/index.tsx | 11 ++- app/components/CoverImageInput/index.tsx | 74 +++++++++++++++++++ .../CapacityAndResourcesFilter/index.tsx | 18 ++++- .../CapacityAndResourcesForm/index.tsx | 3 +- .../ResourceDashboardForm/index.tsx | 3 +- .../ResourceDashboardsFilters/index.tsx | 15 +++- .../ResourceDashboards/index.tsx | 3 + app/views/CapacityAndResources/index.tsx | 3 + .../DataAndReportsFilters/index.tsx | 19 ++++- .../DataAndReportsForm/index.tsx | 53 ++++++------- app/views/DataAndReports/index.tsx | 3 + app/views/Home/index.tsx | 9 +++ app/views/Links/LinkForm/index.tsx | 3 +- app/views/Links/index.tsx | 9 +++ app/views/Login/index.tsx | 10 +-- .../OnlineInteractiveFilter/index.tsx | 36 +++++++-- .../OnlineInteractiveForm/index.tsx | 30 +++++++- app/views/OnlineInteractive/index.tsx | 3 + app/views/OnlineInteractive/query.ts | 4 + app/views/OurWorks/WorksFilter/index.tsx | 18 ++++- app/views/OurWorks/WorksForm/index.tsx | 3 +- app/views/OurWorks/index.tsx | 3 + .../Preparedness/PreparednessFilter/index.tsx | 18 ++++- .../Preparedness/PreparednessForm/index.tsx | 3 +- app/views/Preparedness/index.tsx | 3 + app/views/RootLayout/index.tsx | 5 +- app/views/Teams/TeamForm/index.tsx | 3 +- app/views/Users/UserListFilters/index.tsx | 15 +++- app/views/Users/index.tsx | 3 + 29 files changed, 320 insertions(+), 63 deletions(-) create mode 100644 app/components/CoverImageInput/index.tsx diff --git a/app/Root/index.tsx b/app/Root/index.tsx index 137e872..b67f5e5 100644 --- a/app/Root/index.tsx +++ b/app/Root/index.tsx @@ -27,6 +27,7 @@ import { useGlobalEnumsQuery, } from '#generated/types/graphql'; import useAlertContextProviderValue from '#hooks/useAlertContextProviderValue'; +import { ME_QUERY } from '#views/RootLayout'; const COOKIE_NAME = `ERCS-${environment}-CSRFTOKEN`; const GRAPHQL_ENDPOINT = `${api}/graphql/`; @@ -35,7 +36,15 @@ const cookies = new Cookies(); const gqlClient = new Client({ url: GRAPHQL_ENDPOINT, exchanges: [ - cacheExchange({}), + cacheExchange({ + updates: { + Mutation: { + logout: (_result, _args, cache) => { + cache.updateQuery({ query: ME_QUERY }, () => ({ me: null })); + }, + }, + }, + }), fetchExchange, ], fetchOptions: () => ({ diff --git a/app/components/CoverImageInput/index.tsx b/app/components/CoverImageInput/index.tsx new file mode 100644 index 0000000..956125a --- /dev/null +++ b/app/components/CoverImageInput/index.tsx @@ -0,0 +1,74 @@ +import { + useEffect, + useMemo, +} from 'react'; +import { + Image, + RawFileInput, +} from '@ifrc-go/ui'; +import { isDefined } from '@togglecorp/fujs'; +import { type Error } from '@togglecorp/toggle-form'; + +import NonFieldError from '#components/NonFieldError'; + +interface Props { + name: N; + value: File | undefined; + existingUrl?: string | null; + onChange: (file: File | undefined, name: N) => void; + error?: Error; + accept?: string; + disabled?: boolean; +} + +function CoverImageInput(props: Props) { + const { + name, + value, + existingUrl, + onChange, + error, + accept = 'image/*', + disabled, + } = props; + + const preview = useMemo(() => { + if (value instanceof File) { + return URL.createObjectURL(value); + } + return existingUrl ?? undefined; + }, [value, existingUrl]); + + useEffect(() => () => { + if (value instanceof File && preview) { + URL.revokeObjectURL(preview); + } + }, [preview, value]); + + return ( + <> + + {isDefined(value) || isDefined(preview) + ? 'Change cover image' + : 'Upload cover image'} + + {isDefined(preview) && ( + Cover image preview + )} + + + ); +} + +export default CoverImageInput; diff --git a/app/views/CapacityAndResources/CapacityAndResourcesFilter/index.tsx b/app/views/CapacityAndResources/CapacityAndResourcesFilter/index.tsx index cb816f4..2f85e6e 100644 --- a/app/views/CapacityAndResources/CapacityAndResourcesFilter/index.tsx +++ b/app/views/CapacityAndResources/CapacityAndResourcesFilter/index.tsx @@ -1,4 +1,5 @@ import { + Button, SelectInput, TextInput, } from '@ifrc-go/ui'; @@ -15,9 +16,16 @@ import type { ResourcesFilterType } from '../index'; export interface Props { value: ResourcesFilterType; onChange: (...args: EntriesAsList) => void; + filtered: boolean; + onReset: () => void; } -function CapacityAndResourcesFilter({ value, onChange }: Props) { +function CapacityAndResourcesFilter({ + value, + onChange, + filtered, + onReset, +}: Props) { return ( <> + ); } diff --git a/app/views/CapacityAndResources/CapacityAndResourcesForm/index.tsx b/app/views/CapacityAndResources/CapacityAndResourcesForm/index.tsx index 94fce13..01e8f48 100644 --- a/app/views/CapacityAndResources/CapacityAndResourcesForm/index.tsx +++ b/app/views/CapacityAndResources/CapacityAndResourcesForm/index.tsx @@ -11,6 +11,7 @@ import { ListView, NumberInput, RadioInput, + TextArea, TextInput, } from '@ifrc-go/ui'; import { @@ -212,7 +213,7 @@ function CapacityAndResourcesForm() { title="Description" description="Enter the description about the capacity and resource" > - - ) => void; + filtered: boolean; + onReset: () => void; } -function ResourceDashboardsFilters({ value, onChange }: Props) { +function ResourceDashboardsFilters({ + value, onChange, filtered, onReset, +}: Props) { const [regionOptions, setRegionOptions] = useState< AdminAreaItem[] | undefined | null >([]); @@ -51,6 +56,14 @@ function ResourceDashboardsFilters({ value, onChange }: Props) { value={value.search} onChange={onChange} /> + ); } diff --git a/app/views/CapacityAndResources/ResourceDashboards/index.tsx b/app/views/CapacityAndResources/ResourceDashboards/index.tsx index 79e929b..2ca5bd3 100644 --- a/app/views/CapacityAndResources/ResourceDashboards/index.tsx +++ b/app/views/CapacityAndResources/ResourceDashboards/index.tsx @@ -59,6 +59,7 @@ function ResourceDashboards() { rawFilter, filtered, setFilterField, + resetFilter, page, setPage, limit, @@ -188,6 +189,8 @@ function ResourceDashboards() { )} headerActions={( diff --git a/app/views/CapacityAndResources/index.tsx b/app/views/CapacityAndResources/index.tsx index 0372ccf..d617a2f 100644 --- a/app/views/CapacityAndResources/index.tsx +++ b/app/views/CapacityAndResources/index.tsx @@ -51,6 +51,7 @@ function CapacityAndResourcesList() { rawFilter, filtered, setFilterField, + resetFilter, page, setPage, limit, @@ -155,6 +156,8 @@ function CapacityAndResourcesList() { )} headerDescription="Track, organize, and update capacity and resources" diff --git a/app/views/DataAndReports/DataAndReportsFilters/index.tsx b/app/views/DataAndReports/DataAndReportsFilters/index.tsx index 6cda965..345ef0d 100644 --- a/app/views/DataAndReports/DataAndReportsFilters/index.tsx +++ b/app/views/DataAndReports/DataAndReportsFilters/index.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { + Button, SelectInput, TextInput, } from '@ifrc-go/ui'; @@ -23,9 +24,17 @@ export interface Props { value: DataAndReportsFilterType; onChange: (...args: EntriesAsList) => void; thematicAreaOptions: ThematicAreaOption[] | undefined; + filtered: boolean; + onReset: () => void; } -function DataAndReportsFilters({ value, onChange, thematicAreaOptions }: Props) { +function DataAndReportsFilters({ + value, + onChange, + thematicAreaOptions, + filtered, + onReset, +}: Props) { const [regionOptions, setRegionOptions] = useState< AdminAreaItem[] | undefined | null >([]); @@ -56,6 +65,14 @@ function DataAndReportsFilters({ value, onChange, thematicAreaOptions }: Props) value={value.search} onChange={onChange} /> + ); } diff --git a/app/views/DataAndReports/DataAndReportsForm/index.tsx b/app/views/DataAndReports/DataAndReportsForm/index.tsx index 442751a..cb1cf18 100644 --- a/app/views/DataAndReports/DataAndReportsForm/index.tsx +++ b/app/views/DataAndReports/DataAndReportsForm/index.tsx @@ -1,4 +1,5 @@ import { + use, useCallback, useEffect, useMemo, @@ -9,11 +10,11 @@ import { Button, Container, DateInput, - Image, InputSection, ListView, RadioInput, SelectInput, + TextArea, TextInput, } from '@ifrc-go/ui'; import { @@ -31,10 +32,12 @@ import { useForm, } from '@togglecorp/toggle-form'; +import CoverImageInput from '#components/CoverImageInput'; import EmbedPreview from '#components/EmbedPreview'; import FileInput from '#components/FileInput'; import NonFieldError from '#components/NonFieldError'; import RegionSelectInput from '#components/RegionSelectInput'; +import UserContext from '#contexts/UserContext'; import { AdminAreaLevel, ReportContentType, @@ -141,6 +144,8 @@ function DataAndReportsForm() { const isEditing = isDefined(id); + const { user } = use(UserContext); + const reportSchema = useMemo(() => getReportSchema(isEditing), [isEditing]); const { @@ -186,29 +191,16 @@ function DataAndReportsForm() { const coverImageFileName = getFileNameWithoutExtension( value.coverImage instanceof File ? value.coverImage : detailData?.report?.coverImage, ); - - const coverImagePreview = useMemo(() => { - if (value.coverImage instanceof File) { - return URL.createObjectURL(value.coverImage); - } - return existingCoverImageUrl; - }, [value.coverImage, existingCoverImageUrl]); - - useEffect(() => () => { - if (value.coverImage instanceof File && coverImagePreview) { - URL.revokeObjectURL(coverImagePreview); - } - }, [coverImagePreview, value.coverImage]); - - const handleCoverImageChange = useCallback( - (file: File | undefined, name: 'coverImage') => { + + const handleFileChange = useCallback( + (file: File | undefined, name: 'file') => { setFieldValue(file, name); }, [setFieldValue], ); - const handleFileChange = useCallback( - (file: File | undefined, name: 'file') => { + const handleCoverImageChange = useCallback( + (file: File | undefined, name: 'coverImage') => { setFieldValue(file, name); }, [setFieldValue], @@ -277,6 +269,13 @@ function DataAndReportsForm() { const error = getErrorObject(formError); + useEffect(() => { + if (isEditing || isNotDefined(user?.fullName)) { + return; + } + setFieldValue(user.fullName, 'owner'); + }, [isEditing, user?.fullName, setFieldValue]); + useEffect(() => { if (isNotDefined(detailData?.report)) { return; @@ -359,7 +358,7 @@ function DataAndReportsForm() { title="Description" description="Enter the description of the report" > - - - {isDefined(coverImagePreview) && ( - Cover image preview - )} )} diff --git a/app/views/Home/index.tsx b/app/views/Home/index.tsx index 0aa09b9..ffca938 100644 --- a/app/views/Home/index.tsx +++ b/app/views/Home/index.tsx @@ -51,6 +51,7 @@ function Home() { rawFilter, filtered, setFilterField, + resetFilter, page, setPage, limit, @@ -249,6 +250,14 @@ function Home() { value={rawFilter.search} onChange={setFilterField} /> + )} footerActions={( diff --git a/app/views/Links/LinkForm/index.tsx b/app/views/Links/LinkForm/index.tsx index fc6a9fe..843370f 100644 --- a/app/views/Links/LinkForm/index.tsx +++ b/app/views/Links/LinkForm/index.tsx @@ -16,6 +16,7 @@ import { InputSection, ListView, RadioInput, + TextArea, TextInput, } from '@ifrc-go/ui'; import { @@ -230,7 +231,7 @@ function LinkForm() { title="Description" description="Enter the description of the link" > - + )} footerActions={( diff --git a/app/views/Login/index.tsx b/app/views/Login/index.tsx index 458ff2e..9cf9f4c 100644 --- a/app/views/Login/index.tsx +++ b/app/views/Login/index.tsx @@ -1,7 +1,6 @@ import { use, useCallback, - useMemo, } from 'react'; import { BlockLoading, @@ -124,11 +123,10 @@ function Login() { } }, [alert, navigate, setUser, triggerLogin]); - const handleFormSubmit = useMemo(() => createSubmitHandler( - validate, - setError, - handleMutation, - ), [validate, setError, handleMutation]); + const handleFormSubmit = useCallback( + () => createSubmitHandler(validate, setError, handleMutation)(), + [validate, setError, handleMutation], + ); if (loginPending) { return ( diff --git a/app/views/OnlineInteractive/OnlineInteractiveFilter/index.tsx b/app/views/OnlineInteractive/OnlineInteractiveFilter/index.tsx index 6d20ae2..66d9e49 100644 --- a/app/views/OnlineInteractive/OnlineInteractiveFilter/index.tsx +++ b/app/views/OnlineInteractive/OnlineInteractiveFilter/index.tsx @@ -1,4 +1,7 @@ -import { TextInput } from '@ifrc-go/ui'; +import { + Button, + TextInput, +} from '@ifrc-go/ui'; import { type EntriesAsList } from '@togglecorp/toggle-form'; import { type OnlineInteractiveFilterType } from '../index'; @@ -6,16 +9,33 @@ import { type OnlineInteractiveFilterType } from '../index'; export interface Props { value: OnlineInteractiveFilterType; onChange: (...args: EntriesAsList) => void; + filtered: boolean; + onReset: () => void; } -function OnlineInteractiveFilter({ value, onChange }: Props) { +function OnlineInteractiveFilter({ + value, + onChange, + filtered, + onReset, +}: Props) { return ( - + <> + + + ); } diff --git a/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx b/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx index 2f4456a..37b7872 100644 --- a/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx +++ b/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx @@ -25,6 +25,7 @@ import { useForm, } from '@togglecorp/toggle-form'; +import CoverImageInput from '#components/CoverImageInput'; import FileInput from '#components/FileInput'; import NonFieldError from '#components/NonFieldError'; import { @@ -43,7 +44,7 @@ import { transformToFormError, } from '#utils/common'; -type FormFields = Pick; +type FormFields = Pick; type PartialFormType = PartialForm; type FormSchema = ObjectSchema; type FormSchemaFields = ReturnType; @@ -64,6 +65,7 @@ function getOnlineInteractiveSchema(isEditing: boolean): FormSchema { file: { required: !isEditing, }, + coverImage: {}, }), }; } @@ -112,6 +114,13 @@ function OnlineInteractiveForm() { [setFieldValue], ); + const handleCoverImageChange = useCallback( + (file: File | undefined, name: 'coverImage') => { + setFieldValue(file, name); + }, + [setFieldValue], + ); + const handleResult = useCallback(( result: { ok?: boolean | null; @@ -131,12 +140,13 @@ function OnlineInteractiveForm() { }, [navigate, alert, setError]); const handleCreate = useCallback(async (formValues: PartialFormType) => { - const { file, ...rest } = formValues; + const { file, coverImage, ...rest } = formValues; const res = await createOnlineInteractive({ data: { ...rest, ...(isDefined(file) ? { file } : {}), + ...(isDefined(coverImage) ? { coverImage } : {}), } as ReportCreateInput, }); @@ -147,13 +157,14 @@ function OnlineInteractiveForm() { if (isNotDefined(id)) { return; } - const { file, title } = formValues; + const { file, coverImage, title } = formValues; const res = await updateOnlineInteractive({ id, data: { title, ...(isDefined(file) ? { file } : {}), + ...(isDefined(coverImage) ? { coverImage } : {}), } as ReportUpdateInput, }); @@ -252,6 +263,19 @@ function OnlineInteractiveForm() { disabled={pending} /> + + + ); diff --git a/app/views/OnlineInteractive/index.tsx b/app/views/OnlineInteractive/index.tsx index 1129073..21d12c2 100644 --- a/app/views/OnlineInteractive/index.tsx +++ b/app/views/OnlineInteractive/index.tsx @@ -48,6 +48,7 @@ function OnlineInteractive() { rawFilter, filtered, setFilterField, + resetFilter, page, setPage, limit, @@ -148,6 +149,8 @@ function OnlineInteractive() { )} headerActions={( diff --git a/app/views/OnlineInteractive/query.ts b/app/views/OnlineInteractive/query.ts index 1188273..e9fc04a 100644 --- a/app/views/OnlineInteractive/query.ts +++ b/app/views/OnlineInteractive/query.ts @@ -24,6 +24,10 @@ const ONLINE_INTERACTIVE_DETAIL = gql` url name } + coverImage { + url + name + } } } `; diff --git a/app/views/OurWorks/WorksFilter/index.tsx b/app/views/OurWorks/WorksFilter/index.tsx index 115970f..a0c7906 100644 --- a/app/views/OurWorks/WorksFilter/index.tsx +++ b/app/views/OurWorks/WorksFilter/index.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { + Button, SelectInput, TextInput, } from '@ifrc-go/ui'; @@ -27,9 +28,16 @@ const pageOptions = [ export interface Props { value: WorksFilterType; onChange: (...args: EntriesAsList) => void; + filtered: boolean; + onReset: () => void; } -function WorksFilter({ value, onChange }: Props) { +function WorksFilter({ + value, + onChange, + filtered, + onReset, +}: Props) { const [regionOptions, setRegionOptions] = useState< AdminAreaItem[] | undefined | null >([]); @@ -69,6 +77,14 @@ function WorksFilter({ value, onChange }: Props) { value={value.search} onChange={onChange} /> + ); } diff --git a/app/views/OurWorks/WorksForm/index.tsx b/app/views/OurWorks/WorksForm/index.tsx index 7558420..fc04fdf 100644 --- a/app/views/OurWorks/WorksForm/index.tsx +++ b/app/views/OurWorks/WorksForm/index.tsx @@ -13,6 +13,7 @@ import { NumberInput, RadioInput, SelectInput, + TextArea, TextInput, } from '@ifrc-go/ui'; import { @@ -266,7 +267,7 @@ function WorksForm() { title="Description" description="Enter the description about the dashboard" > - )} headerDescription="Track, organize, and update all ongoing work and initiatives" diff --git a/app/views/Preparedness/PreparednessFilter/index.tsx b/app/views/Preparedness/PreparednessFilter/index.tsx index 0b1e185..e76a567 100644 --- a/app/views/Preparedness/PreparednessFilter/index.tsx +++ b/app/views/Preparedness/PreparednessFilter/index.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { + Button, SelectInput, TextInput, } from '@ifrc-go/ui'; @@ -27,9 +28,16 @@ const pageOptions = [ export interface Props { value: PreparednessFilterType; onChange: (...args: EntriesAsList) => void; + filtered: boolean; + onReset: () => void; } -function PreparednessFilter({ value, onChange }: Props) { +function PreparednessFilter({ + value, + onChange, + filtered, + onReset, +}: Props) { const [regionOptions, setRegionOptions] = useState< AdminAreaItem[] | undefined | null >([]); @@ -69,6 +77,14 @@ function PreparednessFilter({ value, onChange }: Props) { value={value.search} onChange={onChange} /> + ); } diff --git a/app/views/Preparedness/PreparednessForm/index.tsx b/app/views/Preparedness/PreparednessForm/index.tsx index bd7ea6f..cc0b853 100644 --- a/app/views/Preparedness/PreparednessForm/index.tsx +++ b/app/views/Preparedness/PreparednessForm/index.tsx @@ -13,6 +13,7 @@ import { NumberInput, RadioInput, SelectInput, + TextArea, TextInput, } from '@ifrc-go/ui'; import { @@ -265,7 +266,7 @@ function PreparednessForm() { title="Description" description="Enter the description about the dashboard" > - )} headerDescription="Track, organize, and update preparedness dashboards" diff --git a/app/views/RootLayout/index.tsx b/app/views/RootLayout/index.tsx index dfd4cc9..20958cc 100644 --- a/app/views/RootLayout/index.tsx +++ b/app/views/RootLayout/index.tsx @@ -13,14 +13,15 @@ import { useMeQuery } from '#generated/types/graphql'; import styles from './styles.module.css'; +// NOTE: Primes the CSRF cookie before the first mutation. The graphql route is +// currently csrf_exempt on the backend, so this is a no-op until that changes. const fetchHealth = fetch(`${api}/health-check/?format=json`, { method: 'GET', credentials: 'include', }) .then((res) => res.json()); -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const ME_QUERY = gql` +export const ME_QUERY = gql` query Me { me { role diff --git a/app/views/Teams/TeamForm/index.tsx b/app/views/Teams/TeamForm/index.tsx index 8715e68..612372b 100644 --- a/app/views/Teams/TeamForm/index.tsx +++ b/app/views/Teams/TeamForm/index.tsx @@ -9,6 +9,7 @@ import { Container, InputSection, ListView, + TextArea, TextInput, } from '@ifrc-go/ui'; import { @@ -187,7 +188,7 @@ function TeamForm() { title="Description" description="Enter the description about the team" > - ) => void; + filtered: boolean; + onReset: () => void; } -function UserFilter({ value, onChange }: Props) { +function UserFilter({ + value, onChange, filtered, onReset, +}: Props) { const [regionOptions, setRegionOptions] = useState< AdminAreaItem[] | undefined | null >([]); @@ -64,6 +69,14 @@ function UserFilter({ value, onChange }: Props) { value={value.search} onChange={onChange} /> + ); } diff --git a/app/views/Users/index.tsx b/app/views/Users/index.tsx index 0ba0c44..9154d65 100644 --- a/app/views/Users/index.tsx +++ b/app/views/Users/index.tsx @@ -87,6 +87,7 @@ function UsersList() { rawFilter, filtered, setFilterField, + resetFilter, page, setPage, limit, @@ -220,6 +221,8 @@ function UsersList() { )} headerDescription="Manage authenticated users and control access" From 1b2ea32c46f75b1b022545bcc7af8ba1ef657c22 Mon Sep 17 00:00:00 2001 From: amrit Date: Fri, 31 Jul 2026 16:50:35 +0545 Subject: [PATCH 2/6] feat(documents): add cover image in documents --- app/components/CoverImageInput/index.tsx | 37 +++++++++++--- app/components/FileInput/index.tsx | 43 +++------------- app/utils/common.ts | 46 ++++++++++++++++- .../CapacityAndResourcesFilter/index.tsx | 4 +- app/views/CapacityAndResources/index.tsx | 7 +-- .../DataAndReportsForm/index.tsx | 8 --- .../Documents/DocumentsFilters/index.tsx | 19 +++++-- app/views/Documents/DocumentsForm/index.tsx | 49 ++++++++++++++----- app/views/Documents/index.tsx | 11 +++-- app/views/Documents/query.ts | 4 ++ .../OnlineInteractiveForm/index.tsx | 2 +- 11 files changed, 152 insertions(+), 78 deletions(-) diff --git a/app/components/CoverImageInput/index.tsx b/app/components/CoverImageInput/index.tsx index 956125a..56a796b 100644 --- a/app/components/CoverImageInput/index.tsx +++ b/app/components/CoverImageInput/index.tsx @@ -1,15 +1,23 @@ import { + useCallback, useEffect, useMemo, + useState, } from 'react'; import { Image, + ListView, RawFileInput, } from '@ifrc-go/ui'; import { isDefined } from '@togglecorp/fujs'; import { type Error } from '@togglecorp/toggle-form'; import NonFieldError from '#components/NonFieldError'; +import { + ACCEPTED_IMAGE_TYPES, + MAX_IMAGE_SIZE, + validateFile, +} from '#utils/common'; interface Props { name: N; @@ -19,6 +27,7 @@ interface Props { error?: Error; accept?: string; disabled?: boolean; + maxSize?: number; } function CoverImageInput(props: Props) { @@ -28,10 +37,13 @@ function CoverImageInput(props: Props) { existingUrl, onChange, error, - accept = 'image/*', + accept = ACCEPTED_IMAGE_TYPES, disabled, + maxSize = MAX_IMAGE_SIZE, } = props; + const [validationError, setValidationError] = useState(); + const preview = useMemo(() => { if (value instanceof File) { return URL.createObjectURL(value); @@ -45,18 +57,27 @@ function CoverImageInput(props: Props) { } }, [preview, value]); + const handleChange = useCallback( + (file: File | undefined, inputName: N) => { + const message = isDefined(file) + ? validateFile(file, maxSize, accept) + : undefined; + setValidationError(message); + onChange(isDefined(message) ? undefined : file, inputName); + }, + [onChange, maxSize, accept], + ); + return ( - <> + - {isDefined(value) || isDefined(preview) - ? 'Change cover image' - : 'Upload cover image'} + {isDefined(preview) ? 'Change cover image' : 'Upload cover image'} {isDefined(preview) && ( (props: Props) { withContainedFit /> )} - - + + ); } diff --git a/app/components/FileInput/index.tsx b/app/components/FileInput/index.tsx index d4dff04..36c8e4d 100644 --- a/app/components/FileInput/index.tsx +++ b/app/components/FileInput/index.tsx @@ -8,46 +8,15 @@ import { ListView, RawFileInput, } from '@ifrc-go/ui'; -import { - isDefined, - isNotDefined, -} from '@togglecorp/fujs'; +import { isDefined } from '@togglecorp/fujs'; import { type Error } from '@togglecorp/toggle-form'; import NonFieldError from '#components/NonFieldError'; - -const ACCEPTED_REPORT_FILE_TYPES = '.pdf,.doc,.docx,.png,.jpg,.jpeg'; -const MAX_REPORT_FILE_SIZE = 5 * 1024 * 1024; // 5MB - -function isFileAccepted(file: File, accept: string | undefined) { - if (isNotDefined(accept)) { - return true; - } - const fileType = file.type.toLowerCase(); - return accept.split(',').some((token) => { - const type = token.trim().toLowerCase(); - if (type.startsWith('.')) { - return file.name.toLowerCase().endsWith(type); - } - if (type.endsWith('/*')) { - return fileType.startsWith(type.slice(0, -1)); - } - return fileType === type; - }); -} - -function validateFile(file: File, maxSize: number, accept: string | undefined) { - if (file.size === 0) { - return 'File is empty'; - } - if (file.size > maxSize) { - return `File must be less than ${Math.round(maxSize / (1024 * 1024))}MB`; - } - if (!isFileAccepted(file, accept)) { - return 'File type is not supported'; - } - return undefined; -} +import { + ACCEPTED_REPORT_FILE_TYPES, + MAX_REPORT_FILE_SIZE, + validateFile, +} from '#utils/common'; interface Props { name: N; diff --git a/app/utils/common.ts b/app/utils/common.ts index d08c877..09392da 100644 --- a/app/utils/common.ts +++ b/app/utils/common.ts @@ -1,4 +1,7 @@ -import { isFalsyString } from '@togglecorp/fujs'; +import { + isFalsyString, + isNotDefined, +} from '@togglecorp/fujs'; import { nonFieldError } from '@togglecorp/toggle-form'; import type { AdminAreaLevel } from '#generated/types/graphql'; @@ -46,6 +49,7 @@ export const statusFilterOptions = [ export const errorMessage = 'Something went wrong. Please try again. '; +<<<<<<< HEAD export function getReadableFileSize(bytes: number | null | undefined): string { if (!bytes || bytes <= 0) { return '0 B'; @@ -60,6 +64,46 @@ export function getReadableFileSize(bytes: number | null | undefined): string { return `${exponent === 0 ? value : value.toFixed(1)} ${units[exponent]}`; } +||||||| parent of e93c657 (feat(documents): add cover image in documents) +======= +// NOTE: keep in sync with backend/utils/validators.py and +// backend/apps/reports/serializers.py +export const ACCEPTED_REPORT_FILE_TYPES = '.pdf,.doc,.docx,.png,.jpg,.jpeg'; +export const ACCEPTED_IMAGE_TYPES = 'image/*'; +export const MAX_REPORT_FILE_SIZE = 5 * 1024 * 1024; // 5MB +export const MAX_IMAGE_SIZE = 2 * 1024 * 1024; // 2MB + +function isFileAccepted(file: File, accept: string | undefined) { + if (isNotDefined(accept)) { + return true; + } + const fileType = file.type.toLowerCase(); + return accept.split(',').some((token) => { + const type = token.trim().toLowerCase(); + if (type.startsWith('.')) { + return file.name.toLowerCase().endsWith(type); + } + if (type.endsWith('/*')) { + return fileType.startsWith(type.slice(0, -1)); + } + return fileType === type; + }); +} + +export function validateFile(file: File, maxSize: number, accept: string | undefined) { + if (file.size === 0) { + return 'File is empty'; + } + if (file.size > maxSize) { + return `File must be less than ${Math.round(maxSize / (1024 * 1024))}MB`; + } + if (!isFileAccepted(file, accept)) { + return 'File type is not supported'; + } + return undefined; +} + +>>>>>>> e93c657 (feat(documents): add cover image in documents) interface ServerError { field: string; messages: string | null; diff --git a/app/views/CapacityAndResources/CapacityAndResourcesFilter/index.tsx b/app/views/CapacityAndResources/CapacityAndResourcesFilter/index.tsx index 2f85e6e..e072af8 100644 --- a/app/views/CapacityAndResources/CapacityAndResourcesFilter/index.tsx +++ b/app/views/CapacityAndResources/CapacityAndResourcesFilter/index.tsx @@ -38,9 +38,9 @@ function CapacityAndResourcesFilter({ labelSelector={labelSelector} /> ); } diff --git a/app/views/Documents/DocumentsForm/index.tsx b/app/views/Documents/DocumentsForm/index.tsx index 5104e46..549cf98 100644 --- a/app/views/Documents/DocumentsForm/index.tsx +++ b/app/views/Documents/DocumentsForm/index.tsx @@ -32,6 +32,7 @@ import { useForm, } from '@togglecorp/toggle-form'; +import CoverImageInput from '#components/CoverImageInput'; import FileInput from '#components/FileInput'; import NonFieldError from '#components/NonFieldError'; import { @@ -86,6 +87,7 @@ function getDocumentSchema(isEditing: boolean): FormSchema { file: { required: !isEditing, }, + coverImage: {}, }), }; } @@ -94,11 +96,14 @@ const defaultFormValue: PartialFormType = { contentType: ReportContentType.File, }; -function getFileFields(file: File | null | undefined) { - if (isDefined(file)) { - return { file }; - } - return {}; +function getFileFields( + file: File | null | undefined, + coverImage: File | null | undefined, +) { + return { + ...(isDefined(file) ? { file } : {}), + ...(isDefined(coverImage) ? { coverImage } : {}), + }; } function DocumentsForm() { @@ -168,12 +173,12 @@ function DocumentsForm() { }, [navigateToDocuments, alert, setError]); const handleCreate = useCallback(async (formValues: PartialFormType) => { - const { file, ...otherValues } = formValues; + const { file, coverImage, ...otherValues } = formValues; const response = await createDocumentMutate({ data: { ...removeNull(otherValues), - ...getFileFields(file), + ...getFileFields(file, coverImage), } as ReportCreateInput, }); @@ -184,13 +189,13 @@ function DocumentsForm() { if (isNotDefined(id)) { return; } - const { file, ...otherValues } = formValues; + const { file, coverImage, ...otherValues } = formValues; const response = await updateDocumentMutate({ id, data: { ...omitKeys(removeNull(otherValues), ['contentType']), - ...getFileFields(file), + ...getFileFields(file, coverImage), } as ReportUpdateInput, }); @@ -204,12 +209,19 @@ function DocumentsForm() { [setFieldValue], ); + const handleCoverImageChange = useCallback( + (file: File | undefined, name: 'coverImage') => { + setFieldValue(file, name); + }, + [setFieldValue], + ); + const handleFormSubmit = useCallback( () => createSubmitHandler( validate, setError, isDefined(id) ? handleUpdate : handleCreate, - ), + )(), [validate, setError, id, handleUpdate, handleCreate], ); @@ -223,7 +235,7 @@ function DocumentsForm() { useEffect(() => { if (!documentDetailFetching && isDefined(documentData)) { - setValue(omitKeys(removeNull(documentData), ['file'])); + setValue(omitKeys(removeNull(documentData), ['file', 'coverImage'])); } }, [documentDetailFetching, documentData, setValue]); @@ -293,7 +305,7 @@ function DocumentsForm() { + + + ['results'][number]> & { no: number }; -export type DocumentFilterType = Pick & { +export type DocumentFilterType = Pick & { + title: string | undefined; createdAtGte: string | undefined; createdAtLte: string | undefined; }; const defaultFilter: DocumentFilterType = { - search: undefined, + title: undefined, createdAtGte: undefined, createdAtLte: undefined, }; @@ -71,6 +72,7 @@ function Documents() { filter, filtered, setFilterField, + resetFilter, page, setPage, limit, @@ -95,7 +97,8 @@ function Documents() { }, filters: { reportType: activeTab, - search: filter.search || undefined, + title: filter.title ? { iContains: filter.title } : undefined, + // NOTE: createdAt filter is not in ReportFilter yet; backend is adding it createdAt: (filter.createdAtGte || filter.createdAtLte) ? { gte: filter.createdAtGte, lte: filter.createdAtLte, @@ -204,6 +207,8 @@ function Documents() { )} diff --git a/app/views/Documents/query.ts b/app/views/Documents/query.ts index 995b8ef..e0fa9cd 100644 --- a/app/views/Documents/query.ts +++ b/app/views/Documents/query.ts @@ -41,6 +41,10 @@ const DOCUMENT_DETAIL = gql` url name } + coverImage { + url + name + } } } `; diff --git a/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx b/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx index 37b7872..03c0dee 100644 --- a/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx +++ b/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx @@ -265,7 +265,7 @@ function OnlineInteractiveForm() { Date: Wed, 12 Aug 2026 14:44:37 +0545 Subject: [PATCH 3/6] fix(team): common components integrated --- app/components/BulkImportModal/index.tsx | 18 +++++-- app/utils/common.ts | 5 +- app/views/Teams/TeamForm/index.tsx | 12 ++++- .../TeamMembers/TeamMemberForm/index.tsx | 2 +- .../TeamMembers/TeamMembersFilters/index.tsx | 24 +++++++-- app/views/Teams/TeamMembers/index.tsx | 22 ++++---- app/views/Teams/TeamsFilters/index.tsx | 52 +++++++++++++++++++ app/views/Teams/index.tsx | 52 +++++++------------ 8 files changed, 131 insertions(+), 56 deletions(-) create mode 100644 app/views/Teams/TeamsFilters/index.tsx diff --git a/app/components/BulkImportModal/index.tsx b/app/components/BulkImportModal/index.tsx index 15df6b7..7306038 100644 --- a/app/components/BulkImportModal/index.tsx +++ b/app/components/BulkImportModal/index.tsx @@ -40,7 +40,12 @@ import { } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; import useGlobalEnums from '#hooks/useGlobalEnums'; -import { errorMessage } from '#utils/common'; +import { + ACCEPTED_IMPORT_FILE_TYPES, + errorMessage, + MAX_REPORT_FILE_SIZE, + validateFile, +} from '#utils/common'; type MemberField = keyof Omit; @@ -226,6 +231,11 @@ function BulkImportModal(props: Props) { if (isNotDefined(file)) { return; } + const fileError = validateFile(file, MAX_REPORT_FILE_SIZE, ACCEPTED_IMPORT_FILE_TYPES); + if (isDefined(fileError)) { + setResult({ errors: [fileError] }); + return; + } setReading(true); readSheet(file).then((rows) => { setResult(validateRows(rows, { teamId, sexKeyByName, regionIdByName })); @@ -265,8 +275,8 @@ function BulkImportModal(props: Props) { return ( >>>>>> e93c657 (feat(documents): add cover image in documents) interface ServerError { field: string; messages: string | null; diff --git a/app/views/Teams/TeamForm/index.tsx b/app/views/Teams/TeamForm/index.tsx index 612372b..41c8a7f 100644 --- a/app/views/Teams/TeamForm/index.tsx +++ b/app/views/Teams/TeamForm/index.tsx @@ -26,6 +26,7 @@ import { useForm, } from '@togglecorp/toggle-form'; +import NonFieldError from '#components/NonFieldError'; import { type TeamCreateInput, type TeamUpdateInput, @@ -138,7 +139,9 @@ function TeamForm() { } }, [teamDetailFetch, teamData, setValue]); - if (teamDetailFetch || createPending || updatePending) { + const pending = createPending || updatePending || teamDetailFetch; + + if (teamDetailFetch) { return ( Save @@ -172,6 +176,10 @@ function TeamForm() { )} > + diff --git a/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx b/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx index 600cea2..3cd4308 100644 --- a/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx +++ b/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx @@ -175,7 +175,7 @@ function TeamMemberForm() { const pending = createPending || updatePending || teamMemberDetailFetch; - if (pending) { + if (teamMemberDetailFetch) { return ( ) => void; + onReset: () => void; + filtered: boolean; } function TeamMembersFilters(props: Props) { - const { value, onChange } = props; + const { + value, + onChange, + onReset, + filtered, + } = props; const [teamMemberOptions, setTeamMemberOptions] = useState< AdminAreaItem[] | undefined | null @@ -46,6 +56,14 @@ function TeamMembersFilters(props: Props) { value={value.search} onChange={onChange} /> + ); } diff --git a/app/views/Teams/TeamMembers/index.tsx b/app/views/Teams/TeamMembers/index.tsx index 6960fb3..a1cc364 100644 --- a/app/views/Teams/TeamMembers/index.tsx +++ b/app/views/Teams/TeamMembers/index.tsx @@ -17,6 +17,7 @@ import { } from '@ifrc-go/ui'; import { createElementColumn, + createNumberColumn, createStringColumn, } from '@ifrc-go/ui/utils'; import { isDefined } from '@togglecorp/fujs'; @@ -42,7 +43,7 @@ import { import TeamMembersFilters from './TeamMembersFilters'; -type TeamMembersListItem = NonNullable['results'][number] & { no: string }>; +type TeamMembersListItem = NonNullable['results'][number]> & { no: number }; const defaultFilter: TeamMemberFilter = { search: undefined, @@ -56,6 +57,7 @@ function TeamMembers() { rawFilter, filtered, setFilterField, + resetFilter, page, setPage, limit, @@ -94,14 +96,12 @@ function TeamMembers() { pause: !id, }); - const tableData = useMemo(() => ( - data?.teamMembers.results.map((user, index) => { - const no = (page - 1) * limit + index + 1; - return { - ...user, - no, - }; - }) as unknown as TeamMembersListItem[]), [page, data, limit]); + const tableData: TeamMembersListItem[] = useMemo(() => ( + (data?.teamMembers?.results ?? []).map((teamMember, index) => ({ + ...teamMember, + no: (page - 1) * limit + index + 1, + })) + ), [page, data, limit]); const onDeleteClick = useCallback( (memberId: string) => { @@ -120,7 +120,7 @@ function TeamMembers() { [deleteTeamMember, reExecuteQuery, alert], ); const columns = useMemo(() => [ - createStringColumn( + createNumberColumn( 'no', 'No.', (team) => team.no, @@ -191,6 +191,8 @@ function TeamMembers() { )} footerActions={( diff --git a/app/views/Teams/TeamsFilters/index.tsx b/app/views/Teams/TeamsFilters/index.tsx new file mode 100644 index 0000000..7335ad4 --- /dev/null +++ b/app/views/Teams/TeamsFilters/index.tsx @@ -0,0 +1,52 @@ +import { + Button, + DateInput, + TextInput, +} from '@ifrc-go/ui'; +import { type EntriesAsList } from '@togglecorp/toggle-form'; + +import type { TeamsFilterType } from '..'; + +export interface Props { + value: TeamsFilterType; + onChange: (...args: EntriesAsList) => void; + onReset: () => void; + filtered: boolean; +} + +function TeamsFilters({ + value, onChange, onReset, filtered, +}: Props) { + return ( + <> + + + + + + ); +} + +export default TeamsFilters; diff --git a/app/views/Teams/index.tsx b/app/views/Teams/index.tsx index 986e86f..6193a49 100644 --- a/app/views/Teams/index.tsx +++ b/app/views/Teams/index.tsx @@ -6,14 +6,13 @@ import { AddFillIcon } from '@ifrc-go/icons'; import { Button, Container, - DateInput, Pager, Table, - TextInput, } from '@ifrc-go/ui'; import { createDateColumn, createElementColumn, + createNumberColumn, createStringColumn, } from '@ifrc-go/ui/utils'; @@ -33,9 +32,11 @@ import { idSelector, } from '#utils/common'; -type TeamsListItem = NonNullable['results'][number] & { no: string }>; +import TeamsFilters from './TeamsFilters'; -interface TeamsFilterType extends Omit { +type TeamsListItem = NonNullable['results'][number]> & { no: number }; + +export interface TeamsFilterType extends Omit { createdAtGte: string | undefined; createdAtLte: string | undefined; } @@ -52,6 +53,7 @@ function Teams() { rawFilter, filtered, setFilterField, + resetFilter, page, setPage, limit, @@ -80,14 +82,12 @@ function Teams() { const [{ fetching, data }, reExecuteQuery] = useTeamsQuery({ variables: queryVariables }); const [, deleteTeam] = useDeleteTeamMutation(); - const tableData = useMemo(() => ( - data?.teams.results.map((user, index) => { - const no = (page - 1) * limit + index + 1; - return { - ...user, - no, - }; - }) as unknown as TeamsListItem[]), [page, data, limit]); + const tableData: TeamsListItem[] = useMemo(() => ( + (data?.teams?.results ?? []).map((team, index) => ({ + ...team, + no: (page - 1) * limit + index + 1, + })) + ), [page, data, limit]); const onDeleteClick = useCallback( (id: string) => { @@ -107,7 +107,7 @@ function Teams() { ); const columns = useMemo(() => [ - createStringColumn( + createNumberColumn( 'no', 'No.', (team) => team.no, @@ -164,26 +164,12 @@ function Teams() { heading="Teams" headerDescription="Manage a dedicated team committed to delivering impactful solutions" filters={( - <> - - - - + )} footerActions={( Date: Wed, 12 Aug 2026 14:45:02 +0545 Subject: [PATCH 4/6] fix(gallery): common components integrated --- app/views/Galleries/GalleryForm/index.tsx | 65 ++++++++++++++--------- app/views/Galleries/index.tsx | 15 +++--- 2 files changed, 47 insertions(+), 33 deletions(-) diff --git a/app/views/Galleries/GalleryForm/index.tsx b/app/views/Galleries/GalleryForm/index.tsx index 4aa1770..3875e49 100644 --- a/app/views/Galleries/GalleryForm/index.tsx +++ b/app/views/Galleries/GalleryForm/index.tsx @@ -25,6 +25,7 @@ import { import { _cs, isDefined, + isNotDefined, } from '@togglecorp/fujs'; import { createSubmitHandler, @@ -36,6 +37,7 @@ import { useForm, } from '@togglecorp/toggle-form'; +import NonFieldError from '#components/NonFieldError'; import { type GalleryAlbumCreateInput, type GalleryAlbumUpdateInput, @@ -48,7 +50,13 @@ import { } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; import useRouting from '#hooks/useRouting'; -import { errorMessage } from '#utils/common'; +import { + ACCEPTED_IMAGE_TYPES, + errorMessage, + MAX_IMAGE_SIZE, + transformToFormError, + validateFile, +} from '#utils/common'; import styles from './styles.module.css'; @@ -70,8 +78,6 @@ const defaultEditFormValue: PartialFormType = {}; // Maximum number of images to load for an album in the edit view. const MAX_IMAGES = 100; -const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB - function getDisplayName(name: string) { return name.split('/').pop()?.replace(/\.[^.]+$/, '') ?? name; } @@ -89,6 +95,7 @@ function GalleryForm() { const alert = useAlert(); const [newFiles, setNewFiles] = useState([]); + const [fileErrors, setFileErrors] = useState([]); const [removedImageIds, setRemovedImageIds] = useState([]); const [submitting, setSubmitting] = useState(false); @@ -147,8 +154,21 @@ function GalleryForm() { }, [newFilePreviews]); const handleFilesSelect = useCallback((files: File[] | undefined) => { - if (files && files.length > 0) { - setNewFiles((prev) => [...prev, ...files]); + if (isNotDefined(files) || files.length === 0) { + return; + } + const rejected: string[] = []; + const accepted = files.filter((file) => { + const message = validateFile(file, MAX_IMAGE_SIZE, ACCEPTED_IMAGE_TYPES); + if (isDefined(message)) { + rejected.push(`${file.name}: ${message}`); + return false; + } + return true; + }); + setFileErrors(rejected); + if (accepted.length > 0) { + setNewFiles((prev) => [...prev, ...accepted]); } }, []); @@ -213,10 +233,10 @@ function GalleryForm() { }); const result = res.data?.createGalleryAlbum; if (!result?.ok || !result.result?.id) { - if (result?.errors) { - setError(result.errors); + if (isDefined(result) && isDefined(result.errors)) { + setError(transformToFormError(result.errors)); } - alert.show(result?.errors ?? errorMessage, { variant: 'danger' }); + alert.show(errorMessage, { variant: 'danger' }); return; } @@ -255,10 +275,10 @@ function GalleryForm() { }); const result = res.data?.updateGalleryAlbum; if (!result?.ok) { - if (result?.errors) { - setError(result.errors); + if (isDefined(result) && isDefined(result.errors)) { + setError(transformToFormError(result.errors)); } - alert.show(result?.errors ?? errorMessage, { variant: 'danger' }); + alert.show(errorMessage, { variant: 'danger' }); return; } @@ -300,7 +320,6 @@ function GalleryForm() { }, [navigate]); const error = getErrorObject(formError); - const hasOversizedFile = newFiles.some((file) => file.size > MAX_FILE_SIZE); if (albumDetailFetch || imagesFetch) { return ( @@ -334,7 +353,6 @@ function GalleryForm() { submitting || createGalleryPending || updateGalleryPending - || hasOversizedFile } > Save @@ -343,6 +361,10 @@ function GalleryForm() { )} > + - {file.size > MAX_FILE_SIZE && ( - - File size exceeds 2MB limit. - - )} ))} - {hasOversizedFile && ( - - Please remove or replace the images that exceed the 2MB - limit before saving. + {fileErrors.map((fileError) => ( + + {fileError} - )} + ))} diff --git a/app/views/Galleries/index.tsx b/app/views/Galleries/index.tsx index 3192bf9..484f953 100644 --- a/app/views/Galleries/index.tsx +++ b/app/views/Galleries/index.tsx @@ -33,7 +33,7 @@ import { import GalleryFilter from './GalleryFilter'; -type GalleryAlbumListItem = NonNullable['results'][number] & { no: number }>; +type GalleryAlbumListItem = NonNullable['results'][number]> & { no: number }; export interface GalleryAlbumsFilterType extends Omit { createdAtGte: string | undefined; @@ -102,14 +102,11 @@ function Galleries() { [deleteGalleryAlbum, reExecuteQuery, alert], ); - const tableData = useMemo(() => ( - data?.galleryAlbums.results.map((album, index) => { - const no = (page - 1) * limit + index + 1; - return { - ...album, - no, - }; - }) + const tableData: GalleryAlbumListItem[] = useMemo(() => ( + (data?.galleryAlbums?.results ?? []).map((album, index) => ({ + ...album, + no: (page - 1) * limit + index + 1, + })) ), [page, data, limit]); const columns = useMemo(() => [ From de0a6ea30140244aa1478b1fa66e6b18abfdb95b Mon Sep 17 00:00:00 2001 From: amrit Date: Wed, 12 Aug 2026 16:25:44 +0545 Subject: [PATCH 5/6] fix(dashboards): components implemented and issues fixed --- app/Root/index.tsx | 7 + ...oardReorder.tsx => useDashboardReorder.ts} | 59 ++-- app/queries.ts | 4 + app/utils/common.ts | 2 - app/utils/table.ts | 14 + .../index.tsx | 6 +- .../ResourceDashboardsFilters/index.tsx | 2 +- .../ResourceDashboards/index.tsx | 69 +++-- app/views/CapacityAndResources/index.tsx | 42 +-- .../DataAndReports/CategoryModal/index.tsx | 270 ------------------ app/views/OurWorks/index.tsx | 30 +- app/views/OurWorks/query.ts | 4 +- app/views/Preparedness/index.tsx | 30 +- app/views/Preparedness/query.ts | 4 +- 14 files changed, 181 insertions(+), 362 deletions(-) rename app/hooks/{useDashboardReorder.tsx => useDashboardReorder.ts} (73%) create mode 100644 app/utils/table.ts rename app/views/CapacityAndResources/{CapacityAndResourcesFilter => CapacityAndResourcesFilters}/index.tsx (90%) delete mode 100644 app/views/DataAndReports/CategoryModal/index.tsx diff --git a/app/Root/index.tsx b/app/Root/index.tsx index b67f5e5..299fd91 100644 --- a/app/Root/index.tsx +++ b/app/Root/index.tsx @@ -42,6 +42,13 @@ const gqlClient = new Client({ logout: (_result, _args, cache) => { cache.updateQuery({ query: ME_QUERY }, () => ({ me: null })); }, + bulkUpdateExternalDashboards: (_result, _args, cache) => { + cache.inspectFields('Query') + .filter((field) => field.fieldName === 'externalDashboards') + .forEach((field) => { + cache.invalidate('Query', field.fieldName, field.arguments); + }); + }, }, }, }), diff --git a/app/hooks/useDashboardReorder.tsx b/app/hooks/useDashboardReorder.ts similarity index 73% rename from app/hooks/useDashboardReorder.tsx rename to app/hooks/useDashboardReorder.ts index 652c9fd..ad1c64d 100644 --- a/app/hooks/useDashboardReorder.tsx +++ b/app/hooks/useDashboardReorder.ts @@ -4,38 +4,42 @@ import { useRef, useState, } from 'react'; -import { DragDropLineIcon } from '@ifrc-go/icons'; -import { createElementColumn } from '@ifrc-go/ui/utils'; import { useBulkUpdateExternalDashboardsMutation } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; import { errorMessage } from '#utils/common'; -// eslint-disable-next-line react-refresh/only-export-components -function DragHandleCell() { - return ; -} - -export function createDragHandleColumn() { - return createElementColumn( - 'dragHandle', - '', - DragHandleCell, - () => ({}), - { columnWidth: 40 }, - ); -} - interface ReorderItem { id: string; no: string; order: number; } +export function reorderWithinPage( + items: T[], + dragIndex: number, + dropIndex: number, + page: number, + limit: number, +): T[] { + const slots = items.map((item) => item.order).sort((a, b) => a - b); + + const moved = [...items]; + const [dragged] = moved.splice(dragIndex, 1); + moved.splice(dropIndex, 0, dragged); + + return moved.map((item, index) => ({ + ...item, + no: String((page - 1) * limit + index + 1), + order: slots[index], + })); +} + function useDashboardReorder( serverData: T[], page: number, limit: number, + onReorderSuccess?: () => void, ) { const alert = useAlert(); const [, bulkUpdateExternalDashboards] = useBulkUpdateExternalDashboardsMutation(); @@ -55,14 +59,7 @@ function useDashboardReorder( if (dragIndex === undefined || dragIndex === dropIndex) { return; } - const newData = [...tableData]; - const [moved] = newData.splice(dragIndex, 1); - newData.splice(dropIndex, 0, moved); - const reorderedData = newData.map((item, index) => ({ - ...item, - no: String((page - 1) * limit + index + 1), - order: (page - 1) * limit + index + 1, - })); + const reorderedData = reorderWithinPage(tableData, dragIndex, dropIndex, page, limit); setTableData(reorderedData); bulkUpdateExternalDashboards({ data: reorderedData.map((item) => ({ @@ -73,6 +70,7 @@ function useDashboardReorder( const result = resp.data?.bulkUpdateExternalDashboards; if (result?.ok) { alert.show('Dashboard order updated successfully', { variant: 'success' }); + onReorderSuccess?.(); } else { setTableData(serverData); alert.show(errorMessage, { variant: 'danger' }); @@ -81,7 +79,15 @@ function useDashboardReorder( setTableData(serverData); alert.show(errorMessage, { variant: 'danger' }); }); - }, [tableData, serverData, page, limit, bulkUpdateExternalDashboards, alert]); + }, [ + tableData, + serverData, + page, + limit, + bulkUpdateExternalDashboards, + alert, + onReorderSuccess, + ]); const rowModifier = useCallback(({ row, datum }: { row: React.ReactElement; @@ -91,7 +97,6 @@ function useDashboardReorder( return cloneElement(row, { draggable: true, style: { cursor: 'grab' }, - title: 'Drag to reorder', onDragStart: () => { dragIndexRef.current = index; }, diff --git a/app/queries.ts b/app/queries.ts index 086a3e8..be2cd0d 100644 --- a/app/queries.ts +++ b/app/queries.ts @@ -7,6 +7,10 @@ const BULK_UPDATE_EXTERNAL_DASHBOARDS = gql` ... on ExternalDashboardTypeListMutationResponseType { ok errors + result { + id + order + } } } } diff --git a/app/utils/common.ts b/app/utils/common.ts index a6c195e..ac89218 100644 --- a/app/utils/common.ts +++ b/app/utils/common.ts @@ -63,8 +63,6 @@ export function getReadableFileSize(bytes: number | null | undefined): string { return `${exponent === 0 ? value : value.toFixed(1)} ${units[exponent]}`; } -// NOTE: keep in sync with backend/utils/validators.py and -// backend/apps/reports/serializers.py export const ACCEPTED_REPORT_FILE_TYPES = '.pdf,.doc,.docx,.png,.jpg,.jpeg'; export const ACCEPTED_IMAGE_TYPES = 'image/*'; export const ACCEPTED_IMPORT_FILE_TYPES = '.xlsx,.xlsm'; diff --git a/app/utils/table.ts b/app/utils/table.ts new file mode 100644 index 0000000..33f6e50 --- /dev/null +++ b/app/utils/table.ts @@ -0,0 +1,14 @@ +import { DragDropLineIcon } from '@ifrc-go/icons'; +import { createElementColumn } from '@ifrc-go/ui/utils'; + +function createDragHandleColumn() { + return createElementColumn( + 'dragHandle', + '', + DragDropLineIcon, + () => ({ title: 'Drag to reorder' }), + { columnWidth: 40 }, + ); +} + +export default createDragHandleColumn; diff --git a/app/views/CapacityAndResources/CapacityAndResourcesFilter/index.tsx b/app/views/CapacityAndResources/CapacityAndResourcesFilters/index.tsx similarity index 90% rename from app/views/CapacityAndResources/CapacityAndResourcesFilter/index.tsx rename to app/views/CapacityAndResources/CapacityAndResourcesFilters/index.tsx index e072af8..8d36a56 100644 --- a/app/views/CapacityAndResources/CapacityAndResourcesFilter/index.tsx +++ b/app/views/CapacityAndResources/CapacityAndResourcesFilters/index.tsx @@ -11,7 +11,7 @@ import { valueSelector, } from '#utils/common'; -import type { ResourcesFilterType } from '../index'; +import type { ResourcesFilterType } from '..'; export interface Props { value: ResourcesFilterType; @@ -20,7 +20,7 @@ export interface Props { onReset: () => void; } -function CapacityAndResourcesFilter({ +function CapacityAndResourcesFilters({ value, onChange, filtered, @@ -55,4 +55,4 @@ function CapacityAndResourcesFilter({ ); } -export default CapacityAndResourcesFilter; +export default CapacityAndResourcesFilters; diff --git a/app/views/CapacityAndResources/ResourceDashboards/ResourceDashboardsFilters/index.tsx b/app/views/CapacityAndResources/ResourceDashboards/ResourceDashboardsFilters/index.tsx index 9d040f7..a823ffb 100644 --- a/app/views/CapacityAndResources/ResourceDashboards/ResourceDashboardsFilters/index.tsx +++ b/app/views/CapacityAndResources/ResourceDashboards/ResourceDashboardsFilters/index.tsx @@ -14,7 +14,7 @@ import { valueSelector, } from '#utils/common'; -import type { DashboardFilterType } from '../index'; +import type { DashboardFilterType } from '..'; export interface Props { value: DashboardFilterType; diff --git a/app/views/CapacityAndResources/ResourceDashboards/index.tsx b/app/views/CapacityAndResources/ResourceDashboards/index.tsx index 2ca5bd3..b3edb4e 100644 --- a/app/views/CapacityAndResources/ResourceDashboards/index.tsx +++ b/app/views/CapacityAndResources/ResourceDashboards/index.tsx @@ -14,7 +14,10 @@ import { createElementColumn, createStringColumn, } from '@ifrc-go/ui/utils'; -import { isDefined } from '@togglecorp/fujs'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; import EditDeleteActions, { type Props as EditDeleteActionsProps } from '#components/EditDeleteActions'; import StatusCell from '#components/StatusCell'; @@ -28,7 +31,7 @@ import { useResourceDashboardsQuery, } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; -import useDashboardReorder, { createDragHandleColumn } from '#hooks/useDashboardReorder'; +import useDashboardReorder from '#hooks/useDashboardReorder'; import useFilterState from '#hooks/useFilterState'; import useRegionMap from '#hooks/useRegionMap'; import useRouting from '#hooks/useRouting'; @@ -36,6 +39,7 @@ import { errorMessage, idSelector, } from '#utils/common'; +import createDragHandleColumn from '#utils/table'; import ResourceDashboardsFilters from './ResourceDashboardsFilters'; @@ -74,26 +78,28 @@ function ResourceDashboards() { const regionMap = useRegionMap(AdminAreaLevel.Region); const [{ data: detailData }] = useCapacityAndResourceDetailQuery({ - variables: { id: id ?? '' }, - pause: !id, + variables: { id: isDefined(id) ? id : '' }, + pause: isNotDefined(id), }); + const queryVariables = useMemo(() => ({ + pagination: { + limit, + offset, + }, + filters: { + capacityAndResources: isDefined(id) ? [id] : undefined, + isActive: isDefined(filter.isActive) ? filter.isActive === 'true' : undefined, + search: filter.search || undefined, + regions: filter.regions?.length ? filter.regions : undefined, + }, + order: { order: Ordering.Asc }, + }), [limit, offset, filter, id]); + const [, deleteResourceDashboard] = useDeleteResourceDashboardMutation(); const [{ fetching, data }, reExecuteQuery] = useResourceDashboardsQuery({ - variables: { - filters: { - capacityAndResources: isDefined(id) ? [id] : undefined, - isActive: isDefined(filter.isActive) ? filter.isActive === 'true' : undefined, - search: filter.search, - regions: filter.regions?.length === 0 ? undefined : filter.regions, - }, - pagination: { - limit, - offset, - }, - order: { order: Ordering.Asc }, - }, - pause: !id, + variables: queryVariables, + pause: isNotDefined(id), }); const serverData: DashboardListItem[] = useMemo(() => ( @@ -103,14 +109,29 @@ function ResourceDashboards() { })) ), [page, data, limit]); - const { tableData, rowModifier } = useDashboardReorder(serverData, page, limit); + const handleReorderSuccess = useCallback(() => { + reExecuteQuery({ requestPolicy: 'network-only' }); + }, [reExecuteQuery]); + + const { tableData, rowModifier } = useDashboardReorder( + serverData, + page, + limit, + handleReorderSuccess, + ); - const onDeleteClick = useCallback( + const handleDeleteClick = useCallback( (dashboardId: string) => { deleteResourceDashboard({ id: dashboardId }).then((resp) => { const result = resp.data?.deleteExternalDashboard; if (result?.ok) { - reExecuteQuery(); + // NOTE: deleting the only row on a page would leave the + // user on an empty page + if (tableData.length === 1 && page > 1) { + setPage(page - 1); + } else { + reExecuteQuery({ requestPolicy: 'network-only' }); + } alert.show('Dashboard deleted successfully', { variant: 'success' }); } else { alert.show(errorMessage, { variant: 'danger' }); @@ -119,7 +140,7 @@ function ResourceDashboards() { alert.show(errorMessage, { variant: 'danger' }); }); }, - [deleteResourceDashboard, reExecuteQuery, alert], + [deleteResourceDashboard, reExecuteQuery, alert, tableData.length, page, setPage], ); const columns = useMemo(() => [ @@ -164,13 +185,13 @@ function ResourceDashboards() { (_, datum) => ({ id: id ?? '', dashboard: datum.id, - onDelete: () => onDeleteClick(datum.id), + onDelete: handleDeleteClick, itemTitle: datum.title, to: 'editResourceDashboard', }), { columnWidth: 150 }, ), - ], [onDeleteClick, id, regionMap]); + ], [handleDeleteClick, id, regionMap]); const handleCreateClick = useCallback(() => { if (isDefined(id)) { diff --git a/app/views/CapacityAndResources/index.tsx b/app/views/CapacityAndResources/index.tsx index 0084d3a..2fe8eff 100644 --- a/app/views/CapacityAndResources/index.tsx +++ b/app/views/CapacityAndResources/index.tsx @@ -32,7 +32,7 @@ import { idSelector, } from '#utils/common'; -import CapacityAndResourcesFilter from './CapacityAndResourcesFilter'; +import CapacityAndResourcesFilters from './CapacityAndResourcesFilters'; type ResourcesListItem = NonNullable['results'][number]> & { no: string }; @@ -64,18 +64,20 @@ function CapacityAndResourcesList() { const alert = useAlert(); const navigate = useRouting(); + const queryVariables = useMemo(() => ({ + pagination: { + limit, + offset, + }, + filters: { + isActive: isDefined(filter.isActive) ? filter.isActive === 'true' : undefined, + title: filter.title ? { iContains: filter.title } : undefined, + }, + }), [limit, offset, filter]); + const [, deleteCapacityAndResource] = useDeleteCapacityAndResourceMutation(); const [{ fetching, data }, reExecuteQuery] = useCapacityAndResourcesQuery({ - variables: { - filters: { - isActive: isDefined(filter.isActive) ? filter.isActive === 'true' : undefined, - title: filter.title ? { iContains: filter.title } : undefined, - }, - pagination: { - limit, - offset, - }, - }, + variables: queryVariables, }); const tableData: ResourcesListItem[] = useMemo(() => ( @@ -85,12 +87,18 @@ function CapacityAndResourcesList() { })) ), [page, data, limit]); - const onDeleteClick = useCallback( + const handleDeleteClick = useCallback( (id: string) => { deleteCapacityAndResource({ id }).then((resp) => { const result = resp.data?.deleteCapacityAndResource; if (result?.ok) { - reExecuteQuery(); + // NOTE: deleting the only row on a page would leave the + // user on an empty page + if (tableData.length === 1 && page > 1) { + setPage(page - 1); + } else { + reExecuteQuery({ requestPolicy: 'network-only' }); + } alert.show('Resource deleted successfully', { variant: 'success' }); } else { alert.show(errorMessage, { variant: 'danger' }); @@ -99,7 +107,7 @@ function CapacityAndResourcesList() { alert.show(errorMessage, { variant: 'danger' }); }); }, - [deleteCapacityAndResource, reExecuteQuery, alert], + [deleteCapacityAndResource, reExecuteQuery, alert, tableData.length, page, setPage], ); const columns = useMemo(() => [ @@ -137,13 +145,13 @@ function CapacityAndResourcesList() { EditDeleteActions, (_, datum) => ({ id: datum.id, - onDelete: onDeleteClick, + onDelete: handleDeleteClick, itemTitle: datum.title, to: 'editResources', }), { columnWidth: 150 }, ), - ], [onDeleteClick]); + ], [handleDeleteClick]); const handleCreateClick = useCallback(() => { navigate('createResources'); @@ -154,7 +162,7 @@ function CapacityAndResourcesList() { withPadding heading="Capacity and Resources" filters={( - ['results'][number]>; - -interface CategoryFilterType { - search: string | undefined; -} - -const defaultFilter: CategoryFilterType = { - search: undefined, -}; - -interface CategoryItemProps { - category: ThematicArea; - onEdit: (id: string) => void; - onDelete: (id: string) => void; - disabled: boolean; -} - -function CategoryItem(props: CategoryItemProps) { - const { - category, - onEdit, - onDelete, - disabled, - } = props; - - return ( - - {category.name} - - - - - - - - - - ); -} - -export interface Props { - onClose: () => void; - onCategoriesChange: () => void; -} - -function CategoryModal(props: Props) { - const { - onClose, - onCategoriesChange, - } = props; - - const alert = useAlert(); - - const { - rawFilter, - filter, - filtered, - setFilterField, - page, - setPage, - limit, - offset, - } = useFilterState({ - filter: defaultFilter, - }); - - const [categoryName, setCategoryName] = useState(); - const [editingId, setEditingId] = useState(); - - const [{ fetching, data, error }, reExecuteQuery] = useThematicAreasQuery({ - variables: { - pagination: { limit, offset }, - filters: { search: filter.search || undefined }, - }, - }); - const [{ fetching: createPending }, createThematicArea] = useCreateThematicAreaMutation(); - const [{ fetching: updatePending }, updateThematicArea] = useUpdateThematicAreaMutation(); - const [{ fetching: deletePending }, deleteThematicArea] = useDeleteThematicAreaMutation(); - - const categories = data?.thematicAreas?.results; - const actionPending = createPending || updatePending || deletePending; - - const handleResult = useCallback(( - ok: boolean | undefined, - successMessage: string, - ) => { - if (!ok) { - alert.show(errorMessage, { variant: 'danger' }); - return; - } - setCategoryName(undefined); - setEditingId(undefined); - reExecuteQuery({ requestPolicy: 'network-only' }); - onCategoriesChange(); - alert.show(successMessage, { variant: 'success' }); - }, [alert, reExecuteQuery, onCategoriesChange]); - - const handleSave = useCallback(() => { - const name = categoryName?.trim(); - if (!name) { - return; - } - if (isDefined(editingId)) { - updateThematicArea({ id: editingId, data: { name } }).then((resp) => { - handleResult(resp.data?.updateThematicArea?.ok, 'Category updated successfully'); - }).catch(() => { - alert.show(errorMessage, { variant: 'danger' }); - }); - } else { - createThematicArea({ data: { name } }).then((resp) => { - handleResult(resp.data?.createThematicArea?.ok, 'Category added successfully'); - }).catch(() => { - alert.show(errorMessage, { variant: 'danger' }); - }); - } - }, [categoryName, editingId, createThematicArea, updateThematicArea, handleResult, alert]); - - const handleEdit = useCallback((id: string) => { - setEditingId(id); - setCategoryName(categories?.find((item) => item.id === id)?.name); - }, [categories]); - - const handleCancelEdit = useCallback(() => { - setEditingId(undefined); - setCategoryName(undefined); - }, []); - - const handleDelete = useCallback((id: string) => { - deleteThematicArea({ id }).then((resp) => { - handleResult(resp.data?.deleteThematicArea?.ok, 'Category deleted successfully'); - }).catch(() => { - alert.show(errorMessage, { variant: 'danger' }); - }); - }, [deleteThematicArea, handleResult, alert]); - - const rendererParams = useCallback((_: string, category: ThematicArea) => ({ - category, - onEdit: handleEdit, - onDelete: handleDelete, - disabled: actionPending, - }), [handleEdit, handleDelete, actionPending]); - - const isEditing = isDefined(editingId); - - return ( - - {isEditing && ( - - - - )} - - {isEditing ? : } - - - )} - > - - - )} - > - - - - - ); -} - -export default CategoryModal; diff --git a/app/views/OurWorks/index.tsx b/app/views/OurWorks/index.tsx index ce08443..0773f2b 100644 --- a/app/views/OurWorks/index.tsx +++ b/app/views/OurWorks/index.tsx @@ -22,11 +22,12 @@ import { DashboardPage, type ExternalDashboardFilter, type ExternalDashboardsQuery, + Ordering, useDeleteExternalDashboardMutation, useExternalDashboardsQuery, } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; -import useDashboardReorder, { createDragHandleColumn } from '#hooks/useDashboardReorder'; +import useDashboardReorder from '#hooks/useDashboardReorder'; import useFilterState from '#hooks/useFilterState'; import useRegionMap from '#hooks/useRegionMap'; import useRouting from '#hooks/useRouting'; @@ -34,6 +35,7 @@ import { errorMessage, idSelector, } from '#utils/common'; +import createDragHandleColumn from '#utils/table'; import WorksFilter from './WorksFilter'; @@ -87,6 +89,7 @@ function OurWorks() { }, page: filter.page ?? null, }, + order: { order: Ordering.Asc }, }), [limit, offset, filter]); const [{ fetching, data }, reExecuteQuery] = useExternalDashboardsQuery({ @@ -103,14 +106,27 @@ function OurWorks() { }; }) as unknown as WorksListItem[]), [page, data, limit]); - const { tableData, rowModifier } = useDashboardReorder(serverData, page, limit); + const handleReorderSuccess = useCallback(() => { + reExecuteQuery({ requestPolicy: 'network-only' }); + }, [reExecuteQuery]); - const onDeleteClick = useCallback( + const { tableData, rowModifier } = useDashboardReorder( + serverData, + page, + limit, + handleReorderSuccess, + ); + + const handleDeleteClick = useCallback( (id: string) => { deleteExternalDashboard({ id }).then((resp) => { const result = resp.data?.deleteExternalDashboard; if (result?.ok) { - reExecuteQuery(); + if (tableData.length === 1 && page > 1) { + setPage(page - 1); + } else { + reExecuteQuery({ requestPolicy: 'network-only' }); + } alert.show('Dashboard deleted successfully', { variant: 'success' }); } else { alert.show(errorMessage, { variant: 'danger' }); @@ -119,7 +135,7 @@ function OurWorks() { alert.show(errorMessage, { variant: 'danger' }); }); }, - [deleteExternalDashboard, reExecuteQuery, alert], + [deleteExternalDashboard, reExecuteQuery, alert, tableData.length, page, setPage], ); const columns = useMemo(() => [ @@ -163,13 +179,13 @@ function OurWorks() { EditDeleteActions, (_, datum) => ({ id: datum.id, - onDelete: onDeleteClick, + onDelete: handleDeleteClick, itemTitle: datum.title, to: 'editWorks', }), { columnWidth: 150 }, ), - ], [onDeleteClick, regionMap]); + ], [handleDeleteClick, regionMap]); const handleCreateClick = useCallback(() => { navigate('createWorks'); diff --git a/app/views/OurWorks/query.ts b/app/views/OurWorks/query.ts index d1d25f1..0f084c4 100644 --- a/app/views/OurWorks/query.ts +++ b/app/views/OurWorks/query.ts @@ -2,8 +2,8 @@ import { gql } from 'urql'; const EXTERNAL_DASHBOARDS = gql` - query ExternalDashboards($pagination: OffsetPaginationInput, $filters: ExternalDashboardFilter) { - externalDashboards(pagination: $pagination, filters: $filters) { + query ExternalDashboards($pagination: OffsetPaginationInput, $filters: ExternalDashboardFilter, $order: ExternalDashboardOrder) { + externalDashboards(pagination: $pagination, filters: $filters, order: $order) { results { createdAt createdById diff --git a/app/views/Preparedness/index.tsx b/app/views/Preparedness/index.tsx index 7525dfd..26b2415 100644 --- a/app/views/Preparedness/index.tsx +++ b/app/views/Preparedness/index.tsx @@ -21,12 +21,13 @@ import { AdminAreaLevel, DashboardPage, type ExternalDashboardFilter, + Ordering, type PreparednessExternalDashboardsQuery, usePreparednessDeleteExternalDashboardMutation, usePreparednessExternalDashboardsQuery, } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; -import useDashboardReorder, { createDragHandleColumn } from '#hooks/useDashboardReorder'; +import useDashboardReorder from '#hooks/useDashboardReorder'; import useFilterState from '#hooks/useFilterState'; import useRegionMap from '#hooks/useRegionMap'; import useRouting from '#hooks/useRouting'; @@ -34,6 +35,7 @@ import { errorMessage, idSelector, } from '#utils/common'; +import createDragHandleColumn from '#utils/table'; import PreparednessFilter from './PreparednessFilter'; @@ -87,6 +89,7 @@ function PreparednessList() { }, page: filter.page ?? null, }, + order: { order: Ordering.Asc }, }), [limit, offset, filter]); const [{ fetching, data }, reExecuteQuery] = usePreparednessExternalDashboardsQuery({ @@ -101,14 +104,27 @@ function PreparednessList() { })) ), [page, data, limit]); - const { tableData, rowModifier } = useDashboardReorder(serverData, page, limit); + const handleReorderSuccess = useCallback(() => { + reExecuteQuery({ requestPolicy: 'network-only' }); + }, [reExecuteQuery]); - const onDeleteClick = useCallback( + const { tableData, rowModifier } = useDashboardReorder( + serverData, + page, + limit, + handleReorderSuccess, + ); + + const handleDeleteClick = useCallback( (id: string) => { deleteExternalDashboard({ id }).then((resp) => { const result = resp.data?.deleteExternalDashboard; if (result?.ok) { - reExecuteQuery(); + if (tableData.length === 1 && page > 1) { + setPage(page - 1); + } else { + reExecuteQuery({ requestPolicy: 'network-only' }); + } alert.show('Dashboard deleted successfully', { variant: 'success' }); } else { alert.show(errorMessage, { variant: 'danger' }); @@ -117,7 +133,7 @@ function PreparednessList() { alert.show(errorMessage, { variant: 'danger' }); }); }, - [deleteExternalDashboard, reExecuteQuery, alert], + [deleteExternalDashboard, reExecuteQuery, alert, tableData.length, page, setPage], ); const columns = useMemo(() => [ @@ -161,13 +177,13 @@ function PreparednessList() { EditDeleteActions, (_, datum) => ({ id: datum.id, - onDelete: onDeleteClick, + onDelete: handleDeleteClick, itemTitle: datum.title, to: 'editPreparedness', }), { columnWidth: 150 }, ), - ], [onDeleteClick, regionMap]); + ], [handleDeleteClick, regionMap]); const handleCreateClick = useCallback(() => { navigate('createPreparedness'); diff --git a/app/views/Preparedness/query.ts b/app/views/Preparedness/query.ts index 55160d3..0b52282 100644 --- a/app/views/Preparedness/query.ts +++ b/app/views/Preparedness/query.ts @@ -2,8 +2,8 @@ import { gql } from 'urql'; const EXTERNAL_DASHBOARDS = gql` - query PreparednessExternalDashboards($pagination: OffsetPaginationInput, $filters: ExternalDashboardFilter) { - externalDashboards(pagination: $pagination, filters: $filters) { + query PreparednessExternalDashboards($pagination: OffsetPaginationInput, $filters: ExternalDashboardFilter, $order: ExternalDashboardOrder) { + externalDashboards(pagination: $pagination, filters: $filters, order: $order) { results { createdAt createdById From e21ef47acb625dfec7f0c20cfc2a46c62acd2ed7 Mon Sep 17 00:00:00 2001 From: amrit Date: Thu, 13 Aug 2026 11:58:48 +0545 Subject: [PATCH 6/6] feat(cms): add breadcrumb --- app/Root/config/routes.ts | 4 +- app/components/Breadcrumbs/index.tsx | 104 +++++++++++++++++++++++++++ app/views/PrivateLayout/index.tsx | 2 + 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 app/components/Breadcrumbs/index.tsx diff --git a/app/Root/config/routes.ts b/app/Root/config/routes.ts index b436d19..eaa524f 100644 --- a/app/Root/config/routes.ts +++ b/app/Root/config/routes.ts @@ -161,7 +161,7 @@ const resourceDashboards: RouteConfig = { }; const createResourceDashboard: RouteConfig = { - path: '/capacity-and-resources/:id/dashboards/create', + path: '/capacity-and-resources/:id/dashboards/new', load: () => import('#views/CapacityAndResources/ResourceDashboards/ResourceDashboardForm'), visibility: 'is-authenticated', }; @@ -196,7 +196,7 @@ const onlineInteractive: RouteConfig = { }; const createOnlineInteractive: RouteConfig = { - path: '/online-interactive/create', + path: '/online-interactive/new', load: () => import('#views/OnlineInteractive/OnlineInteractiveForm'), visibility: 'is-authenticated', }; diff --git a/app/components/Breadcrumbs/index.tsx b/app/components/Breadcrumbs/index.tsx new file mode 100644 index 0000000..b4e74ec --- /dev/null +++ b/app/components/Breadcrumbs/index.tsx @@ -0,0 +1,104 @@ +import { useMemo } from 'react'; +import { + Link as RouterLink, + matchPath, + useLocation, +} from 'react-router'; +import { + BlockView, + Breadcrumbs as BaseBreadcrumbs, +} from '@ifrc-go/ui'; +import { isDefined } from '@togglecorp/fujs'; + +import routes from '#root/config/routes'; + +const routePaths = Object.values(routes) + .map((route) => route.path) + .filter(isDefined); + +const staticSegments = new Set( + routePaths + .flatMap((path) => path.split('/')) + .filter((segment) => segment !== '' && !segment.startsWith(':')), +); + +function getLabel(segment: string) { + return segment + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +interface Crumb { + label: string; + to: string | undefined; +} + +export interface Props { + className?: string; +} + +function Breadcrumbs(props: Props) { + const { className } = props; + + const { pathname } = useLocation(); + + const crumbs = useMemo( + () => { + const segments = pathname.split('/').filter(Boolean); + + return segments.reduce( + (acc, segment, index) => { + if (!staticSegments.has(segment)) { + return acc; + } + + const to = `/${segments.slice(0, index + 1).join('/')}`; + const isRoute = routePaths.some((path) => matchPath(path, to)); + + acc.push({ + label: getLabel(segment), + to: isRoute ? to : undefined, + }); + + return acc; + }, + [{ label: 'Home', to: '/' }], + ); + }, + [pathname], + ); + + if (crumbs.length <= 1) { + return null; + } + + return ( + + + {crumbs.map((crumb, index) => { + const isLast = index === crumbs.length - 1; + + if (isLast || !isDefined(crumb.to)) { + return ( + + {crumb.label} + + ); + } + + return ( + + {crumb.label} + + ); + })} + + + ); +} + +export default Breadcrumbs; diff --git a/app/views/PrivateLayout/index.tsx b/app/views/PrivateLayout/index.tsx index 90f53f9..bed21a9 100644 --- a/app/views/PrivateLayout/index.tsx +++ b/app/views/PrivateLayout/index.tsx @@ -16,6 +16,7 @@ import { ShieldStarLineIcon, } from '@ifrc-go/icons'; +import Breadcrumbs from '#components/Breadcrumbs'; import Navbar from '#components/Navbar'; import Navigation, { type NavigationItem } from '#components/Navigation'; import Page from '#components/Page'; @@ -105,6 +106,7 @@ function PrivateLayout() { /> )} > +