From dad0741a342633270521b570f7842174b5d30376 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 20 Aug 2025 13:22:17 -0400 Subject: [PATCH] ref(releases): Rebuild ReleasesPromo - Replaces the deprecated dropdownAutoComplete with a CompactSelect - Removes the complex logic to have a with a much simpler interface that uses a familiar drop-down to select the integration to use a token from. --- .../autoCompleteFilter.tsx | 73 --- .../dropdownAutoComplete/index.spec.tsx | 69 --- .../components/dropdownAutoComplete/index.tsx | 92 ---- .../components/dropdownAutoComplete/list.tsx | 120 ----- .../dropdownAutoComplete/menu.spec.tsx | 166 ------ .../components/dropdownAutoComplete/menu.tsx | 507 ------------------ .../components/dropdownAutoComplete/row.tsx | 134 ----- .../components/dropdownAutoComplete/types.tsx | 24 - .../app/views/releases/list/releasesPromo.tsx | 350 +++--------- 9 files changed, 89 insertions(+), 1446 deletions(-) delete mode 100644 static/app/components/dropdownAutoComplete/autoCompleteFilter.tsx delete mode 100644 static/app/components/dropdownAutoComplete/index.spec.tsx delete mode 100644 static/app/components/dropdownAutoComplete/index.tsx delete mode 100644 static/app/components/dropdownAutoComplete/list.tsx delete mode 100644 static/app/components/dropdownAutoComplete/menu.spec.tsx delete mode 100644 static/app/components/dropdownAutoComplete/menu.tsx delete mode 100644 static/app/components/dropdownAutoComplete/row.tsx delete mode 100644 static/app/components/dropdownAutoComplete/types.tsx diff --git a/static/app/components/dropdownAutoComplete/autoCompleteFilter.tsx b/static/app/components/dropdownAutoComplete/autoCompleteFilter.tsx deleted file mode 100644 index dcc43a769ed706..00000000000000 --- a/static/app/components/dropdownAutoComplete/autoCompleteFilter.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type {Item, ItemsAfterFilter, ItemsBeforeFilter} from './types'; - -type Items = ItemsBeforeFilter; -type ItemsWithChildren = Array< - Omit & { - items: Array>; - hideGroupLabel?: boolean; - } ->; - -function hasRootGroup(items: Items): items is ItemsWithChildren { - return !!items[0]?.items; -} - -function filterItems(items: Items, inputValue: string): ItemsBeforeFilter { - return items.filter(item => - (typeof item.searchKey === 'string' && item.searchKey.length > 0 - ? item.searchKey - : `${item.value} ${item.label}` - ) - .toLowerCase() - .includes(inputValue.toLowerCase()) - ); -} - -function filterGroupedItems( - groups: ItemsWithChildren, - inputValue: string -): ItemsWithChildren { - return groups - .map(group => ({ - ...group, - items: filterItems(group.items, inputValue), - })) - .filter(group => group.items.length > 0); -} - -function autoCompleteFilter( - items: ItemsBeforeFilter | null, - inputValue: string -): ItemsAfterFilter { - let itemCount = 0; - - if (!items) { - return []; - } - - if (hasRootGroup(items)) { - // if the first item has children, we assume it is a group - return filterGroupedItems(items, inputValue).flatMap(item => { - const groupItems = item.items.map(groupedItem => ({ - ...groupedItem, - index: itemCount++, - })); - - // Make sure we don't add the group label to list of items - // if we try to hide it, otherwise it will render if the list - // is using virtualized rows (because of fixed row heights) - if (item.hideGroupLabel) { - return groupItems; - } - - return [{...item, groupLabel: true}, ...groupItems]; - }) as ItemsAfterFilter; - } - - return filterItems(items, inputValue).map((item, index) => ({ - ...item, - index, - })) as ItemsAfterFilter; -} - -export default autoCompleteFilter; diff --git a/static/app/components/dropdownAutoComplete/index.spec.tsx b/static/app/components/dropdownAutoComplete/index.spec.tsx deleted file mode 100644 index 323b0d49140820..00000000000000 --- a/static/app/components/dropdownAutoComplete/index.spec.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; - -import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete'; - -describe('DropdownAutoComplete', () => { - const items = [ - { - value: 'apple', - label:
Apple
, - }, - { - value: 'bacon', - label:
Bacon
, - }, - { - value: 'corn', - label:
Corn
, - }, - ]; - - it('has actor wrapper', () => { - render( - {() => 'Click Me!'} - ); - - expect(screen.getByRole('button')).toHaveTextContent('Click Me!'); - }); - - it('does not allow the dropdown to be closed without allowActorToggle', async () => { - render( - {() => 'Click Me!'} - ); - - const actor = screen.getByRole('button'); - - // Starts closed - expect(actor).toHaveAttribute('aria-expanded', 'false'); - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - - // Clicking once opens the menu - await userEvent.click(actor); - expect(actor).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByRole('listbox')).toBeInTheDocument(); - - // Clicking again does not close the menu - await userEvent.click(actor); - expect(actor).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByRole('listbox')).toBeInTheDocument(); - }); - - it('toggles dropdown menu when actor is clicked and allowActorToggle=true', async () => { - render( - - {() => 'Click Me!'} - - ); - const actor = screen.getByRole('button'); - - // Clicking once opens - await userEvent.click(actor); - expect(actor).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByRole('listbox')).toBeInTheDocument(); - - // Clicking again closes - await userEvent.click(actor); - expect(actor).toHaveAttribute('aria-expanded', 'false'); - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - }); -}); diff --git a/static/app/components/dropdownAutoComplete/index.tsx b/static/app/components/dropdownAutoComplete/index.tsx deleted file mode 100644 index 8fccbbebbb2f76..00000000000000 --- a/static/app/components/dropdownAutoComplete/index.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type React from 'react'; -import {useCallback, useState} from 'react'; -import styled from '@emotion/styled'; - -import type {MenuProps} from './menu'; -import Menu from './menu'; - -function makeActorProps( - renderProps: any, - options: { - lazy: boolean; - allowActorToggle?: boolean; - onLazyOpen?: (fn: (e: React.MouseEvent) => void) => void; - } -) { - const {isOpen, actions, getActorProps} = renderProps; - // Don't pass `onClick` from `getActorProps` - const {onClick: _onClick, ...actorProps} = getActorProps(); - const onOpen = - options.lazy && options.onLazyOpen ? options.onLazyOpen(actions.open) : actions.open; - - return { - role: 'button', - tabIndex: 0, - isOpen, - onClick: isOpen && options.allowActorToggle ? actions.close : onOpen, - ...actorProps, - }; -} - -interface BaseProps extends Omit { - // Should clicking the actor toggle visibility - allowActorToggle?: boolean; -} -interface LazyDropdownAutoCompleteProps extends BaseProps { - lazyItems: () => MenuProps['items']; - items?: never; -} - -interface StaticDropdownAutoCompleteProps extends BaseProps { - items: MenuProps['items']; - lazyItems?: never; -} - -/** - * @deprecated prefer using CompactSelect - */ -function DropdownAutoComplete( - props: LazyDropdownAutoCompleteProps | StaticDropdownAutoCompleteProps -) { - const {allowActorToggle, children, items, lazyItems, ...rest} = props; - const [maybeLazyItems, setMaybeLazyItems] = useState( - items ? items : null - ); - - const onLazyOpen = useCallback( - (onActionOpen: (e: React.MouseEvent) => void) => { - return (e: React.MouseEvent) => { - if (typeof lazyItems !== 'function') { - onActionOpen(e); - return; - } - setMaybeLazyItems(lazyItems()); - onActionOpen(e); - }; - }, - [lazyItems] - ); - - const isLazy = typeof props.lazyItems === 'function'; - - return ( - - {renderProps => ( - - {children(renderProps)} - - )} - - ); -} - -const Actor = styled('div')<{isOpen: boolean}>` - position: relative; - width: 100%; - /* This is needed to be able to cover dropdown menu so that it looks like one unit */ - ${p => p.isOpen && `z-index: ${p.theme.zIndex.dropdownAutocomplete.actor}`}; -`; - -export default DropdownAutoComplete; diff --git a/static/app/components/dropdownAutoComplete/list.tsx b/static/app/components/dropdownAutoComplete/list.tsx deleted file mode 100644 index 37e43b5342fdb3..00000000000000 --- a/static/app/components/dropdownAutoComplete/list.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import {Fragment} from 'react'; -import {AutoSizer, List as ReactVirtualizedList} from 'react-virtualized'; - -import Row from './row'; -import type {ItemsAfterFilter} from './types'; - -type RowProps = Pick< - React.ComponentProps, - 'itemSize' | 'getItemProps' | 'registerVisibleItem' ->; - -type Props = { - /** - * The item index that is currently isActive - */ - highlightedIndex: number; - /** - * Flat item array or grouped item array - */ - items: ItemsAfterFilter; - /** - * Max height of dropdown menu. Units are assumed as `px` - */ - maxHeight: number; - /** - * Callback for when dropdown menu is being scrolled - */ - onScroll?: () => void; - /** - * Supplying this height will force the dropdown menu to be a virtualized - * list. This is very useful (and probably required) if you have a large list. - * e.g. Project selector with many projects. - * - * Currently, our implementation of the virtualized list requires a fixed - * height. - */ - virtualizedHeight?: number; - /** - * If you use grouping with virtualizedHeight, the labels will be that height - * unless specified here - */ - virtualizedLabelHeight?: number; -} & RowProps; - -function getHeight( - items: ItemsAfterFilter, - maxHeight: number, - virtualizedHeight: number, - virtualizedLabelHeight?: number -) { - const minHeight = virtualizedLabelHeight - ? items.reduce( - (a, r) => a + (r.groupLabel ? virtualizedLabelHeight : virtualizedHeight), - 0 - ) - : items.length * virtualizedHeight; - return Math.min(minHeight, maxHeight); -} - -function List({ - virtualizedHeight, - virtualizedLabelHeight, - onScroll, - items, - highlightedIndex, - maxHeight, - ...rowProps -}: Props) { - if (virtualizedHeight) { - return ( - - {({width}) => ( - - items[index]!.groupLabel && virtualizedLabelHeight - ? virtualizedLabelHeight - : virtualizedHeight - } - rowRenderer={({key, index, style}) => ( - - )} - /> - )} - - ); - } - - return ( - - {items.map((item, index) => ( - - ))} - - ); -} - -export default List; diff --git a/static/app/components/dropdownAutoComplete/menu.spec.tsx b/static/app/components/dropdownAutoComplete/menu.spec.tsx deleted file mode 100644 index f52f02ed5b815c..00000000000000 --- a/static/app/components/dropdownAutoComplete/menu.spec.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; - -import DropdownAutoCompleteMenu from 'sentry/components/dropdownAutoComplete/menu'; - -describe('DropdownAutoCompleteMenu', () => { - const items = [ - { - value: 'apple', - label:
Apple
, - }, - { - value: 'bacon', - label:
Bacon
, - }, - { - value: 'corn', - label:
Corn
, - }, - ]; - - it('renders without a group', () => { - render( - - {() => 'Click Me!'} - - ); - }); - - it('renders with a group', () => { - render( - New Zealand, - }, - { - value: 'australia', - label:
Australia
, - }, - ], - }, - ]} - > - {() => 'Click Me!'} -
- ); - }); - - it('can select an item by clicking', async () => { - const mock = jest.fn(); - const countries = [ - { - value: 'new zealand', - label:
New Zealand
, - }, - { - value: 'australia', - label:
Australia
, - }, - ]; - render( - - {({selectedItem}) => (selectedItem ? selectedItem.label : 'Click me!')} - - ); - - await userEvent.click(screen.getByRole('option', {name: 'Australia'})); - - expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith( - {index: 1, ...countries[1]}, - {highlightedIndex: 1, inputValue: '', isOpen: true, selectedItem: undefined}, - expect.anything() - ); - }); - - it('shows empty message when there are no items', () => { - render( - - {({selectedItem}) => (selectedItem ? selectedItem.label : 'Click me!')} - - ); - - expect(screen.getByText('No items!')).toBeInTheDocument(); - - // No input because there are no items - expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); - }); - - it('shows default empty results message when there are no items found in search', async () => { - render( - - {({selectedItem}) => (selectedItem ? selectedItem.label : 'Click me!')} - - ); - - await userEvent.type(screen.getByRole('textbox'), 'U-S-A'); - - expect(screen.getByText('No items! found')).toBeInTheDocument(); - }); - - it('overrides default empty results message', async () => { - render( - - {({selectedItem}) => (selectedItem ? selectedItem.label : 'Click me!')} - - ); - - await userEvent.type(screen.getByRole('textbox'), 'U-S-A'); - - expect(screen.getByText('No search results')).toBeInTheDocument(); - expect(screen.queryByRole('option')).not.toBeInTheDocument(); - }); - - it('hides filter with `hideInput` prop', () => { - render( - - {() => 'Click Me!'} - - ); - - expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); - }); - - it('filters using a value from prop instead of input', async () => { - render( - - {() => 'Click Me!'} - - ); - - await userEvent.type(screen.getByRole('textbox'), 'U-S-A'); - - expect(screen.getByRole('option', {name: 'Apple'})).toBeInTheDocument(); - expect(screen.queryByRole('option', {name: 'Corn'})).not.toBeInTheDocument(); - expect(screen.queryByRole('option', {name: 'Bacon'})).not.toBeInTheDocument(); - }); -}); diff --git a/static/app/components/dropdownAutoComplete/menu.tsx b/static/app/components/dropdownAutoComplete/menu.tsx deleted file mode 100644 index ec959fa9160de8..00000000000000 --- a/static/app/components/dropdownAutoComplete/menu.tsx +++ /dev/null @@ -1,507 +0,0 @@ -import {useCallback} from 'react'; -import styled from '@emotion/styled'; -import memoize from 'lodash/memoize'; - -import AutoComplete from 'sentry/components/autoComplete'; -import {InputGroup} from 'sentry/components/core/input/inputGroup'; -import DropdownBubble from 'sentry/components/dropdownBubble'; -import LoadingIndicator from 'sentry/components/loadingIndicator'; -import {t} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; - -import defaultAutoCompleteFilter from './autoCompleteFilter'; -import List from './list'; -import type {Item, ItemsBeforeFilter} from './types'; - -type AutoCompleteChildrenArgs = Parameters['props']['children']>[0]; -type Actions = AutoCompleteChildrenArgs['actions']; - -type MenuFooterChildProps = { - actions: Actions; -}; - -type ListProps = React.ComponentProps; - -// autoFocus react attribute is sync called on render, this causes -// layout thrashing and is bad for performance. This thin wrapper function -// will defer the focus call until the next frame, after the browser and react -// have had a chance to update the DOM, splitting the perf cost across frames. -function focusElement(targetRef: HTMLElement | null) { - if (!targetRef) { - return; - } - - if ('requestAnimationFrame' in window) { - window.requestAnimationFrame(() => { - targetRef.focus(); - }); - } else { - setTimeout(() => { - targetRef.focus(); - }, 1); - } -} - -export interface MenuProps - extends Pick< - ListProps, - 'virtualizedHeight' | 'virtualizedLabelHeight' | 'itemSize' | 'onScroll' - > { - children: ( - args: Pick< - AutoCompleteChildrenArgs, - 'getInputProps' | 'getActorProps' | 'actions' | 'isOpen' | 'selectedItem' - > - ) => React.ReactNode; - /** null items indicates loading */ - items: ItemsBeforeFilter | null; - - /** - * Dropdown menu alignment. - */ - alignMenu?: 'left' | 'right'; - /** - * Optionally provide a custom implementation for filtering result items - * Useful if you want to show items that don't strictly match the input value - */ - autoCompleteFilter?: typeof defaultAutoCompleteFilter; - /** - * Should menu visually lock to a direction (so we don't display a rounded corner) - */ - blendCorner?: boolean; - - /** - * Show loading indicator next to input and "Searching..." text in the list - */ - busy?: boolean; - - /** - * Show loading indicator next to input but don't hide list items - */ - busyItemsStillVisible?: boolean; - - /** - * for passing styles to the DropdownBubble - */ - className?: string; - - /** - * AutoComplete prop - */ - closeOnSelect?: boolean; - - css?: any; - - 'data-test-id'?: string; - - /** - * If true, the menu will be visually detached from actor. - */ - detached?: boolean; - - /** - * Disables padding for the label. - */ - disableLabelPadding?: boolean; - - /** - * passed down to the AutoComplete Component - */ - disabled?: boolean; - - /** - * Hide's the input when there are no items. Avoid using this when querying - * results in an async fashion. - */ - emptyHidesInput?: boolean; - - /** - * Message to display when there are no items initially - */ - emptyMessage?: string; - - /** - * If this is undefined, autocomplete filter will use this value instead of the - * current value in the filter input element. - * - * This is useful if you need to strip characters out of the search - */ - filterValue?: string; - - /** - * Hides the default filter input - */ - hideInput?: boolean; - - /** - * Props to pass to input/filter component - */ - inputProps?: React.HTMLAttributes; - - /** - * Used to control the input value (optional) - */ - inputValue?: string; - - /** - * Used to control dropdown state (optional) - */ - isOpen?: boolean; - - /** - * Max height of dropdown menu. Units are assumed as `px` - */ - maxHeight?: ListProps['maxHeight']; - - menuFooter?: - | React.ReactElement - | ((props: MenuFooterChildProps) => React.ReactElement | null); - - menuHeader?: React.ReactElement; - - /** - * Props to pass to menu component - */ - menuProps?: Parameters[0]; - - /** - * Minimum menu width, defaults to 250 - */ - minWidth?: number; - - /** - * Message to display when there are no items that match the search - */ - noResultsMessage?: React.ReactNode; - - /** - * When AutoComplete input changes - */ - onChange?: (event: React.ChangeEvent) => void; - - /** - * Callback for when dropdown menu closes - */ - onClose?: () => void; - - /** - * Callback for when the input value changes - */ - onInputValueChange?: (value: string) => void; - - /** - * Callback for when dropdown menu opens - */ - onOpen?: (event?: React.MouseEvent) => void; - - /** - * When an item is selected (via clicking dropdown, or keyboard navigation) - */ - onSelect?: ( - item: Item, - state?: AutoComplete['state'], - e?: React.MouseEvent | React.KeyboardEvent - ) => void; - /** - * for passing simple styles to the root container - */ - rootClassName?: string; - - /** - * Search input's placeholder text - */ - searchPlaceholder?: string; - /** - * the styles are forward to the Autocomplete's getMenuProps func - */ - style?: React.CSSProperties; - /** - * Optional element to be rendered on the right side of the dropdown menu - */ - subPanel?: React.ReactNode; -} - -function Menu({ - autoCompleteFilter = defaultAutoCompleteFilter, - maxHeight = 300, - emptyMessage = t('No items'), - searchPlaceholder = t('Filter search'), - blendCorner = true, - detached = false, - alignMenu = 'left', - minWidth = 250, - hideInput = false, - disableLabelPadding = false, - busy = false, - busyItemsStillVisible = false, - disabled = false, - subPanel = null, - itemSize, - virtualizedHeight, - virtualizedLabelHeight, - menuProps, - noResultsMessage, - inputProps, - children, - rootClassName, - className, - emptyHidesInput, - menuHeader, - filterValue, - items, - menuFooter, - style, - onScroll, - onChange, - onSelect, - onOpen, - onClose, - css, - closeOnSelect, - 'data-test-id': dataTestId, - ...props -}: MenuProps) { - // Can't search if there are no items - const hasItems = !!items?.length; - - // Items are loading if null - const itemsLoading = items === null; - - // Hide the input when we have no items to filter, only if - // emptyHidesInput is set to true. - const showInput = !hideInput && (hasItems || !emptyHidesInput); - - // Only redefine the autocomplete function if our items list has changed. - // This avoids producing a new array on every call. - const stableItemFilter = useCallback( - (filterValueOrInput: string) => autoCompleteFilter(items, filterValueOrInput), - [autoCompleteFilter, items] - ); - - // Memoize the filterValueOrInput to the stableItemFilter so that we get the - // same list every time when the filter value doesn't change. - const getFilteredItems = memoize(stableItemFilter); - - return ( - - {({ - getActorProps, - getRootProps, - getInputProps, - getMenuProps, - getItemProps, - registerItemCount, - registerVisibleItem, - inputValue, - selectedItem, - highlightedIndex, - isOpen, - actions, - }) => { - // This is the value to use to filter (default to value in filter input) - const filterValueOrInput = filterValue ?? inputValue; - - // Only filter results if menu is open and there are items. Uses - // `getFilteredItems` to ensure we get a stable items list back. - const autoCompleteResults = - isOpen && hasItems ? getFilteredItems(filterValueOrInput) : []; - - // Has filtered results - const hasResults = !!autoCompleteResults.length; - - // No items to display - const showNoItems = !busy && !filterValueOrInput && !hasItems; - - // Results mean there was an attempt to search - const showNoResultsMessage = - !busy && !busyItemsStillVisible && filterValueOrInput && !hasResults; - - // When virtualization is turned on, we need to pass in the number of - // selectable items for arrow-key limits - const itemCount = virtualizedHeight - ? autoCompleteResults.filter(i => !i.groupLabel).length - : undefined; - - const renderedFooter = - typeof menuFooter === 'function' ? menuFooter({actions}) : menuFooter; - - // XXX(epurkhiser): Would be better if this happened in a useEffect, - // but hooks do not work inside render-prop callbacks. - registerItemCount(itemCount); - - return ( - - {children({ - getInputProps, - getActorProps, - actions, - isOpen, - selectedItem, - })} - {isOpen && ( - - - {showInput && ( - - - {(busy || busyItemsStillVisible) && ( - - - - )} - - )} -
- {menuHeader && ( - - {menuHeader} - - )} - - {showNoItems && {emptyMessage}} - {showNoResultsMessage && ( - - {noResultsMessage ?? `${emptyMessage} ${t('found')}`} - - )} - {(itemsLoading || busy) && ( - - {itemsLoading && } - {busy && {t('Searching\u2026')}} - - )} - {!busy && ( - - )} - - {renderedFooter && ( - - {renderedFooter} - - )} -
-
- {subPanel} -
- )} -
- ); - }} -
- ); -} - -export default Menu; - -const StyledInput = styled(InputGroup.Input)` - flex: 1; - border: 0; - border-radius: ${p => p.theme.borderRadius}; - width: 100%; - &, - &:focus, - &:focus-visible, - &:active, - &:hover { - border: 0; - box-shadow: none; - font-size: 13px; - padding: ${p => p.theme.space.md}; - font-weight: ${p => p.theme.fontWeight.normal}; - color: ${p => p.theme.subText}; - } -`; - -const EmptyMessage = styled('div')` - color: ${p => p.theme.gray200}; - padding: ${space(2)}; - text-align: center; - text-transform: none; -`; - -const AutoCompleteRoot = styled('div')<{disabled?: boolean}>` - position: relative; - display: inline-block; - ${p => p.disabled && 'pointer-events: none;'} -`; - -const StyledDropdownBubble = styled(DropdownBubble)<{minWidth: number}>` - display: flex; - min-width: ${p => p.minWidth}px; - border: none; - - ${p => p.detached && p.alignMenu === 'left' && 'right: auto;'} - ${p => p.detached && p.alignMenu === 'right' && 'left: auto;'} -`; - -const DropdownMainContent = styled('div')<{minWidth: number}>` - width: 100%; - min-width: ${p => p.minWidth}px; - border: 1px solid ${p => p.theme.border}; - border-radius: ${p => p.theme.borderRadius}; -`; - -const LabelWithPadding = styled('div')<{disableLabelPadding: boolean}>` - background-color: ${p => p.theme.backgroundSecondary}; - border-bottom: 1px solid ${p => p.theme.innerBorder}; - border-width: 1px 0; - color: ${p => p.theme.subText}; - font-size: ${p => p.theme.fontSize.md}; - &:first-child { - border-top: none; - } - &:last-child { - border-bottom: none; - } - padding: ${p => !p.disableLabelPadding && `${space(0.25)} ${space(1)}`}; -`; - -const ItemList = styled('div')<{maxHeight: NonNullable}>` - max-height: ${p => `${p.maxHeight}px`}; - overflow-y: auto; -`; - -const BusyMessage = styled('div')` - display: flex; - justify-content: center; - align-items: center; - padding: ${space(3)} ${space(1)}; -`; diff --git a/static/app/components/dropdownAutoComplete/row.tsx b/static/app/components/dropdownAutoComplete/row.tsx deleted file mode 100644 index 7db1f4a74b30b0..00000000000000 --- a/static/app/components/dropdownAutoComplete/row.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import {memo, useEffect, useMemo} from 'react'; -import styled from '@emotion/styled'; - -import type AutoComplete from 'sentry/components/autoComplete'; -import InteractionStateLayer from 'sentry/components/core/interactionStateLayer'; -import {space} from 'sentry/styles/space'; - -import type {Item} from './types'; - -type ItemSize = 'zero' | 'small'; -type AutoCompleteChildrenArgs = Parameters< - AutoComplete['props']['children'] ->[0]; - -type Props = Pick< - AutoCompleteChildrenArgs, - 'getItemProps' | 'registerVisibleItem' -> & - Omit['getItemProps']>[0], 'index'> & { - /** - * Is the row 'active' - */ - isHighlighted: boolean; - /** - * Size for dropdown items - */ - itemSize?: ItemSize; - /** - * Style is used by react-virtualized for alignment - */ - style?: React.CSSProperties; - }; - -function Row({ - item, - style, - itemSize, - isHighlighted, - getItemProps, - registerVisibleItem, -}: Props) { - const {index} = item; - - useEffect(() => registerVisibleItem(item.index, item), [registerVisibleItem, item]); - - const itemProps = useMemo( - () => getItemProps({item, index}), - [getItemProps, item, index] - ); - - if (item.groupLabel) { - return ( - - {item.label && {item.label as string}} - - ); - } - - return ( - - - {item.label} - - ); -} - -// XXX(epurkhiser): We memoize the row component since there will be many of -// them, we do not want them re-rendering every time we change the -// highlightedIndex in the parent List. - -export default memo(Row); - -const getItemPaddingForSize = (itemSize?: ItemSize) => { - if (itemSize === 'small') { - return `${space(0.5)} ${space(1)}`; - } - - if (itemSize === 'zero') { - return '0'; - } - - return space(1); -}; - -const LabelWithBorder = styled('div')` - display: flex; - align-items: center; - background-color: ${p => p.theme.backgroundSecondary}; - border-bottom: 1px solid ${p => p.theme.innerBorder}; - border-width: 1px 0; - color: ${p => p.theme.subText}; - font-size: ${p => p.theme.fontSize.md}; - - :first-child { - border-top: none; - } - :last-child { - border-bottom: none; - } -`; - -const GroupLabel = styled('div')` - padding: ${space(0.25)} ${space(1)}; -`; - -const AutoCompleteItem = styled('div')<{ - isHighlighted: boolean; - disabled?: boolean; - itemSize?: ItemSize; -}>` - position: relative; - /* needed for virtualized lists that do not fill parent height */ - /* e.g. breadcrumbs (org height > project, but want same fixed height for both) */ - display: flex; - flex-direction: column; - justify-content: center; - scroll-margin: 20px 0; - - font-size: ${p => p.theme.fontSize.md}; - color: ${p => (p.isHighlighted ? p.theme.textColor : 'inherit')}; - padding: ${p => getItemPaddingForSize(p.itemSize)}; - cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')}; - border-bottom: 1px solid ${p => p.theme.innerBorder}; - - :last-child { - border-bottom: none; - } -`; diff --git a/static/app/components/dropdownAutoComplete/types.tsx b/static/app/components/dropdownAutoComplete/types.tsx deleted file mode 100644 index 9558b52db34236..00000000000000 --- a/static/app/components/dropdownAutoComplete/types.tsx +++ /dev/null @@ -1,24 +0,0 @@ -export type Item = { - index: number; - label: React.ReactNode; - value: any; - 'data-test-id'?: string; - disabled?: boolean; - /** - * Error message to display for the field - */ - error?: React.ReactNode; - groupLabel?: boolean; - searchKey?: string; -} & Record; - -type Items = Array< - T & { - hideGroupLabel?: boolean; - items?: T[]; // Should hide group label - } ->; - -export type ItemsBeforeFilter = Items>; - -export type ItemsAfterFilter = Items; diff --git a/static/app/views/releases/list/releasesPromo.tsx b/static/app/views/releases/list/releasesPromo.tsx index 2aa54954451031..f42dd54cb6ef03 100644 --- a/static/app/views/releases/list/releasesPromo.tsx +++ b/static/app/views/releases/list/releasesPromo.tsx @@ -1,6 +1,4 @@ -import {useCallback, useEffect, useMemo, useState} from 'react'; -import {css} from '@emotion/react'; -import styled from '@emotion/styled'; +import {useCallback, useEffect, useState} from 'react'; import commitImage from 'sentry-images/spot/releases-tour-commits.svg'; import emailImage from 'sentry-images/spot/releases-tour-email.svg'; @@ -8,21 +6,18 @@ import resolutionImage from 'sentry-images/spot/releases-tour-resolution.svg'; import statsImage from 'sentry-images/spot/releases-tour-stats.svg'; import {openCreateReleaseIntegration} from 'sentry/actionCreators/modal'; -import Access from 'sentry/components/acl/access'; import {CodeSnippet} from 'sentry/components/codeSnippet'; +import {SentryAppAvatar} from 'sentry/components/core/avatar/sentryAppAvatar'; +import {Button} from 'sentry/components/core/button'; import {LinkButton} from 'sentry/components/core/button/linkButton'; -import {Link} from 'sentry/components/core/link'; -import {Tooltip} from 'sentry/components/core/tooltip'; -import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete'; -import type {Item} from 'sentry/components/dropdownAutoComplete/types'; +import {CompactSelect, type SelectOption} from 'sentry/components/core/compactSelect'; +import {Flex, Stack} from 'sentry/components/core/layout'; +import {Heading, Text} from 'sentry/components/core/text'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import type {TourStep} from 'sentry/components/modals/featureTourModal'; import {TourImage, TourText} from 'sentry/components/modals/featureTourModal'; import Panel from 'sentry/components/panels/panel'; -import TextOverflow from 'sentry/components/textOverflow'; -import {IconAdd} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; import type {SentryApp} from 'sentry/types/integrations'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; @@ -104,14 +99,15 @@ function ReleasesPromo({organization, project}: Props) { const api = useApi(); const [token, setToken] = useState(null); - const [integrations, setIntegrations] = useState([]); - const [selectedItem, selectItem] = useState | null>(null); + const [apps, setApps] = useState([]); + const [selectedApp, setSelectedApp] = useState(null); useEffect(() => { if (!isPending && data) { - setIntegrations(data); + setApps(data); } }, [isPending, data]); + useEffect(() => { trackAnalytics('releases.quickstart_viewed', { organization, @@ -160,30 +156,24 @@ function ReleasesPromo({organization, project}: Props) { return newToken.token; }; - const renderIntegrationNode = (integration: SentryApp) => { + const makeAppOption = (app: SentryApp): SelectOption => { return { - value: {slug: integration.slug, name: integration.name}, - searchKey: `${integration.name}`, - label: ( - - - - ), + value: app.slug, + leadingItems: , + textValue: app.name, + label: app.name, }; }; - const codeChunks = useMemo( - () => [ - `# Install the cli + const setupExample = `# Install the cli curl -sL https://sentry.io/get-cli/ | bash # Setup configuration values -export SENTRY_AUTH_TOKEN=`, - - token && selectedItem - ? `${token} # From internal integration: ${selectedItem.value.name}` - : '', - ` +export SENTRY_AUTH_TOKEN=${ + token && selectedApp + ? `${token} # From internal integration: ${selectedApp.name}` + : '[select an integration above]' + } export SENTRY_ORG=${organization.slug} export SENTRY_PROJECT=${project.slug} VERSION=\`sentry-cli releases propose-version\` @@ -191,253 +181,91 @@ VERSION=\`sentry-cli releases propose-version\` # Workflow to create releases sentry-cli releases new "$VERSION" sentry-cli releases set-commits "$VERSION" --auto -sentry-cli releases finalize "$VERSION"`, - ], - [token, selectedItem, organization.slug, project.slug] - ); +sentry-cli releases finalize "$VERSION"`; if (isPending) { return ; } + const canMakeIntegration = organization.access.includes('org:integrations'); + return ( - - -

