Skip to content

Commit

Permalink
[WOR-1821] Add requester pays to settings modal (#5111)
Browse files Browse the repository at this point in the history
  • Loading branch information
trholdridge authored Sep 30, 2024
1 parent 18b4897 commit 3b981b7
Show file tree
Hide file tree
Showing 6 changed files with 258 additions and 11 deletions.
7 changes: 6 additions & 1 deletion src/libs/ajax/workspaces/workspace-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export interface MethodConfiguration {
}

// TYPES RELATED TO WORKSPACE SETTINGS
export type WorkspaceSetting = BucketLifecycleSetting | SoftDeleteSetting;
export type WorkspaceSetting = BucketLifecycleSetting | SoftDeleteSetting | RequesterPaysSetting;

export interface BucketLifecycleSetting {
settingType: 'GcpBucketLifecycle';
Expand All @@ -44,6 +44,11 @@ export interface SoftDeleteSetting {
config: { retentionDurationInSeconds: number };
}

export interface RequesterPaysSetting {
settingType: 'GcpBucketRequesterPays';
config: { enabled: boolean };
}

export interface BucketLifecycleRule {
action: {
actionType: string;
Expand Down
1 change: 1 addition & 0 deletions src/libs/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ const eventsList = {
workspaceSampleTsvDownload: 'workspace:sample-tsv:download',
workspaceSettingsBucketLifecycle: 'workspace:settings:bucketLifecycle',
workspaceSettingsSoftDelete: 'workspace:settings:softDelete',
workspaceSettingsRequesterPays: 'workspace:settings:requesterPays',
workspaceShare: 'workspace:share',
workspaceShareWithSupport: 'workspace:shareWithSupport',
workspaceSnapshotDelete: 'workspace:snapshot:delete',
Expand Down
33 changes: 33 additions & 0 deletions src/workspaces/SettingsModal/RequesterPays.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ExternalLink } from '@terra-ui-packages/components';
import React, { ReactNode } from 'react';
import Setting from 'src/workspaces/SettingsModal/Setting';

interface RequesterPaysProps {
requesterPaysEnabled: boolean;
setRequesterPaysEnabled: (enabled: boolean) => void;
isOwner: boolean;
}

const RequesterPays = (props: RequesterPaysProps): ReactNode => {
const { requesterPaysEnabled, setRequesterPaysEnabled, isOwner } = props;

const settingToggled = (checked: boolean) => setRequesterPaysEnabled(checked);

return (
<Setting
settingEnabled={requesterPaysEnabled}
setSettingEnabled={settingToggled}
label='Requester Pays:'
isOwner={isOwner}
description={
<>
When <ExternalLink href='https://cloud.google.com/storage/docs/requester-pays'>requester pays</ExternalLink>{' '}
is enabled, requests for the bucket&apos;s data will be billed to the requester&apos;s project instead of the
bucket owner. The requester will be prompted to select their own billing project.{' '}
</>
}
/>
);
};

export default RequesterPays;
139 changes: 137 additions & 2 deletions src/workspaces/SettingsModal/SettingsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { DeepPartial } from '@terra-ui-packages/core-utils';
import { screen } from '@testing-library/react';
import { act } from '@testing-library/react';
import { act, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import _ from 'lodash/fp';
Expand All @@ -14,6 +13,7 @@ import { defaultGoogleWorkspace, makeGoogleWorkspace } from 'src/testing/workspa
import SettingsModal from 'src/workspaces/SettingsModal/SettingsModal';
import {
BucketLifecycleSetting,
RequesterPaysSetting,
secondsInADay,
softDeleteDefaultRetention,
SoftDeleteSetting,
Expand Down Expand Up @@ -103,6 +103,16 @@ describe('SettingsModal', () => {
config: { retentionDurationInSeconds: softDeleteDefaultRetention },
};

const requesterPaysEnabledSetting: RequesterPaysSetting = {
settingType: 'GcpBucketRequesterPays',
config: { enabled: true },
};

const requesterPaysDisabledSetting: RequesterPaysSetting = {
settingType: 'GcpBucketRequesterPays',
config: { enabled: false },
};

const setup = (currentSetting: WorkspaceSetting[], updateSettingsMock: jest.Mock<any, any>) => {
jest.resetAllMocks();
jest.spyOn(console, 'log').mockImplementation(() => {});
Expand Down Expand Up @@ -727,4 +737,129 @@ describe('SettingsModal', () => {
expect(captureEvent).not.toHaveBeenCalledWith();
});
});

describe('Requester Pays Settings', () => {
const getRequesterPaysToggle = () => screen.getByLabelText('Requester Pays:');

it('renders the option as disabled if the user is not an owner', async () => {
// Arrange
setup([], jest.fn());

// Act
await act(async () => {
render(<SettingsModal workspace={makeGoogleWorkspace({ accessLevel: 'READER' })} onDismiss={jest.fn()} />);
});

// Assert
expect(getRequesterPaysToggle()).toBeDisabled();
});

it('renders the option as off if no settings exist', async () => {
// Arrange
setup([], jest.fn());

// Act
await act(async () => {
render(<SettingsModal workspace={defaultGoogleWorkspace} onDismiss={jest.fn()} />);
});

// Assert
expect(getRequesterPaysToggle()).not.toBeChecked();
});

it('renders the option as off if requester pays is disabled', async () => {
// Arrange
setup([requesterPaysDisabledSetting], jest.fn());

// Act
await act(async () => {
render(<SettingsModal workspace={defaultGoogleWorkspace} onDismiss={jest.fn()} />);
});

// Assert
expect(getRequesterPaysToggle()).not.toBeChecked();
});

it('renders the option as on if requester pays is enabled', async () => {
// Arrange
setup([requesterPaysEnabledSetting], jest.fn());

// Act
await act(async () => {
render(<SettingsModal workspace={defaultGoogleWorkspace} onDismiss={jest.fn()} />);
});

// Assert
expect(getRequesterPaysToggle()).toBeChecked();
});

it('supports disabling requester pays', async () => {
// Arrange
const user = userEvent.setup();
const updateSettingsMock = jest.fn();
setup([requesterPaysEnabledSetting], updateSettingsMock);

// Act
await act(async () => {
render(<SettingsModal workspace={defaultGoogleWorkspace} onDismiss={jest.fn()} />);
});

const toggle = getRequesterPaysToggle();
expect(toggle).toBeChecked();
await user.click(toggle);
expect(toggle).not.toBeChecked();

await user.click(screen.getByRole('button', { name: 'Save' }));

// Assert
expect(updateSettingsMock).toHaveBeenCalledWith([requesterPaysDisabledSetting, defaultSoftDeleteSetting]);
expect(captureEvent).toHaveBeenCalledWith(Events.workspaceSettingsRequesterPays, {
enabled: false,
...extractWorkspaceDetails(defaultGoogleWorkspace),
});
});

it('supports enabling requester pays', async () => {
// Arrange
const user = userEvent.setup();
const updateSettingsMock = jest.fn();
setup([], updateSettingsMock);

// Act
await act(async () => {
render(<SettingsModal workspace={defaultGoogleWorkspace} onDismiss={jest.fn()} />);
});

const toggle = getRequesterPaysToggle();
expect(toggle).not.toBeChecked();
await user.click(toggle);
expect(toggle).toBeChecked();

await user.click(screen.getByRole('button', { name: 'Save' }));

// Assert
expect(updateSettingsMock).toHaveBeenCalledWith([requesterPaysEnabledSetting, defaultSoftDeleteSetting]);
expect(captureEvent).toHaveBeenCalledWith(Events.workspaceSettingsRequesterPays, {
enabled: true,
...extractWorkspaceDetails(defaultGoogleWorkspace),
});
});

it('does not event if requester pays did not change', async () => {
// Arrange
const user = userEvent.setup();
const updateSettingsMock = jest.fn();
setup([requesterPaysEnabledSetting], updateSettingsMock);

// Act
await act(async () => {
render(<SettingsModal workspace={defaultGoogleWorkspace} onDismiss={jest.fn()} />);
});
await user.click(screen.getByRole('button', { name: 'Save' }));

// Assert
expect(updateSettingsMock).toHaveBeenCalledWith([requesterPaysEnabledSetting, defaultSoftDeleteSetting]);
expect(captureEvent).not.toHaveBeenCalledWith();
});
});
});
47 changes: 41 additions & 6 deletions src/workspaces/SettingsModal/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,20 @@ import { GCP_BUCKET_LIFECYCLE_RULES } from 'src/libs/feature-previews-config';
import { useCancellation } from 'src/libs/react-utils';
import * as Utils from 'src/libs/utils';
import BucketLifecycleSettings from 'src/workspaces/SettingsModal/BucketLifecycleSettings';
import RequesterPays from 'src/workspaces/SettingsModal/RequesterPays';
import SoftDelete from 'src/workspaces/SettingsModal/SoftDelete';
import {
BucketLifecycleSetting,
DeleteBucketLifecycleRule,
isBucketLifecycleSetting,
isDeleteBucketLifecycleRule,
isRequesterPaysSetting,
isSoftDeleteSetting,
modifyFirstBucketDeletionRule,
modifyFirstSoftDeleteSetting,
modifyRequesterPaysSetting,
removeFirstBucketDeletionRule,
RequesterPaysSetting,
secondsInADay,
softDeleteDefaultRetention,
SoftDeleteSetting,
Expand Down Expand Up @@ -47,6 +51,8 @@ const SettingsModal = (props: SettingsModalProps): ReactNode => {
const [softDeleteEnabled, setSoftDeleteEnabled] = useState(false);
const [softDeleteRetention, setSoftDeleteRetention] = useState<number | null>(null);

const [requesterPaysEnabled, setRequesterPaysEnabled] = useState(false);

// Original settings from server, may contain multiple types
const [workspaceSettings, setWorkspaceSettings] = useState<WorkspaceSetting[] | undefined>(undefined);
// Used for both initial loading and saving settings.
Expand Down Expand Up @@ -103,6 +109,10 @@ const SettingsModal = (props: SettingsModalProps): ReactNode => {
return undefined;
};

const getRequesterPaysSetting = (settings: WorkspaceSetting[]): RequesterPaysSetting | undefined => {
return settings.find((setting: WorkspaceSetting) => isRequesterPaysSetting(setting)) as RequesterPaysSetting;
};

useEffect(() => {
const loadSettings = _.flow(
Utils.withBusyState(setBusy),
Expand Down Expand Up @@ -136,6 +146,9 @@ const SettingsModal = (props: SettingsModalProps): ReactNode => {
// because it is confusing with the switch being disabled.
setSoftDeleteRetention(retentionSeconds / secondsInADay);
}
const requesterPays = getRequesterPaysSetting(settings);
const requesterPaysEnabled = requesterPays === undefined ? false : requesterPays.config.enabled;
setRequesterPaysEnabled(requesterPaysEnabled);
});

loadSettings();
Expand All @@ -154,6 +167,8 @@ const SettingsModal = (props: SettingsModalProps): ReactNode => {
}
const softDeleteInDays = softDeleteEnabled ? softDeleteRetention! : 0;
newSettings = modifyFirstSoftDeleteSetting(newSettings, softDeleteInDays);
newSettings = modifyRequesterPaysSetting(newSettings, requesterPaysEnabled);

await Ajax().Workspaces.workspaceV2(namespace, name).updateSettings(newSettings);
props.onDismiss();

Expand Down Expand Up @@ -196,13 +211,26 @@ const SettingsModal = (props: SettingsModalProps): ReactNode => {
) {
// If the bucket had no soft delete setting before, and the current one is the default retention, don't event.
} else if (!_.isEqual(originalSoftDeleteSetting, newSoftDeleteSetting)) {
// Event if an explicit setting existed before and it changed.
// Event if the setting changed.
Ajax().Metrics.captureEvent(Events.workspaceSettingsSoftDelete, {
enabled: softDeleteEnabled,
retention: softDeleteRetention, // will be null if soft delete is disabled
...extractWorkspaceDetails(props.workspace),
});
}

// Event about requester pays setting only if something actually changed.
const originalRequesterPaysSetting = getRequesterPaysSetting(workspaceSettings || []);
const newRequesterPaysSetting = getRequesterPaysSetting(newSettings);
if (originalRequesterPaysSetting === undefined && !newRequesterPaysSetting?.config.enabled) {
// If the bucket had no requester pays setting before, and the current one is disabled, don't event.
} else if (!_.isEqual(originalRequesterPaysSetting, newRequesterPaysSetting)) {
// Event if the setting changed.
Ajax().Metrics.captureEvent(Events.workspaceSettingsRequesterPays, {
enabled: requesterPaysEnabled,
...extractWorkspaceDetails(props.workspace),
});
}
});

const getSaveTooltip = () => {
Expand Down Expand Up @@ -241,11 +269,18 @@ const SettingsModal = (props: SettingsModalProps): ReactNode => {
/>
</div>
)}
<SoftDelete
softDeleteEnabled={softDeleteEnabled}
setSoftDeleteEnabled={setSoftDeleteEnabled}
softDeleteRetention={softDeleteRetention}
setSoftDeleteRetention={setSoftDeleteRetention}
<div style={{ paddingBottom: '1.0rem', borderBottom: `1px solid ${colors.accent()}` }}>
<SoftDelete
softDeleteEnabled={softDeleteEnabled}
setSoftDeleteEnabled={setSoftDeleteEnabled}
softDeleteRetention={softDeleteRetention}
setSoftDeleteRetention={setSoftDeleteRetention}
isOwner={isOwner}
/>
</div>
<RequesterPays
requesterPaysEnabled={requesterPaysEnabled}
setRequesterPaysEnabled={setRequesterPaysEnabled}
isOwner={isOwner}
/>

Expand Down
Loading

0 comments on commit 3b981b7

Please sign in to comment.