Skip to content

Commit 38a807a

Browse files
authored
chore: add search bar and text highlight for choose app modal screen (#949)
## Details - Add search bar for choose app modal screen to maintain functionality as before - Add text highlight and fuzzysearch based on singleselect ## After Screenshot https://github.com/user-attachments/assets/eba28159-66a4-4f46-95ac-551edbecb6c9 ## Tests - Apps should be filtered and highlighted by either the name and description - Toolbox app actions should be filtered as well - Choose trigger won't have the search bar
1 parent 6c95701 commit 38a807a

File tree

3 files changed

+158
-26
lines changed

3 files changed

+158
-26
lines changed

packages/frontend/src/components/FlowStepConfigurationModal/ChooseAppAndEvent/ChooseApp.tsx

Lines changed: 113 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
import type { IAction, IApp, ITrigger } from '@plumber/types'
22

3-
import { useCallback, useContext, useMemo } from 'react'
4-
import { BiArrowFromRight, BiChevronRight } from 'react-icons/bi'
3+
import { useCallback, useContext, useMemo, useState } from 'react'
4+
import {
5+
BiArrowFromRight,
6+
BiChevronRight,
7+
BiSearch,
8+
BiSolidXCircle,
9+
} from 'react-icons/bi'
510
import {
611
Box,
712
Flex,
813
Icon,
914
Image,
15+
InputGroup,
16+
InputLeftElement,
17+
InputRightElement,
1018
ModalBody,
1119
ModalHeader,
1220
Text,
1321
} from '@chakra-ui/react'
14-
import { Badge, ModalCloseButton } from '@opengovsg/design-system-react'
22+
import { Badge, Input, ModalCloseButton } from '@opengovsg/design-system-react'
23+
import fuzzysort from 'fuzzysort'
1524
import { groupBy } from 'lodash'
1625

1726
import { getAppActionFlag, getAppFlag } from '@/config/flags'
@@ -26,6 +35,7 @@ import {
2635
import { FlowStepConfigurationContext } from '../FlowStepConfigurationContext'
2736

2837
import FeedbackFooter from './FeedbackFooter'
38+
import { HighlightedText } from './HighlightedText'
2939
import ToolboxEvent from './ToolboxEvent'
3040

3141
const OTHERS_CATEGORY = 'Other'
@@ -45,6 +55,8 @@ export default function ChooseApp(props: ChooseAppProps) {
4555
const [_, isInitializingIfThen] = useIfThenInitializer()
4656
const isLoading = launchDarkly.isLoading || isInitializingIfThen
4757

58+
const [searchQuery, setSearchQuery] = useState('')
59+
4860
const onSelectApp = useCallback(
4961
(app: IApp) => {
5062
patchModalState({
@@ -55,8 +67,19 @@ export default function ChooseApp(props: ChooseAppProps) {
5567
[patchModalState],
5668
)
5769

70+
const handleSelectOption = useCallback(
71+
(app: IApp, singleTriggerOrAction: ITrigger | IAction | null) => {
72+
if (singleTriggerOrAction) {
73+
onSelectAppEvent(app, singleTriggerOrAction)
74+
} else {
75+
onSelectApp(app)
76+
}
77+
},
78+
[onSelectApp, onSelectAppEvent],
79+
)
80+
5881
const isIfThenSelectable = useIsIfThenSelectable({ isLastStep })
59-
const filteredToolboxActions = useMemo(() => {
82+
const toolboxActionsToDisplay = useMemo(() => {
6083
if (isLoading || !launchDarkly.flags) {
6184
return []
6285
}
@@ -68,7 +91,7 @@ export default function ChooseApp(props: ChooseAppProps) {
6891

6992
const toolboxActions =
7093
apps?.find((app) => app.key === TOOLBOX_APP_KEY)?.actions ?? []
71-
return toolboxActions.filter((action) => {
94+
const filteredToolboxActions = toolboxActions.filter((action) => {
7295
// Filter away actions hidden behind feature flags
7396
if (isLoading || !launchDarkly.flags) {
7497
return true
@@ -77,7 +100,17 @@ export default function ChooseApp(props: ChooseAppProps) {
77100
const ldToolboxActionFlag = getAppActionFlag(TOOLBOX_APP_KEY, action.key)
78101
return launchDarkly.flags[ldToolboxActionFlag] ?? true
79102
})
80-
}, [apps, isLoading, launchDarkly.flags])
103+
104+
const fuzzySearchToolboxActions = fuzzysort
105+
.go(searchQuery, filteredToolboxActions, {
106+
all: true,
107+
keys: ['name', 'description'],
108+
threshold: -1000,
109+
})
110+
.map((result) => result.obj)
111+
112+
return fuzzySearchToolboxActions
113+
}, [apps, isLoading, launchDarkly.flags, searchQuery])
81114

82115
// Combine filtering and grouping logic into a single operation
83116
const groupedApps = useMemo(() => {
@@ -90,9 +123,29 @@ export default function ChooseApp(props: ChooseAppProps) {
90123
return launchDarkly.flags[ldAppFlag] ?? true
91124
})
92125

126+
// Note: Separate toolbox app from other apps because we filter toolbox actions separately
127+
const toolboxApp = filteredApps.find((app) => app.key === TOOLBOX_APP_KEY)
128+
const nonToolboxApps = filteredApps.filter(
129+
(app) => app.key !== TOOLBOX_APP_KEY,
130+
)
131+
132+
const fuzzySearchApps = fuzzysort
133+
.go(searchQuery, nonToolboxApps, {
134+
all: true,
135+
keys: ['name', 'description'],
136+
threshold: -1000,
137+
})
138+
.map((result) => result.obj)
139+
140+
// Add toolbox app back if there are toolbox actions after search and filter
141+
const remainingApps =
142+
toolboxApp && toolboxActionsToDisplay.length > 0
143+
? [...fuzzySearchApps, toolboxApp]
144+
: fuzzySearchApps
145+
93146
// Group the filtered apps
94147
const grouped = groupBy(
95-
filteredApps,
148+
remainingApps,
96149
(app) => app.category || OTHERS_CATEGORY,
97150
)
98151

@@ -106,7 +159,13 @@ export default function ChooseApp(props: ChooseAppProps) {
106159
}
107160
return a[0].localeCompare(b[0])
108161
})
109-
}, [apps, launchDarkly.flags, isLoading])
162+
}, [
163+
apps,
164+
launchDarkly.flags,
165+
isLoading,
166+
searchQuery,
167+
toolboxActionsToDisplay,
168+
])
110169

111170
return (
112171
<>
@@ -122,6 +181,36 @@ export default function ChooseApp(props: ChooseAppProps) {
122181
These are actions that you can add to your workflow.
123182
</Text>
124183
)}
184+
185+
{/* Search bar only appears for actions until we have many more triggers */}
186+
{!isTrigger && (
187+
<InputGroup>
188+
<InputLeftElement pointerEvents="none">
189+
<Icon as={BiSearch} color="base.content.medium" />
190+
</InputLeftElement>
191+
<Input
192+
placeholder="Search for apps..."
193+
value={searchQuery}
194+
onChange={(e) => setSearchQuery(e.target.value)}
195+
_focus={{
196+
borderColor: 'primary.500',
197+
boxShadow: '0 0 0 1px var(--chakra-colors-primary-500)',
198+
}}
199+
autoFocus
200+
/>
201+
<InputRightElement>
202+
{searchQuery && (
203+
<Icon
204+
as={BiSolidXCircle}
205+
cursor="pointer"
206+
opacity={0.6}
207+
_hover={{ opacity: 1 }}
208+
onClick={() => setSearchQuery('')}
209+
/>
210+
)}
211+
</InputRightElement>
212+
</InputGroup>
213+
)}
125214
</Flex>
126215
</ModalHeader>
127216
<ModalCloseButton mt={2} size="xs" />
@@ -173,7 +262,7 @@ export default function ChooseApp(props: ChooseAppProps) {
173262
{apps.map((app) => {
174263
// For toolbox app specifically, show all the toolbox actions
175264
if (app.key === TOOLBOX_APP_KEY) {
176-
return filteredToolboxActions.map((action) => (
265+
return toolboxActionsToDisplay.map((action) => (
177266
<ToolboxEvent
178267
key={action.key}
179268
action={action}
@@ -182,6 +271,7 @@ export default function ChooseApp(props: ChooseAppProps) {
182271
action.key === TOOLBOX_ACTIONS.IfThen &&
183272
!isIfThenSelectable
184273
}
274+
searchQuery={searchQuery}
185275
/>
186276
))
187277
}
@@ -201,13 +291,9 @@ export default function ChooseApp(props: ChooseAppProps) {
201291
borderWidth="1px"
202292
borderColor="base.divider.medium"
203293
borderRadius="lg"
204-
onClick={() => {
205-
if (singleTriggerOrAction) {
206-
onSelectAppEvent(app, singleTriggerOrAction)
207-
} else {
208-
onSelectApp(app)
209-
}
210-
}}
294+
onClick={() =>
295+
handleSelectOption(app, singleTriggerOrAction)
296+
}
211297
justifyContent="space-between"
212298
alignItems="center"
213299
_hover={{
@@ -225,11 +311,7 @@ export default function ChooseApp(props: ChooseAppProps) {
225311
tabIndex={0}
226312
onKeyDown={(e) => {
227313
if (e.key === 'Enter') {
228-
if (singleTriggerOrAction) {
229-
onSelectAppEvent(app, singleTriggerOrAction)
230-
} else {
231-
onSelectApp(app)
232-
}
314+
handleSelectOption(app, singleTriggerOrAction)
233315
}
234316
}}
235317
>
@@ -250,7 +332,10 @@ export default function ChooseApp(props: ChooseAppProps) {
250332

251333
<Flex flexDir="column" gap={1}>
252334
<Flex gap={2}>
253-
<Text textStyle="subhead-1">{app.name}</Text>
335+
<HighlightedText
336+
searchQuery={searchQuery}
337+
textToHighlight={app.name}
338+
/>
254339
{app.isNewApp && (
255340
<Badge
256341
bgColor="interaction.muted.main.active"
@@ -260,7 +345,12 @@ export default function ChooseApp(props: ChooseAppProps) {
260345
</Badge>
261346
)}
262347
</Flex>
263-
<Text textStyle="body-2">{app.description}</Text>
348+
<Text textStyle="body-2">
349+
<HighlightedText
350+
searchQuery={searchQuery}
351+
textToHighlight={app.description ?? ''}
352+
/>
353+
</Text>
264354
</Flex>
265355
</Flex>
266356

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useMemo } from 'react'
2+
import { chakra, Mark } from '@chakra-ui/react'
3+
import fuzzysort from 'fuzzysort'
4+
5+
interface HighlightedTextProps {
6+
searchQuery: string
7+
textToHighlight: string
8+
}
9+
10+
export const HighlightedText = ({
11+
searchQuery,
12+
textToHighlight,
13+
}: HighlightedTextProps) => {
14+
const highlighted = useMemo(() => {
15+
if (!searchQuery) {
16+
return textToHighlight
17+
}
18+
const result = fuzzysort.single(searchQuery, textToHighlight)
19+
// Return the original text if no match is found.
20+
if (!result) {
21+
return textToHighlight
22+
}
23+
return fuzzysort.highlight(result, (match, i) => (
24+
<Mark key={i} bg="primary.100" borderRadius="sm">
25+
{match}
26+
</Mark>
27+
))
28+
}, [searchQuery, textToHighlight])
29+
30+
return <chakra.span>{highlighted}</chakra.span>
31+
}

packages/frontend/src/components/FlowStepConfigurationModal/ChooseAppAndEvent/ToolboxEvent.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { BiFilterAlt, BiGitRepoForked, BiQuestionMark } from 'react-icons/bi'
44
import { Flex, Icon, Text } from '@chakra-ui/react'
55
import { TouchableTooltip } from '@opengovsg/design-system-react'
66

7+
import { HighlightedText } from './HighlightedText'
8+
79
const TOOLBOX_ACTION_TO_ICON_MAP = {
810
onlyContinueIf: BiFilterAlt,
911
ifThen: BiGitRepoForked,
@@ -13,10 +15,11 @@ interface ToolboxEventProps {
1315
action: IAction
1416
onSelectAppEvent: () => void
1517
isDisabled: boolean
18+
searchQuery: string
1619
}
1720

1821
export default function ToolboxEvent(props: ToolboxEventProps): JSX.Element {
19-
const { action, isDisabled, onSelectAppEvent } = props
22+
const { action, isDisabled, onSelectAppEvent, searchQuery } = props
2023
return (
2124
<TouchableTooltip
2225
label={isDisabled ? 'This can only be used as the last step' : ''}
@@ -63,8 +66,16 @@ export default function ToolboxEvent(props: ToolboxEventProps): JSX.Element {
6366
/>
6467

6568
<Flex flexDir="column" gap={1}>
66-
<Text textStyle="subhead-1">{action.name}</Text>
67-
<Text textStyle="body-2">{action.description}</Text>
69+
<HighlightedText
70+
searchQuery={searchQuery}
71+
textToHighlight={action.name}
72+
/>
73+
<Text textStyle="body-2">
74+
<HighlightedText
75+
searchQuery={searchQuery}
76+
textToHighlight={action.description ?? ''}
77+
/>
78+
</Text>
6879
</Flex>
6980
</Flex>
7081
</Flex>

0 commit comments

Comments
 (0)