From 3dd9504a8500fc83f78e13c870f2d0e24891f993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Thu, 24 Aug 2023 14:58:15 +0200 Subject: [PATCH] chore: add option to transition your data - internal feature (#2449) * chore: add option to transition your data - internal feature * chore: fix spec typo --- .../Application/ApplicationInterface.ts | 4 + .../InternalFeatures/InternalFeature.ts | 1 + .../GetTransitionStatus.spec.ts | 34 ++++++ .../GetTransitionStatus.ts | 17 +++ .../StartTransition/StartTransition.spec.ts | 33 +++++ .../StartTransition/StartTransition.ts | 17 +++ packages/services/src/Domain/index.ts | 2 + packages/snjs/lib/Application/Application.ts | 10 ++ .../Application/Dependencies/Dependencies.ts | 11 ++ .../lib/Application/Dependencies/Types.ts | 2 + .../src/javascripts/Application/DevMode.ts | 1 + .../Preferences/Panes/Account/Sync.tsx | 115 +++++++++++++++++- packages/web/src/javascripts/FeatureTrunk.ts | 4 + 13 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 packages/services/src/Domain/UseCase/Transition/GetTransitionStatus/GetTransitionStatus.spec.ts create mode 100644 packages/services/src/Domain/UseCase/Transition/GetTransitionStatus/GetTransitionStatus.ts create mode 100644 packages/services/src/Domain/UseCase/Transition/StartTransition/StartTransition.spec.ts create mode 100644 packages/services/src/Domain/UseCase/Transition/StartTransition/StartTransition.ts diff --git a/packages/services/src/Domain/Application/ApplicationInterface.ts b/packages/services/src/Domain/Application/ApplicationInterface.ts index 28c145686..b5ef604da 100644 --- a/packages/services/src/Domain/Application/ApplicationInterface.ts +++ b/packages/services/src/Domain/Application/ApplicationInterface.ts @@ -13,6 +13,8 @@ import { MfaServiceInterface, GenerateUuid, CreateDecryptedBackupFile, + GetTransitionStatus, + StartTransition, } from '@standardnotes/services' import { VaultLockServiceInterface } from './../VaultLock/VaultLockServiceInterface' import { HistoryServiceInterface } from './../History/HistoryServiceInterface' @@ -76,6 +78,8 @@ export interface ApplicationInterface { get generateUuid(): GenerateUuid get getHost(): GetHost get setHost(): SetHost + get getTransitionStatus(): GetTransitionStatus + get startTransition(): StartTransition // Services get alerts(): AlertService diff --git a/packages/services/src/Domain/InternalFeatures/InternalFeature.ts b/packages/services/src/Domain/InternalFeatures/InternalFeature.ts index 817c91961..7da14d61f 100644 --- a/packages/services/src/Domain/InternalFeatures/InternalFeature.ts +++ b/packages/services/src/Domain/InternalFeatures/InternalFeature.ts @@ -1,4 +1,5 @@ export enum InternalFeature { Vaults = 'vaults', HomeServer = 'home-server', + Transition = 'transition', } diff --git a/packages/services/src/Domain/UseCase/Transition/GetTransitionStatus/GetTransitionStatus.spec.ts b/packages/services/src/Domain/UseCase/Transition/GetTransitionStatus/GetTransitionStatus.spec.ts new file mode 100644 index 000000000..4e24f1bb5 --- /dev/null +++ b/packages/services/src/Domain/UseCase/Transition/GetTransitionStatus/GetTransitionStatus.spec.ts @@ -0,0 +1,34 @@ +import { HttpServiceInterface } from '@standardnotes/api' + +import { GetTransitionStatus } from './GetTransitionStatus' + +describe('GetTransitionStatus', () => { + let httpService: HttpServiceInterface + + const createUseCase = () => new GetTransitionStatus(httpService) + + beforeEach(() => { + httpService = { + get: jest.fn(), + } as unknown as HttpServiceInterface + }) + + it('should get transition status', async () => { + const useCase = createUseCase() + ;(httpService.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: { status: 'TO-DO' } }) + + const result = await useCase.execute() + + expect(result.isFailed()).toBe(false) + expect(result.getValue()).toBe('TO-DO') + }) + + it('should fail to get transition status', async () => { + const useCase = createUseCase() + ;(httpService.get as jest.Mock).mockResolvedValueOnce({ status: 400 }) + + const result = await useCase.execute() + + expect(result.isFailed()).toBe(true) + }) +}) diff --git a/packages/services/src/Domain/UseCase/Transition/GetTransitionStatus/GetTransitionStatus.ts b/packages/services/src/Domain/UseCase/Transition/GetTransitionStatus/GetTransitionStatus.ts new file mode 100644 index 000000000..9c85683df --- /dev/null +++ b/packages/services/src/Domain/UseCase/Transition/GetTransitionStatus/GetTransitionStatus.ts @@ -0,0 +1,17 @@ +import { Result, UseCaseInterface } from '@standardnotes/domain-core' +import { HttpServiceInterface } from '@standardnotes/api' +import { HttpStatusCode } from '@standardnotes/responses' + +export class GetTransitionStatus implements UseCaseInterface<'TO-DO' | 'STARTED' | 'FINISHED' | 'FAILED'> { + constructor(private httpService: HttpServiceInterface) {} + + async execute(): Promise> { + const response = await this.httpService.get('/v1/users/transition-status') + + if (response.status !== HttpStatusCode.Success) { + return Result.fail('Failed to get transition status') + } + + return Result.ok((response.data as { status: 'TO-DO' | 'STARTED' | 'FINISHED' | 'FAILED' }).status) + } +} diff --git a/packages/services/src/Domain/UseCase/Transition/StartTransition/StartTransition.spec.ts b/packages/services/src/Domain/UseCase/Transition/StartTransition/StartTransition.spec.ts new file mode 100644 index 000000000..8493c4003 --- /dev/null +++ b/packages/services/src/Domain/UseCase/Transition/StartTransition/StartTransition.spec.ts @@ -0,0 +1,33 @@ +import { HttpServiceInterface } from '@standardnotes/api' + +import { StartTransition } from './StartTransition' + +describe('StartTransition', () => { + let httpService: HttpServiceInterface + + const createUseCase = () => new StartTransition(httpService) + + beforeEach(() => { + httpService = { + post: jest.fn(), + } as unknown as HttpServiceInterface + }) + + it('should start transition', async () => { + const useCase = createUseCase() + ;(httpService.post as jest.Mock).mockResolvedValueOnce({ status: 200 }) + + const result = await useCase.execute() + + expect(result.isFailed()).toBe(false) + }) + + it('should fail to start transition', async () => { + const useCase = createUseCase() + ;(httpService.post as jest.Mock).mockResolvedValueOnce({ status: 400 }) + + const result = await useCase.execute() + + expect(result.isFailed()).toBe(true) + }) +}) diff --git a/packages/services/src/Domain/UseCase/Transition/StartTransition/StartTransition.ts b/packages/services/src/Domain/UseCase/Transition/StartTransition/StartTransition.ts new file mode 100644 index 000000000..ab5b5c4f3 --- /dev/null +++ b/packages/services/src/Domain/UseCase/Transition/StartTransition/StartTransition.ts @@ -0,0 +1,17 @@ +import { HttpServiceInterface } from '@standardnotes/api' +import { Result, UseCaseInterface } from '@standardnotes/domain-core' +import { HttpStatusCode } from '@standardnotes/responses' + +export class StartTransition implements UseCaseInterface { + constructor(private httpService: HttpServiceInterface) {} + + async execute(): Promise> { + const response = await this.httpService.post('/v1/items/transition') + + if (response.status !== HttpStatusCode.Success) { + return Result.fail('Failed to start transition') + } + + return Result.ok() + } +} diff --git a/packages/services/src/Domain/index.ts b/packages/services/src/Domain/index.ts index a5eced3cc..931d615cf 100644 --- a/packages/services/src/Domain/index.ts +++ b/packages/services/src/Domain/index.ts @@ -184,6 +184,8 @@ export * from './Sync/SyncOptions' export * from './Sync/SyncQueueStrategy' export * from './Sync/SyncServiceInterface' export * from './Sync/SyncSource' +export * from './UseCase/Transition/GetTransitionStatus/GetTransitionStatus' +export * from './UseCase/Transition/StartTransition/StartTransition' export * from './UseCase/ChangeAndSaveItem' export * from './UseCase/DiscardItemsLocally' export * from './UseCase/GenerateUuid' diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index aab54fbfc..17ae2beea 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -81,6 +81,8 @@ import { GenerateUuid, CreateDecryptedBackupFile, CreateEncryptedBackupFile, + GetTransitionStatus, + StartTransition, } from '@standardnotes/services' import { SNNote, @@ -1138,6 +1140,14 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli return this.dependencies.get(TYPES.SetHost) } + get getTransitionStatus(): GetTransitionStatus { + return this.dependencies.get(TYPES.GetTransitionStatus) + } + + get startTransition(): StartTransition { + return this.dependencies.get(TYPES.StartTransition) + } + public get legacyApi(): LegacyApiService { return this.dependencies.get(TYPES.LegacyApiService) } diff --git a/packages/snjs/lib/Application/Dependencies/Dependencies.ts b/packages/snjs/lib/Application/Dependencies/Dependencies.ts index d41fd53ac..084f82e73 100644 --- a/packages/snjs/lib/Application/Dependencies/Dependencies.ts +++ b/packages/snjs/lib/Application/Dependencies/Dependencies.ts @@ -141,6 +141,8 @@ import { CreateDecryptedBackupFile, CreateEncryptedBackupFile, SyncLocalVaultsWithRemoteSharedVaults, + GetTransitionStatus, + StartTransition, } from '@standardnotes/services' import { ItemManager } from '../../Services/Items/ItemManager' import { PayloadManager } from '../../Services/Payloads/PayloadManager' @@ -153,6 +155,7 @@ import { AuthenticatorApiService, AuthenticatorServer, HttpService, + HttpServiceInterface, RevisionApiService, RevisionServer, SharedVaultInvitesServer, @@ -1023,6 +1026,14 @@ export class Dependencies { ) }) + this.factory.set(TYPES.GetTransitionStatus, () => { + return new GetTransitionStatus(this.get(TYPES.HttpService)) + }) + + this.factory.set(TYPES.StartTransition, () => { + return new StartTransition(this.get(TYPES.HttpService)) + }) + this.factory.set(TYPES.ListRevisions, () => { return new ListRevisions(this.get(TYPES.RevisionManager)) }) diff --git a/packages/snjs/lib/Application/Dependencies/Types.ts b/packages/snjs/lib/Application/Dependencies/Types.ts index e145da9c9..e9a93f3f7 100644 --- a/packages/snjs/lib/Application/Dependencies/Types.ts +++ b/packages/snjs/lib/Application/Dependencies/Types.ts @@ -171,6 +171,8 @@ export const TYPES = { AuthorizeVaultDeletion: Symbol.for('AuthorizeVaultDeletion'), CreateDecryptedBackupFile: Symbol.for('CreateDecryptedBackupFile'), CreateEncryptedBackupFile: Symbol.for('CreateEncryptedBackupFile'), + GetTransitionStatus: Symbol.for('GetTransitionStatus'), + StartTransition: Symbol.for('StartTransition'), // Mappers SessionStorageMapper: Symbol.for('SessionStorageMapper'), diff --git a/packages/web/src/javascripts/Application/DevMode.ts b/packages/web/src/javascripts/Application/DevMode.ts index f62fc7711..536e25c18 100644 --- a/packages/web/src/javascripts/Application/DevMode.ts +++ b/packages/web/src/javascripts/Application/DevMode.ts @@ -5,6 +5,7 @@ export class DevMode { constructor(private application: WebApplicationInterface) { InternalFeatureService.get().enableFeature(InternalFeature.Vaults) InternalFeatureService.get().enableFeature(InternalFeature.HomeServer) + InternalFeatureService.get().enableFeature(InternalFeature.Transition) } /** Valid only when running a mock event publisher on port 3124 */ diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/Sync.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/Sync.tsx index e400cfe13..3e816f093 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Account/Sync.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/Sync.tsx @@ -1,22 +1,91 @@ +import { FunctionComponent, useCallback, useEffect, useState } from 'react' +import { observer } from 'mobx-react-lite' + import { Text, Title } from '@/Components/Preferences/PreferencesComponents/Content' import Button from '@/Components/Button/Button' import { SyncQueueStrategy } from '@standardnotes/snjs' import { STRING_GENERIC_SYNC_ERROR } from '@/Constants/Strings' -import { observer } from 'mobx-react-lite' import { WebApplication } from '@/Application/WebApplication' -import { FunctionComponent, useState } from 'react' import { formatLastSyncDate } from '@/Utils/DateUtils' import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup' import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment' +import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' +import { featureTrunkTransitionEnabled } from '@/FeatureTrunk' type Props = { application: WebApplication } const Sync: FunctionComponent = ({ application }: Props) => { + const TRANSITION_STATUS_REFRESH_INTERVAL = 5000 + const [isSyncingInProgress, setIsSyncingInProgress] = useState(false) + const [isTransitionInProgress, setIsTransitionInProgress] = useState(false) + const [showTransitionSegment, setShowTransitionSegment] = useState(false) + const [transitionStatus, setTransitionStatus] = useState('') + const [transitionStatusIntervalRef, setTransitionStatusIntervalRef] = useState(null) const [lastSyncDate, setLastSyncDate] = useState(formatLastSyncDate(application.sync.getLastSyncDate() as Date)) + const setupTransitionStatusRefresh = useCallback(async () => { + const interval = setInterval(async () => { + const statusOrError = await application.getTransitionStatus.execute() + if (statusOrError.isFailed()) { + await application.alerts.alert(statusOrError.getError()) + return + } + const status = statusOrError.getValue() + + setTransitionStatus(status) + }, TRANSITION_STATUS_REFRESH_INTERVAL) + + setTransitionStatusIntervalRef(interval) + }, [application, setTransitionStatus, setTransitionStatusIntervalRef]) + + useEffect(() => { + if (!featureTrunkTransitionEnabled()) { + return + } + + async function checkTransitionStatus() { + const statusOrError = await application.getTransitionStatus.execute() + if (statusOrError.isFailed()) { + await application.alerts.alert(statusOrError.getError()) + return + } + const status = statusOrError.getValue() + + if (status === 'FINISHED') { + if (transitionStatusIntervalRef) { + clearInterval(transitionStatusIntervalRef) + } + setIsTransitionInProgress(false) + setTransitionStatus(status) + setShowTransitionSegment(false) + + return + } + + setShowTransitionSegment(true) + setTransitionStatus(status) + + if (status === 'STARTED') { + setIsTransitionInProgress(true) + if (!transitionStatusIntervalRef) { + await setupTransitionStatusRefresh() + } + } + } + + void checkTransitionStatus() + }, [ + application, + setIsTransitionInProgress, + setTransitionStatus, + setShowTransitionSegment, + setupTransitionStatusRefresh, + transitionStatusIntervalRef, + ]) + const doSynchronization = async () => { setIsSyncingInProgress(true) @@ -32,6 +101,19 @@ const Sync: FunctionComponent = ({ application }: Props) => { } } + const doTransition = useCallback(async () => { + const resultOrError = await application.startTransition.execute() + if (resultOrError.isFailed()) { + await application.alerts.alert(resultOrError.getError()) + + return + } + + setIsTransitionInProgress(true) + + await setupTransitionStatusRefresh() + }, [application, setupTransitionStatusRefresh]) + return ( @@ -50,6 +132,35 @@ const Sync: FunctionComponent = ({ application }: Props) => { + {showTransitionSegment && ( + <> + + +
+
+ Transition Account + + Transition your account to our new infrastructure in order to enable new features and improve your + overall experience. Depending on the amount of data you have, this process may take a few moments. + + {isTransitionInProgress && ( + + Transition status: {transitionStatus} + + )} + {!isTransitionInProgress && ( +
+
+
+ + )}
) } diff --git a/packages/web/src/javascripts/FeatureTrunk.ts b/packages/web/src/javascripts/FeatureTrunk.ts index 515d29b86..d8695a42b 100644 --- a/packages/web/src/javascripts/FeatureTrunk.ts +++ b/packages/web/src/javascripts/FeatureTrunk.ts @@ -15,3 +15,7 @@ export function featureTrunkVaultsEnabled(): boolean { export function featureTrunkHomeServerEnabled(): boolean { return InternalFeatureService.get().isFeatureEnabled(InternalFeature.HomeServer) } + +export function featureTrunkTransitionEnabled(): boolean { + return InternalFeatureService.get().isFeatureEnabled(InternalFeature.Transition) +}