diff --git a/web/src/app/admin/AdminAPIKeys.tsx b/web/src/app/admin/AdminAPIKeys.tsx new file mode 100644 index 0000000000..e99b32b8c7 --- /dev/null +++ b/web/src/app/admin/AdminAPIKeys.tsx @@ -0,0 +1,186 @@ +import React, { useState } from 'react' +import makeStyles from '@mui/styles/makeStyles' +import { Button, Grid, Typography, Card } from '@mui/material' +import { Add } from '@mui/icons-material' +import AdminAPIKeysDrawer from './admin-api-keys/AdminAPIKeyDrawer' +import { GQLAPIKey } from '../../schema' +import { Time } from '../util/Time' +import { gql, useQuery } from 'urql' +import FlatList, { FlatListListItem } from '../lists/FlatList' +import Spinner from '../loading/components/Spinner' +import { GenericError } from '../error-pages' +import { Theme } from '@mui/material/styles' +import AdminAPIKeyCreateDialog from './admin-api-keys/AdminAPIKeyCreateDialog' +import AdminAPIKeyDeleteDialog from './admin-api-keys/AdminAPIKeyDeleteDialog' +import AdminAPIKeyEditDialog from './admin-api-keys/AdminAPIKeyEditDialog' +import OtherActions from '../util/OtherActions' + +const query = gql` + query gqlAPIKeysQuery { + gqlAPIKeys { + id + name + lastUsed { + time + ua + ip + } + expiresAt + allowedFields + } + } +` + +const useStyles = makeStyles((theme: Theme) => ({ + buttons: { + 'margin-bottom': '15px', + }, + containerDefault: { + [theme.breakpoints.up('md')]: { + maxWidth: '100%', + transition: `max-width ${theme.transitions.duration.leavingScreen}ms ease`, + }, + }, + containerSelected: { + [theme.breakpoints.up('md')]: { + maxWidth: '70%', + transition: `max-width ${theme.transitions.duration.enteringScreen}ms ease`, + }, + }, +})) + +export default function AdminAPIKeys(): JSX.Element { + const classes = useStyles() + const [selectedAPIKey, setSelectedAPIKey] = useState(null) + const [createAPIKeyDialogClose, onCreateAPIKeyDialogClose] = useState(false) + const [editDialog, setEditDialog] = useState() + const [deleteDialog, setDeleteDialog] = useState() + + // handles the openning of the create dialog form which is used for creating new API Key + const handleOpenCreateDialog = (): void => { + onCreateAPIKeyDialogClose(!createAPIKeyDialogClose) + } + + // Get API Key triggers/actions + const [{ data, fetching, error }] = useQuery({ query }) + + if (error) { + return + } + + if (fetching && !data) { + return + } + + const items = data.gqlAPIKeys.map( + (key: GQLAPIKey): FlatListListItem => ({ + selected: (key as GQLAPIKey).id === selectedAPIKey?.id, + highlight: (key as GQLAPIKey).id === selectedAPIKey?.id, + primaryText: {key.name}, + disableTypography: true, + subText: ( + + + + + {key.allowedFields.length + + ' allowed fields' + + (key.allowedFields.some((f) => f.startsWith('Mutation.')) + ? '' + : ' (read-only)')} + + + ), + secondaryAction: ( + + + + + + + setEditDialog(key.id), + }, + { + label: 'Delete', + onClick: () => setDeleteDialog(key.id), + }, + ]} + /> + + + ), + onClick: () => setSelectedAPIKey(key), + }), + ) + + return ( + + { + setSelectedAPIKey(null) + }} + apiKeyID={selectedAPIKey?.id} + /> + {createAPIKeyDialogClose ? ( + { + onCreateAPIKeyDialogClose(false) + }} + /> + ) : null} + {deleteDialog ? ( + { + setDeleteDialog('') + }} + apiKeyID={deleteDialog} + /> + ) : null} + {editDialog ? ( + setEditDialog('')} + apiKeyID={editDialog} + /> + ) : null} +
+
+ +
+ + + +
+
+ ) +} diff --git a/web/src/app/admin/admin-api-keys/AdminAPIKeyCreateDialog.tsx b/web/src/app/admin/admin-api-keys/AdminAPIKeyCreateDialog.tsx new file mode 100644 index 0000000000..85ac79f9ed --- /dev/null +++ b/web/src/app/admin/admin-api-keys/AdminAPIKeyCreateDialog.tsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react' +import { gql, useMutation } from 'urql' +import CopyText from '../../util/CopyText' +import { fieldErrors, nonFieldErrors } from '../../util/errutil' +import FormDialog from '../../dialogs/FormDialog' +import AdminAPIKeyForm from './AdminAPIKeyForm' +import { CreateGQLAPIKeyInput } from '../../../schema' +import { CheckCircleOutline as SuccessIcon } from '@mui/icons-material' +import { DateTime } from 'luxon' +import { Grid, Typography, FormHelperText } from '@mui/material' + +// query for creating new api key which accepts CreateGQLAPIKeyInput param +// return token created upon successfull transaction +const newGQLAPIKeyQuery = gql` + mutation CreateGQLAPIKey($input: CreateGQLAPIKeyInput!) { + createGQLAPIKey(input: $input) { + id + token + } + } +` + +function AdminAPIKeyToken(props: { token: string }): React.ReactNode { + return ( + + + + + + Please copy and save the token as this is the only time you'll be able + to view it. + + + ) +} + +export default function AdminAPIKeyCreateDialog(props: { + onClose: () => void +}): React.ReactNode { + const [value, setValue] = useState({ + name: '', + description: '', + expiresAt: DateTime.utc().plus({ days: 7 }).toISO(), + allowedFields: [], + role: 'user', + }) + const [status, createKey] = useMutation(newGQLAPIKeyQuery) + const token = status.data?.createGQLAPIKey?.token || null + + // handles form on submit event, based on the action type (edit, create) it will send the necessary type of parameter + // token is also being set here when create action is used + const handleOnSubmit = (): void => { + createKey( + { + input: { + name: value.name, + description: value.description, + allowedFields: value.allowedFields, + expiresAt: value.expiresAt, + role: value.role, + }, + }, + { additionalTypenames: ['GQLAPIKey'] }, + ) + } + + return ( + + theme.spacing(1) }} /> + Success! + + ) : ( + 'Create New API Key' + ) + } + subTitle={token ? 'Your API key has been created!' : ''} + loading={status.fetching} + errors={nonFieldErrors(status.error)} + onClose={() => { + props.onClose() + }} + onSubmit={token ? props.onClose : handleOnSubmit} + alert={!!token} + disableBackdropClose={!!token} + form={ + token ? ( + + ) : ( + + ) + } + /> + ) +} diff --git a/web/src/app/admin/admin-api-keys/AdminAPIKeyDeleteDialog.tsx b/web/src/app/admin/admin-api-keys/AdminAPIKeyDeleteDialog.tsx new file mode 100644 index 0000000000..ae8f70219a --- /dev/null +++ b/web/src/app/admin/admin-api-keys/AdminAPIKeyDeleteDialog.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { nonFieldErrors } from '../../util/errutil' +import FormDialog from '../../dialogs/FormDialog' +import { gql, useMutation, useQuery } from 'urql' +import { GenericError } from '../../error-pages' +import Spinner from '../../loading/components/Spinner' +import { GQLAPIKey } from '../../../schema' + +// query for deleting API Key which accepts API Key ID +const deleteGQLAPIKeyQuery = gql` + mutation DeleteGQLAPIKey($id: ID!) { + deleteGQLAPIKey(id: $id) + } +` + +// query for getting existing API Keys +const query = gql` + query gqlAPIKeysQuery { + gqlAPIKeys { + id + name + } + } +` + +export default function AdminAPIKeyDeleteDialog(props: { + apiKeyID: string + onClose: (yes: boolean) => void +}): JSX.Element { + const [{ fetching, data, error }] = useQuery({ + query, + }) + const { apiKeyID, onClose } = props + const [deleteAPIKeyStatus, deleteAPIKey] = useMutation(deleteGQLAPIKeyQuery) + + if (fetching && !data) return + if (error) return + + const apiKeyName = data?.gqlAPIKeys?.find((d: GQLAPIKey) => { + return d.id === apiKeyID + })?.name + + function handleOnSubmit(): void { + deleteAPIKey( + { + id: apiKeyID, + }, + { additionalTypenames: ['GQLAPIKey'] }, + ).then((result) => { + if (!result.error) onClose(true) + }) + } + + return ( + + ) +} diff --git a/web/src/app/admin/admin-api-keys/AdminAPIKeyDrawer.tsx b/web/src/app/admin/admin-api-keys/AdminAPIKeyDrawer.tsx new file mode 100644 index 0000000000..02a6f01afc --- /dev/null +++ b/web/src/app/admin/admin-api-keys/AdminAPIKeyDrawer.tsx @@ -0,0 +1,181 @@ +import React, { useState } from 'react' +import { + ClickAwayListener, + Divider, + Drawer, + Grid, + List, + ListItem, + ListItemText, + Toolbar, + Typography, + Button, + ButtonGroup, +} from '@mui/material' +import makeStyles from '@mui/styles/makeStyles' +import { GQLAPIKey } from '../../../schema' +import AdminAPIKeyDeleteDialog from './AdminAPIKeyDeleteDialog' +import AdminAPIKeyEditDialog from './AdminAPIKeyEditDialog' +import { Time } from '../../util/Time' +import { gql, useQuery } from 'urql' +import Spinner from '../../loading/components/Spinner' +import { GenericError } from '../../error-pages' + +// query for getting existing API Keys +const query = gql` + query gqlAPIKeysQuery { + gqlAPIKeys { + id + name + description + createdAt + createdBy { + id + name + } + updatedAt + updatedBy { + id + name + } + lastUsed { + time + ua + ip + } + expiresAt + allowedFields + role + } + } +` + +// property for this object +interface Props { + onClose: () => void + apiKeyID?: string +} + +const useStyles = makeStyles(() => ({ + buttons: { + textAlign: 'right', + width: '30vw', + padding: '15px 10px', + }, +})) + +export default function AdminAPIKeyDrawer(props: Props): JSX.Element { + const { onClose, apiKeyID } = props + const classes = useStyles() + const isOpen = Boolean(apiKeyID) + const [deleteDialog, setDialogDialog] = useState(false) + const [editDialog, setEditDialog] = useState(false) + + // Get API Key triggers/actions + const [{ data, fetching, error }] = useQuery({ query }) + const apiKey: GQLAPIKey = + data?.gqlAPIKeys?.find((d: GQLAPIKey) => { + return d.id === apiKeyID + }) || ({} as GQLAPIKey) + + const allowFieldsStr = (apiKey?.allowedFields || []).join(', ') + + if (error) { + return + } + + if (fetching && !data) { + return + } + + return ( + + + + {deleteDialog ? ( + { + setDialogDialog(false) + + if (yes) { + onClose() + } + }} + apiKeyID={apiKey.id} + /> + ) : null} + {editDialog ? ( + setEditDialog(false)} + apiKeyID={apiKey.id} + /> + ) : null} + + + API Key Details + + + + + + + + + + + + + + } + /> + + + + + + } + /> + + + + + + + + + + + + + + + + + + ) +} diff --git a/web/src/app/admin/admin-api-keys/AdminAPIKeyEditDialog.tsx b/web/src/app/admin/admin-api-keys/AdminAPIKeyEditDialog.tsx new file mode 100644 index 0000000000..74f118a141 --- /dev/null +++ b/web/src/app/admin/admin-api-keys/AdminAPIKeyEditDialog.tsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react' +import { gql, useMutation, useQuery } from 'urql' +import { fieldErrors, nonFieldErrors } from '../../util/errutil' +import FormDialog from '../../dialogs/FormDialog' +import AdminAPIKeyForm from './AdminAPIKeyForm' +import { CreateGQLAPIKeyInput, GQLAPIKey } from '../../../schema' +import Spinner from '../../loading/components/Spinner' +import { GenericError } from '../../error-pages' + +// query for updating api key which accepts UpdateGQLAPIKeyInput +const updateGQLAPIKeyQuery = gql` + mutation UpdateGQLAPIKey($input: UpdateGQLAPIKeyInput!) { + updateGQLAPIKey(input: $input) + } +` +// query for getting existing API Key information +const query = gql` + query gqlAPIKeysQuery { + gqlAPIKeys { + id + name + description + expiresAt + allowedFields + role + } + } +` +export default function AdminAPIKeyEditDialog(props: { + onClose: (param: boolean) => void + apiKeyID: string +}): JSX.Element { + const { apiKeyID, onClose } = props + const [{ fetching, data, error }] = useQuery({ + query, + }) + const key: GQLAPIKey | null = + data?.gqlAPIKeys?.find((d: GQLAPIKey) => d.id === apiKeyID) || null + const [apiKeyActionStatus, apiKeyAction] = useMutation(updateGQLAPIKeyQuery) + const [apiKeyInput, setAPIKeyInput] = useState( + null, + ) + + if (fetching && !data) return + if (error) return + // handles form on submit event, based on the action type (edit, create) it will send the necessary type of parameter + // token is also being set here when create action is used + const handleOnSubmit = (): void => { + apiKeyAction( + { + input: { + name: apiKeyInput?.name, + description: apiKeyInput?.description, + id: apiKeyID, + }, + }, + { additionalTypenames: ['GQLAPIKey'] }, + ).then((result) => { + if (result.error) return + + onClose(false) + }) + } + + if (fetching || key === null) { + return + } + + return ( + + } + /> + ) +} diff --git a/web/src/app/admin/admin-api-keys/AdminAPIKeyExpirationField.tsx b/web/src/app/admin/admin-api-keys/AdminAPIKeyExpirationField.tsx new file mode 100644 index 0000000000..fd0aa96d13 --- /dev/null +++ b/web/src/app/admin/admin-api-keys/AdminAPIKeyExpirationField.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import Grid from '@mui/material/Grid' +import { Typography } from '@mui/material' +import makeStyles from '@mui/styles/makeStyles' +import { ISODateTimePicker } from '../../util/ISOPickers' +import { DateTime } from 'luxon' +import { Time } from '../../util/Time' +import { selectedDaysUntilTimestamp } from './util' + +const useStyles = makeStyles(() => ({ + expiresCon: { + 'padding-top': '15px', + }, +})) +// props object for this compoenent +interface FieldProps { + onChange: (val: string) => void + value: string + disabled: boolean + label: string +} + +const presets = [7, 15, 30, 60, 90] + +export default function AdminAPIKeyExpirationField( + props: FieldProps, +): JSX.Element { + const classes = useStyles() + + const [selected, setSelected] = useState( + selectedDaysUntilTimestamp(props.value, presets), + ) + + useEffect(() => { + if (!selected) return // if custom is selected, do nothing + + // otherwise keep the selected preset in sync with expiration time + setSelected(selectedDaysUntilTimestamp(props.value, presets)) + }, [props.value]) + + if (props.disabled) { + return ( + + ) + } + + return ( + + + {props.label} + + + + {selected ? ( // if a preset is selected, show the expiration time + + + The token will expire + + ) : ( + // if custom is selected, show date picker + + + + )} + + ) +} diff --git a/web/src/app/admin/admin-api-keys/AdminAPIKeyForm.tsx b/web/src/app/admin/admin-api-keys/AdminAPIKeyForm.tsx new file mode 100644 index 0000000000..e319d812fa --- /dev/null +++ b/web/src/app/admin/admin-api-keys/AdminAPIKeyForm.tsx @@ -0,0 +1,115 @@ +import React from 'react' +import Grid from '@mui/material/Grid' +import { FormContainer, FormField } from '../../forms' +import { FieldError } from '../../util/errutil' +import { CreateGQLAPIKeyInput } from '../../../schema' +import AdminAPIKeyExpirationField from './AdminAPIKeyExpirationField' +import { gql, useQuery } from 'urql' +import { GenericError } from '../../error-pages' +import Spinner from '../../loading/components/Spinner' +import { TextField, MenuItem } from '@mui/material' +import MaterialSelect from '../../selection/MaterialSelect' + +const query = gql` + query ListGQLFieldsQuery { + listGQLFields + } +` + +type AdminAPIKeyFormProps = { + errors: FieldError[] + + // even while editing, we need all the fields + value: CreateGQLAPIKeyInput + onChange: (key: CreateGQLAPIKeyInput) => void + + create?: boolean +} + +export default function AdminAPIKeyForm( + props: AdminAPIKeyFormProps, +): JSX.Element { + const [{ data, fetching, error }] = useQuery({ + query, + }) + + if (error) { + return + } + + if (fetching && !data) { + return + } + + return ( + + + + + + + + + + + + User + + + Admin + + + + + + + + ({ + label: field, + value: field, + }))} + mapOnChangeValue={(selected: { value: string }[]) => + selected.map((v) => v.value) + } + mapValue={(value: string[]) => + value.map((v) => ({ label: v, value: v })) + } + multiple + required + /> + + + + ) +} diff --git a/web/src/app/admin/admin-api-keys/util.test.ts b/web/src/app/admin/admin-api-keys/util.test.ts new file mode 100644 index 0000000000..d3757fec6f --- /dev/null +++ b/web/src/app/admin/admin-api-keys/util.test.ts @@ -0,0 +1,64 @@ +import { selectedDaysUntilTimestamp } from './util' + +describe('selectedDaysUntilTimestamp', () => { + it('returns 0 if there are no matching options', () => { + expect( + selectedDaysUntilTimestamp( + '2020-01-01T00:00:00Z', + [1, 2, 3], + '2020-01-01T00:00:00Z', + ), + ).toEqual(0) + + expect( + selectedDaysUntilTimestamp( + '2020-01-09T00:00:00Z', + [1, 2, 3], + '2020-01-01T00:00:00Z', + ), + ).toEqual(0) + }) + + it('returns the number of days since the timestamp', () => { + expect( + selectedDaysUntilTimestamp( + '2020-01-02T00:00:00Z', + [1, 2, 3], + '2020-01-01T00:00:00Z', + ), + ).toEqual(1) + expect( + selectedDaysUntilTimestamp( + '2020-01-04T00:00:00Z', + [1, 2, 3], + '2020-01-01T00:00:00Z', + ), + ).toEqual(3) + }) + + it('returns the number of days since the timestamp, even if there are slight differences in the time', () => { + expect( + selectedDaysUntilTimestamp( + '2020-01-02T01:00:00Z', + [1, 2, 3], + '2020-01-01T00:00:01Z', + ), + ).toEqual(1) + + expect( + selectedDaysUntilTimestamp( + '2020-01-04T01:00:01Z', + [1, 2, 3], + '2020-01-01T00:00:00Z', + ), + ).toEqual(3) + + expect( + selectedDaysUntilTimestamp( + '2023-10-12T17:52:21.467Z', + [7, 14, 30, 60, 90], + '2023-10-05T17:52:23.782Z', + ), + ).toEqual(7) + }) +}) diff --git a/web/src/app/admin/admin-api-keys/util.ts b/web/src/app/admin/admin-api-keys/util.ts new file mode 100644 index 0000000000..c0cf439edf --- /dev/null +++ b/web/src/app/admin/admin-api-keys/util.ts @@ -0,0 +1,19 @@ +import { DateTime } from 'luxon' + +// selectedDaysSinceTimestamp takes a timestamp and returns the number of days until the timestamp, based on the selected options. +// +// If there are no matching options, it returns 0. +export function selectedDaysUntilTimestamp( + ts: string, + dayOptions: number[], + _from: string = DateTime.utc().toISO(), +): number { + const dt = DateTime.fromISO(ts) + const days = Math.round(dt.diff(DateTime.fromISO(_from), 'days').days) + + if (dayOptions.includes(days)) { + return days + } + + return 0 +} diff --git a/web/src/app/dialogs/FormDialog.js b/web/src/app/dialogs/FormDialog.js index 5ead1f9ca7..58901b1732 100644 --- a/web/src/app/dialogs/FormDialog.js +++ b/web/src/app/dialogs/FormDialog.js @@ -92,7 +92,24 @@ function FormDialog(props) { return null } - return
{form}
+ return ( + +
{ + e.preventDefault() + if (valid) { + onNext ? onNext() : onSubmit() + } + }} + > + +
{form}
+
+
+
+ ) } function renderCaption() { @@ -144,6 +161,10 @@ function FormDialog(props) { if (!onNext) { setAttemptCount(attemptCount + 1) } + + if (!props.form) { + onSubmit() + } }} attemptCount={attemptCount} buttonText={primaryActionLabel || (confirm ? 'Confirm' : submitText)} @@ -186,20 +207,7 @@ function FormDialog(props) { title={title} subTitle={subTitle} /> - -
{ - e.preventDefault() - if (valid) { - onNext ? onNext() : onSubmit() - } - }} - > - {renderForm()} -
-
+ {renderForm()} {renderCaption()} {renderErrors()} {renderActions()} diff --git a/web/src/app/forms/FormField.js b/web/src/app/forms/FormField.js index 3d2e6816ee..9f40929201 100644 --- a/web/src/app/forms/FormField.js +++ b/web/src/app/forms/FormField.js @@ -265,8 +265,15 @@ FormField.propTypes = { multiple: p.bool, - options: p.shape({ - label: p.string, - value: p.string, - }), + options: p.arrayOf( + p.shape({ + label: p.string, + value: p.string, + }), + ), + + // material select stuff + clientSideFilter: p.bool, + disableCloseOnSelect: p.bool, + optionsLimit: p.number, } diff --git a/web/src/app/lists/FlatList.tsx b/web/src/app/lists/FlatList.tsx index eef32565bd..f000eb6117 100644 --- a/web/src/app/lists/FlatList.tsx +++ b/web/src/app/lists/FlatList.tsx @@ -101,6 +101,7 @@ export interface FlatListNotice extends Notice { } export interface FlatListItem extends ListItemProps { title?: string + primaryText?: React.ReactNode highlight?: boolean subText?: JSX.Element | string icon?: JSX.Element | null diff --git a/web/src/app/lists/FlatListItem.tsx b/web/src/app/lists/FlatListItem.tsx index e223da20ea..53672a7a32 100644 --- a/web/src/app/lists/FlatListItem.tsx +++ b/web/src/app/lists/FlatListItem.tsx @@ -45,6 +45,8 @@ export default function FlatListItem(props: FlatListItemProps): JSX.Element { draggable, disabled, disableTypography, + onClick, + primaryText, ...muiListItemProps } = props.item @@ -64,10 +66,19 @@ export default function FlatListItem(props: FlatListItemProps): JSX.Element { } } + const onClickProps = onClick && { + onClick, + + // NOTE: needed for error: button: false? not assignable to type 'true' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + button: true as any, + } + return ( {icon && {icon}} > = { '/admin/alert-counts': AdminAlertCounts, '/admin/switchover': AdminSwitchover, '/admin/switchover/guide': AdminSwitchoverGuide, + '/admin/api-keys': AdminAPIKeys, '/wizard': WizardRouter, '/docs': Documentation, diff --git a/web/src/app/main/NavBar.tsx b/web/src/app/main/NavBar.tsx index f97d16a4c3..3219ca8609 100644 --- a/web/src/app/main/NavBar.tsx +++ b/web/src/app/main/NavBar.tsx @@ -19,6 +19,7 @@ import RequireConfig from '../util/RequireConfig' import logo from '../public/logos/black/goalert-alt-logo.png' import darkModeLogo from '../public/logos/white/goalert-alt-logo-white.png' import NavBarLink, { NavBarSubLink } from './NavBarLink' +import { ExpFlag } from '../util/useExpFlag' const useStyles = makeStyles((theme: Theme) => ({ ...globalStyles(theme), @@ -82,6 +83,9 @@ export default function NavBar(): JSX.Element { + + + diff --git a/web/src/app/selection/MaterialSelect.tsx b/web/src/app/selection/MaterialSelect.tsx index b6f8172b3d..92e931d2e5 100644 --- a/web/src/app/selection/MaterialSelect.tsx +++ b/web/src/app/selection/MaterialSelect.tsx @@ -17,8 +17,11 @@ import { List, ListItem, ListItemText, + createFilterOptions, + FilterOptionsState, } from '@mui/material' import makeStyles from '@mui/styles/makeStyles' +import { Check } from '@mui/icons-material' const useStyles = makeStyles({ listItemIcon: { @@ -71,6 +74,9 @@ interface CommonSelectProps { onInputChange?: (value: string) => void options: SelectOption[] placeholder?: string + clientSideFilter?: boolean + disableCloseOnSelect?: boolean + optionsLimit?: number } interface SingleSelectProps { @@ -105,6 +111,7 @@ export default function MaterialSelect( placeholder, required, value, + disableCloseOnSelect, } = props // handle AutoComplete expecting current value to be present within options array @@ -158,18 +165,31 @@ export default function MaterialSelect( return val === value.value } + let filterOptions: ( + options: SelectOption[], + state: FilterOptionsState, + ) => SelectOption[] = (o) => o + if (props.clientSideFilter) { + filterOptions = createFilterOptions() + if (props.optionsLimit) { + const base = filterOptions + filterOptions = (o, s) => base(o, s).slice(0, props.optionsLimit) + } + } + return ( o} + disableCloseOnSelect={disableCloseOnSelect} + filterOptions={props.clientSideFilter ? filterOptions : (o) => o} isOptionEqualToValue={(opt: SelectOption, val: SelectOption) => opt.value === val.value } @@ -184,7 +204,7 @@ export default function MaterialSelect( event: SyntheticEvent, selected: SelectOption | SelectOption[] | null, ) => { - if (selected) { + if (selected && !disableCloseOnSelect) { if (Array.isArray(selected)) { setInputValue('') // clear input so user can keep typing to select another item } else { @@ -249,6 +269,11 @@ export default function MaterialSelect( {icon} )} + {disableCloseOnSelect && isSelected(value) && ( + + + + )} )} diff --git a/web/src/app/styles/materialStyles.ts b/web/src/app/styles/materialStyles.ts index ad1562ce15..efd320eb76 100644 --- a/web/src/app/styles/materialStyles.ts +++ b/web/src/app/styles/materialStyles.ts @@ -21,11 +21,6 @@ export const styles = (theme: Theme): StyleRules => ({ paddingBottom: 0, margin: 0, }, - asLink: { - color: 'blue', - cursor: 'pointer', - textDecoration: 'underline', - }, block: { display: 'inline-block', },