Skip to content

Commit 20b8e6e

Browse files
Serialize the stepper state into the URL, so that awaiting can be resumed across page loads
Keep track of the current stepper state (e.g. taskId, import completion, etc.) in the URL. Update the URL when the state changes, and initialize the state from the URL on page load. Note that we need to default to an "uninitialized" state, and then update the state from the URL via an `initialize_from_url` action, because the `useRouter` hook is ansynchronous, and we don't look at query parameters on the server side with `getInitialProps` or similar. Thus we can show a loading bar before showing the import form (or whatever we're showing based on the current state). This makes development easier, since after a long import we can refresh the page with the URL containing the task ID and start from there, rather than re-importing every time. And it also makes it easier for users who can refresh the page without losing progress if an import has already started (it will just poll the taskId from the URL).
1 parent 6c42ff9 commit 20b8e6e

5 files changed

Lines changed: 230 additions & 14 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { useStepper } from "./StepperContext";
2+
3+
export const DebugPanel = () => {
4+
const [state, _] = useStepper();
5+
6+
return (
7+
<div>
8+
<pre style={{ minWidth: "80%", minHeight: "300px" }}>
9+
{JSON.stringify(state, null, 2)}
10+
</pre>
11+
</div>
12+
);
13+
};

examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ExportTableLoadingBar.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,11 @@ export const ExportTableLoadingBar = ({
4747
completedTable: { tableName, taskId },
4848
});
4949
} else if (data.error) {
50-
throw new Error(data.error);
50+
if (!data.completed) {
51+
console.log("WARN: Failed status, not completed:", data.error);
52+
} else {
53+
throw new Error(data.error);
54+
}
5155
}
5256
} catch (error) {
5357
dispatch({

examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/Stepper.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
1-
import { StepperContextProvider } from "./StepperContext";
2-
import { ImportPanel } from "./ImportPanel"; // will create this component later
3-
import { ExportPanel } from "./ExportPanel"; // will create this component later
1+
import { StepperContextProvider, useStepper } from "./StepperContext";
2+
import { DebugPanel } from "./DebugPanel";
3+
import { ImportPanel } from "./ImportPanel";
4+
import { ExportPanel } from "./ExportPanel";
45

56
import styles from "./Stepper.module.css";
67

8+
const StepperOrLoading = ({ children }: { children: React.ReactNode }) => {
9+
const [{ stepperState }] = useStepper();
10+
11+
return (
12+
<>{stepperState === "uninitialized" ? <div>........</div> : children}</>
13+
);
14+
};
15+
716
export const Stepper = () => {
817
return (
918
<StepperContextProvider>
1019
<div className={styles.stepper}>
11-
<ImportPanel />
12-
<ExportPanel />
20+
<StepperOrLoading>
21+
<DebugPanel />
22+
<ImportPanel />
23+
<ExportPanel />
24+
</StepperOrLoading>
1325
</div>
1426
</StepperContextProvider>
1527
);

examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepperContext.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import React, { useReducer, useContext, ReactNode } from "react";
33
import {
44
StepperState,
55
StepperAction,
6-
initialState,
7-
stepperReducer,
6+
useStepperReducer,
87
} from "./stepper-states";
98

109
// Define the context
@@ -15,7 +14,7 @@ const StepperContext = React.createContext<
1514
export const StepperContextProvider: React.FC<{ children: ReactNode }> = ({
1615
children,
1716
}) => {
18-
const [state, dispatch] = useReducer(stepperReducer, initialState);
17+
const [state, dispatch] = useStepperReducer();
1918

2019
return (
2120
<StepperContext.Provider value={[state, dispatch]}>

examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/stepper-states.ts

Lines changed: 193 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
// stepper-states.ts
1+
import { useRouter, type NextRouter } from "next/router";
2+
import { ParsedUrlQuery } from "querystring";
3+
import { useEffect, useReducer } from "react";
24
export type GitHubRepository = { namespace: string; repository: string };
35

46
type ExportTable = { tableName: string; taskId: string };
57

68
export type StepperState = {
79
stepperState:
10+
| "uninitialized"
811
| "unstarted"
912
| "awaiting_import"
1013
| "import_complete"
@@ -34,9 +37,15 @@ export type StepperAction =
3437
| { type: "export_complete" }
3538
| { type: "export_error"; error: string }
3639
| { type: "import_error"; error: string }
37-
| { type: "reset" };
40+
| { type: "reset" }
41+
| { type: "initialize_from_url"; parsedFromUrl: StepperState };
3842

39-
export const initialState: StepperState = {
43+
type ExtractStepperAction<T extends StepperAction["type"]> = Extract<
44+
StepperAction,
45+
{ type: T }
46+
>;
47+
48+
const initialState: StepperState = {
4049
stepperState: "unstarted",
4150
repository: null,
4251
splitgraphRepository: null,
@@ -56,8 +65,128 @@ export const initialState: StepperState = {
5665
// splitgraphRepository: "import-via-nextjs",
5766
// };
5867

59-
// Reducer function
60-
export const stepperReducer = (
68+
type ActionParams<T extends StepperAction["type"]> = Omit<
69+
ExtractStepperAction<T>,
70+
"type"
71+
>;
72+
73+
const getQueryParamAsString = <T extends string = string>(
74+
query: ParsedUrlQuery,
75+
key: string
76+
): T | null => {
77+
if (Array.isArray(query[key]) && query[key].length > 0) {
78+
throw new Error(`expected only one query param but got multiple: ${key}`);
79+
}
80+
81+
if (!(key in query)) {
82+
return null;
83+
}
84+
85+
return query[key] as T;
86+
};
87+
88+
const queryParamParsers: {
89+
[K in keyof StepperState]: (query: ParsedUrlQuery) => StepperState[K];
90+
} = {
91+
stepperState: (query) =>
92+
getQueryParamAsString<StepperState["stepperState"]>(
93+
query,
94+
"stepperState"
95+
) ?? "unstarted",
96+
repository: (query) => ({
97+
namespace: getQueryParamAsString(query, "githubNamespace"),
98+
repository: getQueryParamAsString(query, "githubRepository"),
99+
}),
100+
importTaskId: (query) => getQueryParamAsString(query, "importTaskId"),
101+
importError: (query) => getQueryParamAsString(query, "importError"),
102+
exportError: (query) => getQueryParamAsString(query, "exportError"),
103+
splitgraphNamespace: (query) =>
104+
getQueryParamAsString(query, "splitgraphNamespace"),
105+
splitgraphRepository: (query) =>
106+
getQueryParamAsString(query, "splitgraphRepository"),
107+
};
108+
109+
const requireKeys = <T extends Record<string, unknown>>(
110+
obj: T,
111+
requiredKeys: (keyof T)[]
112+
) => {
113+
const missingKeys = requiredKeys.filter(
114+
(requiredKey) => !(requiredKey in obj)
115+
);
116+
117+
if (missingKeys.length > 0) {
118+
throw new Error("missing required keys: " + missingKeys.join(", "));
119+
}
120+
};
121+
122+
const stepperStateValidators: {
123+
[K in StepperState["stepperState"]]: (stateFromQuery: StepperState) => void;
124+
} = {
125+
uninitialized: () => {},
126+
unstarted: () => {},
127+
awaiting_import: (stateFromQuery) =>
128+
requireKeys(stateFromQuery, [
129+
"repository",
130+
"importTaskId",
131+
"splitgraphNamespace",
132+
"splitgraphRepository",
133+
]),
134+
import_complete: (stateFromQuery) =>
135+
requireKeys(stateFromQuery, [
136+
"repository",
137+
"splitgraphNamespace",
138+
"splitgraphRepository",
139+
]),
140+
awaiting_export: (stateFromQuery) =>
141+
requireKeys(stateFromQuery, [
142+
"repository",
143+
"splitgraphNamespace",
144+
"splitgraphRepository",
145+
]),
146+
export_complete: (stateFromQuery) =>
147+
requireKeys(stateFromQuery, [
148+
"repository",
149+
"splitgraphNamespace",
150+
"splitgraphRepository",
151+
]),
152+
};
153+
154+
const parseStateFromRouter = (router: NextRouter): StepperState => {
155+
const { query } = router;
156+
157+
const stepperState = queryParamParsers.stepperState(query);
158+
159+
const stepper = {
160+
stepperState: stepperState,
161+
repository: queryParamParsers.repository(query),
162+
importTaskId: queryParamParsers.importTaskId(query),
163+
importError: queryParamParsers.importError(query),
164+
exportError: queryParamParsers.exportError(query),
165+
splitgraphNamespace: queryParamParsers.splitgraphNamespace(query),
166+
splitgraphRepository: queryParamParsers.splitgraphRepository(query),
167+
};
168+
169+
void stepperStateValidators[stepperState](stepper);
170+
171+
return stepper;
172+
};
173+
174+
const serializeStateToQueryParams = (stepper: StepperState) => {
175+
return JSON.parse(
176+
JSON.stringify({
177+
stepperState: stepper.stepperState,
178+
githubNamespace: stepper.repository?.namespace ?? undefined,
179+
githubRepository: stepper.repository?.repository ?? undefined,
180+
importTaskId: stepper.importTaskId ?? undefined,
181+
importError: stepper.importError ?? undefined,
182+
exportError: stepper.exportError ?? undefined,
183+
splitgraphNamespace: stepper.splitgraphNamespace ?? undefined,
184+
splitgraphRepository: stepper.splitgraphRepository ?? undefined,
185+
})
186+
);
187+
};
188+
189+
const stepperReducer = (
61190
state: StepperState,
62191
action: StepperAction
63192
): StepperState => {
@@ -142,7 +271,66 @@ export const stepperReducer = (
142271
case "reset":
143272
return initialState;
144273

274+
case "initialize_from_url":
275+
return {
276+
...state,
277+
...action.parsedFromUrl,
278+
};
279+
145280
default:
146281
return state;
147282
}
148283
};
284+
285+
const urlNeedsChange = (state: StepperState, router: NextRouter) => {
286+
const parsedFromUrl = parseStateFromRouter(router);
287+
288+
return (
289+
state.stepperState !== parsedFromUrl.stepperState ||
290+
state.repository?.namespace !== parsedFromUrl.repository?.namespace ||
291+
state.repository?.repository !== parsedFromUrl.repository?.repository ||
292+
state.importTaskId !== parsedFromUrl.importTaskId ||
293+
state.splitgraphNamespace !== parsedFromUrl.splitgraphNamespace ||
294+
state.splitgraphRepository !== parsedFromUrl.splitgraphRepository
295+
);
296+
};
297+
298+
export const useStepperReducer = () => {
299+
const router = useRouter();
300+
const [state, dispatch] = useReducer(stepperReducer, {
301+
...initialState,
302+
stepperState: "uninitialized",
303+
});
304+
305+
useEffect(() => {
306+
dispatch({
307+
type: "initialize_from_url",
308+
parsedFromUrl: parseStateFromRouter(router),
309+
});
310+
}, [router.query]);
311+
312+
useEffect(() => {
313+
if (!urlNeedsChange(state, router)) {
314+
return;
315+
}
316+
317+
if (state.stepperState === "uninitialized") {
318+
return;
319+
}
320+
321+
console.log("push", {
322+
pathname: router.pathname,
323+
query: serializeStateToQueryParams(state),
324+
});
325+
router.push(
326+
{
327+
pathname: router.pathname,
328+
query: serializeStateToQueryParams(state),
329+
},
330+
undefined,
331+
{ shallow: true }
332+
);
333+
}, [state.stepperState]);
334+
335+
return [state, dispatch] as const;
336+
};

0 commit comments

Comments
 (0)