123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664 |
- import {Fragment, PureComponent} from 'react';
- import {useTheme} from '@emotion/react';
- import styled from '@emotion/styled';
- import {FocusScope} from '@react-aria/focus';
- import {AnimatePresence} from 'framer-motion';
- import type {Location} from 'history';
- import isEqual from 'lodash/isEqual';
- import type {Client} from 'sentry/api';
- import Feature from 'sentry/components/acl/feature';
- import FeatureDisabled from 'sentry/components/acl/featureDisabled';
- import GuideAnchor from 'sentry/components/assistant/guideAnchor';
- import Banner from 'sentry/components/banner';
- import {Button, LinkButton} from 'sentry/components/button';
- import ButtonBar from 'sentry/components/buttonBar';
- import {CreateAlertFromViewButton} from 'sentry/components/createAlertButton';
- import type {MenuItemProps} from 'sentry/components/dropdownMenu';
- import {DropdownMenu} from 'sentry/components/dropdownMenu';
- import {Hovercard} from 'sentry/components/hovercard';
- import InputControl from 'sentry/components/input';
- import {Overlay, PositionWrapper} from 'sentry/components/overlay';
- import {IconBookmark, IconDelete, IconEllipsis, IconStar} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
- import type {Organization, SavedQuery} from 'sentry/types/organization';
- import type {Project} from 'sentry/types/project';
- import {defined} from 'sentry/utils';
- import {trackAnalytics} from 'sentry/utils/analytics';
- import {browserHistory} from 'sentry/utils/browserHistory';
- import EventView from 'sentry/utils/discover/eventView';
- import {DiscoverDatasets} from 'sentry/utils/discover/types';
- import {getDiscoverQueriesUrl} from 'sentry/utils/discover/urls';
- import normalizeUrl from 'sentry/utils/url/normalizeUrl';
- import useOverlay from 'sentry/utils/useOverlay';
- import withApi from 'sentry/utils/withApi';
- import withProjects from 'sentry/utils/withProjects';
- import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
- import {
- handleAddQueryToDashboard,
- SAVED_QUERY_DATASET_TO_WIDGET_TYPE,
- } from 'sentry/views/discover/utils';
- import {DEFAULT_EVENT_VIEW} from '../data';
- import {
- getDatasetFromLocationOrSavedQueryDataset,
- getSavedQueryDataset,
- handleCreateQuery,
- handleDeleteQuery,
- handleResetHomepageQuery,
- handleUpdateHomepageQuery,
- handleUpdateQuery,
- } from './utils';
- const renderDisabled = p => (
- <Hovercard
- body={
- <FeatureDisabled
- features={p.features}
- hideHelpToggle
- message={t('Discover queries are disabled')}
- featureName={t('Discover queries')}
- />
- }
- >
- {p.children(p)}
- </Hovercard>
- );
- type SaveAsDropdownProps = {
- disabled: boolean;
- modifiedHandleCreateQuery: (e: React.MouseEvent<Element>) => void;
- onChangeInput: (e: React.FormEvent<HTMLInputElement>) => void;
- queryName: string;
- };
- function SaveAsDropdown({
- queryName,
- disabled,
- onChangeInput,
- modifiedHandleCreateQuery,
- }: SaveAsDropdownProps) {
- const {isOpen, triggerProps, overlayProps, arrowProps} = useOverlay();
- const theme = useTheme();
- return (
- <div>
- <Button
- {...triggerProps}
- size="sm"
- icon={<IconStar />}
- aria-label={t('Save as')}
- disabled={disabled}
- >
- {`${t('Save as')}\u2026`}
- </Button>
- <AnimatePresence>
- {isOpen && (
- <FocusScope contain restoreFocus autoFocus>
- <PositionWrapper zIndex={theme.zIndex.dropdown} {...overlayProps}>
- <StyledOverlay arrowProps={arrowProps} animated>
- <SaveAsInput
- type="text"
- name="query_name"
- placeholder={t('Display name')}
- value={queryName || ''}
- onChange={onChangeInput}
- disabled={disabled}
- />
- <SaveAsButton
- onClick={modifiedHandleCreateQuery}
- priority="primary"
- disabled={disabled || !queryName}
- >
- {t('Save for Org')}
- </SaveAsButton>
- </StyledOverlay>
- </PositionWrapper>
- </FocusScope>
- )}
- </AnimatePresence>
- </div>
- );
- }
- type DefaultProps = {
- disabled: boolean;
- };
- type Props = DefaultProps & {
- api: Client;
- eventView: EventView;
- /**
- * DO NOT USE `Location` TO GENERATE `EventView` IN THIS COMPONENT.
- *
- * In this component, state is generated from EventView and SavedQueriesStore.
- * Using Location to rebuild EventView will break the tests. `Location` is
- * passed down only because it is needed for navigation.
- */
- location: Location;
- organization: Organization;
- projects: Project[];
- queryDataLoading: boolean;
- router: InjectedRouter;
- savedQuery: SavedQuery | undefined;
- setHomepageQuery: (homepageQuery?: SavedQuery) => void;
- setSavedQuery: (savedQuery: SavedQuery) => void;
- updateCallback: () => void;
- yAxis: string[];
- homepageQuery?: SavedQuery;
- isHomepage?: boolean;
- };
- type State = {
- isEditingQuery: boolean;
- isNewQuery: boolean;
- queryName: string;
- };
- class SavedQueryButtonGroup extends PureComponent<Props, State> {
- static getDerivedStateFromProps(nextProps: Readonly<Props>, prevState: State): State {
- const {eventView: nextEventView, savedQuery, queryDataLoading, yAxis} = nextProps;
- // For a new unsaved query
- if (!savedQuery) {
- return {
- isNewQuery: true,
- isEditingQuery: false,
- queryName: prevState.queryName || '',
- };
- }
- if (queryDataLoading) {
- return prevState;
- }
- const savedEventView = EventView.fromSavedQuery(savedQuery);
- // Switching from a SavedQuery to another SavedQuery
- if (savedEventView.id !== nextEventView.id) {
- return {
- isNewQuery: false,
- isEditingQuery: false,
- queryName: '',
- };
- }
- // For modifying a SavedQuery
- const isEqualQuery = nextEventView.isEqualTo(savedEventView);
- // undefined saved yAxis defaults to count() and string values are converted to array
- const isEqualYAxis = isEqual(
- yAxis,
- !savedQuery.yAxis
- ? ['count()']
- : typeof savedQuery.yAxis === 'string'
- ? [savedQuery.yAxis]
- : savedQuery.yAxis
- );
- return {
- isNewQuery: false,
- isEditingQuery: !isEqualQuery || !isEqualYAxis,
- // HACK(leedongwei): See comment at SavedQueryButtonGroup.onFocusInput
- queryName: prevState.queryName || '',
- };
- }
- /**
- * Stop propagation for the input and container so people can interact with
- * the inputs in the dropdown.
- */
- static stopEventPropagation = (event: React.MouseEvent) => {
- const capturedElements = ['LI', 'INPUT'];
- if (
- event.target instanceof Element &&
- capturedElements.includes(event.target.nodeName)
- ) {
- event.preventDefault();
- event.stopPropagation();
- }
- };
- static defaultProps: DefaultProps = {
- disabled: false,
- };
- state: State = {
- isNewQuery: true,
- isEditingQuery: false,
- queryName: '',
- };
- onChangeInput = (event: React.FormEvent<HTMLInputElement>) => {
- const target = event.target as HTMLInputElement;
- this.setState({queryName: target.value});
- };
- /**
- * There are two ways to create a query
- * 1) Creating a query from scratch and saving it
- * 2) Modifying an existing query and saving it
- */
- handleCreateQuery = (event: React.MouseEvent<Element>) => {
- event.preventDefault();
- event.stopPropagation();
- const {api, organization, eventView, yAxis} = this.props;
- if (!this.state.queryName) {
- return;
- }
- const nextEventView = eventView.clone();
- nextEventView.name = this.state.queryName;
- // Checks if "Save as" button is clicked from a clean state, or it is
- // clicked while modifying an existing query
- const isNewQuery = !eventView.id;
- handleCreateQuery(api, organization, nextEventView, yAxis, isNewQuery).then(
- (savedQuery: SavedQuery) => {
- const view = EventView.fromSavedQuery(savedQuery);
- Banner.dismiss('discover');
- this.setState({queryName: ''});
- browserHistory.push(
- normalizeUrl(view.getResultsViewUrlTarget(organization.slug))
- );
- }
- );
- };
- handleUpdateQuery = (event: React.MouseEvent<Element>) => {
- event.preventDefault();
- event.stopPropagation();
- const {api, organization, eventView, updateCallback, yAxis, setSavedQuery} =
- this.props;
- handleUpdateQuery(api, organization, eventView, yAxis).then(
- (savedQuery: SavedQuery) => {
- const view = EventView.fromSavedQuery(savedQuery);
- setSavedQuery(savedQuery);
- this.setState({queryName: ''});
- browserHistory.push(view.getResultsViewShortUrlTarget(organization.slug));
- updateCallback();
- }
- );
- };
- handleDeleteQuery = (event?: React.MouseEvent<Element>) => {
- event?.preventDefault();
- event?.stopPropagation();
- const {api, organization, eventView} = this.props;
- handleDeleteQuery(api, organization, eventView).then(() => {
- browserHistory.push(
- normalizeUrl({
- pathname: getDiscoverQueriesUrl(organization),
- query: {},
- })
- );
- });
- };
- handleCreateAlertSuccess = () => {
- const {organization} = this.props;
- trackAnalytics('discover_v2.create_alert_clicked', {
- organization,
- status: 'success',
- });
- };
- renderButtonViewSaved(disabled: boolean) {
- const {organization} = this.props;
- return (
- <LinkButton
- onClick={() => {
- trackAnalytics('discover_v2.view_saved_queries', {organization});
- }}
- data-test-id="discover2-savedquery-button-view-saved"
- disabled={disabled}
- size="sm"
- icon={<IconStar isSolid />}
- to={getDiscoverQueriesUrl(organization)}
- >
- {t('Saved Queries')}
- </LinkButton>
- );
- }
- renderButtonSaveAs(disabled: boolean) {
- const {queryName} = this.state;
- return (
- <SaveAsDropdown
- queryName={queryName}
- onChangeInput={this.onChangeInput}
- modifiedHandleCreateQuery={this.handleCreateQuery}
- disabled={disabled}
- />
- );
- }
- renderButtonSave(disabled: boolean) {
- const {isNewQuery, isEditingQuery} = this.state;
- if (!isNewQuery && !isEditingQuery) {
- return null;
- }
- // Existing query with edits, show save and save as.
- if (!isNewQuery && isEditingQuery) {
- return (
- <Fragment>
- <Button
- onClick={this.handleUpdateQuery}
- data-test-id="discover2-savedquery-button-update"
- disabled={disabled}
- size="sm"
- >
- <IconUpdate />
- {t('Save Changes')}
- </Button>
- {this.renderButtonSaveAs(disabled)}
- </Fragment>
- );
- }
- // Is a new query enable saveas
- return this.renderButtonSaveAs(disabled);
- }
- renderButtonDelete(disabled: boolean) {
- const {isNewQuery} = this.state;
- if (isNewQuery) {
- return null;
- }
- return (
- <Button
- data-test-id="discover2-savedquery-button-delete"
- onClick={this.handleDeleteQuery}
- disabled={disabled}
- size="sm"
- icon={<IconDelete />}
- aria-label={t('Delete')}
- />
- );
- }
- renderButtonCreateAlert() {
- const {eventView, organization, projects, location, savedQuery} = this.props;
- const currentDataset = getDatasetFromLocationOrSavedQueryDataset(
- location,
- savedQuery?.queryDataset
- );
- let alertType;
- let buttonEventView = eventView;
- if (hasDatasetSelector(organization)) {
- alertType = defined(currentDataset)
- ? {
- [DiscoverDatasets.TRANSACTIONS]: 'throughput',
- [DiscoverDatasets.ERRORS]: 'num_errors',
- }[currentDataset]
- : undefined;
- if (currentDataset === DiscoverDatasets.TRANSACTIONS) {
- // Inject the event.type:transaction filter for to avoid triggering
- // the event.type missing banner error in the alerts form
- buttonEventView = eventView.clone();
- buttonEventView.query = eventView.query
- ? `(${eventView.query}) AND (event.type:transaction)`
- : 'event.type:transaction';
- }
- }
- return (
- <GuideAnchor target="create_alert_from_discover">
- <CreateAlertFromViewButton
- eventView={buttonEventView}
- organization={organization}
- projects={projects}
- onClick={this.handleCreateAlertSuccess}
- referrer="discover"
- size="sm"
- aria-label={t('Create Alert')}
- data-test-id="discover2-create-from-discover"
- alertType={alertType}
- />
- </GuideAnchor>
- );
- }
- renderButtonAddToDashboard() {
- const {organization, eventView, savedQuery, yAxis, router, location} = this.props;
- return (
- <Button
- key="add-dashboard-widget-from-discover"
- data-test-id="add-dashboard-widget-from-discover"
- size="sm"
- onClick={() =>
- handleAddQueryToDashboard({
- organization,
- location,
- eventView,
- query: savedQuery,
- yAxis,
- router,
- widgetType: hasDatasetSelector(organization)
- ? SAVED_QUERY_DATASET_TO_WIDGET_TYPE[
- getSavedQueryDataset(organization, location, savedQuery)
- ]
- : undefined,
- })
- }
- >
- {t('Add to Dashboard')}
- </Button>
- );
- }
- renderSaveAsHomepage(disabled: boolean) {
- const {
- api,
- organization,
- eventView,
- location,
- isHomepage,
- setHomepageQuery,
- homepageQuery,
- queryDataLoading,
- } = this.props;
- const buttonDisabled = disabled || queryDataLoading;
- const analyticsEventSource = isHomepage
- ? 'homepage'
- : eventView.id
- ? 'saved-query'
- : 'prebuilt-query';
- if (
- homepageQuery &&
- eventView.isEqualTo(EventView.fromSavedQuery(homepageQuery), ['id', 'name'])
- ) {
- return (
- <Button
- key="reset-discover-homepage"
- data-test-id="reset-discover-homepage"
- onClick={async () => {
- await handleResetHomepageQuery(api, organization);
- trackAnalytics('discover_v2.remove_default', {
- organization,
- source: analyticsEventSource,
- });
- setHomepageQuery(undefined);
- if (isHomepage) {
- const nextEventView = EventView.fromNewQueryWithLocation(
- DEFAULT_EVENT_VIEW,
- location
- );
- browserHistory.push({
- pathname: location.pathname,
- query: nextEventView.generateQueryStringObject(),
- });
- }
- }}
- size="sm"
- icon={<IconBookmark isSolid />}
- disabled={buttonDisabled}
- >
- {t('Remove Default')}
- </Button>
- );
- }
- return (
- <Button
- key="set-as-default"
- data-test-id="set-as-default"
- onClick={async () => {
- const updatedHomepageQuery = await handleUpdateHomepageQuery(
- api,
- organization,
- eventView.toNewQuery()
- );
- trackAnalytics('discover_v2.set_as_default', {
- organization,
- source: analyticsEventSource,
- });
- if (updatedHomepageQuery) {
- setHomepageQuery(updatedHomepageQuery);
- }
- }}
- size="sm"
- icon={<IconBookmark />}
- disabled={buttonDisabled}
- >
- {t('Set as Default')}
- </Button>
- );
- }
- renderQueryButton(renderFunc: (disabled: boolean) => React.ReactNode) {
- const {organization} = this.props;
- return (
- <Feature
- organization={organization}
- features="discover-query"
- hookName="feature-disabled:discover-saved-query-create"
- renderDisabled={renderDisabled}
- >
- {({hasFeature}) => renderFunc(!hasFeature || this.props.disabled)}
- </Feature>
- );
- }
- render() {
- const {organization, eventView, savedQuery, yAxis, router, location, isHomepage} =
- this.props;
- const contextMenuItems: MenuItemProps[] = [];
- if (organization.features.includes('dashboards-edit')) {
- contextMenuItems.push({
- key: 'add-to-dashboard',
- label: t('Add to Dashboard'),
- onAction: () => {
- handleAddQueryToDashboard({
- organization,
- location,
- eventView,
- query: savedQuery,
- yAxis,
- router,
- widgetType: hasDatasetSelector(organization)
- ? SAVED_QUERY_DATASET_TO_WIDGET_TYPE[
- getSavedQueryDataset(organization, location, savedQuery)
- ]
- : undefined,
- });
- },
- });
- }
- if (!isHomepage && savedQuery) {
- contextMenuItems.push({
- key: 'delete-saved-query',
- label: t('Delete Saved Query'),
- onAction: () => this.handleDeleteQuery(),
- });
- }
- const contextMenu = (
- <DropdownMenu
- items={contextMenuItems}
- trigger={triggerProps => (
- <Button
- {...triggerProps}
- aria-label={t('Discover Context Menu')}
- size="sm"
- onClick={e => {
- e.stopPropagation();
- e.preventDefault();
- triggerProps.onClick?.(e);
- }}
- icon={<IconEllipsis />}
- />
- )}
- position="bottom-end"
- offset={4}
- />
- );
- return (
- <ResponsiveButtonBar gap={1}>
- {this.renderQueryButton(disabled => this.renderSaveAsHomepage(disabled))}
- {this.renderQueryButton(disabled => this.renderButtonSave(disabled))}
- <Feature organization={organization} features="incidents">
- {({hasFeature}) => hasFeature && this.renderButtonCreateAlert()}
- </Feature>
- {contextMenuItems.length > 0 && contextMenu}
- {this.renderQueryButton(disabled => this.renderButtonViewSaved(disabled))}
- </ResponsiveButtonBar>
- );
- }
- }
- const ResponsiveButtonBar = styled(ButtonBar)`
- @media (min-width: ${p => p.theme.breakpoints.medium}) {
- margin-top: 0;
- }
- `;
- const StyledOverlay = styled(Overlay)`
- padding: ${space(1)};
- `;
- const SaveAsButton = styled(Button)`
- width: 100%;
- `;
- const SaveAsInput = styled(InputControl)`
- margin-bottom: ${space(1)};
- `;
- const IconUpdate = styled('div')`
- display: inline-block;
- width: 10px;
- height: 10px;
- margin-right: ${space(0.75)};
- border-radius: 5px;
- background-color: ${p => p.theme.yellow300};
- `;
- export default withProjects(withApi(SavedQueryButtonGroup));
|