diff --git a/static/app/components/core/form/scrapsForm.tsx b/static/app/components/core/form/scrapsForm.tsx index 4230e9f4663bb3..3f5bcf597c7dd1 100644 --- a/static/app/components/core/form/scrapsForm.tsx +++ b/static/app/components/core/form/scrapsForm.tsx @@ -13,6 +13,7 @@ import {FieldMeta} from '@sentry/scraps/form/field/meta'; import {FieldLayout} from '@sentry/scraps/form/layout'; import {FieldGroup} from '@sentry/scraps/form/layout/fieldGroup'; +import {CheckboxField} from 'sentry/components/forms/fields/checkboxField'; import {RequestError} from 'sentry/utils/requestError/requestError'; import {InputField} from './field/inputField'; @@ -42,6 +43,7 @@ export const defaultFormOptions = formOptions({ const fieldComponents = { Base: BaseField, + Checkbox: CheckboxField, Input: InputField, Number: NumberField, Password: PasswordField, diff --git a/static/app/components/dataExport.spec.tsx b/static/app/components/dataExport.spec.tsx deleted file mode 100644 index 8d5f687bb85094..00000000000000 --- a/static/app/components/dataExport.spec.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; - -import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import {DataExport, ExportQueryType} from 'sentry/components/dataExport'; -import type {Organization} from 'sentry/types/organization'; - -jest.mock('sentry/actionCreators/indicator'); - -const mockUnauthorizedOrg = OrganizationFixture({ - features: [], -}); - -const mockAuthorizedOrg = OrganizationFixture({ - features: ['discover-query'], -}); - -const mockPayload = { - queryType: ExportQueryType.ISSUES_BY_TAG, - queryInfo: {project_id: '1', group_id: '1027', key: 'user'}, -}; - -const mockContext = (organization: Organization) => { - return {organization}; -}; - -describe('DataExport', () => { - it('should not render anything for an unauthorized organization', () => { - render(, { - ...mockContext(mockUnauthorizedOrg), - }); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - }); - - it('should render the button for an authorized organization', () => { - render(, { - ...mockContext(mockAuthorizedOrg), - }); - expect(screen.getByText(/Export All to CSV/)).toBeInTheDocument(); - }); - - it('should render the button for an unauthorized organization using flag override', () => { - render(, { - ...mockContext(mockUnauthorizedOrg), - }); - expect(screen.getByRole('button')).toBeInTheDocument(); - }); - - it('should render custom children if provided', () => { - render(This is an example string, { - ...mockContext(mockAuthorizedOrg), - }); - expect(screen.getByText(/This is an example string/)).toBeInTheDocument(); - }); - - it('should respect the disabled prop and not be clickable', async () => { - const postDataExport = MockApiClient.addMockResponse({ - url: `/organizations/${mockAuthorizedOrg.slug}/data-export/`, - method: 'POST', - body: {id: 721}, - }); - - render(, { - ...mockContext(mockAuthorizedOrg), - }); - - await userEvent.click(screen.getByRole('button')); - expect(postDataExport).not.toHaveBeenCalled(); - expect(screen.getByRole('button')).toBeDisabled(); - }); - - it('should send a request and disable itself when clicked', async () => { - const postDataExport = MockApiClient.addMockResponse({ - url: `/organizations/${mockAuthorizedOrg.slug}/data-export/`, - method: 'POST', - body: {id: 721}, - }); - render(, { - ...mockContext(mockAuthorizedOrg), - }); - - await userEvent.click(screen.getByRole('button')); - - expect(postDataExport).toHaveBeenCalledWith( - `/organizations/${mockAuthorizedOrg.slug}/data-export/`, - { - data: { - query_type: mockPayload.queryType, - query_info: mockPayload.queryInfo, - }, - method: 'POST', - error: expect.anything(), - success: expect.anything(), - } - ); - - await waitFor(() => { - expect(screen.getByRole('button')).toBeDisabled(); - }); - }); - - it('should reset the state when receiving a new payload', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockAuthorizedOrg.slug}/data-export/`, - method: 'POST', - body: {id: 721}, - }); - - const {rerender} = render(, { - ...mockContext(mockAuthorizedOrg), - }); - - await userEvent.click(screen.getByText(/Export All to CSV/)); - await waitFor(() => { - expect(screen.getByRole('button')).toBeDisabled(); - }); - - rerender( - - ); - - await waitFor(() => { - expect(screen.getByRole('button')).toBeEnabled(); - }); - }); - - it('should display default error message if non provided', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockAuthorizedOrg.slug}/data-export/`, - method: 'POST', - statusCode: 400, - }); - - render(, { - ...mockContext(mockAuthorizedOrg), - }); - - await userEvent.click(screen.getByRole('button')); - - await waitFor(() => { - expect(addErrorMessage).toHaveBeenCalledWith( - "We tried our hardest, but we couldn't export your data. Give it another go." - ); - }); - - await waitFor(() => { - expect(screen.getByRole('button')).toBeEnabled(); - }); - }); - - it('should display provided error message', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockAuthorizedOrg.slug}/data-export/`, - method: 'POST', - statusCode: 400, - body: {detail: 'uh oh'}, - }); - - render(, { - ...mockContext(mockAuthorizedOrg), - }); - - await userEvent.click(screen.getByRole('button')); - - await waitFor(() => { - expect(addErrorMessage).toHaveBeenCalledWith('uh oh'); - }); - }); -}); diff --git a/static/app/components/dataExport.tsx b/static/app/components/dataExport.tsx index 5593a2db03d198..859792927c1ed8 100644 --- a/static/app/components/dataExport.tsx +++ b/static/app/components/dataExport.tsx @@ -1,25 +1,16 @@ -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useEffect, useRef} from 'react'; import debounce from 'lodash/debounce'; import {Button} from '@sentry/scraps/button'; -import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import Feature from 'sentry/components/acl/feature'; +import {ExportQueryType, useDataExport} from 'sentry/components/useDataExport'; import {t} from 'sentry/locale'; -import {useApi} from 'sentry/utils/useApi'; -import {useOrganization} from 'sentry/utils/useOrganization'; -// NOTE: Coordinate with other ExportQueryType (src/sentry/data_export/base.py) -export enum ExportQueryType { - ISSUES_BY_TAG = 'Issues-by-Tag', - DISCOVER = 'Discover', - EXPLORE = 'Explore', -} - -interface DataExportPayload { +type DataExportPayload = { queryInfo: any; queryType: ExportQueryType; // TODO(ts): Formalize different possible payloads -} +}; interface DataExportProps { payload: DataExportPayload; @@ -31,69 +22,6 @@ interface DataExportProps { size?: 'xs' | 'sm' | 'md'; } -export function useDataExport({ - payload, - inProgressCallback, - unmountedRef, -}: { - payload: DataExportPayload; - inProgressCallback?: (inProgress: boolean) => void; - unmountedRef?: React.RefObject; -}) { - const organization = useOrganization(); - const api = useApi(); - - return useCallback(() => { - inProgressCallback?.(true); - - // This is a fire and forget request. - api - .requestPromise(`/organizations/${organization.slug}/data-export/`, { - includeAllArgs: true, - method: 'POST', - data: { - query_type: payload.queryType, - query_info: payload.queryInfo, - }, - }) - .then(([_data, _, response]) => { - // If component has unmounted, don't do anything - if (unmountedRef?.current) { - return; - } - - addSuccessMessage( - response?.status === 201 - ? t( - "Sit tight. We'll shoot you an email when your data is ready for download." - ) - : t("It looks like we're already working on it. Sit tight, we'll email you.") - ); - }) - .catch(err => { - // If component has unmounted, don't do anything - if (unmountedRef?.current) { - return; - } - const message = - err?.responseJSON?.detail ?? - t( - "We tried our hardest, but we couldn't export your data. Give it another go." - ); - - addErrorMessage(message); - inProgressCallback?.(false); - }); - }, [ - payload.queryInfo, - payload.queryType, - organization.slug, - api, - inProgressCallback, - unmountedRef, - ]); -} - export function DataExport({ children, disabled, @@ -104,27 +32,11 @@ export function DataExport({ onClick, }: DataExportProps): React.ReactElement { const unmountedRef = useRef(false); - const [inProgress, setInProgress] = useState(false); - const handleDataExport = useDataExport({ + const {isExportWorking, runExport} = useDataExport({ payload, unmountedRef, - inProgressCallback: setInProgress, }); - // We clear the indicator if export props change so that the user - // can fire another export without having to wait for the previous one to finish. - useEffect(() => { - if (inProgress) { - setInProgress(false); - } - // We are skipping the inProgress dependency because it would have fired on each handleDataExport - // call and would have immediately turned off the value giving users no feedback on their click action. - // An alternative way to handle this would have probably been to key the component by payload/queryType, - // but that seems like it can be a complex object so tracking changes could result in very brittle behavior. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [payload.queryType, payload.queryInfo]); - - // Tracking unmounting of the component to prevent setState call on unmounted component useEffect(() => { return () => { unmountedRef.current = true; @@ -132,13 +44,15 @@ export function DataExport({ }, []); const handleClick = () => { - debounce(handleDataExport, 500)(); + debounce(() => { + void runExport(); + }, 500)(); onClick?.(); }; return ( - {inProgress ? ( + {isExportWorking ? ( + {t('Export')} + + + + ); +} diff --git a/static/app/views/explore/logs/logsExportModalButton.tsx b/static/app/views/explore/logs/logsExportModalButton.tsx new file mode 100644 index 00000000000000..2c05329404b86d --- /dev/null +++ b/static/app/views/explore/logs/logsExportModalButton.tsx @@ -0,0 +1,56 @@ +import {Button} from '@sentry/scraps/button'; + +import {openModal} from 'sentry/actionCreators/modal'; +import {type LogsQueryInfo} from 'sentry/components/useDataExport'; +import {IconDownload} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {getExportDisabledTooltip} from 'sentry/views/explore/components/getExportDisabledTooltip'; +import {LogsExportModal} from 'sentry/views/explore/logs/logsExportModal'; +import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; + +type LogsExportModalButtonProps = { + downloadLocally: boolean; + isLoading: boolean; + queryInfo: LogsQueryInfo; + tableData: OurLogsResponseItem[] | null | undefined; + threshold: number; + error?: Error | null; +}; + +export function LogsExportModalButton(props: LogsExportModalButtonProps) { + const {isLoading, tableData, error, queryInfo, downloadLocally, threshold} = props; + const isDataEmpty = !tableData?.length; + const isDataError = error !== null; + + const disabledTooltip = getExportDisabledTooltip({ + isDataEmpty, + isDataError, + isDataLoading: isLoading, + }); + + return ( + + ); +} diff --git a/static/app/views/explore/logs/logsExportSwitch.tsx b/static/app/views/explore/logs/logsExportSwitch.tsx new file mode 100644 index 00000000000000..31399a864efe76 --- /dev/null +++ b/static/app/views/explore/logs/logsExportSwitch.tsx @@ -0,0 +1,66 @@ +import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; +import {type LogsQueryInfo} from 'sentry/components/useDataExport'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {LogsExportButton} from 'sentry/views/explore/logs/logsExportButton'; +import {LogsExportModalButton} from 'sentry/views/explore/logs/logsExportModalButton'; +import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; +import { + useQueryParamsFields, + useQueryParamsSearch, + useQueryParamsSortBys, +} from 'sentry/views/explore/queryParams/context'; + +type LogsExportSwitchProps = { + downloadLocally: boolean; + isLoading: boolean; + tableData: OurLogsResponseItem[] | null | undefined; + threshold: number; + error?: Error | null; +}; + +export function LogsExportSwitch({ + isLoading, + tableData, + error, + downloadLocally, + threshold, +}: LogsExportSwitchProps) { + const organization = useOrganization(); + const location = useLocation(); + const showModalExport = + organization.features.includes('ourlogs-modal-export') || + location.query.logsModalExport === 'true'; + + const {selection} = usePageFilters(); + const logsSearch = useQueryParamsSearch(); + const fields = useQueryParamsFields(); + const sortBys = useQueryParamsSortBys(); + const {start, end, period: statsPeriod} = selection.datetime; + const {environments, projects} = selection; + + const queryInfo: LogsQueryInfo = { + dataset: 'logs', + field: [...fields], + query: logsSearch.formatString(), + project: projects, + sort: sortBys.map(sort => `${sort.kind === 'desc' ? '-' : ''}${sort.field}`), + start: start ? new Date(start).toISOString() : undefined, + end: end ? new Date(end).toISOString() : undefined, + statsPeriod: statsPeriod || undefined, + environment: environments, + }; + + const ButtonComponent = showModalExport ? LogsExportModalButton : LogsExportButton; + + return ( + + ); +} diff --git a/static/app/views/explore/logs/logsTab.tsx b/static/app/views/explore/logs/logsTab.tsx index a2c10489ad57c0..d1a46d21be278f 100644 --- a/static/app/views/explore/logs/logsTab.tsx +++ b/static/app/views/explore/logs/logsTab.tsx @@ -52,10 +52,12 @@ import {useLogAnalytics} from 'sentry/views/explore/hooks/useAnalytics'; import { HiddenColumnEditorLogFields, HiddenLogSearchFields, + QUERY_PAGE_LIMIT, + QUERY_PAGE_LIMIT_WITH_AUTO_REFRESH, } from 'sentry/views/explore/logs/constants'; import {AutorefreshToggle} from 'sentry/views/explore/logs/logsAutoRefresh'; import {LogsDownSamplingAlert} from 'sentry/views/explore/logs/logsDownsamplingAlert'; -import {LogsExportButton} from 'sentry/views/explore/logs/logsExport'; +import {LogsExportSwitch} from 'sentry/views/explore/logs/logsExportSwitch'; import {LogsGraph} from 'sentry/views/explore/logs/logsGraph'; import {LogsTabSeerComboBox} from 'sentry/views/explore/logs/logsTabSeerComboBox'; import {LogsToolbar} from 'sentry/views/explore/logs/logsToolbar'; @@ -253,6 +255,10 @@ export function LogsTabContent({datePageFilterProps, tableExpando}: LogsTabProps const setFields = useSetQueryParamsFields(); const tableData = useLogsPageDataQueryResult(); const autorefreshEnabled = useLogsAutoRefreshEnabled(); + const logsExportThreshold = autorefreshEnabled + ? QUERY_PAGE_LIMIT_WITH_AUTO_REFRESH + : QUERY_PAGE_LIMIT; + const logsExportDownloadLocally = !tableData.isPending && !tableData.hasNextPage; const [timeseriesIngestDelay, setTimeseriesIngestDelay] = useState( getMaxIngestDelayTimestamp() @@ -455,10 +461,12 @@ export function LogsTabContent({datePageFilterProps, tableExpando}: LogsTabProps > {sidebarOpen ? null : t('Advanced')} - )}