diff --git a/web/src/app/users/UserDetails.tsx b/web/src/app/users/UserDetails.tsx
index 47df34c945..38ad309a6c 100644
--- a/web/src/app/users/UserDetails.tsx
+++ b/web/src/app/users/UserDetails.tsx
@@ -9,6 +9,7 @@ import UserContactMethodListDest from './UserContactMethodListDest'
import { AddAlarm, SettingsPhone } from '@mui/icons-material'
import SpeedDial from '../util/SpeedDial'
import UserNotificationRuleList from './UserNotificationRuleList'
+import UserNotificationRuleListDest from './UserNotificationRuleListDest'
import { Grid } from '@mui/material'
import UserContactMethodCreateDialog from './UserContactMethodCreateDialog'
import UserNotificationRuleCreateDialog from './UserNotificationRuleCreateDialog'
@@ -250,10 +251,17 @@ export default function UserDetails(props: {
readOnly={props.readOnly}
/>
)}
-
+ {hasDestTypesFlag ? (
+
+ ) : (
+
+ )}
{!mobile && (
diff --git a/web/src/app/users/UserNotificationRuleListDest.stories.tsx b/web/src/app/users/UserNotificationRuleListDest.stories.tsx
new file mode 100644
index 0000000000..2026c9cc5d
--- /dev/null
+++ b/web/src/app/users/UserNotificationRuleListDest.stories.tsx
@@ -0,0 +1,177 @@
+import React from 'react'
+import type { Meta, StoryObj } from '@storybook/react'
+import UserNotificationRuleListDest from './UserNotificationRuleListDest'
+import { expect, within, userEvent, screen } from '@storybook/test'
+import { handleDefaultConfig, handleExpFlags } from '../storybook/graphql'
+import { HttpResponse, graphql } from 'msw'
+
+const meta = {
+ title: 'users/UserNotificationRuleListDest',
+ component: UserNotificationRuleListDest,
+ tags: ['autodocs'],
+ parameters: {
+ msw: {
+ handlers: [
+ handleDefaultConfig,
+ handleExpFlags('dest-types'),
+ graphql.query('nrList', ({ variables: vars }) => {
+ return HttpResponse.json({
+ data:
+ vars.id === '00000000-0000-0000-0000-000000000000'
+ ? {
+ user: {
+ id: '00000000-0000-0000-0000-000000000000',
+ contactMethods: [
+ {
+ id: '12345',
+ },
+ ],
+ notificationRules: [
+ {
+ id: '123',
+ delayMinutes: 33,
+ contactMethod: {
+ id: '12345',
+ name: 'Josiah',
+ dest: {
+ type: 'single-field',
+ displayInfo: {
+ text: '+1 555-555-5555',
+ iconAltText: 'Voice Call',
+ },
+ },
+ },
+ },
+ ],
+ },
+ }
+ : {
+ user: {
+ id: '00000000-0000-0000-0000-000000000001',
+ contactMethods: [
+ {
+ id: '67890',
+ },
+ {
+ id: '1111',
+ },
+ ],
+ notificationRules: [
+ {
+ id: '96814869-1199-4477-832d-e714e7d94aea',
+ delayMinutes: 71,
+ contactMethod: {
+ id: '67890',
+ name: 'Bridget',
+ dest: {
+ type: 'builtin-twilio-voice',
+ displayInfo: {
+ text: '+1 763-351-1103',
+ iconAltText: 'Voice Call',
+ },
+ },
+ },
+ },
+ {
+ id: 'eea77488-3748-4af8-99ba-18855f9a540d',
+ delayMinutes: 247,
+ contactMethod: {
+ id: '1111',
+ name: 'Dewayne',
+ dest: {
+ type: 'builtin-twilio-sms',
+ displayInfo: {
+ text: '+1 763-346-2643',
+ iconAltText: 'Text Message',
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ })
+ }),
+ ],
+ },
+ },
+ render: function Component(args) {
+ return
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const SingleContactMethod: Story = {
+ args: {
+ userID: '00000000-0000-0000-0000-000000000000',
+ readOnly: false,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+
+ // ensure correct info is displayed for single-field NR
+ await expect(
+ await canvas.findByText(
+ 'After 33 minutes notify me via Voice Call at +1 555-555-5555 (Josiah)',
+ ),
+ ).toBeVisible()
+
+ await expect(await screen.findByText('Add Rule')).toHaveAttribute(
+ 'type',
+ 'button',
+ )
+ await userEvent.click(
+ await screen.findByLabelText('Delete notification rule'),
+ )
+ await userEvent.click(await screen.findByText('Cancel'))
+ },
+}
+
+export const MultiContactMethods: Story = {
+ args: {
+ userID: '00000000-0000-0000-0000-000000000001',
+ readOnly: false,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+
+ await expect(
+ await canvas.findByText(
+ 'After 71 minutes notify me via Voice Call at +1 763-351-1103 (Bridget)',
+ ),
+ ).toBeVisible()
+ await expect(
+ await canvas.findByText(
+ 'After 247 minutes notify me via Text Message at +1 763-346-2643 (Dewayne)',
+ ),
+ ).toBeVisible()
+
+ await expect(await screen.findByText('Add Rule')).toHaveAttribute(
+ 'type',
+ 'button',
+ )
+ const deleteButtons = await screen.findAllByLabelText(
+ 'Delete notification rule',
+ )
+ expect(deleteButtons).toHaveLength(2)
+ await userEvent.click(deleteButtons[0])
+ await userEvent.click(await screen.findByText('Cancel'))
+ },
+}
+
+export const SingleReadOnlyContactMethods: Story = {
+ args: {
+ userID: '00000000-0000-0000-0000-000000000000',
+ readOnly: true,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+
+ // ensure no edit icons exist for read-only NR
+ await expect(
+ await canvas.queryByLabelText('Delete notification rule'),
+ ).not.toBeInTheDocument()
+ },
+}
diff --git a/web/src/app/users/UserNotificationRuleListDest.tsx b/web/src/app/users/UserNotificationRuleListDest.tsx
new file mode 100644
index 0000000000..c7731bfb28
--- /dev/null
+++ b/web/src/app/users/UserNotificationRuleListDest.tsx
@@ -0,0 +1,142 @@
+import React, { useState, ReactNode, Suspense } from 'react'
+import { gql, useQuery } from 'urql'
+import {
+ Button,
+ Card,
+ CardHeader,
+ Grid,
+ IconButton,
+ Theme,
+} from '@mui/material'
+import makeStyles from '@mui/styles/makeStyles'
+import { Add, Delete } from '@mui/icons-material'
+import FlatList from '../lists/FlatList'
+import { formatNotificationRule, sortNotificationRules } from './util'
+import UserNotificationRuleDeleteDialog from './UserNotificationRuleDeleteDialog'
+import { styles as globalStyles } from '../styles/materialStyles'
+import UserNotificationRuleCreateDialog from './UserNotificationRuleCreateDialog'
+import { useIsWidthDown } from '../util/useWidth'
+import { ObjectNotFound, GenericError } from '../error-pages'
+import { User } from '../../schema'
+
+const query = gql`
+ query nrList($id: ID!) {
+ user(id: $id) {
+ id
+ contactMethods {
+ id
+ }
+ notificationRules {
+ id
+ delayMinutes
+ contactMethod {
+ id
+ name
+ dest {
+ type
+ displayInfo {
+ text
+ iconAltText
+ }
+ }
+ }
+ }
+ }
+ }
+`
+
+const useStyles = makeStyles((theme: Theme) => {
+ const { cardHeader } = globalStyles(theme)
+ return {
+ cardHeader,
+ }
+})
+
+export default function UserNotificationRuleListDest(props: {
+ userID: string
+ readOnly: boolean
+}): ReactNode {
+ const classes = useStyles()
+ const mobile = useIsWidthDown('md')
+ const [showAddDialog, setShowAddDialog] = useState(false)
+ const [deleteID, setDeleteID] = useState(null)
+
+ const [{ data, error }] = useQuery({
+ query,
+ variables: { id: props.userID },
+ })
+
+ if (data && !data.user)
+ return
+ if (error) return
+
+ const { user }: { user: User } = data
+
+ return (
+
+
+ setShowAddDialog(true)}
+ startIcon={}
+ disabled={user?.contactMethods.length === 0}
+ >
+ Add Rule
+
+ ) : null
+ }
+ />
+ {
+ const formattedValue =
+ nr.contactMethod.dest.displayInfo.text || 'Unknown Label'
+ const name = nr.contactMethod.name || 'Unknown User'
+ const type =
+ nr.contactMethod.dest.displayInfo.iconAltText || 'Unknown Type'
+ return {
+ title: formatNotificationRule(nr.delayMinutes, {
+ type,
+ name,
+ formattedValue,
+ }),
+ secondaryAction: props.readOnly ? null : (
+ setDeleteID(nr.id)}
+ color='secondary'
+ >
+
+
+ ),
+ }
+ },
+ )}
+ emptyMessage='No notification rules'
+ />
+
+
+ {showAddDialog && (
+ setShowAddDialog(false)}
+ />
+ )}
+ {deleteID && (
+ setDeleteID(null)}
+ />
+ )}
+
+
+ )
+}