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 ? (
+ );
+}
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')}
-
)}