11import 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'
510import {
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'
1524import { groupBy } from 'lodash'
1625
1726import { getAppActionFlag , getAppFlag } from '@/config/flags'
@@ -26,6 +35,7 @@ import {
2635import { FlowStepConfigurationContext } from '../FlowStepConfigurationContext'
2736
2837import FeedbackFooter from './FeedbackFooter'
38+ import { HighlightedText } from './HighlightedText'
2939import ToolboxEvent from './ToolboxEvent'
3040
3141const 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
0 commit comments