Skip to content

Commit b241e14

Browse files
m0ngghkevinkim-ogp
authored andcommitted
PLU-479: [SIDE-DRAWER-29]: add announcement modal (#1014)
**Waiting for design for the last modal screen from Stacey** ## Details - Add modal for editor to show announcements using local storage key, only when local storage timestamp matches the `LATEST_ANNOUNCEMENT_MODAL_TIMESTAMP` in the code ## Tests - [ ] Announcement modal appears on first load into any pipe - [ ] Modal should still load if you refresh the page without closing it - [ ] Modal should not load again after clicking `Experience it now` or closing the modal - [ ] Modal appears below the DemoFlowModal for template with demo video
1 parent f12a93e commit b241e14

File tree

9 files changed

+532
-0
lines changed

9 files changed

+532
-0
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useMemo } from 'react'
2+
import { Components } from 'react-markdown'
3+
import { Box, Image, ModalBody, ModalHeader, Text } from '@chakra-ui/react'
4+
import { AnimationConfigWithData } from 'lottie-web'
5+
import { RequireExactlyOne } from 'type-fest'
6+
7+
import MarkdownRenderer from '@/components/MarkdownRenderer'
8+
import LottieWebAnimation from '@/components/NewsDrawer/LottieWebAnimation'
9+
10+
type AnnouncementItemMultimedia = RequireExactlyOne<
11+
{
12+
url: string
13+
animationData: AnimationConfigWithData['animationData']
14+
},
15+
'url' | 'animationData'
16+
>
17+
18+
export interface AnnouncementItemProps {
19+
title: string
20+
details: string
21+
multimedia?: AnnouncementItemMultimedia
22+
}
23+
24+
const mdComponents: Components = {
25+
li: ({ node, ...props }) => {
26+
// Check if this is a top-level <ul>
27+
const isTopLevel =
28+
!node.position?.start?.offset || node.position.start.column === 1
29+
30+
return (
31+
<li
32+
{...props}
33+
style={{
34+
listStyleType: isTopLevel ? 'none' : 'disc',
35+
lineHeight: '1.75',
36+
paddingLeft: isTopLevel ? '0' : '0.5rem',
37+
marginLeft: isTopLevel ? '-1rem' : '0rem',
38+
}}
39+
/>
40+
)
41+
},
42+
}
43+
44+
export default function AnnouncementItem(props: AnnouncementItemProps) {
45+
const { title, details, multimedia } = props
46+
const displayedMultimedia = useMemo(() => {
47+
if (!multimedia) {
48+
return
49+
}
50+
if (multimedia.animationData) {
51+
return (
52+
<LottieWebAnimation
53+
title={title}
54+
animationData={multimedia.animationData}
55+
/>
56+
)
57+
}
58+
return (
59+
<Image
60+
borderTopRadius="lg"
61+
fit="fill"
62+
src={multimedia.url}
63+
title={title}
64+
alt={title}
65+
/>
66+
)
67+
}, [multimedia, title])
68+
69+
return (
70+
<>
71+
{displayedMultimedia && <Box>{displayedMultimedia}</Box>}
72+
<ModalHeader mb={displayedMultimedia ? 0 : 2}>
73+
<Text textStyle="h4">{title}</Text>
74+
</ModalHeader>
75+
76+
<ModalBody>
77+
<MarkdownRenderer source={details} components={mdComponents} />
78+
</ModalBody>
79+
</>
80+
)
81+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import dedent from 'dedent'
2+
3+
import GraphicAnimation from './assets/Graphic.json'
4+
import RedesignPipe from './assets/redesign-pipe.svg'
5+
import WorkflowVisualisation from './assets/workflow-visualisation.svg'
6+
import { AnnouncementItemProps } from './AnnouncementItem'
7+
8+
export const ANNOUNCEMENT_ITEM_LIST: AnnouncementItemProps[] = [
9+
{
10+
title: 'Redesigned pipe building experience',
11+
details:
12+
'Set up steps on the side without losing an overview of your workflow. This helps to reduce the need to scroll up and down to reference previous steps and also to focus your attention on setting up the current step.',
13+
multimedia: {
14+
url: RedesignPipe,
15+
},
16+
},
17+
{
18+
title: 'Rename your steps',
19+
details:
20+
'Add more context to your workflow by renaming steps - make handovers easier and help others understand what happens in each step.',
21+
multimedia: {
22+
animationData: GraphicAnimation,
23+
},
24+
},
25+
{
26+
title: 'Better workflow visualisation',
27+
details:
28+
'View steps within branches when using if then - for a more complete overview of your workflow.',
29+
multimedia: {
30+
url: WorkflowVisualisation,
31+
},
32+
},
33+
{
34+
title: 'Other fixes and improvements',
35+
details: dedent`
36+
- ✏️ &nbsp;**Set up steps faster**
37+
* Added prompts to save your step so you don't lose your work
38+
* Rearranged fields for easier mapping and readability
39+
40+
41+
- 🔍 &nbsp;**Find what you need quickly**
42+
* Sorted apps and tools by categories to help you choose the right ones faster
43+
* Added quick links to view connected forms and locate your M365 Excel folder
44+
45+
46+
- ☝️ &nbsp;**One time setup**
47+
* Created a one-time connection to your M365 account — no repeated connection needed
48+
`,
49+
},
50+
]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { FC } from 'react'
2+
import { Box, BoxProps } from '@chakra-ui/react'
3+
import { HTMLMotionProps, motion } from 'framer-motion'
4+
import { Merge } from 'type-fest'
5+
6+
export type MotionBoxProps = Merge<BoxProps, HTMLMotionProps<'div'>>
7+
export const MotionBox = motion(Box) as FC<MotionBoxProps>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { useMemo } from 'react'
2+
import { Box } from '@chakra-ui/react'
3+
4+
import { MotionBox } from './MotionBox'
5+
6+
const ActiveIndicator = (): JSX.Element => (
7+
<Box
8+
// Top required to align it with CircleIndicators
9+
top="0.125rem"
10+
width="1.5rem"
11+
height="0.5rem"
12+
borderRadius="full"
13+
backgroundColor="secondary.500"
14+
position="absolute"
15+
/>
16+
)
17+
18+
interface CircleIndicatorProps {
19+
onClick: () => void
20+
isActiveIndicator: boolean
21+
}
22+
23+
const CircleIndicator = ({
24+
onClick,
25+
isActiveIndicator,
26+
}: CircleIndicatorProps): JSX.Element => {
27+
return (
28+
<Box
29+
width="0.75rem"
30+
height="0.75rem"
31+
padding="0.125rem"
32+
borderRadius="full"
33+
backgroundColor="secondary.200"
34+
marginRight={isActiveIndicator ? '1.25rem' : '0.25rem'}
35+
onClick={onClick}
36+
_hover={{ backgroundColor: 'secondary.300' }}
37+
_focus={
38+
isActiveIndicator
39+
? undefined
40+
: {
41+
backgroundColor: 'secondary.300',
42+
boxShadow: `0 0 0 1px var(--chakra-colors-secondary-400)`,
43+
}
44+
}
45+
backgroundClip="content-box"
46+
as="button"
47+
/>
48+
)
49+
}
50+
51+
interface ProgressIndicatorProps {
52+
numIndicators: number
53+
currActiveIdx: number
54+
onClick: (indicatorIdx: number) => void
55+
}
56+
57+
export const ProgressIndicator = ({
58+
numIndicators,
59+
currActiveIdx,
60+
onClick,
61+
}: ProgressIndicatorProps): JSX.Element => {
62+
const animationProps = useMemo(() => {
63+
return { x: `${currActiveIdx + 0.125}rem` }
64+
}, [currActiveIdx])
65+
66+
return (
67+
<Box display="inline-flex" alignSelf="center">
68+
{[...Array(numIndicators)].map((_, idx) => (
69+
<CircleIndicator
70+
key={idx}
71+
isActiveIndicator={idx === currActiveIdx}
72+
onClick={() => onClick(idx)}
73+
aria-label={`Page ${idx + 1} of ${numIndicators}`}
74+
/>
75+
))}
76+
77+
<MotionBox
78+
// Absolute positioning is required for the active progress indicator to slide over inactive ones
79+
pos="absolute"
80+
animate={animationProps}
81+
transition={{ stiffness: 100 }}
82+
>
83+
<ActiveIndicator />
84+
</MotionBox>
85+
</Box>
86+
)
87+
}

packages/frontend/src/components/EditorLayout/AnnouncementModal/assets/Graphic.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

packages/frontend/src/components/EditorLayout/AnnouncementModal/assets/redesign-pipe.svg

Lines changed: 53 additions & 0 deletions
Loading

packages/frontend/src/components/EditorLayout/AnnouncementModal/assets/workflow-visualisation.svg

Lines changed: 119 additions & 0 deletions
Loading
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { useState } from 'react'
2+
import {
3+
Box,
4+
Flex,
5+
Modal,
6+
ModalContent,
7+
ModalFooter,
8+
ModalOverlay,
9+
} from '@chakra-ui/react'
10+
import {
11+
Button,
12+
ModalCloseButton,
13+
useIsMobile,
14+
} from '@opengovsg/design-system-react'
15+
import { AnimatePresence } from 'framer-motion'
16+
17+
import AnnouncementItem from './AnnouncementItem'
18+
import { ANNOUNCEMENT_ITEM_LIST } from './AnnouncementItemList'
19+
import { MotionBox } from './MotionBox'
20+
import { ProgressIndicator } from './ProgressIndicator'
21+
22+
const ITEMS_LENGTH = ANNOUNCEMENT_ITEM_LIST.length
23+
export const LOCAL_STORAGE_ANNOUNCEMENT_LAST_OPENED_KEY =
24+
'announcement-modal-last-opened'
25+
26+
export const LATEST_ANNOUNCEMENT_MODAL_TIMESTAMP = '2025-05-28'
27+
28+
interface AnnouncementModalProps {
29+
isOpen: boolean
30+
onClose: () => void
31+
}
32+
33+
export default function AnnouncementModal(props: AnnouncementModalProps) {
34+
const { isOpen, onClose } = props
35+
const [currActiveIdx, setCurrActiveIdx] = useState<number>(0)
36+
const currAnnouncement = ANNOUNCEMENT_ITEM_LIST[currActiveIdx]
37+
const isFirstAnnouncement = currActiveIdx === 0
38+
const isLastAnnouncement = currActiveIdx === ITEMS_LENGTH - 1
39+
const isMobile = useIsMobile()
40+
41+
return (
42+
<Modal
43+
isOpen={isOpen}
44+
onClose={onClose}
45+
autoFocus={false}
46+
closeOnOverlayClick={false}
47+
closeOnEsc={false}
48+
>
49+
<ModalOverlay />
50+
<ModalContent
51+
borderRadius="lg"
52+
display="flex"
53+
flexDirection="column"
54+
h="630px"
55+
>
56+
<ModalCloseButton size="xs" zIndex={1} mr={-4} mt={-2} />
57+
58+
<Box flexGrow={1} overflow={isMobile ? 'scroll' : 'none'}>
59+
<AnimatePresence mode="wait">
60+
<MotionBox
61+
key={currActiveIdx}
62+
initial={{ opacity: 0 }}
63+
animate={{ opacity: 1 }}
64+
exit={{ opacity: 0 }}
65+
transition={{ duration: 0.1 }}
66+
>
67+
<AnnouncementItem {...currAnnouncement} />
68+
</MotionBox>
69+
</AnimatePresence>
70+
</Box>
71+
72+
<ModalFooter>
73+
<Flex
74+
width="100vw"
75+
alignItems="center"
76+
justifyContent="space-between"
77+
>
78+
<ProgressIndicator
79+
numIndicators={ITEMS_LENGTH}
80+
currActiveIdx={currActiveIdx}
81+
onClick={setCurrActiveIdx}
82+
/>
83+
<Flex gap={4}>
84+
{!isFirstAnnouncement && (
85+
<Button
86+
onClick={() => setCurrActiveIdx(currActiveIdx - 1)}
87+
variant="clear"
88+
colorScheme="secondary"
89+
>
90+
Back
91+
</Button>
92+
)}
93+
94+
{isLastAnnouncement ? (
95+
<Button onClick={onClose}>Experience it now</Button>
96+
) : (
97+
<Button onClick={() => setCurrActiveIdx(currActiveIdx + 1)}>
98+
Next
99+
</Button>
100+
)}
101+
</Flex>
102+
</Flex>
103+
</ModalFooter>
104+
</ModalContent>
105+
</Modal>
106+
)
107+
}

packages/frontend/src/components/EditorLayout/index.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ import InvalidEditorPage from '@/pages/Editor/components/InvalidEditorPage'
3535
import { EDITOR_MARGIN_TOP } from '../Editor/constants'
3636
import UnsavedChangesAlert from '../Editor/UnsavedChangesAlert'
3737

38+
import AnnouncementModal, {
39+
LATEST_ANNOUNCEMENT_MODAL_TIMESTAMP,
40+
LOCAL_STORAGE_ANNOUNCEMENT_LAST_OPENED_KEY,
41+
} from './AnnouncementModal'
3842
import EditorSnackbar from './EditorSnackbar'
3943
import { LensSurvey } from './LensSurvey'
4044

@@ -66,6 +70,22 @@ export default function EditorLayout() {
6670
setSearchParams(searchParams, { replace: true })
6771
}, [searchParams, setSearchParams])
6872