{t('Set up Releases')}

- - + + + {t('Set up Releases')} + {t('Full Documentation')} -
- -

+ + {t( 'Find which release caused an issue, apply source maps, and get notified about your deploys.' )} -

-

+ + {t( - 'Add the following commands to your CI config when you deploy your application.' + 'Select an Integration to provide your Auth Token, then add the following script to your CI config when you deploy your application.' )} -

- - - - {codeChunks.join('')} - - - {codeChunks[0]} - - { - // This can be called multiple times and does not always have `event` - e?.stopPropagation(); - }} - items={[ - { - label: {t('Available Integrations')}, - id: 'available-integrations', - items: (integrations || []).map(renderIntegrationNode), + + + ( + + )} + triggerLabel={selectedApp ? undefined : t('Select Integration')} + triggerProps={{prefix: selectedApp ? t('Token From') : undefined}} + onChange={option => { + const app = apps.find(i => i.slug === option.value)!; + setSelectedApp(app); + generateAndSetNewToken(app.slug); + }} + /> + + + {setupExample} + +
); } -const Container = styled('div')` - padding: ${space(3)}; -`; - -const ContainerHeader = styled('div')` - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: ${space(3)}; - min-height: 32px; - - h3 { - margin: 0; - } - - @media (max-width: ${p => p.theme.breakpoints.sm}) { - flex-direction: column; - align-items: flex-start; - - h3 { - margin-bottom: ${space(2)}; - } - } -`; - -const CodeSnippetWrapper = styled('div')` - position: relative; -`; - -/** - * CodeSnippet stringifies all inner children (due to Prism code highlighting), so we - * can't put CodeSnippetDropdown inside of it. Instead, we can render a pre wrap - * containing the same code (without Prism highlighting) with CodeSnippetDropdown in the - * middle and overlay it on top of CodeSnippet. - */ -const CodeSnippetOverlay = styled('pre')` - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - z-index: 2; - margin-bottom: 0; - pointer-events: none; - - && { - background: transparent; - } -`; - -/** - * Invisible code span overlaid on top of the highlighted code. Exists only to - * properly position inside . - */ -const CodeSnippetOverlaySpan = styled('span')` - visibility: hidden; -`; - -const CodeSnippetDropdownWrapper = styled('span')` - /* Re-enable pointer events (disabled by CodeSnippetOverlay) */ - pointer-events: initial; -`; - -const CodeSnippetDropdown = styled(DropdownAutoComplete)` - position: absolute; - font-family: ${p => p.theme.text.family}; - border: none; - border-radius: 4px; - width: 300px; -`; - -const GroupHeader = styled('div')` - font-size: ${p => p.theme.fontSize.sm}; - font-family: ${p => p.theme.text.family}; - font-weight: ${p => p.theme.fontWeight.bold}; - margin: ${space(1)} 0; - color: ${p => p.theme.subText}; - line-height: ${p => p.theme.fontSize.sm}; - text-align: left; -`; -const CreateIntegrationLink = styled(Link)` - color: ${p => (p.disabled ? p.theme.disabled : p.theme.textColor)}; -`; - -const MenuItemWrapper = styled('div')<{ - disabled?: boolean; - py?: number; -}>` - cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')}; - display: flex; - align-items: center; - font-family: ${p => p.theme.text.family}; - font-size: 13px; - ${p => - typeof p.py !== 'undefined' && - css` - padding-top: ${p.py}; - padding-bottom: ${p.py}; - `}; -`; - -const MenuItemFooterWrapper = styled(MenuItemWrapper)` - padding: ${space(0.25)} ${space(1)}; - border-top: 1px solid ${p => p.theme.innerBorder}; - background-color: ${p => p.theme.tag.highlight.background}; - color: ${p => p.theme.active}; - :hover { - color: ${p => p.theme.activeHover}; - svg { - fill: ${p => p.theme.activeHover}; - } - } -`; - -const IconContainer = styled('div')` - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - flex-shrink: 0; -`; - -const Label = styled(TextOverflow)` - margin-left: 6px; -`; - export default ReleasesPromo;