diff --git a/src/api/alerts.ts b/src/api/alerts.ts index d5af31a99e..e9043ff303 100644 --- a/src/api/alerts.ts +++ b/src/api/alerts.ts @@ -1,9 +1,14 @@ -import type { ReducedAlertSubscriptionQueryResponse } from 'src/api/types'; +import type { + AlertConfigQueryResponse, + ReducedAlertSubscriptionQueryResponse, +} from 'src/api/types'; import type { DataProcessingAlert, AlertSubscription as LegacyAlertSubscription, } from 'src/types'; import type { + AlertConfigMutationInput, + AlertConfigQueryInput, AlertSubscriptionMutationInput, AlertSubscriptionsBy, AlertTypeQueryResponse, @@ -22,6 +27,57 @@ import { updateSupabase, } from 'src/services/supabase'; +const AlertConfigQuery = gql` + query AlertConfigs( + $filter: AlertConfigsFilter + $after: String + $first: Int + ) { + alertConfigs(filter: $filter, after: $after, first: $first) { + edges { + node { + catalogPrefixOrName + config + createdAt + detail + effective { + config + provenance { + source + } + } + id + lastModifiedBy + updatedAt + } + } + pageInfo { + endCursor + hasNextPage + } + } + } +`; + +const AlertConfigUpdateMutation = gql< + { catalogPrefixOrName: string }, + AlertConfigMutationInput +>` + mutation UpdateAlertConfigMutation( + $catalogPrefixOrName: String! + $config: JSON! + $detail: String + ) { + updateAlertConfig( + catalogPrefixOrName: $catalogPrefixOrName + config: $config + detail: $detail + ) { + catalogPrefixOrName + } + } +`; + const AlertSubscriptionCreateMutation = gql< { catalogPrefix: string; email: string }, AlertSubscriptionMutationInput @@ -68,7 +124,6 @@ const AlertSubscriptionQuery = gql< alertTypes catalogPrefix email - updatedAt } } `; @@ -177,6 +232,8 @@ const getTaskNotification = async (catalogName: string) => { }; export { + AlertConfigQuery, + AlertConfigUpdateMutation, AlertSubscriptionCreateMutation, AlertSubscriptionDeleteMutation, AlertSubscriptionQuery, diff --git a/src/api/gql/useAllPages.ts b/src/api/gql/useAllPages.ts index e6e5addfe8..a8eaafe30e 100644 --- a/src/api/gql/useAllPages.ts +++ b/src/api/gql/useAllPages.ts @@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react'; import { useQuery } from 'urql'; -interface Connection { +export interface Connection { edges: { node: TNode }[]; pageInfo: { hasNextPage: boolean; diff --git a/src/api/types.ts b/src/api/types.ts index 11d406c4a7..6c5282ba63 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,6 +1,16 @@ +import type { Connection } from 'src/api/gql/useAllPages'; +import type { AlertConfigEntry } from 'src/gql-types/graphql'; import type { Entity } from 'src/types'; import type { AlertSubscription } from 'src/types/gql'; +// The `AlertConfigEntryConnection` interface provided by GraphQL +// cannot be used here, unfortunately, if the hook `useAllPages` +// must be invoked. The client defines a generic type for GraphQL +// connections that omits properties found in these connections. +export interface AlertConfigQueryResponse { + alertConfigs: Connection; +} + export interface DraftSpecData { spec: any; catalog_name?: string; @@ -56,7 +66,7 @@ export interface LiveSpecsExtQuery_GroupedUpdates { export type ReducedAlertSubscription = Pick< AlertSubscription, - 'alertTypes' | 'catalogPrefix' | 'email' | 'updatedAt' + 'alertTypes' | 'catalogPrefix' | 'email' >; export interface ReducedAlertSubscriptionQueryResponse { diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/AlertTypeField.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/AlertTypeField.tsx deleted file mode 100644 index 571a6f75be..0000000000 --- a/src/components/admin/Settings/PrefixAlerts/Dialog/AlertTypeField.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { AlertTypeFieldProps } from 'src/components/admin/Settings/PrefixAlerts/types'; -import type { AlertTypeInfo } from 'src/gql-types/graphql'; - -import { useEffect } from 'react'; - -import { Grid, Skeleton } from '@mui/material'; - -import AlertTypeSelector from 'src/components/admin/Settings/PrefixAlerts/Dialog/AlertTypeSelector'; -import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; -import { useGetAlertTypes } from 'src/context/AlertType'; - -const DEFAULT_OPTIONS: AlertTypeInfo[] = []; -const AlertTypeField = ({ existingAlertTypes }: AlertTypeFieldProps) => { - const [{ fetching, data, error }] = useGetAlertTypes(); - - const setServerError = useAlertSubscriptionsStore( - (state) => state.setSaveErrors - ); - - const setAlertTypes = useAlertSubscriptionsStore( - (state) => state.setAlertTypes - ); - - useEffect(() => { - if (!fetching && data?.alertTypes) { - const existingAlertTypeDefs = existingAlertTypes - ? data.alertTypes.filter(({ alertType }) => - existingAlertTypes.includes(alertType) - ) - : null; - - setAlertTypes( - existingAlertTypeDefs === null - ? data.alertTypes.filter(({ isSystem }) => isSystem) - : existingAlertTypeDefs - ); - } - }, [data?.alertTypes, existingAlertTypes, fetching, setAlertTypes]); - - useEffect(() => { - if (error) { - setServerError([error]); - } - }, [error, setServerError]); - - return ( - - {fetching || !data ? ( - - ) : ( - - )} - - ); -}; - -export default AlertTypeField; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/AlertTypeSelector.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/AlertTypeSelector.tsx deleted file mode 100644 index 27243b1c55..0000000000 --- a/src/components/admin/Settings/PrefixAlerts/Dialog/AlertTypeSelector.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import type { AutocompleteRenderInputParams } from '@mui/material'; -import type { AlertTypeSelectorProps } from 'src/components/admin/Settings/PrefixAlerts/types'; -import type { AlertTypeInfo } from 'src/gql-types/graphql'; - -import { useMemo } from 'react'; - -import { - Autocomplete, - FormControl, - Stack, - TextField, - Typography, - useTheme, -} from '@mui/material'; - -import { union } from 'lodash'; -import { useIntl } from 'react-intl'; - -import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; -import SelectableAutocompleteOption from 'src/components/shared/Dialog/SelectableAutocompleteOption'; -import { diminishedTextColor } from 'src/context/Theme'; -import { OutlinedChip } from 'src/styledComponents/chips/OutlinedChip'; -import { basicSort_string, sortByAlertType } from 'src/utils/misc-utils'; - -const AlertTypeSelector = ({ options }: AlertTypeSelectorProps) => { - const intl = useIntl(); - const theme = useTheme(); - - const serverError = useAlertSubscriptionsStore( - (state) => state.initializationError - ); - const alertTypes = useAlertSubscriptionsStore( - (state) => state.subscription.alertTypes - ); - const setAlertTypes = useAlertSubscriptionsStore( - (state) => state.setAlertTypes - ); - - const systemAlerts: AlertTypeInfo[] = useMemo( - () => options.filter(({ isSystem }) => isSystem), - [options] - ); - - const values: AlertTypeInfo[] = useMemo( - () => options.filter(({ alertType }) => alertTypes.includes(alertType)), - [options, alertTypes] - ); - - return ( - - alertType} - isOptionEqualToValue={(option, value) => - option.alertType === value.alertType - } - multiple - onChange={(_event, values) => { - const evaluatedValues = union(values, systemAlerts); - - setAlertTypes(evaluatedValues); - }} - options={options.sort((first, second) => - basicSort_string( - first.displayName, - second.displayName, - 'asc' - ) - )} - renderInput={({ - InputProps, - ...params - }: AutocompleteRenderInputParams) => ( - - )} - renderOption={(renderOptionProps, option, state) => { - const { key, ...restRenderOptionProps } = renderOptionProps; - const { description, displayName, isSystem } = option; - - return isSystem ? null : ( - - - {displayName} - - - {description ? ( - - {description} - - ) : null} - - } - renderOptionProps={restRenderOptionProps} - state={state} - /> - ); - }} - renderTags={(values, getTagProps) => { - return values - .sort((first, second) => - sortByAlertType( - { - isSystemAlert: first.isSystem, - value: first.displayName, - }, - { - isSystemAlert: second.isSystem, - value: second.displayName, - }, - 'asc' - ) - ) - .map(({ alertType, displayName, isSystem }, index) => { - const { onDelete, key, ...restOfTagProps } = - getTagProps({ - index, - }); - - return ( - - ); - }); - }} - value={values} - /> - - ); -}; - -export default AlertTypeSelector; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/DeleteButton.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/DeleteButton.tsx index d1a4f6a3a4..1342ddd9c2 100644 --- a/src/components/admin/Settings/PrefixAlerts/Dialog/DeleteButton.tsx +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/DeleteButton.tsx @@ -1,40 +1,69 @@ import type { DialogActionProps } from 'src/components/admin/Settings/PrefixAlerts/types'; +import { useMemo } from 'react'; + import { Button } from '@mui/material'; import { useIntl } from 'react-intl'; import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; -import { useModifyAlertSubscription } from 'src/components/admin/Settings/PrefixAlerts/useModifyAlertSubscription'; +import { useModifyAlertMetadata } from 'src/components/admin/Settings/PrefixAlerts/useModifyAlertMetadata'; const DeleteButton = ({ closeDialog }: DialogActionProps) => { const intl = useIntl(); + const { loading, onClick } = useModifyAlertMetadata(closeDialog, true); - const { loading, onClick } = useModifyAlertSubscription(closeDialog, true); - - const prefixErrorsExist = useAlertSubscriptionsStore( - (state) => state.prefixErrorsExist + const errorsExist = useAlertSubscriptionsStore( + (state) => + state.prefixErrorsExist || + state.mutableSubscriptionMetadata.subscriptions.some( + ({ emailErrorsExist }) => emailErrorsExist + ) ); - const subscription = useAlertSubscriptionsStore( - (state) => state.subscription + const catalogPrefix = useAlertSubscriptionsStore( + (state) => state.catalogPrefix + ); + const mutableSubscriptionMetadata = useAlertSubscriptionsStore( + (state) => state.mutableSubscriptionMetadata + ); + const subscriptionMetadata = useAlertSubscriptionsStore( + (state) => state.subscriptionMetadata ); + const disabled = useMemo(() => { + const { subscriptions } = mutableSubscriptionMetadata; + + const emptyEmailExists = subscriptions.some( + ({ email }) => email.length === 0 + ); + + return Boolean( + errorsExist || + loading || + catalogPrefix.length === 0 || + subscriptions.length === 0 || + emptyEmailExists || + !Object.keys(subscriptionMetadata).includes(catalogPrefix) + ); + }, [ + catalogPrefix, + errorsExist, + loading, + mutableSubscriptionMetadata, + subscriptionMetadata, + ]); + return ( ); }; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/EmailListField.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/EmailListField.tsx deleted file mode 100644 index 839a649e99..0000000000 --- a/src/components/admin/Settings/PrefixAlerts/Dialog/EmailListField.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { EmailListFieldProps } from 'src/components/admin/Settings/PrefixAlerts/types'; - -import { Grid, TextField } from '@mui/material'; - -import { useIntl } from 'react-intl'; - -import EmailSelector from 'src/components/admin/Settings/PrefixAlerts/EmailSelector'; - -const EmailListField = ({ staticEmail }: EmailListFieldProps) => { - const intl = useIntl(); - - return ( - - {staticEmail ? ( - - ) : ( - - )} - - ); -}; - -export default EmailListField; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/GlobalSettings/DataMovementSetting.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/GlobalSettings/DataMovementSetting.tsx new file mode 100644 index 0000000000..5b10668c93 --- /dev/null +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/GlobalSettings/DataMovementSetting.tsx @@ -0,0 +1,107 @@ +import type { AutocompleteRenderInputParams } from '@mui/material'; +import type { GlobalSettingProps } from 'src/components/admin/Settings/PrefixAlerts/types'; + +import React from 'react'; + +import { + Autocomplete, + Skeleton, + Stack, + TextField, + Typography, +} from '@mui/material'; + +import { useIntl } from 'react-intl'; + +import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; +import useSettingIntervalOptions from 'src/components/shared/Entity/Details/Overview/NotificationSettings/useSettingIntervalOptions'; +import { + fromUnconventionalTimeFormat, + toUnconventionalTimeFormat, +} from 'src/utils/notification-utils'; + +const DataMovementSetting = ({ + config, + loading, + prefix, + targetSetting, +}: GlobalSettingProps<{ stalledFor: string }>) => { + const intl = useIntl(); + + const { options } = useSettingIntervalOptions(); + + const setGlobalPrefixSettings = useAlertSubscriptionsStore( + (state) => state.setGlobalPrefixSettings + ); + + return ( + + + + {intl.formatMessage({ + id: 'alerts.alertType.humanReadable.data_movement_stalled', + })} + + + + {intl.formatMessage({ + id: 'details.settings.notifications.dataProcessing.noDataProcessedInInterval.message', + })} + + + + {loading ? ( + + ) : ( + options[interval]} + onChange={(_event: React.SyntheticEvent, value: string) => { + const formattedValue = + toUnconventionalTimeFormat(value); + + setGlobalPrefixSettings( + formattedValue !== 'none' + ? { + [targetSetting]: { + condition: { + stalledFor: formattedValue, + }, + }, + } + : {}, + targetSetting + ); + }} + options={Object.keys(options)} + renderInput={({ + InputProps, + ...params + }: AutocompleteRenderInputParams) => ( + + )} + value={fromUnconventionalTimeFormat( + config?.condition.stalledFor + )} + /> + )} + + ); +}; + +export default DataMovementSetting; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/GlobalSettings/index.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/GlobalSettings/index.tsx new file mode 100644 index 0000000000..fe2845e2a6 --- /dev/null +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/GlobalSettings/index.tsx @@ -0,0 +1,62 @@ +import { Stack, Typography } from '@mui/material'; + +import { useIntl } from 'react-intl'; + +import DataMovementSetting from 'src/components/admin/Settings/PrefixAlerts/Dialog/GlobalSettings/DataMovementSetting'; +import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; +import { useInitializeAlertConfigs } from 'src/components/admin/Settings/PrefixAlerts/useInitializeAlertConfigs'; +import { defaultOutline } from 'src/context/Theme'; +import { AlertConfigKeys } from 'src/utils/notification-utils'; + +const GlobalSettings = () => { + const intl = useIntl(); + + const { loading: loadingAlertConfigs } = useInitializeAlertConfigs(); + + const catalogPrefix = useAlertSubscriptionsStore( + (state) => state.catalogPrefix + ); + const mutableSubscriptionMetadata = useAlertSubscriptionsStore( + (state) => state.mutableSubscriptionMetadata + ); + + return ( + + theme.palette.mode === 'dark' ? 'transparent' : 'white', + border: (theme) => defaultOutline[theme.palette.mode], + borderRadius: '6px', + padding: 2, + }} + > + + + {intl.formatMessage({ + id: 'alerts.config.dialog.label.globalSettings', + })} + + + + {intl.formatMessage({ + id: 'alerts.config.dialog.message.globalSettings', + })} + + + + + + ); +}; + +export default GlobalSettings; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/PrefixField.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/PrefixField.tsx index 9a79e7ad0a..741f375184 100644 --- a/src/components/admin/Settings/PrefixAlerts/Dialog/PrefixField.tsx +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/PrefixField.tsx @@ -1,6 +1,6 @@ import type { PrefixFieldProps } from 'src/components/admin/Settings/PrefixAlerts/types'; -import { Grid, TextField } from '@mui/material'; +import { TextField } from '@mui/material'; import { useIntl } from 'react-intl'; import { useMount } from 'react-use'; @@ -21,35 +21,34 @@ export default function PrefixField({ staticPrefix }: PrefixFieldProps) { } }); - return ( - - {staticPrefix ? ( - - ) : ( - - )} - + return staticPrefix ? ( + + ) : ( + ); } diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/SaveButton.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/SaveButton.tsx index 3dd5affab3..95683b6a98 100644 --- a/src/components/admin/Settings/PrefixAlerts/Dialog/SaveButton.tsx +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/SaveButton.tsx @@ -7,18 +7,27 @@ import { Button } from '@mui/material'; import { useIntl } from 'react-intl'; import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; -import { useModifyAlertSubscription } from 'src/components/admin/Settings/PrefixAlerts/useModifyAlertSubscription'; +import { useModifyAlertMetadata } from 'src/components/admin/Settings/PrefixAlerts/useModifyAlertMetadata'; const SaveButton = ({ closeDialog }: DialogActionProps) => { const intl = useIntl(); - const { loading, onClick } = useModifyAlertSubscription(closeDialog); + const { loading, onClick } = useModifyAlertMetadata(closeDialog); const errorsExist = useAlertSubscriptionsStore( - (state) => state.emailErrorsExist || state.prefixErrorsExist + (state) => + state.prefixErrorsExist || + state.mutableSubscriptionMetadata.subscriptions.some( + ({ emailErrorsExist }) => emailErrorsExist + ) ); - const subscription = useAlertSubscriptionsStore( - (state) => state.subscription + const catalogPrefix = useAlertSubscriptionsStore( + (state) => state.catalogPrefix + ); + const emptyEmailExists = useAlertSubscriptionsStore((state) => + state.mutableSubscriptionMetadata.subscriptions.some( + ({ email }) => email.length === 0 + ) ); const disabled = useMemo( @@ -26,10 +35,10 @@ const SaveButton = ({ closeDialog }: DialogActionProps) => { Boolean( errorsExist || loading || - subscription.catalogPrefix.length === 0 || - subscription.email.length === 0 + catalogPrefix.length === 0 || + emptyEmailExists ), - [errorsExist, loading, subscription.catalogPrefix, subscription.email] + [catalogPrefix, emptyEmailExists, errorsExist, loading] ); return ( diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/ServerErrors.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/ServerErrors.tsx index 9b13301e7f..0f96ee5dfb 100644 --- a/src/components/admin/Settings/PrefixAlerts/Dialog/ServerErrors.tsx +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/ServerErrors.tsx @@ -9,7 +9,7 @@ import { hasLength } from 'src/utils/misc-utils'; export default function ServerErrors() { const serverErrors = useAlertSubscriptionsStore( useShallow((state) => - [state.initializationError].concat(state.saveErrors) + [state.initializationError].concat(state.serverErrors) ) ); diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/AddButton.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/AddButton.tsx new file mode 100644 index 0000000000..d6c89b29f7 --- /dev/null +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/AddButton.tsx @@ -0,0 +1,34 @@ +import { Button } from '@mui/material'; + +import { useIntl } from 'react-intl'; + +import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; +import { useEvaluateSubscriptionIneligibility } from 'src/components/admin/Settings/PrefixAlerts/useEvaluateSubscriptionIneligibility'; + +const AddButton = () => { + const intl = useIntl(); + + const addTemplatedSubscription = useAlertSubscriptionsStore( + (state) => state.addTemplatedSubscription + ); + const { emptyEmailDetected, duplicateSubscriptionEmails } = + useEvaluateSubscriptionIneligibility(); + + return ( + + ); +}; + +export default AddButton; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/AlertTypeField.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/AlertTypeField.tsx new file mode 100644 index 0000000000..d9684a645a --- /dev/null +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/AlertTypeField.tsx @@ -0,0 +1,23 @@ +import type { SubscriptionDependentProps } from 'src/components/admin/Settings/PrefixAlerts/types'; + +import { Skeleton } from '@mui/material'; + +import AlertTypeList from 'src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/AlertTypeList'; +import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; + +const AlertTypeField = ({ subscription }: SubscriptionDependentProps) => { + const alertTypeOptions = useAlertSubscriptionsStore( + (state) => state.alertTypeOptions + ); + const alertTypeOptionsFetching = useAlertSubscriptionsStore( + (state) => state.alertTypeOptionsFetching + ); + + return alertTypeOptionsFetching ? ( + + ) : ( + + ); +}; + +export default AlertTypeField; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/AlertTypeList.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/AlertTypeList.tsx new file mode 100644 index 0000000000..9991b2a7a0 --- /dev/null +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/AlertTypeList.tsx @@ -0,0 +1,157 @@ +import type { AlertTypeListProps } from 'src/components/admin/Settings/PrefixAlerts/types'; + +import { + Checkbox, + FormControl, + FormControlLabel, + List, + ListItem, + Stack, + Typography, + useTheme, +} from '@mui/material'; + +import { Lock } from 'iconoir-react'; +import { useIntl } from 'react-intl'; + +import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; +import { + defaultOutline, + defaultOutlineColor_hovered, + diminishedTextColor, +} from 'src/context/Theme'; + +const AlertTypeList = ({ options, subscription }: AlertTypeListProps) => { + const intl = useIntl(); + const theme = useTheme(); + + const serverError = useAlertSubscriptionsStore( + (state) => state.initializationError + ); + const setSingleAlertType = useAlertSubscriptionsStore( + (state) => state.setSingleAlertType + ); + + return ( + + + {intl.formatMessage({ + id: 'entityTable.data.alertTypes', + })} + + + + {options.map((option, index) => { + const { alertType, description, displayName, isSystem } = + option; + const alertTypes = subscription?.alertTypes ?? []; + const selected = alertTypes.includes(alertType); + + return ( + + + { + setSingleAlertType( + option.alertType, + event.target.checked, + subscription?.catalogPrefix, + subscription?.email + ); + }} + /> + } + disabled={Boolean(serverError)} + label={ + + + + {displayName} + + + {isSystem ? ( + + ) : null} + + + {description ? ( + + {description} + + ) : null} + + } + slotProps={{ + typography: { + component: 'div', + width: '100%', + }, + }} + style={{ + alignItems: 'flex-start', + marginRight: 'unset', + }} + /> + + + ); + })} + + + ); +}; + +export default AlertTypeList; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/DeleteButton.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/DeleteButton.tsx new file mode 100644 index 0000000000..a067c55b44 --- /dev/null +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/DeleteButton.tsx @@ -0,0 +1,41 @@ +import type { SubscriptionDependentProps } from 'src/components/admin/Settings/PrefixAlerts/types'; + +import { IconButton, useTheme } from '@mui/material'; + +import { Xmark } from 'iconoir-react'; + +import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; + +const DeleteButton = ({ + subscription: { catalogPrefix, id }, +}: SubscriptionDependentProps) => { + const theme = useTheme(); + + const markSubscriptionForDeletion = useAlertSubscriptionsStore( + (state) => state.markSubscriptionForDeletion + ); + + return ( + { + event.stopPropagation(); + + markSubscriptionForDeletion(catalogPrefix, id); + }} + size="small" + sx={{ + display: 'inline-flex', + mr: '3px', + mt: '4px', + }} + > + + + ); +}; + +export default DeleteButton; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/Details.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/Details.tsx new file mode 100644 index 0000000000..7a43783f92 --- /dev/null +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/Details.tsx @@ -0,0 +1,20 @@ +import type { SubscriberAccordionProps } from 'src/components/admin/Settings/PrefixAlerts/types'; + +import { AccordionDetails, Stack } from '@mui/material'; + +import AlertTypeField from 'src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/AlertTypeField'; +import EmailListField from 'src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/EmailListField'; + +const Details = ({ subscription }: SubscriberAccordionProps) => { + return ( + + + + + + + + ); +}; + +export default Details; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/EmailListField.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/EmailListField.tsx new file mode 100644 index 0000000000..5fbab63912 --- /dev/null +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/EmailListField.tsx @@ -0,0 +1,34 @@ +import type { EmailListFieldProps } from 'src/components/admin/Settings/PrefixAlerts/types'; + +import { TextField } from '@mui/material'; + +import { useIntl } from 'react-intl'; + +import EmailSelector from 'src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/EmailSelector'; + +const EmailListField = ({ subscription, staticEmail }: EmailListFieldProps) => { + const intl = useIntl(); + + return staticEmail ? ( + + ) : ( + + ); +}; + +export default EmailListField; diff --git a/src/components/admin/Settings/PrefixAlerts/EmailSelector.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/EmailSelector.tsx similarity index 65% rename from src/components/admin/Settings/PrefixAlerts/EmailSelector.tsx rename to src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/EmailSelector.tsx index d0e173949a..186827d0d6 100644 --- a/src/components/admin/Settings/PrefixAlerts/EmailSelector.tsx +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/EmailSelector.tsx @@ -1,4 +1,5 @@ import type { AutocompleteRenderInputParams } from '@mui/material'; +import type { SubscriptionDependentProps } from 'src/components/admin/Settings/PrefixAlerts/types'; import type { Grant_UserExt } from 'src/types'; import { useEffect, useMemo, useState } from 'react'; @@ -18,6 +19,7 @@ import { useShallow } from 'zustand/react/shallow'; import { useIntl } from 'react-intl'; import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; +import { useEvaluateSubscriptionIneligibility } from 'src/components/admin/Settings/PrefixAlerts/useEvaluateSubscriptionIneligibility'; import UserAvatar from 'src/components/shared/UserAvatar'; import usePrefixAdministrators from 'src/hooks/usePrefixAdministrators'; import useUserInformationByPrefix from 'src/hooks/useUserInformationByPrefix'; @@ -31,21 +33,27 @@ const sanitizeEmail = (value: string) => { return value.trim(); }; -function EmailSelector() { +// TODO: Investigate an issue that prevents the list of admin email from +// being displayed as options. Confirm this is not an existing issue in +// production. +function EmailSelector({ + subscription: { email: subscribedEmail, id: subscriptionId }, +}: SubscriptionDependentProps) { const intl = useIntl(); const serverError = useAlertSubscriptionsStore( (state) => state.initializationError ); - const [prefix, subscribedEmail, setSubscribedEmail, setEmailErrorsExist] = + const [prefix, setSubscribedEmail, setEmailErrorsExist] = useAlertSubscriptionsStore( useShallow((state) => [ - state.subscription.catalogPrefix, - state.subscription.email, + state.catalogPrefix, state.setSubscribedEmail, state.setEmailErrorsExist, ]) ); + const { emptyEmailDetected, duplicateSubscriptionEmails } = + useEvaluateSubscriptionIneligibility(); const [inputValue, setInputValue] = useState(subscribedEmail); @@ -64,14 +72,30 @@ function EmailSelector() { [inputValue] ); + const duplicateEmailDetected = + duplicateSubscriptionEmails.includes(subscribedEmail); + useEffect(() => { - setEmailErrorsExist(inputErrorExists); - }, [inputErrorExists, setEmailErrorsExist]); + setEmailErrorsExist( + inputErrorExists || duplicateEmailDetected, + subscriptionId + ); + }, [ + duplicateEmailDetected, + inputErrorExists, + setEmailErrorsExist, + subscriptionId, + ]); return ( 0) || + (duplicateSubscriptionEmails.length > 0 && + !duplicateEmailDetected) + } filterOptions={(options) => options.filter((option) => { if (typeof option === 'string') { @@ -107,7 +131,7 @@ function EmailSelector() { }} onChange={(_event, value, reason) => { if (!value) { - setSubscribedEmail(''); + setSubscribedEmail('', subscriptionId); return; } @@ -125,12 +149,20 @@ function EmailSelector() { setSubscribedEmail( typeof value === 'string' ? sanitizeEmail(value) - : value.user_email + : value.user_email, + subscriptionId ); }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === 'Tab') { + setSubscribedEmail( + sanitizeEmail(inputValue), + subscriptionId + ); + } + }} onInputChange={(_event, value) => { setInputValue(value); - setSubscribedEmail(sanitizeEmail(value)); }} options={userInfo as Option[]} renderInput={({ @@ -139,16 +171,23 @@ function EmailSelector() { }: AutocompleteRenderInputParams) => ( { + setSubscribedEmail( + sanitizeEmail(inputValue), + subscriptionId + ); + }} required size="small" + slotProps={{ + input: { + sx: { borderRadius: 3 }, + }, + }} variant="outlined" /> )} @@ -175,16 +214,19 @@ function EmailSelector() { - theme.palette.text.primary, + secondary: { + sx: { + color: (theme) => + theme.palette.text + .primary, + }, }, }} sx={{ ml: 2 }} @@ -199,12 +241,20 @@ function EmailSelector() { /> {inputErrorExists ? ( - + {intl.formatMessage({ id: 'alerts.config.dialog.emailSelector.inputError', })} ) : null} + + {duplicateEmailDetected ? ( + + {intl.formatMessage({ + id: 'alerts.config.dialog.emailSelector.duplicationError', + })} + + ) : null} ); } diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/Summary.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/Summary.tsx new file mode 100644 index 0000000000..893853f0b8 --- /dev/null +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/Summary.tsx @@ -0,0 +1,134 @@ +import type { SubscriberAccordionSummaryProps } from 'src/components/admin/Settings/PrefixAlerts/types'; +import type { ChipDisplay } from 'src/components/shared/ChipList/types'; +import type { AlertTypeInfo } from 'src/gql-types/graphql'; + +import { useMemo } from 'react'; + +import { + AccordionSummary, + accordionSummaryClasses, + Box, + Stack, + Typography, + useTheme, +} from '@mui/material'; + +import { NavArrowDown, NavArrowRight, WarningCircle } from 'iconoir-react'; +import { useIntl } from 'react-intl'; + +import ChipList from 'src/components/shared/ChipList'; +import { useGetAlertTypes } from 'src/context/AlertType'; +import { sortByAlertType } from 'src/utils/misc-utils'; + +const Summary = ({ + duplicateSubscriptionEmails, + expanded, + subscription, +}: SubscriberAccordionSummaryProps) => { + const theme = useTheme(); + const intl = useIntl(); + + const [alertTypeResponse] = useGetAlertTypes(); + + const alertTypeDefs: AlertTypeInfo[] = useMemo( + () => + !alertTypeResponse.data ? [] : alertTypeResponse.data.alertTypes, + [alertTypeResponse.data] + ); + + const { alertTypes, email } = subscription; + + const evaluatedAlertTypes: ChipDisplay[] = useMemo( + () => + alertTypes + .map((alertType) => + alertTypeDefs.find((def) => def.alertType === alertType) + ) + .filter((def) => typeof def !== 'undefined') + .sort((first, second) => + sortByAlertType( + { + isSystemAlert: first.isSystem, + value: first.displayName, + }, + { + isSystemAlert: second.isSystem, + value: second.displayName, + }, + 'asc' + ) + ) + .map(({ displayName }) => ({ display: displayName })), + [alertTypeDefs, alertTypes] + ); + + const ineligibleSubscription = + duplicateSubscriptionEmails.length > 0 && + duplicateSubscriptionEmails.includes(subscription.email); + + return ( + + + + {expanded ? ( + + ) : ( + + )} + + + {email.length > 0 + ? email + : intl.formatMessage({ + id: 'alerts.config.dialog.label.placeholderSubscriberId', + })} + + + {ineligibleSubscription ? ( + + ) : null} + + + {expanded ? null : ( + + + + )} + + + ); +}; + +export default Summary; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/SummaryEmpty.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/SummaryEmpty.tsx new file mode 100644 index 0000000000..bcc2cd3c4b --- /dev/null +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/SummaryEmpty.tsx @@ -0,0 +1,40 @@ +import { Stack, Typography, useTheme } from '@mui/material'; + +import { useIntl } from 'react-intl'; + +import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; +import { defaultOutline } from 'src/context/Theme'; + +const SummaryEmpty = () => { + const intl = useIntl(); + const theme = useTheme(); + + const catalogPrefix = useAlertSubscriptionsStore( + (state) => state.catalogPrefix + ); + + return ( + + + {intl.formatMessage( + { + id: 'alerts.config.dialog.message.noExistingSubscriptions', + }, + { prefix: {catalogPrefix} } + )} + + + ); +}; + +export default SummaryEmpty; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/index.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/index.tsx new file mode 100644 index 0000000000..c077aa0db5 --- /dev/null +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/index.tsx @@ -0,0 +1,70 @@ +import type { SubscriberAccordionProps } from 'src/components/admin/Settings/PrefixAlerts/types'; + +import { Accordion, Stack, useTheme } from '@mui/material'; + +import DeleteButton from 'src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/DeleteButton'; +import Details from 'src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/Details'; +import Summary from 'src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/Summary'; +import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; +import { useEvaluateSubscriptionIneligibility } from 'src/components/admin/Settings/PrefixAlerts/useEvaluateSubscriptionIneligibility'; +import { defaultOutline, defaultOutlineColor_hovered } from 'src/context/Theme'; + +const SubscriberInfo = ({ + subscription, +}: Omit) => { + const theme = useTheme(); + + const toggleSubscriptionViewingStatus = useAlertSubscriptionsStore( + (state) => state.toggleSubscriptionViewingStatus + ); + + const { duplicateSubscriptionEmails, emptyEmailDetected } = + useEvaluateSubscriptionIneligibility(); + + return ( + toggleSubscriptionViewingStatus(subscription.id)} + sx={{ + 'backgroundColor': + theme.palette.mode === 'dark' ? 'transparent' : 'white', + 'border': defaultOutline[theme.palette.mode], + 'borderWidth': + emptyEmailDetected && subscription.email.length === 0 + ? 3 + : undefined, + 'borderRadius': '6px', + '&:hover': { + borderColor: + defaultOutlineColor_hovered[theme.palette.mode], + }, + [`&:first-of-type`]: { + borderRadius: '6px', + }, + [`&:last-of-type`]: { + borderRadius: '6px', + }, + }} + > + + + + + + +
+ + ); +}; + +export default SubscriberInfo; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/index.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/index.tsx new file mode 100644 index 0000000000..77fe11ef58 --- /dev/null +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/index.tsx @@ -0,0 +1,89 @@ +import type { SubscriptionMetadata } from 'src/components/admin/Settings/PrefixAlerts/types'; + +import { useEffect, useMemo } from 'react'; + +import { Stack, Typography } from '@mui/material'; + +import { useIntl } from 'react-intl'; + +import AddButton from 'src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/AddButton'; +import SubscriberInfo from 'src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo'; +import SummaryEmpty from 'src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/SubscriberInfo/SummaryEmpty'; +import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; +import { useGetAlertTypes } from 'src/context/AlertType'; + +const SubscriberSection = () => { + const intl = useIntl(); + + const [{ fetching, data, error }] = useGetAlertTypes(); + + const mutableSubscriptionMetadata = useAlertSubscriptionsStore( + (state) => state.mutableSubscriptionMetadata + ); + const setServerError = useAlertSubscriptionsStore( + (state) => state.setServerErrors + ); + + const initializeAlertTypeOptions = useAlertSubscriptionsStore( + (state) => state.initializeAlertTypeOptions + ); + + const targetSubscriptionMetadata: SubscriptionMetadata = useMemo( + () => ({ + ...mutableSubscriptionMetadata, + subscriptions: mutableSubscriptionMetadata.subscriptions.filter( + ({ deleted }) => !deleted + ), + }), + [mutableSubscriptionMetadata] + ); + + useEffect(() => { + if (error) { + setServerError([error]); + } + }, [error, setServerError]); + + useEffect(() => { + initializeAlertTypeOptions(data?.alertTypes ?? [], fetching); + }, [data, fetching, initializeAlertTypeOptions]); + + return ( + + + + {intl.formatMessage( + { id: 'alerts.config.dialog.label.subscribers' }, + { + count: targetSubscriptionMetadata.subscriptions + .length, + } + )} + + + + + + {targetSubscriptionMetadata.subscriptions.length > 0 ? ( + targetSubscriptionMetadata.subscriptions.map( + (subscription, index) => ( + + ) + ) + ) : ( + + )} + + ); +}; + +export default SubscriberSection; diff --git a/src/components/admin/Settings/PrefixAlerts/Dialog/index.tsx b/src/components/admin/Settings/PrefixAlerts/Dialog/index.tsx index fefc0e3f26..a1ab5bde77 100644 --- a/src/components/admin/Settings/PrefixAlerts/Dialog/index.tsx +++ b/src/components/admin/Settings/PrefixAlerts/Dialog/index.tsx @@ -6,19 +6,18 @@ import { Dialog, DialogActions, DialogContent, - Grid, Stack, } from '@mui/material'; import { useIntl } from 'react-intl'; import { useUnmount } from 'react-use'; -import AlertTypeField from 'src/components/admin/Settings/PrefixAlerts/Dialog/AlertTypeField'; import DeleteButton from 'src/components/admin/Settings/PrefixAlerts/Dialog/DeleteButton'; -import EmailListField from 'src/components/admin/Settings/PrefixAlerts/Dialog/EmailListField'; +import GlobalSettings from 'src/components/admin/Settings/PrefixAlerts/Dialog/GlobalSettings'; import PrefixField from 'src/components/admin/Settings/PrefixAlerts/Dialog/PrefixField'; import SaveButton from 'src/components/admin/Settings/PrefixAlerts/Dialog/SaveButton'; import ServerErrors from 'src/components/admin/Settings/PrefixAlerts/Dialog/ServerErrors'; +import SubscriberSection from 'src/components/admin/Settings/PrefixAlerts/Dialog/SubscriberSection/index'; import useAlertSubscriptionsStore from 'src/components/admin/Settings/PrefixAlerts/useAlertSubscriptionsStore'; import MessageWithLink from 'src/components/content/MessageWithLink'; import DialogTitleWithClose from 'src/components/shared/Dialog/TitleWithClose'; @@ -27,12 +26,9 @@ const TITLE_ID = 'alert-subscription-dialog-title'; const AlertSubscriptionDialog = ({ descriptionId, - enableDeletion, - existingAlertTypes, headerId, open, setOpen, - staticEmail, staticPrefix, }: AlertSubscriptionDialogProps) => { const intl = useIntl(); @@ -63,25 +59,21 @@ const AlertSubscriptionDialog = ({ - + - + - - + + - {enableDeletion ? ( - closeDialog()} /> - ) : null} + closeDialog()} />