diff --git a/app/Root/config/routes.ts b/app/Root/config/routes.ts
index 7e42768..cac5c3d 100644
--- a/app/Root/config/routes.ts
+++ b/app/Root/config/routes.ts
@@ -81,9 +81,22 @@ const editUser: RouteConfig = {
const ourWorks: RouteConfig = {
index: true,
path: '/our-works',
- load: () => import('#views/OurWorks'),
+ load: () => import('#views/OurWorks/WorksList'),
visibility: 'is-authenticated',
};
+
+const createWorks: RouteConfig = {
+ path: '/our-works/new',
+ load: () => import('#views/OurWorks/WorksForm'),
+ visibility: 'is-authenticated',
+};
+
+const editWorks: RouteConfig = {
+ path: '/our-works/:id/edit',
+ load: () => import('#views/OurWorks/WorksForm'),
+ visibility: 'is-authenticated',
+};
+
const preparedness: RouteConfig = {
index: true,
path: '/preparedness',
@@ -128,6 +141,8 @@ const routes = {
createUser,
editUser,
ourWorks,
+ createWorks,
+ editWorks,
preparedness,
dataAndReports,
documents,
diff --git a/app/components/EmbedPreview/index.tsx b/app/components/EmbedPreview/index.tsx
new file mode 100644
index 0000000..6a6ad82
--- /dev/null
+++ b/app/components/EmbedPreview/index.tsx
@@ -0,0 +1,54 @@
+import { useMemo } from 'react';
+import { Container } from '@ifrc-go/ui';
+import {
+ _cs,
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
+import useDebouncedValue from '#hooks/useDebouncedValue';
+import { getSafeUrl } from '#utils/common';
+
+import styles from './styles.module.css';
+
+export interface Props {
+ className?: string;
+ url: string | undefined | null;
+ title?: string;
+ placeholder?: string;
+}
+
+function EmbedPreview({
+ className,
+ url,
+ title = 'Embed Preview',
+ placeholder = 'Enter a valid embed link to see a live preview',
+}: Props) {
+ const debouncedUrl = useDebouncedValue(url, 500);
+ const previewUrl = useMemo(() => getSafeUrl(debouncedUrl), [debouncedUrl]);
+
+ return (
+
+ {isDefined(previewUrl) && (
+
+ )}
+
+ );
+}
+
+export default EmbedPreview;
diff --git a/app/components/EmbedPreview/styles.module.css b/app/components/EmbedPreview/styles.module.css
new file mode 100644
index 0000000..e5ad2e8
--- /dev/null
+++ b/app/components/EmbedPreview/styles.module.css
@@ -0,0 +1,13 @@
+.preview-section {
+ position: sticky;
+ top: var(--go-ui-spacing-md, 1rem);
+ height: calc(100vh - 12rem);
+ min-height: 30rem;
+ overflow: hidden;
+}
+
+.preview-iframe {
+ border: none;
+ width: 100%;
+ height: 100%;
+}
diff --git a/app/components/StatusCell/index.tsx b/app/components/StatusCell/index.tsx
new file mode 100644
index 0000000..511afe7
--- /dev/null
+++ b/app/components/StatusCell/index.tsx
@@ -0,0 +1,23 @@
+import { ListView } from '@ifrc-go/ui';
+
+export interface Props {
+ isActive: boolean;
+}
+
+function StatusCell({ isActive }: Props) {
+ return (
+
+
+ {isActive ? 'Active' : 'Inactive'}
+
+ );
+}
+
+export default StatusCell;
diff --git a/app/index.css b/app/index.css
index 781eba8..8172ebd 100644
--- a/app/index.css
+++ b/app/index.css
@@ -36,4 +36,21 @@ p {
a {
text-decoration: none;
color: inherit;
+}
+
+.status-indicator-active,
+.status-indicator-inactive {
+ display: inline-block;
+ border-radius: 50%;
+ width: var(--go-ui-spacing-sm);
+ height: var(--go-ui-spacing-sm);
+}
+
+
+.status-indicator-active {
+ background-color: var(--go-ui-color-positive);
+}
+
+.status-indicator-inactive {
+ background-color: var(--go-ui-color-negative);
}
\ No newline at end of file
diff --git a/app/utils/common.ts b/app/utils/common.ts
index 2b9ff35..463e508 100644
--- a/app/utils/common.ts
+++ b/app/utils/common.ts
@@ -1,3 +1,4 @@
+import { isFalsyString } from '@togglecorp/fujs';
import { nonFieldError } from '@togglecorp/toggle-form';
import type { AdminAreaLevel } from '#generated/types/graphql';
@@ -63,3 +64,26 @@ export function transformToFormError(
export type REGION_LEVEL = AdminAreaLevel.Region;
export type ZONE_LEVEL = AdminAreaLevel.Zone;
export type WOREDA_LEVEL = AdminAreaLevel.Woreda;
+
+const SAFE_URL_PROTOCOLS = ['http:', 'https:'];
+
+export function getSafeUrl(value: string | null | undefined): string | undefined {
+ if (isFalsyString(value)) {
+ return undefined;
+ }
+ try {
+ const parsed = new URL(value);
+ return SAFE_URL_PROTOCOLS.includes(parsed.protocol) ? parsed.href : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+export function safeUrlCondition(value: string | null | undefined) {
+ if (isFalsyString(value)) {
+ return undefined;
+ }
+ return getSafeUrl(value)
+ ? undefined
+ : 'Enter a valid URL starting with http:// or https://';
+}
diff --git a/app/views/OurWorks/WorksForm/index.tsx b/app/views/OurWorks/WorksForm/index.tsx
new file mode 100644
index 0000000..8d51af1
--- /dev/null
+++ b/app/views/OurWorks/WorksForm/index.tsx
@@ -0,0 +1,327 @@
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+} from 'react';
+import { useParams } from 'react-router';
+import {
+ BlockLoading,
+ Button,
+ Container,
+ InputSection,
+ ListView,
+ NumberInput,
+ RadioInput,
+ SelectInput,
+ TextInput,
+} from '@ifrc-go/ui';
+import {
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+import {
+ createSubmitHandler,
+ getErrorObject,
+ type ObjectSchema,
+ type PartialForm,
+ removeNull,
+ requiredStringCondition,
+ useForm,
+} from '@togglecorp/toggle-form';
+
+import EmbedPreview from '#components/EmbedPreview';
+import RegionSelectInput from '#components/RegionSelectInput';
+import {
+ AdminAreaLevel,
+ DashboardPage,
+ type ExternalDashboardCreateInput,
+ type ExternalDashboardUpdateInput,
+ useCreateExternalDashboardMutation,
+ useDashboardEnumsQuery,
+ useExternalDashboardDetailQuery,
+ useUpdateExternalDashboardMutation,
+} from '#generated/types/graphql';
+import useAlert from '#hooks/useAlert';
+import useRouting from '#hooks/useRouting';
+import {
+ keySelector,
+ labelSelector,
+ safeUrlCondition,
+ statusOptions,
+ valueSelector,
+} from '#utils/common';
+
+type PartialFormType = PartialForm;
+type FormSchema = ObjectSchema;
+type FormSchemaFields = ReturnType;
+
+const worksSchema: FormSchema = {
+ fields: (): FormSchemaFields => ({
+ page: {
+ required: true,
+ },
+ title: {
+ required: true,
+ requiredValidation: requiredStringCondition,
+ },
+ description: {},
+ region: {},
+ url: {
+ required: true,
+ requiredValidation: requiredStringCondition,
+ validations: [safeUrlCondition],
+ },
+ isActive: {},
+ order: {},
+ showOnHome: {},
+ }),
+};
+
+const defaultEditFormValue: PartialFormType = {
+ isActive: false,
+ showOnHome: false,
+};
+
+function WorksForm() {
+ const { id } = useParams();
+ const navigate = useRouting();
+ const alert = useAlert();
+
+ const {
+ setFieldValue,
+ error: formError,
+ value,
+ validate,
+ setError,
+ setValue,
+ } = useForm(worksSchema, { value: defaultEditFormValue });
+
+ const [{ data, fetching: worksDetailFetch }] = useExternalDashboardDetailQuery({
+ variables: { id: isDefined(id) ? id : '' },
+ pause: isNotDefined(id),
+ });
+
+ const [{ data: enumsData }] = useDashboardEnumsQuery();
+
+ const pageOptions = useMemo(
+ () => enumsData?.enums?.DashboardPage?.filter(
+ (option) => option.key !== DashboardPage.EmergencyAlerts
+ && option.key !== DashboardPage.DisasterResponse,
+ ),
+ [enumsData],
+ );
+
+ const [
+ { fetching: createPending },
+ createExternalDashboard,
+ ] = useCreateExternalDashboardMutation();
+ const [
+ { fetching: updatePending },
+ updateExternalDashboard,
+ ] = useUpdateExternalDashboardMutation();
+
+ const pending = createPending || updatePending || worksDetailFetch;
+
+ const handleMutation = useCallback(async (mutationData: PartialFormType) => {
+ const redirectPath = 'ourWorks';
+ const alertMessage = `Initiative ${isDefined(id) ? 'updated' : 'created'} successfully`;
+
+ if (isDefined(id)) {
+ const updatePayload = removeNull(mutationData) as ExternalDashboardUpdateInput;
+
+ const res = await updateExternalDashboard({
+ id,
+ data: updatePayload,
+ });
+ const result = res.data?.updateExternalDashboard;
+ if (isDefined(result) && result.ok) {
+ navigate(redirectPath);
+ alert.show(alertMessage, { variant: 'success' });
+ } else if (isDefined(result) && isDefined(result.errors)) {
+ setError(result.errors);
+ alert.show(result.errors[0]?.messages, { variant: 'danger' });
+ }
+ } else {
+ const createPayload = removeNull(mutationData) as ExternalDashboardCreateInput;
+ const res = await createExternalDashboard({
+ data: createPayload,
+ });
+ const result = res.data?.createExternalDashboard;
+ if (isDefined(result) && result.ok) {
+ navigate(redirectPath);
+ alert.show(alertMessage, { variant: 'success' });
+ } else if (isDefined(result) && isDefined(result.errors)) {
+ setError(result.errors);
+ alert.show(result.errors[0]?.messages, { variant: 'danger' });
+ }
+ }
+ }, [alert, updateExternalDashboard, id, navigate, setError, createExternalDashboard]);
+
+ const handleFormSubmit = useCallback(
+ () => createSubmitHandler(
+ validate,
+ setError,
+ handleMutation,
+ )(),
+ [validate, setError, handleMutation],
+ );
+
+ const handleCancel = useCallback(() => {
+ navigate('ourWorks');
+ }, [navigate]);
+
+ const error = getErrorObject(formError);
+
+ useEffect(() => {
+ if (isNotDefined(data?.externalDashboard)) {
+ return;
+ }
+ const { regionId, ...otherValues } = removeNull(data.externalDashboard);
+ setValue({
+ ...otherValues,
+ region: regionId ?? undefined,
+ });
+ }, [data, setValue]);
+
+ if (worksDetailFetch || createPending || updatePending) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ )}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default WorksForm;
diff --git a/app/views/OurWorks/WorksList/WorksFilter/index.tsx b/app/views/OurWorks/WorksList/WorksFilter/index.tsx
new file mode 100644
index 0000000..5d833a1
--- /dev/null
+++ b/app/views/OurWorks/WorksList/WorksFilter/index.tsx
@@ -0,0 +1,67 @@
+import { useMemo } from 'react';
+import {
+ SelectInput,
+ TextInput,
+} from '@ifrc-go/ui';
+import { type EntriesAsList } from '@togglecorp/toggle-form';
+
+import {
+ DashboardPage,
+ useDashboardEnumsQuery,
+} from '#generated/types/graphql';
+import {
+ keySelector,
+ labelSelector,
+ statusFilterOptions,
+ valueSelector,
+} from '#utils/common';
+
+import type { WorksFilterType } from '../index';
+
+export interface Props {
+ value: WorksFilterType;
+ onChange: (...args: EntriesAsList) => void;
+}
+
+function WorksFilter({ value, onChange }: Props) {
+ const [{ data: enumsData }] = useDashboardEnumsQuery();
+
+ const pageOptions = useMemo(
+ () => enumsData?.enums?.DashboardPage?.filter(
+ (option) => option.key !== DashboardPage.EmergencyAlerts
+ && option.key !== DashboardPage.DisasterResponse,
+ ),
+ [enumsData],
+ );
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+export default WorksFilter;
diff --git a/app/views/OurWorks/WorksList/index.tsx b/app/views/OurWorks/WorksList/index.tsx
new file mode 100644
index 0000000..364d85c
--- /dev/null
+++ b/app/views/OurWorks/WorksList/index.tsx
@@ -0,0 +1,194 @@
+import {
+ useCallback,
+ useMemo,
+} from 'react';
+import { AddFillIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ Container,
+ Pager,
+ Table,
+} from '@ifrc-go/ui';
+import {
+ createElementColumn,
+ createStringColumn,
+} from '@ifrc-go/ui/utils';
+import { isDefined } from '@togglecorp/fujs';
+
+import EditDeleteActions, { type Props as EditDeleteActionsProps } from '#components/EditDeleteActions';
+import StatusCell from '#components/StatusCell';
+import {
+ type ExternalDashboardFilter,
+ type ExternalDashboardsQuery,
+ useDeleteExternalDashboardMutation,
+ useExternalDashboardsQuery,
+} 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 WorksFilter from './WorksFilter';
+
+type WorksListItem = NonNullable['results'][number]> & { no: string };
+
+export interface WorksFilterType extends Omit {
+ isActive: string | undefined;
+}
+
+const defaultFilter: WorksFilterType = {
+ isActive: undefined,
+ page: undefined,
+ search: undefined,
+};
+
+function OurWorks() {
+ 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: {
+ isActive: isDefined(filter.isActive) ? filter.isActive === 'true' : undefined,
+ page: filter.page,
+ search: filter.search || undefined,
+ },
+ }), [limit, offset, filter]);
+
+ const [{ fetching, data }, reExecuteQuery] = useExternalDashboardsQuery({
+ variables: queryVariables,
+ });
+ const [, deleteExternalDashboard] = useDeleteExternalDashboardMutation();
+
+ const tableData = useMemo(() => (
+ (data?.externalDashboards?.results ?? []).map((dashboard, index) => {
+ const no = (page - 1) * limit + index + 1;
+ return {
+ ...dashboard,
+ no: String(no),
+ };
+ }) as unknown as WorksListItem[]), [page, data, limit]);
+
+ const onDeleteClick = useCallback(
+ (id: string) => {
+ deleteExternalDashboard({ id }).then((resp) => {
+ const result = resp.data?.deleteExternalDashboard;
+ if (isDefined(result) && 'ok' in result && result.ok) {
+ reExecuteQuery();
+ alert.show('Dashboard deleted successfully', { variant: 'success' });
+ return;
+ }
+ const message = isDefined(result) && 'messages' in result
+ ? result.messages.map((item) => item.message).join(' ')
+ : undefined;
+ alert.show(message || errorMessage, { variant: 'danger' });
+ }).catch(() => {
+ alert.show(errorMessage, { variant: 'danger' });
+ });
+ },
+ [deleteExternalDashboard, reExecuteQuery, alert],
+ );
+
+ const columns = useMemo(() => [
+ createStringColumn(
+ 'no',
+ 'No.',
+ (item) => item.no,
+ ),
+ createStringColumn(
+ 'title',
+ 'Title',
+ (item) => item.title,
+ ),
+ createStringColumn(
+ 'operation',
+ 'Operation',
+ (item) => item.pageDisplay,
+ ),
+ createElementColumn(
+ 'status',
+ 'Status',
+ StatusCell,
+ (_, datum) => ({
+ isActive: datum.isActive,
+ }),
+ ),
+ createElementColumn(
+ 'actions',
+ '',
+ EditDeleteActions,
+ (_, datum) => ({
+ id: datum.id,
+ onDelete: onDeleteClick,
+ itemTitle: datum.title,
+ to: 'editWorks',
+ }),
+ { columnWidth: 150 },
+ ),
+ ], [onDeleteClick]);
+
+ const handleCreateClick = useCallback(() => {
+ navigate('createWorks');
+ }, [navigate]);
+
+ return (
+
+ )}
+ headerDescription="Track, organize, and update all ongoing work and initiatives"
+ headerActions={(
+ )}
+ styleVariant="filled"
+ >
+ Create
+
+ )}
+ footerActions={(
+
+ )}
+ >
+
+
+ );
+}
+
+export default OurWorks;
diff --git a/app/views/OurWorks/index.tsx b/app/views/OurWorks/index.tsx
deleted file mode 100644
index 50839be..0000000
--- a/app/views/OurWorks/index.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-function OurWorks() {
- return (
-
- OurWorks
-
- );
-}
-
-export default OurWorks;
diff --git a/app/views/OurWorks/query.ts b/app/views/OurWorks/query.ts
new file mode 100644
index 0000000..3ae4f19
--- /dev/null
+++ b/app/views/OurWorks/query.ts
@@ -0,0 +1,98 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import { gql } from 'urql';
+
+const EXTERNAL_DASHBOARDS = gql`
+ query ExternalDashboards($pagination: OffsetPaginationInput, $filters: ExternalDashboardFilter) {
+ externalDashboards(pagination: $pagination, filters: $filters) {
+ results {
+ createdAt
+ createdById
+ description
+ id
+ isActive
+ order
+ page
+ pageDisplay
+ regionId
+ showOnHome
+ title
+ updatedAt
+ url
+ }
+ totalCount
+ }
+ }
+`;
+
+const DELETE_EXTERNAL_DASHBOARD = gql`
+ mutation DeleteExternalDashboard($id: ID!) {
+ deleteExternalDashboard(id: $id) {
+ ... on ExternalDashboardTypeMutationResponseType {
+ errors
+ ok
+ result {
+ id
+ }
+ }
+ ... on OperationInfo {
+ messages {
+ message
+ }
+ }
+ }
+ }
+`;
+
+const DASHBOARD_ENUMS = gql`
+ query DashboardEnums {
+ enums {
+ DashboardPage {
+ key
+ label
+ }
+ }
+ }
+`;
+
+const EXTERNAL_DASHBOARD_DETAIL = gql`
+ query ExternalDashboardDetail($id: ID!) {
+ externalDashboard(id: $id) {
+ id
+ description
+ isActive
+ order
+ page
+ regionId
+ title
+ url
+ }
+ }
+`;
+
+const CREATE_EXTERNAL_DASHBOARD = gql`
+ mutation CreateExternalDashboard($data: ExternalDashboardCreateInput!) {
+ createExternalDashboard(data: $data) {
+ ... on ExternalDashboardTypeMutationResponseType {
+ errors
+ ok
+ result {
+ id
+ }
+ }
+ }
+ }
+`;
+
+const UPDATE_EXTERNAL_DASHBOARD = gql`
+ mutation UpdateExternalDashboard($id: ID!, $data: ExternalDashboardUpdateInput!) {
+ updateExternalDashboard(id: $id, data: $data) {
+ ... on ExternalDashboardTypeMutationResponseType {
+ errors
+ ok
+ result {
+ id
+ }
+ }
+ }
+ }
+`;
diff --git a/app/views/Users/UserForm/index.tsx b/app/views/Users/UserForm/index.tsx
index 6633e7d..6b590ab 100644
--- a/app/views/Users/UserForm/index.tsx
+++ b/app/views/Users/UserForm/index.tsx
@@ -124,7 +124,7 @@ function UserForm() {
alert.show(alertMessage, { variant: 'success' });
} else if (isDefined(result) && isDefined(result.errors)) {
setError(result.errors);
- alert.show(result.errors?.messages, { variant: 'danger' });
+ alert.show(result.errors[0]?.messages, { variant: 'danger' });
}
} else {
const createPayload = removeNull(mutationData) as unknown as UserCreateInput;
diff --git a/app/views/Users/UsersList/UserListFilters/index.tsx b/app/views/Users/UsersList/UserListFilters/index.tsx
index 849cc69..4c1900f 100644
--- a/app/views/Users/UsersList/UserListFilters/index.tsx
+++ b/app/views/Users/UsersList/UserListFilters/index.tsx
@@ -1,10 +1,11 @@
+import { useState } from 'react';
import {
SelectInput,
TextInput,
} from '@ifrc-go/ui';
import { type EntriesAsList } from '@togglecorp/toggle-form';
-import RegionSelectInput from '#components/RegionSelectInput';
+import RegionSearchMultiSelectInput, { type AdminAreaItem } from '#components/RegionSearchMultiSelectInput';
import {
AdminAreaLevel,
useEnumsQuery,
@@ -26,15 +27,21 @@ export interface Props {
function UserFilter({ value, onChange }: Props) {
const [{ data: enumsData }] = useEnumsQuery();
+ const [regionOptions, setRegionOptions] = useState<
+ AdminAreaItem[] | undefined | null
+ >([]);
+
const roleOptions = enumsData?.enums?.UserRole;
return (
<>
-
['results'][number]> & { no: string };
interface UserInfoCellProps {
@@ -68,31 +67,12 @@ function UserInfoCell({ fullName, email }: UserInfoCellProps) {
);
}
-function StatusCell({ isActive }: { isActive: boolean }) {
- return (
-
-
- {isActive ? 'Active' : 'Inactive'}
-
- );
-}
-
-export interface UsersFilterType {
- region: string | undefined;
- role: string | undefined;
+export interface UsersFilterType extends Omit {
isActive: string | undefined;
- search: string | undefined;
}
const defaultFilter: UsersFilterType = {
- region: undefined,
+ regions: undefined,
role: undefined,
isActive: undefined,
search: undefined,
@@ -121,8 +101,8 @@ function UsersList() {
offset,
},
filters: {
- regions: filter.region ? [filter.region] : undefined,
- role: (filter.role as UserRole) ?? undefined,
+ regions: filter.regions?.length === 0 ? undefined : filter.regions,
+ role: filter.role ?? undefined,
isActive: filter.isActive !== undefined ? filter.isActive === 'true' : undefined,
search: filter.search || undefined,
},
diff --git a/app/views/Users/UsersList/styles.module.css b/app/views/Users/UsersList/styles.module.css
deleted file mode 100644
index fce862c..0000000
--- a/app/views/Users/UsersList/styles.module.css
+++ /dev/null
@@ -1,16 +0,0 @@
-.status-indicator-active,
-.status-indicator-inactive {
- display: inline-block;
- border-radius: 50%;
- width: var(--go-ui-spacing-sm);
- height: var(--go-ui-spacing-sm);
-}
-
-
-.status-indicator-active {
- background-color: var(--go-ui-color-positive);
-}
-
-.status-indicator-inactive {
- background-color: var(--go-ui-color-negative);
-}
\ No newline at end of file