Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/Root/config/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand Down Expand Up @@ -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',
};
Expand Down
18 changes: 17 additions & 1 deletion app/Root/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/`;
Expand All @@ -35,7 +36,22 @@ 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 }));
},
bulkUpdateExternalDashboards: (_result, _args, cache) => {
cache.inspectFields('Query')
.filter((field) => field.fieldName === 'externalDashboards')
.forEach((field) => {
cache.invalidate('Query', field.fieldName, field.arguments);
});
},
},
},
}),
fetchExchange,
],
fetchOptions: () => ({
Expand Down
104 changes: 104 additions & 0 deletions app/components/Breadcrumbs/index.tsx
Original file line number Diff line number Diff line change
@@ -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<Crumb[]>(
(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 (
<BlockView withPadding>
<BaseBreadcrumbs className={className}>
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1;

if (isLast || !isDefined(crumb.to)) {
return (
<span key={crumb.label}>
{crumb.label}
</span>
);
}

return (
<RouterLink
key={crumb.label}
to={crumb.to}
>
{crumb.label}
</RouterLink>
);
})}
</BaseBreadcrumbs>
</BlockView>
);
}

export default Breadcrumbs;
18 changes: 14 additions & 4 deletions app/components/BulkImportModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TeamMemberCreateInput, 'team' | 'order'>;

Expand Down Expand Up @@ -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 }));
Expand Down Expand Up @@ -265,8 +275,8 @@ function BulkImportModal(props: Props) {

return (
<Modal
heading={`IMPORT TEAM MEMBERS FOR ${teamName ?? ''}`}
headerDescription="Please upload team member in xlxs format"
heading={isDefined(teamName) ? `Import Team Members for ${teamName}` : 'Import Team Members'}
headerDescription="Please upload team members in xlsx format"
onClose={onClose}
footerActions={isDefined(members) ? (
<Button
Expand All @@ -290,7 +300,7 @@ function BulkImportModal(props: Props) {
>
<RawFileInput
name="file"
accept=".xlsx, .xlsm"
accept={ACCEPTED_IMPORT_FILE_TYPES}
onChange={handleFileChange}
styleVariant="outline"
colorVariant="primary"
Expand Down
95 changes: 95 additions & 0 deletions app/components/CoverImageInput/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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<N, T> {
name: N;
value: File | undefined;
existingUrl?: string | null;
onChange: (file: File | undefined, name: N) => void;
error?: Error<T>;
accept?: string;
disabled?: boolean;
maxSize?: number;
}

function CoverImageInput<N, T>(props: Props<N, T>) {
const {
name,
value,
existingUrl,
onChange,
error,
accept = ACCEPTED_IMAGE_TYPES,
disabled,
maxSize = MAX_IMAGE_SIZE,
} = props;

const [validationError, setValidationError] = useState<string>();

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]);

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 (
<ListView layout="block">
<RawFileInput
name={name}
onChange={handleChange}
accept={accept}
disabled={disabled}
styleVariant="outline"
>
{isDefined(preview) ? 'Change cover image' : 'Upload cover image'}
</RawFileInput>
{isDefined(preview) && (
<Image
src={preview}
alt="Cover image preview"
size="md"
withContainedFit
/>
)}
<NonFieldError error={validationError ?? error} />
</ListView>
);
}

export default CoverImageInput;
43 changes: 6 additions & 37 deletions app/components/FileInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<N, T> {
name: N;
Expand Down
Loading
Loading