diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 40f02ebc420fd4..891c0ad90ce9e0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -345,6 +345,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/views/performance/ @getsentry/data-browsing /static/app/components/performance/ @getsentry/data-browsing /static/app/utils/performance/ @getsentry/data-browsing +/static/app/components/events/groupingInfo @getsentry/data-browsing /static/app/components/events/interfaces/spans/ @getsentry/data-browsing /static/app/components/events/viewHierarchy/* @getsentry/data-browsing /static/app/components/searchQueryBuilder/ @getsentry/data-browsing diff --git a/static/app/components/core/button/button.mdx b/static/app/components/core/button/button.mdx index b8a20588c1cbea..e2f32282c7e93c 100644 --- a/static/app/components/core/button/button.mdx +++ b/static/app/components/core/button/button.mdx @@ -14,6 +14,9 @@ resources: WAI-ARIA Button Practices: https://www.w3.org/WAI/ARIA/apg/patterns/button/ --- +import {useState} from 'react'; + +import {Flex} from '@sentry/scraps/layout'; import {Alert} from '@sentry/scraps/alert'; import {Button} from '@sentry/scraps/button'; @@ -158,29 +161,68 @@ The following table defines standardized call to action copy and icon pairings f | | `zoom` | Zoom Out | Applies to charts and zooms out (i.e. 200%) | | | `star` | Favorite | Hoisting item to primary view | -## Disabled and Busy Buttons +## Disabled Buttons -Disabled and busy buttons are used to indicate the status of an item. Busy buttons should be used to indicate that an action is in progress. Disabled buttons should be used to indicate that an action is not available. +Disabled buttons should be used to indicate that an action is not available. + - - - ```jsx + - - - +``` + +### Busy Buttons + +Busy buttons should be used to indicate that an async action is in progress, usually connected to the `isPending` state of a query mutation. + +export function BusyDemo() { + const [busy, setBusy] = useState({cancel: false, submit: false}); + /** @param key {'cancel' | 'submit'} */ + const handleClick = key => { + setBusy(v => ({...v, [key]: true})); + setTimeout(() => { + setBusy(v => ({...v, [key]: false})); + }, 2500); + }; + return ( + + + + + ); +} + + + + +```jsx + + + + ``` ## Icon-only Buttons diff --git a/static/app/components/core/button/button.tsx b/static/app/components/core/button/button.tsx index c305a65ee421a1..621a02d06268b3 100644 --- a/static/app/components/core/button/button.tsx +++ b/static/app/components/core/button/button.tsx @@ -1,7 +1,7 @@ -import {keyframes} from '@emotion/react'; import styled from '@emotion/styled'; import {Flex} from '@sentry/scraps/layout'; +import {IndeterminateLoader} from '@sentry/scraps/loader'; import {useSizeContext} from '@sentry/scraps/sizeContext'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -86,7 +86,7 @@ export function Button({ visibility="visible" inset={0} > - {({className}) => } + )} @@ -103,22 +103,3 @@ const StyledButton = styled('button')< >` ${p => getButtonStyles(p)} `; - -const spin = keyframes` - to { - transform: rotate(360deg); - } -`; - -const BusySpinner = styled('span')` - &::after { - content: ''; - display: block; - width: 1em; - height: 1em; - border-radius: 50%; - border: 2px solid currentColor; - border-top-color: transparent; - animation: ${spin} 0.6s linear infinite; - } -`; diff --git a/static/app/components/core/button/styles.tsx b/static/app/components/core/button/styles.tsx index 82f6c9f5bc57a0..d206c1962e593e 100644 --- a/static/app/components/core/button/styles.tsx +++ b/static/app/components/core/button/styles.tsx @@ -75,7 +75,7 @@ export function DO_NOT_USE_getButtonStyles( fontWeight: p.theme.font.weight.sans.medium, - opacity: p.busy || p.disabled ? 0.6 : undefined, + opacity: p.disabled ? 0.6 : undefined, cursor: 'pointer', '&[disabled]': { @@ -135,6 +135,10 @@ export function DO_NOT_USE_getButtonStyles( }, }, + '&[aria-busy="true"] > span:last-child': { + overflow: 'visible', + }, + '> span:last-child': { zIndex: 1, position: 'relative', @@ -180,7 +184,7 @@ export function DO_NOT_USE_getButtonStyles( }, }, - '&:disabled, &[aria-disabled="true"]': { + '&:disabled, &[aria-disabled="true"], &[aria-busy="true"]': { '&::after': { transform: 'translateY(0px)', }, @@ -189,6 +193,10 @@ export function DO_NOT_USE_getButtonStyles( }, }, + '&[aria-busy="true"]': { + cursor: 'progress', + }, + ...(p.priority === 'link' && { transform: 'translateY(0px)', diff --git a/static/app/components/core/loader/indeterminateLoader.tsx b/static/app/components/core/loader/indeterminateLoader.tsx new file mode 100644 index 00000000000000..bf723e44a29098 --- /dev/null +++ b/static/app/components/core/loader/indeterminateLoader.tsx @@ -0,0 +1,215 @@ +import {useEffect, useRef, useState} from 'react'; +import {keyframes} from '@emotion/react'; +import {useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; +import {useResizeObserver} from '@react-aria/utils'; +import {AnimatePresence, motion} from 'framer-motion'; + +import {Stack} from '@sentry/scraps/layout'; + +import {testableTransition} from 'sentry/utils/testableTransition'; + +// required to break import cycle +// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths +import {Text} from '../text/text'; + +interface IndeterminateLoaderProps extends React.HTMLAttributes { + messages?: React.ReactNode[]; + variant?: 'vibrant' | 'monochrome'; +} + +const SQUIGGLE_TILE = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='1 0 16 8'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M17 6c-4 0-4-4-8-4S5 6 1 6'/%3E%3C/svg%3E")`; + +const indeterminateSlow = keyframes` + 0% { left: -35%; right: 100%; } + 60% { left: 100%; right: -90%; } + 100% { left: 100%; right: -90%; } +`; + +const indeterminateFast = keyframes` + 0% { left: -200%; right: 100%; } + 60% { left: 107%; right: -8%; } + 100% { left: 107%; right: -8%; } +`; + +// Lerp animation timing based on track width. +// Small (~128px): 2.0s duration, 1.0s delay +// Large (~400px+): 3.2s duration, 1.6s delay +const WIDTH = {MIN: 128, MAX: 400}; +const DURATION = {MIN: 2.0, MAX: 2.8}; +const DELAY = {MIN: 0.8, MAX: 1.2}; + +function lerp(min: number, max: number, t: number): number { + return min + (max - min) * Math.min(1, Math.max(0, t)); +} + +function useAnimationTiming() { + const ref = useRef(null); + const [duration, setDuration] = useState(DURATION.MAX); + const [delay, setDelay] = useState(DELAY.MAX); + + useResizeObserver({ + ref, + onResize() { + const w = ref.current?.offsetWidth ?? WIDTH.MAX; + const t = (w - WIDTH.MIN) / (WIDTH.MAX - WIDTH.MIN); + setDuration(lerp(DURATION.MIN, DURATION.MAX, t)); + setDelay(lerp(DELAY.MIN, DELAY.MAX, t)); + }, + }); + + return {ref, duration, delay}; +} + +const MESSAGE_INTERVAL_MS = 10_000; + +function useMessageCycler(messages: React.ReactNode[]) { + const [index, setIndex] = useState(0); + + useEffect(() => { + if (messages.length <= 1 || index >= messages.length - 1) { + return undefined; + } + const timer = setTimeout(() => setIndex(i => i + 1), MESSAGE_INTERVAL_MS); + return () => clearTimeout(timer); + }, [index, messages.length]); + + return {message: messages.length > 0 ? messages[index] : null, index}; +} + +export function IndeterminateLoader({ + variant = 'vibrant', + messages, + ...props +}: IndeterminateLoaderProps) { + const theme = useTheme(); + const {ref, duration, delay} = useAnimationTiming(); + const {message: currentMessage, index: messageIndex} = useMessageCycler(messages ?? []); + + const track = ( + + + + + + + ); + + if (!messages?.length) { + return track; + } + + return ( + + {track} + + + + {currentMessage} + + + + + + ); +} + +const dotFadeInOut = keyframes` + 0%, 30% { opacity: 0; } + 40%, 70% { opacity: 1; } + 80%, 100% { opacity: 0; } +`; + +function Ellipsis() { + return ( + + . + . + . + + ); +} + +const Dot = styled('span')<{delay: number}>` + opacity: 0; + animation: ${dotFadeInOut} 2.5s ${p => p.delay}s infinite; +`; + +const Track = styled('div')<{color: string; opacity: string}>` + position: relative; + overflow: hidden; + width: 100%; + width: calc(round(down, 100% - 16px, 8px) + 16px); + height: 8px; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: ${p => p.color}; + opacity: ${p => p.opacity}; + mask-image: ${SQUIGGLE_TILE}; + mask-repeat: repeat-x; + mask-size: 16px 8px; + -webkit-mask-image: ${SQUIGGLE_TILE}; + -webkit-mask-repeat: repeat-x; + -webkit-mask-size: 16px 8px; + } +`; + +const ColorMask = styled('span')` + position: absolute; + inset: 0; + mask-image: ${SQUIGGLE_TILE}; + mask-repeat: repeat-x; + mask-size: 16px 8px; + -webkit-mask-image: ${SQUIGGLE_TILE}; + -webkit-mask-repeat: repeat-x; + -webkit-mask-size: 16px 8px; +`; + +const Bar = styled('span')<{ + animation: ReturnType; + color: string; + delay: string; + duration: string; + timing: string; +}>` + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: ${p => p.color}; + animation: ${p => p.animation} ${p => p.duration} ${p => p.timing} ${p => p.delay} + infinite backwards; +`; diff --git a/static/app/components/core/loader/index.tsx b/static/app/components/core/loader/index.tsx new file mode 100644 index 00000000000000..f763feb1695b1c --- /dev/null +++ b/static/app/components/core/loader/index.tsx @@ -0,0 +1 @@ +export {IndeterminateLoader} from './indeterminateLoader'; diff --git a/static/app/components/core/loader/loader.mdx b/static/app/components/core/loader/loader.mdx new file mode 100644 index 00000000000000..cb33feafb2e88a --- /dev/null +++ b/static/app/components/core/loader/loader.mdx @@ -0,0 +1,108 @@ +--- +title: IndeterminateLoader +description: An animated squiggle loader that fills its container to indicate indeterminate progress. +category: status +source: '@sentry/scraps/loader' +resources: + js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/loader/indeterminateLoader.tsx +--- + +import {Container, Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; +import {IndeterminateLoader} from '@sentry/scraps/loader'; + +import {Demo} from 'sentry/stories/demo'; + +export const documentation = import('!!type-loader!@sentry/scraps/loader'); + +The `IndeterminateLoader` renders a repeating squiggle pattern with an animated color wipe to indicate indeterminate loading progress. It fills 100% of its parent's width. + +## Usage + + + + + +```jsx + +``` + +## Messages + +Pass an array of `messages` to step through loading messages as the loader runs. + + + + + +```jsx + +``` + +## Contained Width + +Constrain the loader's width by wrapping it in a sized container. + + + + + + + + + + + + + +```jsx + + + + + + + + + +``` + +## Monochrome + +Use `variant="monochrome"` to inherit `currentColor` from the parent. The track renders at 25% opacity and the accent at full opacity. + +This is used internally in the Button component. + + + + + + + + + + + + + +```jsx + + + +``` + +## Accessibility + +The component renders with `role="progressbar"` and a default `aria-label` of `"Loading"`. Override the label to provide more specific context: + +```jsx + +``` diff --git a/static/app/components/core/statusIndicator/statusIndicator.tsx b/static/app/components/core/statusIndicator/statusIndicator.tsx index 6ef1d4f1567094..59c0fdfcc61fb6 100644 --- a/static/app/components/core/statusIndicator/statusIndicator.tsx +++ b/static/app/components/core/statusIndicator/statusIndicator.tsx @@ -91,6 +91,18 @@ function getDotTokens( } const gentlePulse = keyframes` + 0%, 100% { + transform: scale(1); + } + 33% { + transform: scale(0.95); + } + 67% { + transform: scale(1.05); + } +`; + +const gentleSpin = keyframes` 0% { opacity: 0; border-radius: 6px; @@ -110,22 +122,28 @@ const gentlePulse = keyframes` const Dot = styled('span')<{variant: StatusIndicatorVariant}>` position: relative; - isolation: isolate; - border-radius: ${p => p.theme.radius.xs}; width: 8px; height: 8px; - background-color: ${p => getDotTokens(p.variant, p.theme).dot}; - &::before { content: ''; position: absolute; - z-index: -1; top: -2px; left: -2px; width: 12px; height: 12px; border-radius: ${p => p.theme.radius['2xs']}; background-color: ${p => getDotTokens(p.variant, p.theme).pulse}; - animation: ${gentlePulse} 2.2s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite; + animation: ${gentleSpin} 2.2s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite; + } + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 8px; + height: 8px; + border-radius: ${p => p.theme.radius.xs}; + background-color: ${p => getDotTokens(p.variant, p.theme).dot}; + animation: ${gentlePulse} 2.2s cubic-bezier(0.445, 0.05, 0.55, 0.95) infinite; } `; diff --git a/static/app/components/events/groupingInfo/groupingInfoSection.spec.tsx b/static/app/components/events/groupingInfo/groupingInfo.spec.tsx similarity index 80% rename from static/app/components/events/groupingInfo/groupingInfoSection.spec.tsx rename to static/app/components/events/groupingInfo/groupingInfo.spec.tsx index d9904f574b5403..c157c449650eeb 100644 --- a/static/app/components/events/groupingInfo/groupingInfoSection.spec.tsx +++ b/static/app/components/events/groupingInfo/groupingInfo.spec.tsx @@ -1,12 +1,12 @@ import {EventFixture} from 'sentry-fixture/event'; import {GroupFixture} from 'sentry-fixture/group'; -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {render, screen} from 'sentry-test/reactTestingLibrary'; import {EventGroupVariantType} from 'sentry/types/event'; import {IssueCategory} from 'sentry/types/group'; -import {EventGroupingInfoSection} from './groupingInfoSection'; +import GroupingInfo from './groupingInfo'; describe('EventGroupingInfo', () => { const group = GroupFixture(); @@ -45,15 +45,6 @@ describe('EventGroupingInfo', () => { }); }); - it('fetches and renders grouping info for errors', async () => { - render(); - await userEvent.click( - screen.getByRole('button', {name: 'View Event Grouping Information Section'}) - ); - expect(await screen.findByText('variant description')).toBeInTheDocument(); - expect(screen.getByText('123')).toBeInTheDocument(); - }); - it('gets performance grouping info from group/event data', async () => { const perfEvent = EventFixture({ type: 'transaction', @@ -61,9 +52,7 @@ describe('EventGroupingInfo', () => { }); const perfGroup = GroupFixture({issueCategory: IssueCategory.PERFORMANCE}); - render( - - ); + render(); expect(await screen.findByText('performance problem')).toBeInTheDocument(); expect(screen.getByText('123')).toBeInTheDocument(); @@ -88,7 +77,7 @@ describe('EventGroupingInfo', () => { }, }, }); - render(); + render(); expect(await screen.findByText('variant description')).toBeInTheDocument(); expect(screen.getByText('123')).toBeInTheDocument(); @@ -116,9 +105,7 @@ describe('EventGroupingInfo', () => { }); const perfGroup = GroupFixture({issueCategory: IssueCategory.PERFORMANCE}); - render( - - ); + render(); expect(await screen.findByText('performance problem')).toBeInTheDocument(); expect(screen.getByText('123')).toBeInTheDocument(); diff --git a/static/app/views/settings/account/notifications/notificationSettings.tsx b/static/app/views/settings/account/notifications/notificationSettings.tsx index b3cbbdc5eab131..ea0c6caf012b25 100644 --- a/static/app/views/settings/account/notifications/notificationSettings.tsx +++ b/static/app/views/settings/account/notifications/notificationSettings.tsx @@ -1,6 +1,6 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; -import {mutationOptions} from '@tanstack/react-query'; +import {mutationOptions, useQueryClient} from '@tanstack/react-query'; import {z} from 'zod'; import {LinkButton} from '@sentry/scraps/button'; @@ -17,7 +17,7 @@ import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t, tct} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {fetchMutation, useApiQuery} from 'sentry/utils/queryClient'; +import {fetchMutation, setApiQueryData, useApiQuery} from 'sentry/utils/queryClient'; import {withOrganizations} from 'sentry/utils/withOrganizations'; import type {NotificationSettingsType} from 'sentry/views/settings/account/notifications/constants'; import { @@ -45,6 +45,7 @@ interface NotificationSettingsProps { } function NotificationSettings({organizations}: NotificationSettingsProps) { + const queryClient = useQueryClient(); const checkFeatureFlag = (flag: string) => { return organizations.some(org => org.features?.includes(flag)); }; @@ -102,8 +103,13 @@ function NotificationSettings({organizations}: NotificationSettingsProps) { data, }); }, - onSuccess: () => { + onSuccess: (_, variables) => { addSuccessMessage(t('Notification preferences saved')); + setApiQueryData( + queryClient, + [NOTIFICATIONS_ENDPOINT], + existing => (existing ? {...existing, ...variables} : undefined) + ); }, }); diff --git a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx index 6f5270697b632b..4e9f207b0fa789 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx @@ -389,7 +389,7 @@ export function NotificationSettingsByType({notificationType}: Props) { const optionMutationOptions = (fieldName: string) => mutationOptions({ mutationFn: (data: Record) => - fetchMutation({ + fetchMutation({ method: 'PUT', url: '/users/me/notification-options/', data: { @@ -399,7 +399,23 @@ export function NotificationSettingsByType({notificationType}: Props) { value: data[fieldName], }, }), - onSuccess: () => trackTuningUpdated('general'), + onSuccess: notificationOption => { + trackTuningUpdated('general'); + setApiQueryData( + queryClient, + notificationOptionsQueryKey(notificationType), + currentOptions => { + const existing = currentOptions ?? []; + const idx = existing.findIndex(opt => opt.id === notificationOption.id); + if (idx >= 0) { + return existing.map(opt => + opt.id === notificationOption.id ? notificationOption : opt + ); + } + return [...existing, notificationOption]; + } + ); + }, }); const providerChoices = ( @@ -422,6 +438,14 @@ export function NotificationSettingsByType({notificationType}: Props) { providers: data.provider, }, }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ + getApiUrl('/users/$userId/notification-providers/', {path: {userId: 'me'}}), + {query: getQueryParams(notificationType)}, + ], + }); + }, }); const renderQuotaFields = () => {