From b706e2fa8a1234ec0987afdb4d2a0e1ff1c34a54 Mon Sep 17 00:00:00 2001 From: Shreeyash Shrestha Date: Mon, 29 Jun 2026 11:53:00 +0545 Subject: [PATCH 1/2] feat(links): add links page module --- app/Root/config/routes.ts | 22 ++ app/Root/index.tsx | 45 ++-- app/Root/query.ts | 25 ++ app/contexts/GlobalEnumsContext.ts | 24 ++ app/hooks/useGlobalEnums.ts | 7 + app/views/Links/LinkForm/index.tsx | 246 ++++++++++++++++++ app/views/Links/index.tsx | 201 ++++++++++++++ app/views/Links/query.ts | 90 +++++++ app/views/OurWorks/query.ts | 11 - app/views/Preparedness/query.ts | 11 - app/views/PrivateLayout/index.tsx | 6 + .../TeamMembers/TeamMemberForm/index.tsx | 8 +- app/views/Users/UserForm/index.tsx | 8 +- app/views/Users/query.ts | 15 -- 14 files changed, 659 insertions(+), 60 deletions(-) create mode 100644 app/Root/query.ts create mode 100644 app/contexts/GlobalEnumsContext.ts create mode 100644 app/hooks/useGlobalEnums.ts create mode 100644 app/views/Links/LinkForm/index.tsx create mode 100644 app/views/Links/index.tsx create mode 100644 app/views/Links/query.ts diff --git a/app/Root/config/routes.ts b/app/Root/config/routes.ts index d048cce..0150db8 100644 --- a/app/Root/config/routes.ts +++ b/app/Root/config/routes.ts @@ -191,6 +191,25 @@ const galleries: RouteConfig = { visibility: 'is-authenticated', }; +const links: RouteConfig = { + index: true, + path: '/links', + load: () => import('#views/Links'), + visibility: 'is-authenticated', +}; + +const createLink: RouteConfig = { + path: '/links/new', + load: () => import('#views/Links/LinkForm'), + visibility: 'is-authenticated', +}; + +const editLink: RouteConfig = { + path: '/links/:id/edit', + load: () => import('#views/Links/LinkForm'), + visibility: 'is-authenticated', +}; + const routes = { home, login, @@ -221,6 +240,9 @@ const routes = { onlineInteractive, galleries, editTeamMember, + links, + createLink, + editLink, } satisfies Record; export type RouteKeys = keyof typeof routes; diff --git a/app/Root/index.tsx b/app/Root/index.tsx index c125a13..d68cc97 100644 --- a/app/Root/index.tsx +++ b/app/Root/index.tsx @@ -20,8 +20,12 @@ import { appTitle, environment, } from '#config'; +import GlobalEnumsContext, { type GlobalEnumsContextInterface } from '#contexts/GlobalEnumsContext'; import UserContext, { type UserContextInterface } from '#contexts/UserContext'; -import type { MeQuery } from '#generated/types/graphql'; +import { + type MeQuery, + useGlobalEnumsQuery, +} from '#generated/types/graphql'; import useAlertContextProviderValue from '#hooks/useAlertContextProviderValue'; const COOKIE_NAME = `ERCS-${environment}-CSRFTOKEN`; @@ -55,23 +59,34 @@ function Root() { const alertContextValue = useAlertContextProviderValue(); + const [{ data: globalEnumsData }] = useGlobalEnumsQuery(); + + const globalEnumsContext: GlobalEnumsContextInterface = useMemo(() => ({ + linkType: globalEnumsData?.enums.LinkType, + userRole: globalEnumsData?.enums.UserRole, + teamMemberSex: globalEnumsData?.enums.TeamMemberSex, + dashboardPage: globalEnumsData?.enums.DashboardPage, + }), [globalEnumsData]); + return ( - - - - {appTitle} - {' '} - loading... - - )} - > - - - + + + + + {appTitle} + {' '} + loading... + + )} + > + + + + ); diff --git a/app/Root/query.ts b/app/Root/query.ts new file mode 100644 index 0000000..cda40cb --- /dev/null +++ b/app/Root/query.ts @@ -0,0 +1,25 @@ +import { gql } from 'urql'; + +// eslint-disable-next-line import/prefer-default-export +export const GLOBAL_ENUMS = gql` + query GlobalEnums { + enums { + LinkType { + key + label + } + UserRole { + key + label + } + TeamMemberSex { + key + label + } + DashboardPage { + key + label + } + } + } +`; diff --git a/app/contexts/GlobalEnumsContext.ts b/app/contexts/GlobalEnumsContext.ts new file mode 100644 index 0000000..b9786a8 --- /dev/null +++ b/app/contexts/GlobalEnumsContext.ts @@ -0,0 +1,24 @@ +import { createContext } from 'react'; + +import type { + AppEnumCollectionDashboardPage, + AppEnumCollectionLinkType, + AppEnumCollectionTeamMemberSex, + AppEnumCollectionUserRole, +} from '#generated/types/graphql'; + +export interface GlobalEnumsContextInterface { + linkType: AppEnumCollectionLinkType[] | undefined; + userRole: AppEnumCollectionUserRole[] | undefined; + teamMemberSex: AppEnumCollectionTeamMemberSex[] | undefined; + dashboardPage: AppEnumCollectionDashboardPage[] | undefined; +} + +const GlobalEnumsContext = createContext({ + linkType: undefined, + userRole: undefined, + teamMemberSex: undefined, + dashboardPage: undefined, +}); + +export default GlobalEnumsContext; diff --git a/app/hooks/useGlobalEnums.ts b/app/hooks/useGlobalEnums.ts new file mode 100644 index 0000000..b8821ce --- /dev/null +++ b/app/hooks/useGlobalEnums.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import GlobalEnumsContext from '#contexts/GlobalEnumsContext'; + +export default function useGlobalEnums() { + return useContext(GlobalEnumsContext); +} diff --git a/app/views/Links/LinkForm/index.tsx b/app/views/Links/LinkForm/index.tsx new file mode 100644 index 0000000..bbb0d1d --- /dev/null +++ b/app/views/Links/LinkForm/index.tsx @@ -0,0 +1,246 @@ +import { + useCallback, + useEffect, +} from 'react'; +import { useParams } from 'react-router'; +import { + BlockLoading, + Button, + Container, + InputSection, + ListView, + RadioInput, + TextInput, +} from '@ifrc-go/ui'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import { + createSubmitHandler, + getErrorObject, + type ObjectSchema, + type PartialForm, + removeNull, + requiredStringCondition, + useForm, +} from '@togglecorp/toggle-form'; + +import { + type LinkCreateInput, + type LinkUpdateInput, + useCreateLinkMutation, + useLinkDetailQuery, + useUpdateLinkMutation, +} from '#generated/types/graphql'; +import useAlert from '#hooks/useAlert'; +import useGlobalEnums from '#hooks/useGlobalEnums'; +import useRouting from '#hooks/useRouting'; +import { + errorMessage, + keySelector, + labelSelector, + transformToFormError, +} from '#utils/common'; + +type PartialFormType = PartialForm; +type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType; + +const LinkSchema: FormSchema = { + fields: (): FormSchemaFields => ({ + title: { + required: true, + requiredValidation: requiredStringCondition, + }, + url: { + required: true, + requiredValidation: requiredStringCondition, + }, + linkType: { + required: true, + }, + description: {}, + }), +}; + +const defaultEditFormValue: PartialFormType = {}; + +function LinkForm() { + const { id } = useParams(); + const alert = useAlert(); + const navigate = useRouting(); + + const [{ data, fetching: linkDetailFetch }] = useLinkDetailQuery({ + variables: { id: isDefined(id) ? id : '' }, + pause: isNotDefined(id), + }); + + const [{ fetching: createPending }, createLinkMutate] = useCreateLinkMutation(); + const [{ fetching: updatePending }, updateLinkMutate] = useUpdateLinkMutation(); + + const pending = createPending || updatePending || linkDetailFetch; + + const { + setFieldValue, + error: formError, + value, + validate, + setError, + setValue, + } = useForm(LinkSchema, { value: defaultEditFormValue }); + + const { + linkType: linkTypeOptions, + } = useGlobalEnums(); + + const error = getErrorObject(formError); + + const handleCreate = useCallback(async (mutationData: PartialFormType) => { + const createPayload = removeNull(mutationData) as unknown as LinkCreateInput; + const res = await createLinkMutate({ data: createPayload }); + const result = res.data?.createLink; + + if (isDefined(result) && result.ok) { + navigate('links'); + alert.show('Link created successfully', { variant: 'success' }); + } else if (isDefined(result) && isDefined(result.errors)) { + setError(transformToFormError(result.errors)); + alert.show(errorMessage, { variant: 'danger' }); + } else { + alert.show(errorMessage, { variant: 'danger' }); + } + }, [createLinkMutate, navigate, alert, setError]); + + const handleUpdate = useCallback(async (mutationData: PartialFormType) => { + if (isNotDefined(id)) { + return; + } + const updatePayload = Object.fromEntries( + Object.entries(removeNull(mutationData)).filter(([key]) => key !== 'email'), + ) as LinkUpdateInput; + + const res = await updateLinkMutate({ id, data: updatePayload }); + const result = res.data?.updateLink; + + if (isDefined(result) && result.ok) { + navigate('links'); + alert.show('Link updated successfully', { variant: 'success' }); + } else if (isDefined(result) && isDefined(result.errors)) { + setError(transformToFormError(result.errors)); + alert.show(errorMessage, { variant: 'danger' }); + } else { + alert.show(errorMessage, { variant: 'danger' }); + } + }, [updateLinkMutate, id, navigate, alert, setError]); + + const handleFormSubmit = useCallback( + () => createSubmitHandler( + validate, + setError, + isDefined(id) ? handleUpdate : handleCreate, + )(), + [validate, setError, id, handleUpdate, handleCreate], + ); + + const handleCancelClick = useCallback(() => { + navigate('links'); + }, [navigate]); + + useEffect(() => { + if (isNotDefined(data?.link)) { + return; + } + const linkValue = removeNull(data.link); + setValue(linkValue); + }, [data, setValue]); + + if (linkDetailFetch || createPending || updatePending) { + return ( + + ); + } + + return ( + + + + + )} + > + + + + + + + + + + + + + + + + ); +} + +export default LinkForm; diff --git a/app/views/Links/index.tsx b/app/views/Links/index.tsx new file mode 100644 index 0000000..d9e0e4d --- /dev/null +++ b/app/views/Links/index.tsx @@ -0,0 +1,201 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { AddFillIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + DateInput, + Pager, + Table, + TextInput, +} from '@ifrc-go/ui'; +import { + createDateColumn, + createElementColumn, + createStringColumn, +} from '@ifrc-go/ui/utils'; + +import EditDeleteActions, { type Props as EditDeleteActionsProps } from '#components/EditDeleteActions'; +import { + type InternalLinksQuery, + type LinkFilter, + useDeleteLinkMutation, + useInternalLinksQuery, +} from '#generated/types/graphql'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useRouting from '#hooks/useRouting'; +import { + errorMessage, + idSelector, +} from '#utils/common'; + +type InternalLinkListItem = NonNullable['results'][number]> & { no: string } + +interface LinkFilterType extends Omit { + createdAtGte: string | undefined; + createdAtLte: string | undefined; +} + +const defaultFilter: LinkFilterType = { + search: undefined, + createdAtGte: undefined, + createdAtLte: undefined, +}; +function Links() { + const { + filter, + rawFilter, + filtered, + setFilterField, + page, + setPage, + limit, + offset, + } = useFilterState({ + filter: defaultFilter, + }); + + const alert = useAlert(); + const navigate = useRouting(); + + const queryVariables = useMemo(() => ({ + pagination: { + limit, + offset, + }, + filters: { + search: filter.search, + createdAt: (filter.createdAtGte || filter.createdAtLte) ? { + gte: filter.createdAtGte, + lte: filter.createdAtLte, + } : undefined, + }, + }), [limit, offset, filter]); + + const [{ fetching, data }, reExecuteQuery] = useInternalLinksQuery( + { variables: queryVariables }, + ); + const [, deleteLink] = useDeleteLinkMutation(); + + const onDeleteClick = useCallback( + (id: string) => { + deleteLink({ id }).then((resp) => { + const result = resp.data?.deleteLink; + if (result?.ok) { + reExecuteQuery(); + alert.show('Link deleted successfully', { variant: 'success' }); + } else { + alert.show(errorMessage, { variant: 'danger' }); + } + }).catch(() => { + alert.show(errorMessage, { variant: 'danger' }); + }); + }, + [deleteLink, reExecuteQuery, alert], + ); + + const tableData = useMemo(() => ( + data?.internalLinks.results.map((user, index) => { + const no = (page - 1) * limit + index + 1; + return { + ...user, + no, + }; + }) as unknown as InternalLinkListItem[]), [page, data, limit]); + + 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: 'editUser', + }), + { columnWidth: 150 }, + ), + ], [onDeleteClick]); + + const handleCreateClick = useCallback(() => { + navigate('createLink'); + }, [navigate]); + + return ( + + + + + + )} + footerActions={( + + )} + headerActions={( + + )} + > + + + ); +} + +export default Links; diff --git a/app/views/Links/query.ts b/app/views/Links/query.ts new file mode 100644 index 0000000..f7c7a3d --- /dev/null +++ b/app/views/Links/query.ts @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { gql } from 'urql'; + +const EXTERNAL_LINKS = gql` + query ExternalLinks($pagination: OffsetPaginationInput, $filters: LinkFilter) { + publicLinks(filters: $filters, pagination: $pagination) { + totalCount + results { + createdAt + description + id + title + updatedAt + url + } + } + } +`; + +const INTERNAL_LINKS = gql` + query InternalLinks($pagination: OffsetPaginationInput, $filters: LinkFilter) { + internalLinks(filters: $filters, pagination: $pagination) { + totalCount + results { + createdAt + description + id + title + updatedAt + url + } + } + } +`; + +const CREATE_LINK_MUTATION = gql` + mutation CreateLink($data: LinkCreateInput!) { + createLink(data: $data) { + ... on LinkTypeMutationResponseType { + errors + ok + } + } + } +`; + +const UPDATE_LINK_MUTATION = gql` + mutation UpdateLink($id: ID!, $data: LinkUpdateInput!) { + updateLink(id: $id, data: $data) { + ... on LinkTypeMutationResponseType { + errors + ok + result { + id + title + linkType + description + url + } + } + } + } +`; + +const LINK_DETAILS = gql` + query LinkDetail($id: ID!) { + link(id: $id) { + id + title + description + url + linkType + } + } +`; + +const DELETE_LINK = gql` + mutation DeleteLink($id: ID!) { + deleteLink(id: $id) { + ... on LinkTypeMutationResponseType { + errors + ok + result { + id + title + } + } + } + } +`; diff --git a/app/views/OurWorks/query.ts b/app/views/OurWorks/query.ts index 17ec84d..d1d25f1 100644 --- a/app/views/OurWorks/query.ts +++ b/app/views/OurWorks/query.ts @@ -37,17 +37,6 @@ const DELETE_EXTERNAL_DASHBOARD = gql` } `; -const DASHBOARD_ENUMS = gql` - query DashboardEnums { - enums { - DashboardPage { - key - label - } - } - } -`; - const EXTERNAL_DASHBOARD_DETAIL = gql` query ExternalDashboardDetail($id: ID!) { externalDashboard(id: $id) { diff --git a/app/views/Preparedness/query.ts b/app/views/Preparedness/query.ts index 8f0263c..55160d3 100644 --- a/app/views/Preparedness/query.ts +++ b/app/views/Preparedness/query.ts @@ -37,17 +37,6 @@ const DELETE_EXTERNAL_DASHBOARD = gql` } `; -const DASHBOARD_ENUMS = gql` - query PreparednessDashboardEnums { - enums { - DashboardPage { - key - label - } - } - } -`; - const EXTERNAL_DASHBOARD_DETAIL = gql` query PreparednessExternalDashboardDetail($id: ID!) { externalDashboard(id: $id) { diff --git a/app/views/PrivateLayout/index.tsx b/app/views/PrivateLayout/index.tsx index babddb4..90f53f9 100644 --- a/app/views/PrivateLayout/index.tsx +++ b/app/views/PrivateLayout/index.tsx @@ -10,6 +10,7 @@ import { FocusTwoLineIcon, ImStrategyIcon, LeadershipIcon, + LinkLineIcon, ScreenshotTwoFillIcon, ShareBoxLineIcon, ShieldStarLineIcon, @@ -85,6 +86,11 @@ function PrivateLayout() { to: 'onlineInteractive', icon: , }, + { + title: 'Links', + to: 'links', + icon: , + }, ], }, ]; diff --git a/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx b/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx index 8cd8556..600cea2 100644 --- a/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx +++ b/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx @@ -34,11 +34,11 @@ import { type TeamMemberCreateInput, type TeamMemberUpdateInput, useCreateTeamMemberMutation, - useEnumsQuery, useTeamMemberDetailsQuery, useUpdateTeamMemberMutation, } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; +import useGlobalEnums from '#hooks/useGlobalEnums'; import useRouting from '#hooks/useRouting'; import { errorMessage, @@ -98,9 +98,9 @@ function TeamMemberForm() { const [{ fetching: createPending }, createTeamMemberMutate] = useCreateTeamMemberMutation(); const [{ fetching: updatePending }, updateTeamMemberMutate] = useUpdateTeamMemberMutation(); - const [{ data: enumsData }] = useEnumsQuery(); - - const sexOptions = enumsData?.enums?.TeamMemberSex; + const { + teamMemberSex: sexOptions, + } = useGlobalEnums(); const handleCreate = useCallback(async (mutationData: PartialFormType) => { const res = await createTeamMemberMutate({ diff --git a/app/views/Users/UserForm/index.tsx b/app/views/Users/UserForm/index.tsx index 48ef982..87fbf9c 100644 --- a/app/views/Users/UserForm/index.tsx +++ b/app/views/Users/UserForm/index.tsx @@ -32,13 +32,13 @@ import RegionSelectInput from '#components/RegionSelectInput'; import { AdminAreaLevel, useCreateUserMutation, - useEnumsQuery, type UserCreateInput, type UserUpdateInput, useUpdateUserMutation, useUserDetailQuery, } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; +import useGlobalEnums from '#hooks/useGlobalEnums'; import useRouting from '#hooks/useRouting'; import { errorMessage, @@ -98,9 +98,9 @@ function UserForm() { pause: isNotDefined(id), }); - const [{ data: enumsData }] = useEnumsQuery(); - - const roleOptions = enumsData?.enums?.UserRole; + const { + userRole: roleOptions, + } = useGlobalEnums(); const [{ fetching: createPending }, createUserMutate] = useCreateUserMutation(); const [{ fetching: updatePending }, updateUserMutate] = useUpdateUserMutation(); diff --git a/app/views/Users/query.ts b/app/views/Users/query.ts index 85575db..f4f5c11 100644 --- a/app/views/Users/query.ts +++ b/app/views/Users/query.ts @@ -97,21 +97,6 @@ const DELETE_USER = gql` } `; -const ENUMS = gql` - query Enums { - enums { - UserRole { - key - label - } - TeamMemberSex { - key - label - } - } - } -`; - const ADMIN_AREAS = gql` query AdminAreas($filters: AdminAreaFilter) { adminAreas(filters: $filters) { From b3fb6a8a1f0feaf539dbde81cf68d882c15578cc Mon Sep 17 00:00:00 2001 From: Shreeyash Shrestha Date: Wed, 1 Jul 2026 11:19:48 +0545 Subject: [PATCH 2/2] Replace with useGlobalEnum hook --- app/views/Users/UserListFilters/index.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/views/Users/UserListFilters/index.tsx b/app/views/Users/UserListFilters/index.tsx index 4c1900f..6e0dfe6 100644 --- a/app/views/Users/UserListFilters/index.tsx +++ b/app/views/Users/UserListFilters/index.tsx @@ -6,10 +6,8 @@ import { import { type EntriesAsList } from '@togglecorp/toggle-form'; import RegionSearchMultiSelectInput, { type AdminAreaItem } from '#components/RegionSearchMultiSelectInput'; -import { - AdminAreaLevel, - useEnumsQuery, -} from '#generated/types/graphql'; +import { AdminAreaLevel } from '#generated/types/graphql'; +import useGlobalEnums from '#hooks/useGlobalEnums'; import { keySelector, labelSelector, @@ -25,13 +23,11 @@ export interface Props { } function UserFilter({ value, onChange }: Props) { - const [{ data: enumsData }] = useEnumsQuery(); - const [regionOptions, setRegionOptions] = useState< AdminAreaItem[] | undefined | null >([]); - const roleOptions = enumsData?.enums?.UserRole; + const { userRole: roleOptions } = useGlobalEnums(); return ( <>