Skip to content

Commit c060939

Browse files
committed
Add migration helper.
1 parent dce8cf9 commit c060939

File tree

6 files changed

+225
-1
lines changed

6 files changed

+225
-1
lines changed

Diff for: index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export { createImageBackgroundComponent } from './react-native/components/create
2323
export { createInputComponent } from './react-native/components/createInputComponent'
2424
export { createLimitedHeightComponent } from './react-native/components/createLimitedHeightComponent'
2525
export { createLimitedWidthComponent } from './react-native/components/createLimitedWidthComponent'
26+
export { createMigratorManagerComponent } from './react-native/components/createMigratorManagerComponent'
2627
export { createMinimumHeightComponent } from './react-native/components/createMinimumHeightComponent'
2728
export { createMinimumWidthComponent } from './react-native/components/createMinimumWidthComponent'
2829
export { createNullableEmailInputComponent } from './react-native/components/createNullableEmailInputComponent'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as React from 'react'
2+
import type { Json } from '../../types/Json'
3+
import type { MigratorInterface } from '../../types/MigratorInterface'
4+
import type { MigratableState } from '../../types/MigratableState'
5+
6+
/**
7+
* Creates a React component which automatically manages a migrator, displaying
8+
* a loading screen and executing it if appropriate.
9+
* @template T The type of state to migrate.
10+
* @param migrator The migrator.
11+
* @returns A React component which automatically manages the migrator,
12+
* displaying a loading screen and executing it if appropriate.
13+
*/
14+
export const createMigratorManagerComponent = <T extends Readonly<Record<string | number, Json>>>(
15+
migrator: MigratorInterface<T>
16+
): React.FunctionComponent<{
17+
/**
18+
* The state to migrate.
19+
*/
20+
readonly state: MigratableState<T>
21+
22+
/**
23+
* Called once migration completes.
24+
* @param to The resulting state..
25+
*/
26+
readonly setState: (to: MigratableState<T>) => void
27+
28+
/**
29+
* The JSX to display while the state is migrated.
30+
*/
31+
readonly migrating: JSX.Element
32+
33+
/**
34+
* The JSX to display once the state is migrated.
35+
*/
36+
readonly ready: JSX.Element
37+
}> => {
38+
return ({ state, setState, migrating, ready }) => {
39+
const executionRequired = migrator.executionRequired(state)
40+
41+
React.useEffect(() => {
42+
if (executionRequired) {
43+
setState(migrator.execute(state))
44+
}
45+
}, [executionRequired])
46+
47+
return executionRequired ? migrating : ready
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# `react-native-app-helpers/createMigratorManagerComponent`
2+
3+
Creates a React component which automatically manages a migrator, displaying a
4+
loading screen and executing it if appropriate.
5+
6+
## Usage
7+
8+
```tsx
9+
import {
10+
Migrator,
11+
MigratableState,
12+
createMigratorManagerComponent,
13+
} from "react-native-app-helpers";
14+
15+
type State = { readonly items: ReadonlyArray<number> }
16+
17+
const migrator = new Migrator<State>([
18+
['b4dac8cd-af18-4e7d-a723-27f61d368228', (previous) => ({
19+
...previous,
20+
items: [...previous.items, 1],
21+
})],
22+
['1b69c28f-454e-4511-aa05-596fe5ae23a8', (previous) => ({
23+
...previous,
24+
items: [...previous.items, 2],
25+
})],
26+
['b07bc75d-1ba2-4bf7-b510-51a93d554a56', (previous) => ({
27+
...previous,
28+
items: [...previous.items, 3],
29+
})],
30+
]);
31+
32+
const MigratorManager = createMigratorManagerComponent(migrator);
33+
34+
export default () => {
35+
const [state, setState] = React.useState<MigratableState<State>>({
36+
executedMigrationUuids: ['1b69c28f-454e-4511-aa05-596fe5ae23a8'],
37+
items: [],
38+
});
39+
40+
return (
41+
<MigratorManager
42+
state={state}
43+
setState={setState}
44+
migrating={<Text>Migrations are in progress...</Text>}
45+
ready={<Text>All migrations have completed.</Text>}
46+
/>
47+
);
48+
};
49+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import * as React from 'react'
2+
import { Text } from 'react-native'
3+
import * as TestRenderer from 'react-test-renderer'
4+
import { createMigratorManagerComponent, type MigratorInterface } from '../../..'
5+
6+
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
7+
type State = { readonly items: readonly number[] }
8+
9+
test('displays the migrating screen when no migrations are required', async () => {
10+
const migrator: MigratorInterface<State> = {
11+
executionRequired: jest.fn().mockReturnValue(true),
12+
execute: jest.fn()
13+
}
14+
const MigratorManager = createMigratorManagerComponent(migrator)
15+
const setState = jest.fn()
16+
17+
const renderer = TestRenderer.create(
18+
<MigratorManager
19+
state={{ items: [1, 2, 3] }}
20+
setState={setState}
21+
migrating={<Text>Migrating</Text>}
22+
ready={<Text>Ready</Text>}
23+
/>
24+
)
25+
26+
expect(renderer.toTree()?.rendered).toEqual(
27+
expect.objectContaining({
28+
props: expect.objectContaining({
29+
children: 'Migrating'
30+
})
31+
})
32+
)
33+
34+
expect(setState).not.toHaveBeenCalled()
35+
expect(migrator.executionRequired).toHaveBeenCalledTimes(1)
36+
expect(migrator.executionRequired).toHaveBeenCalledWith({ items: [1, 2, 3] })
37+
expect(migrator.execute).not.toHaveBeenCalled()
38+
39+
renderer.unmount()
40+
41+
await TestRenderer.act(async () => {
42+
await new Promise((resolve) => setTimeout(resolve, 250))
43+
})
44+
})
45+
46+
test('executes migrations when required', async () => {
47+
const migrator: MigratorInterface<State> = {
48+
executionRequired: jest.fn().mockReturnValue(true),
49+
execute: jest.fn().mockReturnValue({ items: [4, 5, 6] })
50+
}
51+
const MigratorManager = createMigratorManagerComponent(migrator)
52+
const setState = jest.fn()
53+
54+
const renderer = TestRenderer.create(
55+
<MigratorManager
56+
state={{ items: [1, 2, 3] }}
57+
setState={setState}
58+
migrating={<Text>Migrating</Text>}
59+
ready={<Text>Ready</Text>}
60+
/>
61+
)
62+
63+
expect(renderer.toTree()?.rendered).toEqual(
64+
expect.objectContaining({
65+
props: expect.objectContaining({
66+
children: 'Migrating'
67+
})
68+
})
69+
)
70+
71+
await TestRenderer.act(async () => {
72+
await new Promise((resolve) => setTimeout(resolve, 250))
73+
})
74+
75+
expect(migrator.executionRequired).toHaveBeenCalledTimes(1)
76+
expect(migrator.execute).toHaveBeenCalledTimes(1)
77+
expect(migrator.execute).toHaveBeenCalledWith({ items: [1, 2, 3] })
78+
expect(setState).toHaveBeenCalledTimes(1)
79+
expect(setState).toHaveBeenCalledWith({ items: [4, 5, 6] })
80+
81+
renderer.unmount()
82+
83+
await TestRenderer.act(async () => {
84+
await new Promise((resolve) => setTimeout(resolve, 250))
85+
})
86+
})
87+
88+
test('displays the ready screen when no migrations are required', async () => {
89+
const migrator: MigratorInterface<State> = {
90+
executionRequired: jest.fn().mockReturnValue(false),
91+
execute: jest.fn()
92+
}
93+
const MigratorManager = createMigratorManagerComponent(migrator)
94+
const setState = jest.fn()
95+
96+
const renderer = TestRenderer.create(
97+
<MigratorManager
98+
state={{ items: [1, 2, 3] }}
99+
setState={setState}
100+
migrating={<Text>Migrating</Text>}
101+
ready={<Text>Ready</Text>}
102+
/>
103+
)
104+
105+
expect(renderer.toTree()?.rendered).toEqual(
106+
expect.objectContaining({
107+
props: expect.objectContaining({
108+
children: 'Ready'
109+
})
110+
})
111+
)
112+
113+
renderer.unmount()
114+
115+
await TestRenderer.act(async () => {
116+
await new Promise((resolve) => setTimeout(resolve, 250))
117+
})
118+
119+
expect(setState).not.toHaveBeenCalled()
120+
expect(migrator.executionRequired).toHaveBeenCalledTimes(1)
121+
expect(migrator.executionRequired).toHaveBeenCalledWith({ items: [1, 2, 3] })
122+
expect(migrator.execute).not.toHaveBeenCalled()
123+
})

Diff for: react-native/services/Migrator/readme.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import { Migrator, MigratableState } from "react-native-app-helpers";
1010
type State = { readonly items: ReadonlyArray<number> }
1111

1212
const state: MigratableState<State> = {
13-
executedMigrationUuids: ['1b69c28f-454e-4511-aa05-596fe5ae23a8']
13+
executedMigrationUuids: ['1b69c28f-454e-4511-aa05-596fe5ae23a8'],
14+
items: [],
1415
}
1516

1617
const migrator = new Migrator<State>([

Diff for: readme.md

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { createTextComponent } from "react-native-app-helpers";
4242
- [createInputComponent](./react-native/components/createInputComponent/readme.md)
4343
- [createLimitedHeightComponent](./react-native/components/createLimitedHeightComponent/readme.md)
4444
- [createLimitedWidthComponent](./react-native/components/createLimitedWidthComponent/readme.md)
45+
- [createMigratorManagerComponent](./react-native/components/createMigratorManagerComponent/readme.md)
4546
- [createMinimumHeightComponent](./react-native/components/createMinimumHeightComponent/readme.md)
4647
- [createMinimumWidthComponent](./react-native/components/createMinimumWidthComponent/readme.md)
4748
- [createNullableEmailInputComponent](./react-native/components/createNullableEmailInputComponent/readme.md)

0 commit comments

Comments
 (0)