123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373 |
- import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
- import {useTheme} from '@emotion/react';
- import styled from '@emotion/styled';
- import {FocusScope} from '@react-aria/focus';
- import {uuid4} from '@sentry/utils';
- import {AnimatePresence} from 'framer-motion';
- import isEmpty from 'lodash/isEmpty';
- import GuideAnchor from 'sentry/components/assistant/guideAnchor';
- import {Button} from 'sentry/components/button';
- import {CompactSelect} from 'sentry/components/compactSelect';
- import {openConfirmModal} from 'sentry/components/confirm';
- import InputControl from 'sentry/components/input';
- import {Overlay, PositionWrapper} from 'sentry/components/overlay';
- import {Tooltip} from 'sentry/components/tooltip';
- import {IconBookmark, IconDashboard, IconDelete, IconStar} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {trackAnalytics} from 'sentry/utils/analytics';
- import {clearQuery, updateQuery} from 'sentry/utils/metrics';
- import useKeyPress from 'sentry/utils/useKeyPress';
- import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
- import useOrganization from 'sentry/utils/useOrganization';
- import useOverlay from 'sentry/utils/useOverlay';
- import useRouter from 'sentry/utils/useRouter';
- import {useCreateDashboard} from './useCreateDashboard';
- type Scratchpad = {
- id: string;
- name: string;
- query: Record<string, unknown>;
- };
- type ScratchpadState = {
- default: string | null;
- scratchpads: Record<string, Scratchpad>;
- };
- function makeLocalStorageKey(orgSlug: string) {
- return `ddm-scratchpads:${orgSlug}`;
- }
- export function useScratchpads() {
- const {slug} = useOrganization();
- const [state, setState] = useLocalStorageState<ScratchpadState>(
- makeLocalStorageKey(slug),
- {
- default: null,
- scratchpads: {},
- }
- );
- const [selected, setSelected] = useState<string | null | undefined>(undefined); // scratchpad id
- const router = useRouter();
- const routerQuery = useMemo(() => router.location.query ?? {}, [router.location.query]);
- useEffect(() => {
- if (state.default && selected === undefined && !routerQuery.widgets) {
- setSelected(state.default);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [state.default, selected]);
- // changes the query when a scratchpad is selected, clears it when none is selected
- useEffect(() => {
- if (selected) {
- const selectedQuery = state.scratchpads[selected].query;
- const queryToUpdate = selectedQuery;
- // if the selected scratchpad has a start and end date, override the statsPeriod
- if (selectedQuery.start && selectedQuery.end) {
- updateQuery(router, {...selectedQuery, statsPeriod: null});
- } else {
- updateQuery(router, queryToUpdate);
- }
- } else if (selected === null) {
- clearQuery(router);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selected]);
- // saves all changes to the selected scratchpad to local storage
- useEffect(() => {
- if (selected && !isEmpty(routerQuery)) {
- update(selected, routerQuery);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [routerQuery]);
- const toggleSelected = useCallback(
- (id: string | null) => {
- if (id === selected) {
- setSelected(null);
- } else {
- setSelected(id);
- }
- },
- [setSelected, selected]
- );
- const setDefault = useCallback(
- (id: string | null) => {
- setState({...state, default: id});
- },
- [state, setState]
- );
- const add = useCallback(
- (name: string) => {
- const id = uuid4();
- const newScratchpads = {
- ...state.scratchpads,
- [id]: {name, id, query: {environment: null, statsPeriod: null, ...routerQuery}},
- };
- setState({...state, scratchpads: newScratchpads});
- toggleSelected(id);
- },
- [state, setState, toggleSelected, routerQuery]
- );
- const update = useCallback(
- (id: string, query: Scratchpad['query']) => {
- const oldScratchpad = state.scratchpads[id];
- const newQuery = {environment: null, statsPeriod: null, ...query};
- const newScratchpads = {
- ...state.scratchpads,
- [id]: {...oldScratchpad, query: newQuery},
- };
- setState({...state, scratchpads: newScratchpads});
- },
- [state, setState]
- );
- const remove = useCallback(
- (id: string) => {
- const newScratchpads = {...state.scratchpads};
- delete newScratchpads[id];
- if (state.default === id) {
- setState({...state, default: null, scratchpads: newScratchpads});
- } else {
- setState({...state, scratchpads: newScratchpads});
- }
- if (selected === id) {
- toggleSelected(null);
- }
- },
- [state, setState, toggleSelected, selected]
- );
- return {
- all: state.scratchpads,
- default: state.default,
- selected,
- add,
- update,
- remove,
- toggleSelected,
- setDefault,
- };
- }
- export function ScratchpadSelector() {
- const scratchpads = useScratchpads();
- const organization = useOrganization();
- const createDashboard = useCreateDashboard();
- const isDefault = useCallback(
- scratchpad => scratchpads.default === scratchpad.id,
- [scratchpads]
- );
- const scratchpadOptions = useMemo(
- () =>
- Object.values(scratchpads.all).map((s: any) => ({
- value: s.id,
- label: s.name,
- trailingItems: (
- <Fragment>
- <Tooltip
- title={
- isDefault(s)
- ? t('Remove as default scratchpad')
- : t('Set as default scratchpad')
- }
- >
- <Button
- size="zero"
- borderless
- onPointerDown={e => e.stopPropagation()}
- onClick={() => {
- trackAnalytics('ddm.scratchpad.set-default', {
- organization,
- });
- if (isDefault(s)) {
- scratchpads.setDefault(null);
- } else {
- scratchpads.setDefault(s.id ?? null);
- }
- }}
- >
- <StyledDropdownIcon>
- <IconBookmark isSolid={isDefault(s)} />
- </StyledDropdownIcon>
- </Button>
- </Tooltip>
- <Tooltip title={t('Remove scratchpad')}>
- <Button
- size="zero"
- borderless
- onPointerDown={e => e.stopPropagation()}
- onClick={() => {
- openConfirmModal({
- onConfirm: () => {
- trackAnalytics('ddm.scratchpad.remove', {
- organization,
- });
- return scratchpads.remove(s.id);
- },
- message: t('Are you sure you want to delete this scratchpad?'),
- confirmText: t('Delete'),
- });
- }}
- >
- <StyledDropdownIcon danger>
- <IconDelete size="sm" />
- </StyledDropdownIcon>
- </Button>
- </Tooltip>
- </Fragment>
- ),
- })),
- [scratchpads, isDefault, organization]
- );
- const selectedScratchpad = scratchpads.selected
- ? scratchpads.all[scratchpads.selected]
- : undefined;
- return (
- <ScratchpadGroup>
- <Button
- icon={<IconDashboard />}
- onClick={() => createDashboard(selectedScratchpad)}
- >
- {t('Save to Dashboard')}
- </Button>
- <SaveAsDropdown
- onSave={name => {
- scratchpads.add(name);
- }}
- mode={scratchpads.selected ? 'fork' : 'save'}
- />
- <CompactSelect
- grid
- options={scratchpadOptions}
- value={scratchpads.selected ?? `None`}
- closeOnSelect={false}
- onChange={option => {
- scratchpads.toggleSelected(option.value);
- }}
- triggerProps={{prefix: t('Scratchpad')}}
- emptyMessage="No scratchpads yet."
- disabled={false}
- />
- </ScratchpadGroup>
- );
- }
- function SaveAsDropdown({
- onSave,
- mode,
- }: {
- mode: 'save' | 'fork';
- onSave: (name: string) => void;
- }) {
- const {
- isOpen,
- triggerProps,
- overlayProps,
- arrowProps,
- state: {setOpen},
- } = useOverlay({});
- const theme = useTheme();
- const organization = useOrganization();
- const [name, setName] = useState('');
- const save = useCallback(() => {
- trackAnalytics('ddm.scratchpad.save', {
- organization,
- });
- onSave(name);
- setOpen(false);
- setName('');
- }, [name, onSave, setOpen, organization]);
- const enterKeyPressed = useKeyPress('Enter');
- useEffect(() => {
- if (isOpen && enterKeyPressed && name) {
- save();
- }
- }, [enterKeyPressed, isOpen, name, save]);
- const isFork = mode === 'fork';
- return (
- <div>
- <Button icon={isFork ? null : <IconStar isSolid={isFork} />} {...triggerProps}>
- {isFork ? `${t('Duplicate as')}\u2026` : `${t('Save as')}\u2026`}
- </Button>
- <AnimatePresence>
- {isOpen && (
- <FocusScope contain restoreFocus autoFocus>
- <PositionWrapper zIndex={theme.zIndex.dropdown} {...overlayProps}>
- <StyledOverlay arrowProps={arrowProps} animated>
- <SaveAsInput
- type="txt"
- name="scratchpad-name"
- placeholder={t('Scratchpad name')}
- value={name}
- size="sm"
- onChange={({target}) => setName(target.value)}
- />
- <GuideAnchor target="create_scratchpad" position="bottom">
- <SaveAsButton
- priority="primary"
- disabled={!name}
- onClick={() => {
- save();
- }}
- >
- {mode === 'fork' ? t('Fork') : t('Save')}
- </SaveAsButton>
- </GuideAnchor>
- </StyledOverlay>
- </PositionWrapper>
- </FocusScope>
- )}
- </AnimatePresence>
- </div>
- );
- }
- const ScratchpadGroup = styled('div')`
- display: flex;
- gap: ${space(1)};
- `;
- const StyledOverlay = styled(Overlay)`
- padding: ${space(1)};
- `;
- const SaveAsButton = styled(Button)`
- width: 100%;
- `;
- const SaveAsInput = styled(InputControl)`
- margin-bottom: ${space(1)};
- `;
- const StyledDropdownIcon = styled('span')<{danger?: boolean}>`
- padding: ${space(0.5)} ${space(0.5)} 0 ${space(0.5)};
- opacity: 0.5;
- :hover {
- opacity: 0.9;
- color: ${p => (p.danger ? p.theme.red300 : p.theme.gray300)};
- }
- `;
|