Skip to content

Commit a6f45ab

Browse files
committed
feat(eap): add status transition
1 parent 5ccc1bc commit a6f45ab

File tree

8 files changed

+336
-38
lines changed

8 files changed

+336
-38
lines changed

app/src/components/printable/PrintableContainer/styles.module.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
}
88
}
99

10-
.heading:has(+ :empty) {
10+
.heading:has(+ :empty),
11+
.heading:has(+ .heading + :empty) {
1112
display: none;
1213
}
1314

app/src/components/printable/PrintablePage/index.tsx

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import React from 'react';
1+
import React, {
2+
useEffect,
3+
useRef,
4+
useState,
5+
} from 'react';
26
import { Heading } from '@ifrc-go/ui/printable';
37
import { _cs } from '@togglecorp/fujs';
48

@@ -11,6 +15,7 @@ interface Props {
1115
children: React.ReactNode;
1216
heading: React.ReactNode;
1317
description: React.ReactNode;
18+
dataReady?: boolean;
1419
}
1520

1621
function PrintablePage(props: Props) {
@@ -19,10 +24,58 @@ function PrintablePage(props: Props) {
1924
children,
2025
heading,
2126
description,
27+
dataReady = false,
2228
} = props;
2329

30+
const [previewReady, setPreviewReady] = useState(false);
31+
32+
const mainRef = useRef<HTMLDivElement>(null);
33+
34+
useEffect(() => {
35+
if (!dataReady) {
36+
return;
37+
}
38+
39+
const mainContainer = mainRef.current;
40+
41+
async function waitForImages() {
42+
if (!mainContainer) {
43+
return;
44+
}
45+
46+
const images = mainContainer.querySelectorAll('img');
47+
48+
if (images.length === 0) {
49+
setPreviewReady(true);
50+
return;
51+
}
52+
53+
const promises = Array.from(images).map(
54+
(image) => {
55+
if (image.complete) {
56+
return undefined;
57+
}
58+
59+
return new Promise((accept) => {
60+
image.addEventListener('load', () => {
61+
accept(true);
62+
});
63+
});
64+
},
65+
);
66+
67+
await Promise.all(promises);
68+
setPreviewReady(true);
69+
}
70+
71+
waitForImages();
72+
}, [dataReady]);
73+
2474
return (
25-
<main className={_cs(styles.printablePage, className)}>
75+
<main
76+
className={_cs(styles.printablePage, className)}
77+
ref={mainRef}
78+
>
2679
<div className={styles.headerSection}>
2780
<img
2881
className={styles.ifrcLogo}
@@ -40,6 +93,7 @@ function PrintablePage(props: Props) {
4093
</div>
4194
</div>
4295
{children}
96+
{previewReady && <div id="pdf-preview-ready" />}
4397
</main>
4498
);
4599
}

app/src/hooks/useWaitForImages.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {
2+
useEffect,
3+
useState,
4+
} from 'react';
5+
6+
function useWaitForImages() {
7+
const [imagesReady, setImagesReady] = useState(false);
8+
9+
useEffect(() => {
10+
let isCancelled = false;
11+
12+
async function waitForImages() {
13+
const images = Array.from(document.querySelectorAll('img'));
14+
15+
if (images.length === 0) {
16+
if (!isCancelled) {
17+
setImagesReady(true);
18+
}
19+
return;
20+
}
21+
22+
const listeners: Array<{
23+
img: HTMLImageElement;
24+
handler: () => void;
25+
}> = [];
26+
27+
const promises = images.map((image) => {
28+
if (image.complete) {
29+
// Already loaded, nothing to wait for
30+
return undefined;
31+
}
32+
33+
return new Promise<void>((resolve) => {
34+
const handler = () => {
35+
image.removeEventListener('load', handler);
36+
image.removeEventListener('error', handler);
37+
resolve();
38+
};
39+
40+
image.addEventListener('load', handler);
41+
image.addEventListener('error', handler); // treat error as "done"
42+
43+
listeners.push({ img: image, handler });
44+
});
45+
}).filter((p): p is Promise<void> => p !== undefined);
46+
47+
if (promises.length === 0) {
48+
if (!isCancelled) {
49+
setImagesReady(true);
50+
}
51+
return;
52+
}
53+
54+
await Promise.all(promises);
55+
56+
if (!isCancelled) {
57+
setImagesReady(true);
58+
}
59+
}
60+
61+
waitForImages();
62+
63+
return () => {
64+
// avoid setting state after unmount
65+
isCancelled = true;
66+
};
67+
}, []);
68+
69+
return imagesReady;
70+
}
71+
72+
export default useWaitForImages;
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import {
2+
useCallback,
3+
useMemo,
4+
useState,
5+
} from 'react';
6+
import { ArrowRightFillIcon } from '@ifrc-go/icons';
7+
import {
8+
Button,
9+
DropdownMenu,
10+
ListView,
11+
Modal,
12+
} from '@ifrc-go/ui';
13+
import {
14+
isDefined,
15+
listToMap,
16+
} from '@togglecorp/fujs';
17+
18+
import DropdownMenuItem from '#components/DropdownMenuItem';
19+
import { type components } from '#generated/types';
20+
import useGlobalEnums from '#hooks/domain/useGlobalEnums';
21+
import {
22+
type GoApiBody,
23+
useLazyRequest,
24+
} from '#utils/restRequest';
25+
26+
type EapStatus = components['schemas']['EapEapStatusEnumKey'];
27+
type EapStatusBody = GoApiBody<'/api/v2/eap-registration/{id}/status/', 'POST'>;
28+
29+
const EAP_STATUS_UNDER_DEVELOPMENT = 10 satisfies EapStatus;
30+
const EAP_STATUS_UNDER_REVIEW = 20 satisfies EapStatus;
31+
const EAP_STATUS_NS_ADDRESSING_COMMENTS = 30 satisfies EapStatus;
32+
const EAP_STATUS_TECHNICALLY_VALIDATED = 40 satisfies EapStatus;
33+
const EAP_STATUS_APPROVED = 50 satisfies EapStatus;
34+
const EAP_STATUS_PFA_SIGNED = 60 satisfies EapStatus;
35+
const EAP_STATUS_ACTIVATED = 70 satisfies EapStatus;
36+
37+
const validStatusTransition: Record<EapStatus, EapStatus[]> = {
38+
[EAP_STATUS_UNDER_DEVELOPMENT]: [EAP_STATUS_UNDER_REVIEW],
39+
[EAP_STATUS_UNDER_REVIEW]: [EAP_STATUS_NS_ADDRESSING_COMMENTS],
40+
[EAP_STATUS_NS_ADDRESSING_COMMENTS]: [
41+
EAP_STATUS_UNDER_REVIEW,
42+
EAP_STATUS_TECHNICALLY_VALIDATED,
43+
],
44+
[EAP_STATUS_TECHNICALLY_VALIDATED]: [
45+
EAP_STATUS_UNDER_REVIEW,
46+
EAP_STATUS_APPROVED,
47+
],
48+
[EAP_STATUS_APPROVED]: [EAP_STATUS_PFA_SIGNED],
49+
[EAP_STATUS_PFA_SIGNED]: [EAP_STATUS_ACTIVATED],
50+
[EAP_STATUS_ACTIVATED]: [],
51+
};
52+
53+
export interface Props {
54+
eapId: number;
55+
status: EapStatus;
56+
onStatusUpdate?: () => void;
57+
}
58+
59+
function EapStatus(props: Props) {
60+
const {
61+
eapId,
62+
status,
63+
onStatusUpdate,
64+
} = props;
65+
66+
const { eap_eap_status: eapStatusOptions } = useGlobalEnums();
67+
const [newStatus, setNewStatus] = useState<EapStatus | undefined>();
68+
69+
const statusLabelMapping = listToMap(
70+
eapStatusOptions,
71+
({ key }) => key,
72+
({ value }) => value,
73+
);
74+
75+
const { trigger: triggerStatusUpdate } = useLazyRequest({
76+
method: 'POST',
77+
url: '/api/v2/eap-registration/{id}/status/',
78+
pathVariables: {
79+
id: eapId,
80+
},
81+
body: (fields: EapStatusBody) => fields,
82+
onSuccess: () => {
83+
setNewStatus(undefined);
84+
if (onStatusUpdate) {
85+
onStatusUpdate();
86+
}
87+
// TODO alert on status update
88+
},
89+
});
90+
91+
// FIXME: fix typings in the server
92+
const requestBody = useMemo<EapStatusBody>(() => ({
93+
status: newStatus,
94+
review_checklist_file: undefined,
95+
}), [newStatus]);
96+
97+
const handleStatusUpdateCancel = useCallback(() => {
98+
setNewStatus(undefined);
99+
}, []);
100+
101+
return (
102+
<>
103+
<DropdownMenu
104+
label={statusLabelMapping?.[status] ?? '--'}
105+
labelColorVariant="text"
106+
labelStyleVariant="translucent"
107+
>
108+
{eapStatusOptions?.map((option) => (
109+
<DropdownMenuItem
110+
key={option.key}
111+
type="button"
112+
name={option.key}
113+
disabled={!validStatusTransition[status].includes(option.key)}
114+
onClick={setNewStatus}
115+
>
116+
{option.value}
117+
</DropdownMenuItem>
118+
))}
119+
</DropdownMenu>
120+
{isDefined(newStatus) && (
121+
<Modal
122+
// FIXME: use strings
123+
heading="Update Status"
124+
onClose={handleStatusUpdateCancel}
125+
footerActions={(
126+
<Button
127+
name={requestBody}
128+
onClick={triggerStatusUpdate}
129+
>
130+
Confirm
131+
</Button>
132+
)}
133+
>
134+
<ListView layout="block">
135+
<div>
136+
Are you sure you want to update the status?
137+
</div>
138+
<ListView>
139+
<div>
140+
{statusLabelMapping?.[status]}
141+
</div>
142+
<ArrowRightFillIcon />
143+
<div>
144+
{statusLabelMapping?.[newStatus]}
145+
</div>
146+
</ListView>
147+
</ListView>
148+
</Modal>
149+
)}
150+
</>
151+
);
152+
}
153+
154+
export default EapStatus;

app/src/views/AccountMyFormsEap/index.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
useRequest,
3333
} from '#utils/restRequest';
3434

35+
import EapStatus, { type Props as EapStatusProps } from './EapStatus';
3536
import EapTableActions, { type Props as EapTableActionProps } from './EapTableActions';
3637
import Filters, { type FilterValue } from './Filters';
3738

@@ -65,6 +66,7 @@ export function Component() {
6566
const {
6667
response: eapListResponse,
6768
pending: eapListPending,
69+
retrigger: reloadEapList,
6870
} = useRequest({
6971
url: '/api/v2/eap-registration/',
7072
preserveResponse: true,
@@ -113,10 +115,15 @@ export function Component() {
113115
strings.eapType,
114116
(item) => item.eap_type_display,
115117
),
116-
createStringColumn<EapListItem, number>(
118+
createElementColumn<EapListItem, number, EapStatusProps>(
117119
'status_display',
118120
strings.eapStatus,
119-
(item) => item.status_display,
121+
EapStatus,
122+
(key, row) => ({
123+
eapId: key,
124+
status: row.status,
125+
onStatusUpdate: reloadEapList,
126+
}),
120127
),
121128
createExpandColumn<EapListItem, Key>(
122129
'expandRow',
@@ -134,6 +141,7 @@ export function Component() {
134141
strings.eapStatus,
135142
expandedRow,
136143
handleExpandClick,
144+
reloadEapList,
137145
],
138146
);
139147

0 commit comments

Comments
 (0)