diff --git a/app/Root/config/routes.ts b/app/Root/config/routes.ts index 0150db8..0353e2f 100644 --- a/app/Root/config/routes.ts +++ b/app/Root/config/routes.ts @@ -184,6 +184,19 @@ const onlineInteractive: RouteConfig = { load: () => import('#views/OnlineInteractive'), visibility: 'is-authenticated', }; + +const createOnlineInteractive: RouteConfig = { + path: '/online-interactive/create', + load: () => import('#views/OnlineInteractive/OnlineInteractiveForm'), + visibility: 'is-authenticated', +}; + +const editOnlineInteractive: RouteConfig = { + path: '/online-interactive/:id/edit', + load: () => import('#views/OnlineInteractive/OnlineInteractiveForm'), + visibility: 'is-authenticated', +}; + const galleries: RouteConfig = { index: true, path: '/galleries', @@ -238,6 +251,8 @@ const routes = { editResourceDashboard, documents, onlineInteractive, + createOnlineInteractive, + editOnlineInteractive, galleries, editTeamMember, links, diff --git a/app/Root/index.tsx b/app/Root/index.tsx index 8cb8b76..920f5af 100644 --- a/app/Root/index.tsx +++ b/app/Root/index.tsx @@ -69,6 +69,7 @@ function RootContent() { userRole: globalEnumsData?.enums.UserRole, teamMemberSex: globalEnumsData?.enums.TeamMemberSex, dashboardPage: globalEnumsData?.enums.DashboardPage, + reportContentType: globalEnumsData?.enums.ReportContentType, }), [globalEnumsData]); return ( diff --git a/app/Root/query.ts b/app/Root/query.ts index cda40cb..48ae983 100644 --- a/app/Root/query.ts +++ b/app/Root/query.ts @@ -20,6 +20,10 @@ export const GLOBAL_ENUMS = gql` key label } + ReportContentType { + key + label + } } } `; diff --git a/app/contexts/GlobalEnumsContext.ts b/app/contexts/GlobalEnumsContext.ts index b9786a8..2d0c352 100644 --- a/app/contexts/GlobalEnumsContext.ts +++ b/app/contexts/GlobalEnumsContext.ts @@ -3,6 +3,7 @@ import { createContext } from 'react'; import type { AppEnumCollectionDashboardPage, AppEnumCollectionLinkType, + AppEnumCollectionReportContentType, AppEnumCollectionTeamMemberSex, AppEnumCollectionUserRole, } from '#generated/types/graphql'; @@ -12,6 +13,7 @@ export interface GlobalEnumsContextInterface { userRole: AppEnumCollectionUserRole[] | undefined; teamMemberSex: AppEnumCollectionTeamMemberSex[] | undefined; dashboardPage: AppEnumCollectionDashboardPage[] | undefined; + reportContentType: AppEnumCollectionReportContentType[] | undefined; } const GlobalEnumsContext = createContext({ @@ -19,6 +21,7 @@ const GlobalEnumsContext = createContext({ userRole: undefined, teamMemberSex: undefined, dashboardPage: undefined, + reportContentType: undefined, }); export default GlobalEnumsContext; diff --git a/app/views/OnlineInteractive/OnlineInteractiveFilter/index.tsx b/app/views/OnlineInteractive/OnlineInteractiveFilter/index.tsx new file mode 100644 index 0000000..6d20ae2 --- /dev/null +++ b/app/views/OnlineInteractive/OnlineInteractiveFilter/index.tsx @@ -0,0 +1,22 @@ +import { TextInput } from '@ifrc-go/ui'; +import { type EntriesAsList } from '@togglecorp/toggle-form'; + +import { type OnlineInteractiveFilterType } from '../index'; + +export interface Props { + value: OnlineInteractiveFilterType; + onChange: (...args: EntriesAsList) => void; +} + +function OnlineInteractiveFilter({ value, onChange }: Props) { + return ( + + ); +} + +export default OnlineInteractiveFilter; diff --git a/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx b/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx new file mode 100644 index 0000000..3019d65 --- /dev/null +++ b/app/views/OnlineInteractive/OnlineInteractiveForm/index.tsx @@ -0,0 +1,280 @@ +import { + useCallback, + useEffect, + useMemo, +} from 'react'; +import { useParams } from 'react-router'; +import { + BlockLoading, + Button, + Container, + Description, + InlineLayout, + InputSection, + ListView, + RawFileInput, + TextInput, +} from '@ifrc-go/ui'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import { + createSubmitHandler, + getErrorObject, + type ObjectSchema, + type PartialForm, + requiredStringCondition, + useForm, +} from '@togglecorp/toggle-form'; + +import NonFieldError from '#components/NonFieldError'; +import { + ReportContentType, + type ReportCreateInput, + ReportTypeEnum, + type ReportUpdateInput, + useCreateOnlineInteractiveMutation, + useOnlineInteractiveDetailQuery, + useUpdateOnlineInteractiveMutation, +} from '#generated/types/graphql'; +import useAlert from '#hooks/useAlert'; +import useRouting from '#hooks/useRouting'; +import { + errorMessage, + transformToFormError, +} from '#utils/common'; + +type FormFields = Pick; +type PartialFormType = PartialForm; +type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType; + +function getOnlineInteractiveSchema(isEditing: boolean): FormSchema { + return { + fields: (): FormSchemaFields => ({ + title: { + required: true, + requiredValidation: requiredStringCondition, + }, + contentType: { + required: true, + }, + reportType: { + required: true, + }, + file: { + required: !isEditing, + }, + }), + }; +} + +const defaultFormValue: PartialFormType = { + contentType: ReportContentType.File, + reportType: ReportTypeEnum.OnlineInteractive, +}; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +function OnlineInteractiveForm() { + const { id } = useParams(); + const navigate = useRouting(); + const alert = useAlert(); + + const isEditing = isDefined(id); + + const schema = useMemo(() => getOnlineInteractiveSchema(isEditing), [isEditing]); + + const { + setFieldValue, + error: formError, + value, + validate, + setError, + setValue, + } = useForm(schema, { value: defaultFormValue }); + + const [{ data: detailData, fetching: detailFetching }] = useOnlineInteractiveDetailQuery({ + variables: { id: isDefined(id) ? id : '' }, + pause: isNotDefined(id), + }); + + const [{ fetching: creating }, createOnlineInteractive] = useCreateOnlineInteractiveMutation(); + const [{ fetching: updating }, updateOnlineInteractive] = useUpdateOnlineInteractiveMutation(); + + const pending = creating || updating || detailFetching; + + const fileName = value.file instanceof File + ? value.file.name + : detailData?.report?.file?.name?.split('/').pop(); + + const handleFileInputChange = useCallback( + (file: File | undefined, name: 'file') => { + if (isDefined(file) && file.size > MAX_FILE_SIZE) { + alert.show('File must be 5MB or smaller', { variant: 'danger' }); + return; + } + setFieldValue(file, name); + }, + [alert, setFieldValue], + ); + + const handleResult = useCallback(( + result: { + ok?: boolean | null; + errors?: Parameters[0] | null; + } | undefined | null, + successMessage: string, + ) => { + if (isDefined(result) && result.ok) { + navigate('onlineInteractive'); + alert.show(successMessage, { variant: 'success' }); + } else if (isDefined(result) && isDefined(result.errors)) { + setError(transformToFormError(result.errors)); + alert.show(errorMessage, { variant: 'danger' }); + } else { + alert.show(errorMessage, { variant: 'danger' }); + } + }, [navigate, alert, setError]); + + const handleCreate = useCallback(async (formValues: PartialFormType) => { + const { file, ...rest } = formValues; + + const res = await createOnlineInteractive({ + data: { + ...rest, + ...(isDefined(file) ? { file } : {}), + } as ReportCreateInput, + }); + + handleResult(res.data?.createReport, 'Online interactive created successfully'); + }, [createOnlineInteractive, handleResult]); + + const handleUpdate = useCallback(async (formValues: PartialFormType) => { + if (isNotDefined(id)) { + return; + } + const { file, title } = formValues; + + const res = await updateOnlineInteractive({ + id, + data: { + title, + ...(isDefined(file) ? { file } : {}), + } as ReportUpdateInput, + }); + + handleResult(res.data?.updateReport, 'Online interactive updated successfully'); + }, [id, updateOnlineInteractive, handleResult]); + + const handleFormSubmit = useCallback( + () => createSubmitHandler( + validate, + setError, + isDefined(id) ? handleUpdate : handleCreate, + )(), + [validate, setError, id, handleUpdate, handleCreate], + ); + + const handleCancel = useCallback(() => { + navigate('onlineInteractive'); + }, [navigate]); + + const error = getErrorObject(formError); + + useEffect(() => { + if (isNotDefined(detailData?.report)) { + return; + } + setValue({ + ...defaultFormValue, + title: detailData.report.title, + }); + }, [detailData, setValue]); + + if (detailFetching) { + return ( + + ); + } + + return ( + + + + + )} + > + + + + + + + + {isDefined(fileName) ? 'Change file' : 'Upload file'} + + )} + > + + {isDefined(fileName) ? fileName : 'Please upload a file'} + + + + + + + ); +} + +export default OnlineInteractiveForm; diff --git a/app/views/OnlineInteractive/index.tsx b/app/views/OnlineInteractive/index.tsx index 7afa396..1129073 100644 --- a/app/views/OnlineInteractive/index.tsx +++ b/app/views/OnlineInteractive/index.tsx @@ -1,8 +1,182 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { AddFillIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + Pager, + Table, +} from '@ifrc-go/ui'; +import { + createDateColumn, + createElementColumn, + createNumberColumn, + createStringColumn, +} from '@ifrc-go/ui/utils'; + +import EditDeleteActions, { type Props as EditDeleteActionsProps } from '#components/EditDeleteActions'; +import { + type OnlineInteractivesQuery, + type ReportFilter, + ReportTypeEnum, + useDeleteOnlineInteractiveMutation, + useOnlineInteractivesQuery, +} 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'; + +import OnlineInteractiveFilter from './OnlineInteractiveFilter'; + +type OnlineInteractiveListItem = NonNullable['results'][number]> & { no: number }; + +export type OnlineInteractiveFilterType = Pick; + +const defaultFilter: OnlineInteractiveFilterType = { + search: undefined, +}; + function OnlineInteractive() { + 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: { + reportType: ReportTypeEnum.OnlineInteractive, + title: filter.search ? { iContains: filter.search } : undefined, + }, + }), [limit, offset, filter]); + + const [ + { fetching, data }, + reExecuteQuery, + ] = useOnlineInteractivesQuery({ variables: queryVariables }); + const [, deleteOnlineInteractive] = useDeleteOnlineInteractiveMutation(); + + const tableData: OnlineInteractiveListItem[] = useMemo(() => ( + (data?.reports?.results ?? []).map((report, index) => ({ + ...report, + no: (page - 1) * limit + index + 1, + })) + ), [page, data, limit]); + + const onDeleteClick = useCallback( + (id: string) => { + deleteOnlineInteractive({ id }).then((resp) => { + const result = resp.data?.deleteReport; + if (result?.ok) { + reExecuteQuery(); + alert.show('Online interactive deleted successfully', { variant: 'success' }); + } else { + alert.show(errorMessage, { variant: 'danger' }); + } + }).catch(() => { + alert.show(errorMessage, { variant: 'danger' }); + }); + }, + [deleteOnlineInteractive, reExecuteQuery, alert], + ); + + const columns = useMemo(() => [ + createNumberColumn( + 'no', + 'No.', + (item) => item.no, + ), + createDateColumn( + 'createdAt', + 'Created At', + (item) => item.createdAt, + ), + createStringColumn( + 'title', + 'Title', + (item) => item.title, + ), + createDateColumn( + 'updatedAt', + 'Updated At', + (item) => item.updatedAt, + ), + createElementColumn( + 'actions', + '', + EditDeleteActions, + (_, datum) => ({ + id: datum.id, + onDelete: onDeleteClick, + itemTitle: datum.title, + to: 'editOnlineInteractive', + }), + { columnWidth: 150 }, + ), + ], [onDeleteClick]); + + const handleCreateClick = useCallback(() => { + navigate('createOnlineInteractive'); + }, [navigate]); + return ( -
- OnlineInteractive -
+ + )} + headerActions={( + + )} + footerActions={( + + )} + > + + ); } diff --git a/app/views/OnlineInteractive/query.ts b/app/views/OnlineInteractive/query.ts new file mode 100644 index 0000000..1188273 --- /dev/null +++ b/app/views/OnlineInteractive/query.ts @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { gql } from 'urql'; + +const ONLINE_INTERACTIVES = gql` + query OnlineInteractives($pagination: OffsetPaginationInput, $filters: ReportFilter) { + reports(pagination: $pagination, filters: $filters) { + results { + id + createdAt + title + updatedAt + } + totalCount + } + } +`; + +const ONLINE_INTERACTIVE_DETAIL = gql` + query OnlineInteractiveDetail($id: ID!) { + report(id: $id) { + id + title + file { + url + name + } + } + } +`; + +const CREATE_ONLINE_INTERACTIVE = gql` + mutation CreateOnlineInteractive($data: ReportCreateInput!) { + createReport(data: $data) { + ... on ReportTypeMutationResponseType { + errors + ok + result { + id + } + } + } + } +`; + +const UPDATE_ONLINE_INTERACTIVE = gql` + mutation UpdateOnlineInteractive($id: ID!, $data: ReportUpdateInput!) { + updateReport(id: $id, data: $data) { + ... on ReportTypeMutationResponseType { + errors + ok + result { + id + } + } + } + } +`; + +const DELETE_ONLINE_INTERACTIVE = gql` + mutation DeleteOnlineInteractive($id: ID!) { + deleteReport(id: $id) { + ... on ReportTypeMutationResponseType { + errors + ok + result { + id + } + } + } + } +`;