73+
// for loading announcement modal
74+
const [localLatestTimestamp, setLocalLatestTimestamp] = useState(
75+
localStorage.getItem(LOCAL_STORAGE_ANNOUNCEMENT_LAST_OPENED_KEY),
76+
)
77+
78+
const handleCloseAnnouncementModal = useCallback(() => {
79+
localStorage.setItem(
80+
LOCAL_STORAGE_ANNOUNCEMENT_LAST_OPENED_KEY,
81+
LATEST_ANNOUNCEMENT_MODAL_TIMESTAMP,
82+
)
83+
setLocalLatestTimestamp(LATEST_ANNOUNCEMENT_MODAL_TIMESTAMP)
84+
}, [])
85+
86+
const shouldOpenAnnouncementModal =
87+
localLatestTimestamp !== LATEST_ANNOUNCEMENT_MODAL_TIMESTAMP
88+
6989
// phase 1: add check to prevent user from publishing pipe after submitting request
7090
const requestedEmail = flow?.pendingTransfer?.newOwner.email ?? ''
7191
const hasFlowTransfer = requestedEmail !== ''
@@ -292,6 +312,13 @@ export default function EditorLayout() {
292312
handleUnpublish={() => onFlowStatusUpdate(!flow.active)}
293313
></EditorSnackbar>
294314

315+
{shouldOpenAnnouncementModal && (
316+
<AnnouncementModal
317+
isOpen={shouldOpenAnnouncementModal}
318+
onClose={handleCloseAnnouncementModal}
319+
/>
320+
)}
321+
295322
{shouldOpenDemoModal && (
296323
<DemoFlowModal
297324
onClose={handleClose}

0 commit comments

Comments
 (0)