diff --git a/index.ts b/index.ts index ba56200..e4e9de9 100644 --- a/index.ts +++ b/index.ts @@ -23,6 +23,7 @@ export { createImageBackgroundComponent } from './react-native/components/create export { createInputComponent } from './react-native/components/createInputComponent' export { createLimitedHeightComponent } from './react-native/components/createLimitedHeightComponent' export { createLimitedWidthComponent } from './react-native/components/createLimitedWidthComponent' +export { createMigratorManagerComponent } from './react-native/components/createMigratorManagerComponent' export { createMinimumHeightComponent } from './react-native/components/createMinimumHeightComponent' export { createMinimumWidthComponent } from './react-native/components/createMinimumWidthComponent' export { createNullableEmailInputComponent } from './react-native/components/createNullableEmailInputComponent' diff --git a/react-native/components/createMigratorManagerComponent/index.tsx b/react-native/components/createMigratorManagerComponent/index.tsx new file mode 100644 index 0000000..92ff022 --- /dev/null +++ b/react-native/components/createMigratorManagerComponent/index.tsx @@ -0,0 +1,49 @@ +import * as React from 'react' +import type { Json } from '../../types/Json' +import type { MigratorInterface } from '../../types/MigratorInterface' +import type { MigratableState } from '../../types/MigratableState' + +/** + * Creates a React component which automatically manages a migrator, displaying + * a loading screen and executing it if appropriate. + * @template T The type of state to migrate. + * @param migrator The migrator. + * @returns A React component which automatically manages the migrator, + * displaying a loading screen and executing it if appropriate. + */ +export const createMigratorManagerComponent = >>( + migrator: MigratorInterface +): React.FunctionComponent<{ + /** + * The state to migrate. + */ + readonly state: MigratableState + + /** + * Called once migration completes. + * @param to The resulting state.. + */ + readonly setState: (to: MigratableState) => void + + /** + * The JSX to display while the state is migrated. + */ + readonly migrating: JSX.Element + + /** + * The JSX to display once the state is migrated. + */ + readonly ready: JSX.Element + }> => { + return ({ state, setState, migrating, ready }) => { + const executionRequired = migrator.executionRequired(state) + + React.useEffect(() => { + if (executionRequired) { + setState(migrator.execute(state)) + } + }, [executionRequired]) + + return executionRequired ? migrating : ready + } +} diff --git a/react-native/components/createMigratorManagerComponent/readme.md b/react-native/components/createMigratorManagerComponent/readme.md new file mode 100644 index 0000000..7f131d9 --- /dev/null +++ b/react-native/components/createMigratorManagerComponent/readme.md @@ -0,0 +1,49 @@ +# `react-native-app-helpers/createMigratorManagerComponent` + +Creates a React component which automatically manages a migrator, displaying a +loading screen and executing it if appropriate. + +## Usage + +```tsx +import { + Migrator, + MigratableState, + createMigratorManagerComponent, +} from "react-native-app-helpers"; + +type State = { readonly items: ReadonlyArray } + +const migrator = new Migrator([ + ['b4dac8cd-af18-4e7d-a723-27f61d368228', (previous) => ({ + ...previous, + items: [...previous.items, 1], + })], + ['1b69c28f-454e-4511-aa05-596fe5ae23a8', (previous) => ({ + ...previous, + items: [...previous.items, 2], + })], + ['b07bc75d-1ba2-4bf7-b510-51a93d554a56', (previous) => ({ + ...previous, + items: [...previous.items, 3], + })], +]); + +const MigratorManager = createMigratorManagerComponent(migrator); + +export default () => { + const [state, setState] = React.useState>({ + executedMigrationUuids: ['1b69c28f-454e-4511-aa05-596fe5ae23a8'], + items: [], + }); + + return ( + Migrations are in progress...} + ready={All migrations have completed.} + /> + ); +}; +``` diff --git a/react-native/components/createMigratorManagerComponent/unit.tsx b/react-native/components/createMigratorManagerComponent/unit.tsx new file mode 100644 index 0000000..7901e36 --- /dev/null +++ b/react-native/components/createMigratorManagerComponent/unit.tsx @@ -0,0 +1,123 @@ +import * as React from 'react' +import { Text } from 'react-native' +import * as TestRenderer from 'react-test-renderer' +import { createMigratorManagerComponent, type MigratorInterface } from '../../..' + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type State = { readonly items: readonly number[] } + +test('displays the migrating screen when no migrations are required', async () => { + const migrator: MigratorInterface = { + executionRequired: jest.fn().mockReturnValue(true), + execute: jest.fn() + } + const MigratorManager = createMigratorManagerComponent(migrator) + const setState = jest.fn() + + const renderer = TestRenderer.create( + Migrating} + ready={Ready} + /> + ) + + expect(renderer.toTree()?.rendered).toEqual( + expect.objectContaining({ + props: expect.objectContaining({ + children: 'Migrating' + }) + }) + ) + + expect(setState).not.toHaveBeenCalled() + expect(migrator.executionRequired).toHaveBeenCalledTimes(1) + expect(migrator.executionRequired).toHaveBeenCalledWith({ items: [1, 2, 3] }) + expect(migrator.execute).not.toHaveBeenCalled() + + renderer.unmount() + + await TestRenderer.act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)) + }) +}) + +test('executes migrations when required', async () => { + const migrator: MigratorInterface = { + executionRequired: jest.fn().mockReturnValue(true), + execute: jest.fn().mockReturnValue({ items: [4, 5, 6] }) + } + const MigratorManager = createMigratorManagerComponent(migrator) + const setState = jest.fn() + + const renderer = TestRenderer.create( + Migrating} + ready={Ready} + /> + ) + + expect(renderer.toTree()?.rendered).toEqual( + expect.objectContaining({ + props: expect.objectContaining({ + children: 'Migrating' + }) + }) + ) + + await TestRenderer.act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)) + }) + + expect(migrator.executionRequired).toHaveBeenCalledTimes(1) + expect(migrator.execute).toHaveBeenCalledTimes(1) + expect(migrator.execute).toHaveBeenCalledWith({ items: [1, 2, 3] }) + expect(setState).toHaveBeenCalledTimes(1) + expect(setState).toHaveBeenCalledWith({ items: [4, 5, 6] }) + + renderer.unmount() + + await TestRenderer.act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)) + }) +}) + +test('displays the ready screen when no migrations are required', async () => { + const migrator: MigratorInterface = { + executionRequired: jest.fn().mockReturnValue(false), + execute: jest.fn() + } + const MigratorManager = createMigratorManagerComponent(migrator) + const setState = jest.fn() + + const renderer = TestRenderer.create( + Migrating} + ready={Ready} + /> + ) + + expect(renderer.toTree()?.rendered).toEqual( + expect.objectContaining({ + props: expect.objectContaining({ + children: 'Ready' + }) + }) + ) + + renderer.unmount() + + await TestRenderer.act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)) + }) + + expect(setState).not.toHaveBeenCalled() + expect(migrator.executionRequired).toHaveBeenCalledTimes(1) + expect(migrator.executionRequired).toHaveBeenCalledWith({ items: [1, 2, 3] }) + expect(migrator.execute).not.toHaveBeenCalled() +}) diff --git a/react-native/services/Migrator/readme.md b/react-native/services/Migrator/readme.md index 6704bea..f83bd51 100644 --- a/react-native/services/Migrator/readme.md +++ b/react-native/services/Migrator/readme.md @@ -10,7 +10,8 @@ import { Migrator, MigratableState } from "react-native-app-helpers"; type State = { readonly items: ReadonlyArray } const state: MigratableState = { - executedMigrationUuids: ['1b69c28f-454e-4511-aa05-596fe5ae23a8'] + executedMigrationUuids: ['1b69c28f-454e-4511-aa05-596fe5ae23a8'], + items: [], } const migrator = new Migrator([ diff --git a/readme.md b/readme.md index f00b8df..54f45fd 100644 --- a/readme.md +++ b/readme.md @@ -42,6 +42,7 @@ import { createTextComponent } from "react-native-app-helpers"; - [createInputComponent](./react-native/components/createInputComponent/readme.md) - [createLimitedHeightComponent](./react-native/components/createLimitedHeightComponent/readme.md) - [createLimitedWidthComponent](./react-native/components/createLimitedWidthComponent/readme.md) +- [createMigratorManagerComponent](./react-native/components/createMigratorManagerComponent/readme.md) - [createMinimumHeightComponent](./react-native/components/createMinimumHeightComponent/readme.md) - [createMinimumWidthComponent](./react-native/components/createMinimumWidthComponent/readme.md) - [createNullableEmailInputComponent](./react-native/components/createNullableEmailInputComponent/readme.md)