diff --git a/api/src/main/java/org/pmiops/workbench/utils/mappers/WorkspaceMapper.java b/api/src/main/java/org/pmiops/workbench/utils/mappers/WorkspaceMapper.java index b80e2a2c3a3..36f183a12c3 100644 --- a/api/src/main/java/org/pmiops/workbench/utils/mappers/WorkspaceMapper.java +++ b/api/src/main/java/org/pmiops/workbench/utils/mappers/WorkspaceMapper.java @@ -53,6 +53,10 @@ public interface WorkspaceMapper { target = "initialCredits.expirationEpochMillis", source = "dbWorkspace.creator", qualifiedByName = "getInitialCreditsExpiration") + @Mapping( + target = "initialCredits.extensionEpochMillis", + source = "dbWorkspace.creator", + qualifiedByName = "getInitialCreditsExpiration") @Mapping(target = "cdrVersionId", source = "dbWorkspace.cdrVersion") @Mapping(target = "accessTierShortName", source = "dbWorkspace.cdrVersion.accessTier.shortName") @Mapping(target = "googleProject", source = "dbWorkspace.googleProject") @@ -128,6 +132,10 @@ default List toApiWorkspaceResponseList( target = "initialCredits.expirationEpochMillis", source = "creator", qualifiedByName = "getInitialCreditsExpiration") + @Mapping( + target = "initialCredits.extensionEpochMillis", + source = "creator", + qualifiedByName = "getInitialCreditsExpiration") @Mapping(target = "initialCredits.exhausted", source = "dbWorkspace.initialCreditsExhausted") @Mapping(target = "etag", source = "version", qualifiedByName = "versionToEtag") @Mapping( diff --git a/api/src/main/resources/workbench-api.yaml b/api/src/main/resources/workbench-api.yaml index f6996f0ddb2..6b239878bb3 100644 --- a/api/src/main/resources/workbench-api.yaml +++ b/api/src/main/resources/workbench-api.yaml @@ -13064,6 +13064,9 @@ components: expirationEpochMillis: type: integer format: int64 + extensionEpochMillis: + type: integer + format: int64 parameters: userId: name: userId diff --git a/ui/src/app/components/invalid-billing-banner.spec.tsx b/ui/src/app/components/invalid-billing-banner.spec.tsx new file mode 100644 index 00000000000..0ad080309f3 --- /dev/null +++ b/ui/src/app/components/invalid-billing-banner.spec.tsx @@ -0,0 +1,198 @@ +import '@testing-library/jest-dom'; + +import * as React from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +import { ProfileApi } from 'generated/fetch'; + +import { screen } from '@testing-library/dom'; +import { render } from '@testing-library/react'; +import { DuccSignatureState } from 'app/components/data-user-code-of-conduct'; +import { InvalidBillingBanner } from 'app/pages/workspace/invalid-billing-banner'; +import { + profileApi, + registerApiClient, +} from 'app/services/swagger-fetch-clients'; +import { plusDays } from 'app/utils/dates'; +import { currentWorkspaceStore } from 'app/utils/navigation'; +import { profileStore, serverConfigStore } from 'app/utils/stores'; + +import defaultServerConfig from 'testing/default-server-config'; +import { + ProfileApiStub, + ProfileStubVariables, +} from 'testing/stubs/profile-api-stub'; +import { workspaceDataStub } from 'testing/stubs/workspaces'; + +describe('InvalidBillingBanner', () => { + const load = jest.fn(); + const reload = jest.fn(); + const updateCache = jest.fn(); + const warningThresholdDays = 5; // arbitrary + const me = ProfileStubVariables.PROFILE_STUB.username; + const someOneElse = 'someOneElse@fake-research-aou.org'; + + const component = ( + signatureState: DuccSignatureState = DuccSignatureState.UNSIGNED + ) => + render( + + {}} + showSpinner={() => {}} + /> + + ); + + beforeEach(() => { + registerApiClient(ProfileApi, new ProfileApiStub()); + + // Do I need this? + reload.mockImplementation(async () => { + const newProfile = await profileApi().getMe(); + profileStore.set({ profile: newProfile, load, reload, updateCache }); + }); + + profileStore.set({ + profile: ProfileStubVariables.PROFILE_STUB, + load, + reload, + updateCache, + }); + serverConfigStore.set({ + config: { + ...defaultServerConfig, + initialCreditsExpirationWarningDays: warningThresholdDays, + }, + }); + }); + + const setupWorkspace = ( + exhausted: boolean, + expired: boolean, + expiringSoon: boolean, + ownedByMe: boolean + ) => { + // Set expiration date to be in the past if expired, in the future if not. + // If expiring soon, set it to be within the warning threshold, and just outside otherwise. + // Expired and expiringSoon are mutually exclusive. + const daysUntilExpiration = expired + ? -1 + : warningThresholdDays + (expiringSoon ? -1 : 1); + currentWorkspaceStore.next({ + ...workspaceDataStub, + initialCredits: { + exhausted, + expired, + expirationEpochMillis: plusDays(Date.now(), daysUntilExpiration), + }, + creator: ownedByMe ? me : someOneElse, + }); + }; + + /* All banners have "initial credits" in the text. Banner text can have one or more links in it. + * React Testing Library has a hard time finding text that is split across multiple elements + * (like text and links), so we can't use getByText to find the banner text. Instead, this + * function will return the textContent of the element that contains the banner text. This will + * include the plain text and the text found in the links. + */ + + const getBannerText = () => + screen.getAllByText(/initial credits/).pop().textContent; + + const setProfileExtensionEligibility = (isEligible: boolean) => { + profileStore.set({ + profile: { + ...ProfileStubVariables.PROFILE_STUB, + eligibleForInitialCreditsExtension: isEligible, + }, + load, + reload, + updateCache, + }); + }; + + it('should show expiring soon banner to user who created the workspace', async () => { + setupWorkspace(false, false, true, true); + setProfileExtensionEligibility(true); + + component(); + + await screen.findByText('Workspace credits are expiring soon'); + expect(getBannerText()).toMatch( + 'Your initial credits are expiring soon. You can request an extension here. For more ' + + 'information, read the Using All of Us Initial Credits article on the User Support Hub.' + ); + }); + + it('should show expiring soon banner to user who did not create the workspace', async () => { + setupWorkspace(false, false, true, false); + setProfileExtensionEligibility(true); + + component(); + + await screen.findByText('Workspace credits are expiring soon'); + expect(getBannerText()).toMatch( + 'This workspace creator’s initial credits are expiring soon. This workspace was ' + + 'created by someOneElse@fake-research-aou.org. You can request an extension here. For more information, ' + + 'read the Using All of Us Initial Credits article on the User Support Hub.' + ); + }); + + it('should show expired banner with option to extend to eligible user who created the workspace', async () => { + setupWorkspace(false, true, false, true); + setProfileExtensionEligibility(true); + + component(); + + await screen.findByText('Workspace credits have expired'); + expect(getBannerText()).toMatch( + 'Your initial credits have expired. You can request an extension here. For more ' + + 'information, read the Using All of Us Initial Credits article on the User Support Hub.' + ); + }); + + it('should show expired banner to user who did not create the workspace and the owner is eligible for extension', async () => { + setupWorkspace(false, true, false, false); + setProfileExtensionEligibility(true); + + component(); + + await screen.findByText('Workspace credits have expired'); + expect(getBannerText()).toMatch( + 'This workspace creator’s initial credits have expired. This workspace was created by ' + + 'someOneElse@fake-research-aou.org. You can request an extension here. For more information, read the ' + + 'Using All of Us Initial Credits article on the User Support Hub.' + ); + }); + + it('should show expired banner with no option to extend to ineligible user who created the workspace', async () => { + setupWorkspace(false, true, false, true); + setProfileExtensionEligibility(false); + + component(); + + await screen.findByText('This workspace is out of initial credits'); + expect(getBannerText()).toMatch( + 'Your initial credits have run out. To use the workspace, a valid billing account needs ' + + 'to be provided. To learn more about establishing a billing account, read the Paying for Your ' + + 'Research article on the User Support Hub.' + ); + }); + + it('should show expired banner to user who did not create the workspace and the owner is not eligible for extension', async () => { + setupWorkspace(false, true, false, false); + setProfileExtensionEligibility(false); + + component(); + + await screen.findByText('This workspace is out of initial credits'); + expect(getBannerText()).toMatch( + 'This workspace creator’s initial credits have run out. This workspace was created by ' + + 'someOneElse@fake-research-aou.org. To use the workspace, a valid billing account needs to be provided. ' + + 'To learn more about establishing a billing account, read the Paying for Your Research article ' + + 'on the User Support Hub.' + ); + }); +}); diff --git a/ui/src/app/pages/workspace/invalid-billing-banner.tsx b/ui/src/app/pages/workspace/invalid-billing-banner.tsx index 42b5edda038..5e5adc687d6 100644 --- a/ui/src/app/pages/workspace/invalid-billing-banner.tsx +++ b/ui/src/app/pages/workspace/invalid-billing-banner.tsx @@ -4,9 +4,13 @@ import * as fp from 'lodash'; import { Profile } from 'generated/fetch'; import { Button, LinkButton } from 'app/components/buttons'; +import { ExtendInitialCreditsModal } from 'app/components/extend-initial-credits-modal'; +import { AoU } from 'app/components/text-wrappers'; import { ToastBanner, ToastType } from 'app/components/toast-banner'; import { withCurrentWorkspace, withUserProfile } from 'app/utils'; +import { plusDays } from 'app/utils/dates'; import { NavigationProps } from 'app/utils/navigation'; +import { serverConfigStore } from 'app/utils/stores'; import { withNavigation } from 'app/utils/with-navigation-hoc'; import { WorkspaceData } from 'app/utils/workspace-data'; import { supportUrls } from 'app/utils/zendesk'; @@ -19,23 +23,167 @@ interface Props extends NavigationProps { onClose: Function; } +const InitialCreditsArticleLink = () => ( + window.open(supportUrls.createBillingAccount, '_blank')} + > + Using Initial Credits + +); + +const BillingAccountArticleLink = () => ( + window.open(supportUrls.createBillingAccount, '_blank')} + > + Paying for Your Research + +); + +interface ExtensionRequestLinkProps { + onClick: Function; +} + +const ExtensionRequestLink = ({ onClick }: ExtensionRequestLinkProps) => ( + <> + You can request an extension{' '} + + here + + . + +); + +const whoseCredits = (isCreator: boolean) => { + return isCreator ? 'Your' : 'This workspace creator’s'; +}; + +const workspaceCreatorInformation = ( + isCreator: boolean, + creatorUsername: string +) => { + return isCreator ? '' : `This workspace was created by ${creatorUsername}. `; +}; + +const whatHappened = ( + isExhausted: boolean, + isExpired: boolean, + isExpiringSoon: boolean, + isEligibleForExtension: boolean, + isCreator: boolean +) => { + const whose = whoseCredits(isCreator); + let whatIsHappening: string; + if (isExhausted || (isExpired && !isEligibleForExtension)) { + whatIsHappening = 'have run out.'; + } else if (isExpired && isEligibleForExtension) { + whatIsHappening = 'have expired.'; + } else if (isExpiringSoon && isEligibleForExtension) { + whatIsHappening = 'are expiring soon.'; + } + return ( + <> + {whose} initial credits {whatIsHappening} + + ); +}; + +const whatToDo = ( + isExhausted: boolean, + isExpired: boolean, + isExpiringSoon: boolean, + isEligibleForExtension: boolean, + onClick: Function +) => { + if (isExhausted || (isExpired && !isEligibleForExtension)) { + return ( + <> + To use the workspace, a valid billing account needs to be provided. To + learn more about establishing a billing account, read the{' '} + article on the User Support Hub. + + ); + } else { + return ( + <> + {isEligibleForExtension && (isExpired || isExpiringSoon) && ( + + )}{' '} + For more information, read the article on + the User Support Hub. + + ); + } +}; + +const titleText = ( + isExhausted: boolean, + isExpired: boolean, + isExpiringSoon: boolean, + isEligibleForExtension: boolean +) => { + if (isExhausted || (isExpired && !isEligibleForExtension)) { + return 'This workspace is out of initial credits'; + } else if (isExpired && isEligibleForExtension) { + return 'Workspace credits have expired'; + } else if (isExpiringSoon && isEligibleForExtension) { + return 'Workspace credits are expiring soon'; + } +}; + export const InvalidBillingBanner = fp.flow( withCurrentWorkspace(), withUserProfile(), withNavigation -)(({ onClose, navigate, workspace }: Props) => { +)(({ onClose, navigate, workspace, profileState }: Props) => { + const [profile, setProfile] = React.useState( + profileState?.profile + ); + const [showExtensionModal, setShowExtensionModal] = React.useState(false); + const isCreator = profile?.username === workspace?.creator; + const isEligibleForExtension = profile?.eligibleForInitialCreditsExtension; + const isExpired = + workspace?.initialCredits.expirationEpochMillis < Date.now(); + const isExpiringSoon = + workspace && + !isExpired && + plusDays( + workspace.initialCredits.expirationEpochMillis, + -serverConfigStore.get().config.initialCreditsExpirationWarningDays + ) < Date.now(); + const isExhausted = workspace?.initialCredits.exhausted; + const title = titleText( + isExhausted, + isExpired, + isExpiringSoon, + isEligibleForExtension + ); + + const workspaceCreatorInformationIfApplicable = workspaceCreatorInformation( + isCreator, + workspace?.creator + ); + const whatHappenedMessage = whatHappened( + isExhausted, + isExpired, + isExpiringSoon, + isEligibleForExtension, + isCreator + ); + const whatToDoMessage = whatToDo( + isExhausted, + isExpired, + isExpiringSoon, + isEligibleForExtension, + () => setShowExtensionModal(true) + ); + const message = ( -
- The initial credits for the creator of this workspace have run out. Please - provide a valid billing account. - window.open(supportUrls.createBillingAccount, '_blank')} - > - Learn how to link a billing account. - -
+ <> + {whatHappenedMessage} {workspaceCreatorInformationIfApplicable} + {whatToDoMessage} + ); - const footer = ( + const footer = isCreator && ( ); + return ( - + <> + {((isExpiringSoon && isEligibleForExtension) || isExpired) && ( + + )} + {showExtensionModal && ( + { + setShowExtensionModal(false); + setProfile(updatedProfile); + }} + /> + )} + ); });