@learnpack/learnpack 5.0.355 → 5.0.357
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands/serve.d.ts +0 -17
- package/lib/commands/serve.js +159 -129
- package/lib/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
- package/lib/creatorDist/assets/index-zrPponAn.css +1701 -0
- package/lib/creatorDist/index.html +2 -2
- package/lib/models/creator.d.ts +1 -1
- package/lib/services/ingest/adapters/gcsCourseStorage.d.ts +13 -0
- package/lib/services/ingest/adapters/gcsCourseStorage.js +58 -0
- package/lib/services/ingest/adapters/rigoUserTokenAuth.d.ts +15 -0
- package/lib/services/ingest/adapters/rigoUserTokenAuth.js +46 -0
- package/lib/services/ingest/adapters/rigobotPackageRegistry.d.ts +7 -0
- package/lib/services/ingest/adapters/rigobotPackageRegistry.js +119 -0
- package/lib/services/ingest/core/buildIngestConfig.d.ts +27 -0
- package/lib/services/ingest/core/buildIngestConfig.js +40 -0
- package/lib/services/ingest/core/buildInitialSyllabus.d.ts +24 -0
- package/lib/services/ingest/core/buildInitialSyllabus.js +104 -0
- package/lib/services/ingest/core/buildSidebar.d.ts +13 -0
- package/lib/services/ingest/core/buildSidebar.js +28 -0
- package/lib/services/ingest/core/coursePaths.d.ts +31 -0
- package/lib/services/ingest/core/coursePaths.js +50 -0
- package/lib/services/ingest/core/ingestCoursePackage.d.ts +52 -0
- package/lib/services/ingest/core/ingestCoursePackage.js +217 -0
- package/lib/services/ingest/core/normalizeLearnJson.d.ts +25 -0
- package/lib/services/ingest/core/normalizeLearnJson.js +29 -0
- package/lib/services/ingest/core/pairLessons.d.ts +29 -0
- package/lib/services/ingest/core/pairLessons.js +102 -0
- package/lib/services/ingest/core/planWrites.d.ts +44 -0
- package/lib/services/ingest/core/planWrites.js +59 -0
- package/lib/services/ingest/core/resolveSlug.d.ts +53 -0
- package/lib/services/ingest/core/resolveSlug.js +93 -0
- package/lib/services/ingest/core/types.d.ts +53 -0
- package/lib/services/ingest/core/types.js +2 -0
- package/lib/services/ingest/core/validatePackage.d.ts +38 -0
- package/lib/services/ingest/core/validatePackage.js +97 -0
- package/lib/services/ingest/createIngestService.d.ts +34 -0
- package/lib/services/ingest/createIngestService.js +38 -0
- package/lib/services/ingest/errors.d.ts +22 -0
- package/lib/services/ingest/errors.js +34 -0
- package/lib/services/ingest/http/errorMapping.d.ts +8 -0
- package/lib/services/ingest/http/errorMapping.js +40 -0
- package/lib/services/ingest/http/router.d.ts +32 -0
- package/lib/services/ingest/http/router.js +124 -0
- package/lib/services/ingest/http/zipPackageReader.d.ts +26 -0
- package/lib/services/ingest/http/zipPackageReader.js +62 -0
- package/lib/services/ingest/ports/courseStorage.d.ts +25 -0
- package/lib/services/ingest/ports/courseStorage.js +2 -0
- package/lib/services/ingest/ports/packageRegistry.d.ts +58 -0
- package/lib/services/ingest/ports/packageRegistry.js +2 -0
- package/lib/services/ingest/ports/requestAuthenticator.d.ts +25 -0
- package/lib/services/ingest/ports/requestAuthenticator.js +2 -0
- package/lib/utils/coursePackage/bucketIo.d.ts +23 -0
- package/lib/utils/coursePackage/bucketIo.js +36 -0
- package/lib/utils/coursePackage/learnJson.d.ts +26 -0
- package/lib/utils/coursePackage/learnJson.js +36 -0
- package/lib/utils/coursePackage/sidebar.d.ts +15 -0
- package/lib/utils/coursePackage/sidebar.js +26 -0
- package/lib/utils/creatorSocket.js +15 -21
- package/lib/utils/gcpCredentials.d.ts +18 -0
- package/lib/utils/gcpCredentials.js +27 -0
- package/lib/utils/rigoActions.d.ts +16 -0
- package/lib/utils/rigoActions.js +102 -1
- package/lib/utils/socketRegistry.d.ts +46 -0
- package/lib/utils/socketRegistry.js +83 -0
- package/package.json +1 -1
- package/src/commands/serve.ts +162 -114
- package/src/creator/README.md +63 -51
- package/src/creator/package-lock.json +1188 -8
- package/src/creator/package.json +5 -1
- package/src/creator/src/App.tsx +120 -54
- package/src/creator/src/components/FileUploader.tsx +1 -12
- package/src/creator/src/components/NotificationListener.tsx +1 -8
- package/src/creator/src/components/syllabus/SyllabusEditor.tsx +101 -36
- package/src/creator/src/locales/en.json +8 -0
- package/src/creator/src/locales/es.json +8 -0
- package/src/creator/src/utils/completionResult.test.ts +144 -0
- package/src/creator/src/utils/completionResult.ts +154 -0
- package/src/creator/src/utils/constants.ts +17 -4
- package/src/creator/src/utils/socket.test.ts +113 -0
- package/src/creator/src/utils/socket.ts +60 -37
- package/src/creator/src/utils/store.ts +84 -67
- package/src/creator/src/utils/useCompletionWatchdog.test.ts +99 -0
- package/src/creator/src/utils/useCompletionWatchdog.ts +37 -0
- package/src/creator/vitest.config.ts +16 -0
- package/src/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
- package/src/creatorDist/assets/index-zrPponAn.css +1701 -0
- package/src/creatorDist/index.html +2 -2
- package/src/models/creator.ts +1 -1
- package/src/services/ingest/adapters/gcsCourseStorage.ts +70 -0
- package/src/services/ingest/adapters/rigoUserTokenAuth.ts +65 -0
- package/src/services/ingest/adapters/rigobotPackageRegistry.ts +161 -0
- package/src/services/ingest/core/buildIngestConfig.ts +51 -0
- package/src/services/ingest/core/buildInitialSyllabus.ts +122 -0
- package/src/services/ingest/core/buildSidebar.ts +32 -0
- package/src/services/ingest/core/coursePaths.ts +59 -0
- package/src/services/ingest/core/ingestCoursePackage.ts +343 -0
- package/src/services/ingest/core/normalizeLearnJson.ts +47 -0
- package/src/services/ingest/core/pairLessons.ts +132 -0
- package/src/services/ingest/core/planWrites.ts +91 -0
- package/src/services/ingest/core/resolveSlug.ts +119 -0
- package/src/services/ingest/core/types.ts +57 -0
- package/src/services/ingest/core/validatePackage.ts +132 -0
- package/src/services/ingest/createIngestService.ts +66 -0
- package/src/services/ingest/errors.ts +45 -0
- package/src/services/ingest/http/errorMapping.ts +49 -0
- package/src/services/ingest/http/router.ts +156 -0
- package/src/services/ingest/http/zipPackageReader.ts +81 -0
- package/src/services/ingest/ports/courseStorage.ts +25 -0
- package/src/services/ingest/ports/packageRegistry.ts +60 -0
- package/src/services/ingest/ports/requestAuthenticator.ts +28 -0
- package/src/ui/_app/app.js +325 -325
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/coursePackage/bucketIo.ts +46 -0
- package/src/utils/coursePackage/learnJson.ts +37 -0
- package/src/utils/coursePackage/sidebar.ts +28 -0
- package/src/utils/creatorSocket.ts +16 -22
- package/src/utils/gcpCredentials.ts +38 -0
- package/src/utils/rigoActions.ts +163 -0
- package/src/utils/socketRegistry.ts +85 -0
- package/lib/creatorDist/assets/index-CjddKHB_.css +0 -1
- package/src/creatorDist/assets/index-CjddKHB_.css +0 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interpretation of the completion payloads Rigobot delivers over the socket.
|
|
3
|
+
*
|
|
4
|
+
* Course generation is asynchronous: `publicInteractiveCreation` gets a 201 back
|
|
5
|
+
* with a pending job, and the real outcome arrives later through the webhook the
|
|
6
|
+
* CLI relays. A 2xx on the initial POST therefore says nothing about success —
|
|
7
|
+
* the job can still fail against the LLM provider and reach us as
|
|
8
|
+
* `{ status: "ERROR", status_text: "...", parsed: null }`.
|
|
9
|
+
*
|
|
10
|
+
* Every consumer used to read `payload.parsed.listOfSteps` directly, which threw
|
|
11
|
+
* a TypeError inside the socket.io listener and left the UI on its loading
|
|
12
|
+
* screen forever. Everything now goes through `interpretCompletionPayload`, which
|
|
13
|
+
* never throws and always returns a branch the caller has to handle.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type TCompletionErrorCode =
|
|
17
|
+
| "QUOTA_EXCEEDED"
|
|
18
|
+
| "RATE_LIMITED"
|
|
19
|
+
| "PROVIDER_UNAVAILABLE"
|
|
20
|
+
| "MALFORMED_RESPONSE"
|
|
21
|
+
| "TIMEOUT"
|
|
22
|
+
| "UNKNOWN";
|
|
23
|
+
|
|
24
|
+
/** The shape the syllabus screens consume, with every field guaranteed present. */
|
|
25
|
+
export type TSyllabusPayload = {
|
|
26
|
+
listOfSteps: string[];
|
|
27
|
+
title: string;
|
|
28
|
+
description: string;
|
|
29
|
+
languageCode: string;
|
|
30
|
+
technologies: string[];
|
|
31
|
+
aiMessage: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type TCompletionResult =
|
|
35
|
+
| { ok: true; parsed: TSyllabusPayload }
|
|
36
|
+
| { ok: false; code: TCompletionErrorCode; detail: string };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* i18n key per failure code.
|
|
40
|
+
*
|
|
41
|
+
* Declared as a total Record rather than a lookup with a fallback: adding a code
|
|
42
|
+
* to the union without deciding what the user is told then fails the build,
|
|
43
|
+
* instead of quietly rendering a raw key or an empty toast.
|
|
44
|
+
*/
|
|
45
|
+
export const COMPLETION_ERROR_I18N_KEY: Record<TCompletionErrorCode, string> = {
|
|
46
|
+
QUOTA_EXCEEDED: "completionError.quotaExceeded",
|
|
47
|
+
RATE_LIMITED: "completionError.rateLimited",
|
|
48
|
+
PROVIDER_UNAVAILABLE: "completionError.providerUnavailable",
|
|
49
|
+
MALFORMED_RESPONSE: "completionError.malformedResponse",
|
|
50
|
+
TIMEOUT: "completionError.timeout",
|
|
51
|
+
UNKNOWN: "completionError.unknown",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Which failure a Rigobot `status_text` describes.
|
|
56
|
+
*
|
|
57
|
+
* The provider errors reach us as the string body of an OpenAI-compatible SDK
|
|
58
|
+
* exception, so matching is by substring and deliberately loose. The quota case
|
|
59
|
+
* is the one worth naming: it is not transient, and telling the user to retry
|
|
60
|
+
* would be wrong.
|
|
61
|
+
* @param statusText - The `status_text` field of an errored completion job.
|
|
62
|
+
* @returns The failure code the message describes.
|
|
63
|
+
*/
|
|
64
|
+
const classifyStatusText = (statusText: string): TCompletionErrorCode => {
|
|
65
|
+
const text = statusText.toLowerCase();
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
text.includes("permission-denied") ||
|
|
69
|
+
text.includes("credit") ||
|
|
70
|
+
text.includes("spending limit") ||
|
|
71
|
+
text.includes("quota")
|
|
72
|
+
) {
|
|
73
|
+
return "QUOTA_EXCEEDED";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (text.includes("rate limit") || text.includes("429")) {
|
|
77
|
+
return "RATE_LIMITED";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (
|
|
81
|
+
text.includes("unavailable") ||
|
|
82
|
+
text.includes("timeout") ||
|
|
83
|
+
text.includes("timed out") ||
|
|
84
|
+
/\b5\d{2}\b/.test(text)
|
|
85
|
+
) {
|
|
86
|
+
return "PROVIDER_UNAVAILABLE";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return "UNKNOWN";
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const asString = (value: unknown): string =>
|
|
93
|
+
typeof value === "string" ? value : "";
|
|
94
|
+
|
|
95
|
+
const asStringArray = (value: unknown): string[] =>
|
|
96
|
+
Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Turns a raw notification payload into a result the caller must branch on.
|
|
100
|
+
*
|
|
101
|
+
* Never throws: anything unrecognisable comes back as `MALFORMED_RESPONSE`
|
|
102
|
+
* rather than as an exception inside a socket listener, where nothing catches it.
|
|
103
|
+
* @param payload - Whatever arrived over the socket.
|
|
104
|
+
* @returns The parsed syllabus, or the failure to report.
|
|
105
|
+
*/
|
|
106
|
+
export function interpretCompletionPayload(
|
|
107
|
+
payload: unknown
|
|
108
|
+
): TCompletionResult {
|
|
109
|
+
if (typeof payload !== "object" || payload === null) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
code: "MALFORMED_RESPONSE",
|
|
113
|
+
detail: `Expected an object, received ${typeof payload}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const job = payload as Record<string, unknown>;
|
|
118
|
+
|
|
119
|
+
if (job.status === "ERROR") {
|
|
120
|
+
const detail = asString(job.status_text) || "No status text provided";
|
|
121
|
+
return { ok: false, code: classifyStatusText(detail), detail };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const parsed = job.parsed;
|
|
125
|
+
|
|
126
|
+
// Also covers a SUCCESS job whose output degenerated: the syllabus screens
|
|
127
|
+
// cannot render without a list of steps, so it is a malformed response
|
|
128
|
+
// regardless of what the job status claims.
|
|
129
|
+
if (
|
|
130
|
+
typeof parsed !== "object" ||
|
|
131
|
+
parsed === null ||
|
|
132
|
+
!Array.isArray((parsed as Record<string, unknown>).listOfSteps)
|
|
133
|
+
) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
code: "MALFORMED_RESPONSE",
|
|
137
|
+
detail: "The completion carried no list of steps",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const fields = parsed as Record<string, unknown>;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
parsed: {
|
|
146
|
+
listOfSteps: asStringArray(fields.listOfSteps),
|
|
147
|
+
title: asString(fields.title),
|
|
148
|
+
description: asString(fields.description),
|
|
149
|
+
languageCode: asString(fields.languageCode),
|
|
150
|
+
technologies: asStringArray(fields.technologies),
|
|
151
|
+
aiMessage: asString(fields.aiMessage),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -1,13 +1,26 @@
|
|
|
1
|
-
export const DEV_MODE = false
|
|
1
|
+
export const DEV_MODE = false;
|
|
2
2
|
|
|
3
3
|
export const RIGOBOT_HOST = DEV_MODE
|
|
4
4
|
? "https://rigobot-test-cca7d841c9d8.herokuapp.com"
|
|
5
5
|
: // "https://8000-charlytoc-rigobot-bmwdeam7cev.ws-us120.gitpod.io"
|
|
6
|
-
"https://rigobot.herokuapp.com"
|
|
6
|
+
"https://rigobot.herokuapp.com";
|
|
7
7
|
|
|
8
8
|
export const BREATHECODE_HOST = DEV_MODE
|
|
9
9
|
? "https://breathecode-test.herokuapp.com"
|
|
10
|
-
: "https://breathecode.herokuapp.com"
|
|
10
|
+
: "https://breathecode.herokuapp.com";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How long to wait for a completion notification before giving up.
|
|
14
|
+
*
|
|
15
|
+
* The trade-off runs both ways. Too long and a user whose notification is never
|
|
16
|
+
* coming stares at a spinner; too short and a slow but healthy job is reported
|
|
17
|
+
* as a failure, because giving up also unsubscribes — a result arriving after
|
|
18
|
+
* the deadline is lost even though the server produced it.
|
|
19
|
+
*
|
|
20
|
+
* A minute is the agreed ceiling. If timeouts start showing up on generations
|
|
21
|
+
* that actually succeed, raise this rather than making the user retry.
|
|
22
|
+
*/
|
|
23
|
+
export const COMPLETION_TIMEOUT_MS = 60_000;
|
|
11
24
|
|
|
12
25
|
export const RIGO_FLOAT_GIF =
|
|
13
|
-
"https://raw.githubusercontent.com/learnpack/ide/20ed3f4c3ead9b33d5d6acb20154dcd93a0ec4af/public/rigo-float.gif"
|
|
26
|
+
"https://raw.githubusercontent.com/learnpack/ide/20ed3f4c3ead9b33d5d6acb20154dcd93a0ec4af/public/rigo-float.gif";
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => {
|
|
4
|
+
const created: Array<Record<string, ReturnType<typeof vi.fn>>> = [];
|
|
5
|
+
|
|
6
|
+
const io = vi.fn(() => {
|
|
7
|
+
const socket = {
|
|
8
|
+
on: vi.fn(),
|
|
9
|
+
off: vi.fn(),
|
|
10
|
+
emit: vi.fn(),
|
|
11
|
+
connect: vi.fn(),
|
|
12
|
+
disconnect: vi.fn(),
|
|
13
|
+
};
|
|
14
|
+
created.push(socket);
|
|
15
|
+
return socket;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return { io, created };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
vi.mock("socket.io-client", () => ({ io: mocks.io }));
|
|
22
|
+
|
|
23
|
+
import CreatorSocket from "./socket";
|
|
24
|
+
|
|
25
|
+
const lastSocket = () => mocks.created[mocks.created.length - 1];
|
|
26
|
+
|
|
27
|
+
describe("CreatorSocket", () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
mocks.io.mockClear();
|
|
30
|
+
mocks.created.length = 0;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("opens one connection and reuses it for further subscribers", () => {
|
|
34
|
+
const client = new CreatorSocket("");
|
|
35
|
+
|
|
36
|
+
client.subscribe("notification-1", vi.fn());
|
|
37
|
+
client.subscribe("notification-2", vi.fn());
|
|
38
|
+
|
|
39
|
+
expect(mocks.io).toHaveBeenCalledTimes(1);
|
|
40
|
+
expect(client.subscriptionCount()).toBe(2);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("registers the channel with the server", () => {
|
|
44
|
+
const client = new CreatorSocket("");
|
|
45
|
+
const callback = vi.fn();
|
|
46
|
+
|
|
47
|
+
client.subscribe("notification-1", callback);
|
|
48
|
+
|
|
49
|
+
expect(lastSocket().on).toHaveBeenCalledWith("notification-1", callback);
|
|
50
|
+
expect(lastSocket().emit).toHaveBeenCalledWith("registerNotification", {
|
|
51
|
+
notificationId: "notification-1",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// The bug: disconnect() used to close the shared socket for everybody, so
|
|
56
|
+
// unmounting one uploaded-file card killed the subscription the wizard was
|
|
57
|
+
// waiting on.
|
|
58
|
+
it("keeps the connection open while another subscriber remains", () => {
|
|
59
|
+
const client = new CreatorSocket("");
|
|
60
|
+
const releaseFirst = client.subscribe("notification-1", vi.fn());
|
|
61
|
+
client.subscribe("notification-2", vi.fn());
|
|
62
|
+
|
|
63
|
+
releaseFirst();
|
|
64
|
+
|
|
65
|
+
expect(lastSocket().off).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(lastSocket().disconnect).not.toHaveBeenCalled();
|
|
67
|
+
expect(client.subscriptionCount()).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("closes the connection when the last subscriber leaves", () => {
|
|
71
|
+
const client = new CreatorSocket("");
|
|
72
|
+
const releaseFirst = client.subscribe("notification-1", vi.fn());
|
|
73
|
+
const releaseSecond = client.subscribe("notification-2", vi.fn());
|
|
74
|
+
|
|
75
|
+
releaseFirst();
|
|
76
|
+
releaseSecond();
|
|
77
|
+
|
|
78
|
+
expect(lastSocket().disconnect).toHaveBeenCalledTimes(1);
|
|
79
|
+
expect(client.subscriptionCount()).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("ignores a repeated unsubscribe instead of miscounting", () => {
|
|
83
|
+
const client = new CreatorSocket("");
|
|
84
|
+
const release = client.subscribe("notification-1", vi.fn());
|
|
85
|
+
client.subscribe("notification-2", vi.fn());
|
|
86
|
+
|
|
87
|
+
release();
|
|
88
|
+
release();
|
|
89
|
+
|
|
90
|
+
expect(client.subscriptionCount()).toBe(1);
|
|
91
|
+
expect(lastSocket().disconnect).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("opens a fresh connection after the previous one was closed", () => {
|
|
95
|
+
const client = new CreatorSocket("");
|
|
96
|
+
|
|
97
|
+
client.subscribe("notification-1", vi.fn())();
|
|
98
|
+
client.subscribe("notification-2", vi.fn());
|
|
99
|
+
|
|
100
|
+
expect(mocks.io).toHaveBeenCalledTimes(2);
|
|
101
|
+
expect(client.subscriptionCount()).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("does not connect for an empty notification id", () => {
|
|
105
|
+
const client = new CreatorSocket("");
|
|
106
|
+
|
|
107
|
+
const release = client.subscribe("", vi.fn());
|
|
108
|
+
|
|
109
|
+
expect(mocks.io).not.toHaveBeenCalled();
|
|
110
|
+
expect(client.subscriptionCount()).toBe(0);
|
|
111
|
+
expect(() => release()).not.toThrow();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -1,61 +1,84 @@
|
|
|
1
|
-
import { io, Socket } from "socket.io-client"
|
|
1
|
+
import { io, Socket } from "socket.io-client";
|
|
2
2
|
|
|
3
|
-
type EventCallback = (...args: any[]) => void
|
|
3
|
+
type EventCallback = (...args: any[]) => void;
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* A socket.io connection shared by every notification listener in the app.
|
|
7
|
+
*
|
|
8
|
+
* The connection is shared but its lifetime is not owned by any one caller,
|
|
9
|
+
* which the previous API got wrong: `disconnect()` tore down the socket for
|
|
10
|
+
* everybody, so unmounting one uploaded-file card killed the subscription the
|
|
11
|
+
* wizard was waiting on. Callers now get an unsubscribe function that only
|
|
12
|
+
* releases their own listener, and the socket closes when the last one goes.
|
|
13
|
+
*/
|
|
5
14
|
class CreatorSocket {
|
|
6
|
-
private socket: Socket | null = null
|
|
7
|
-
|
|
15
|
+
private socket: Socket | null = null;
|
|
16
|
+
|
|
17
|
+
private subscriptions = 0;
|
|
18
|
+
|
|
19
|
+
private readonly url: string;
|
|
8
20
|
|
|
9
21
|
constructor(url: string) {
|
|
10
|
-
this.url = url
|
|
22
|
+
this.url = url;
|
|
11
23
|
}
|
|
12
24
|
|
|
13
25
|
/**
|
|
14
|
-
*
|
|
26
|
+
* Listens for one notification, connecting on the first subscriber.
|
|
27
|
+
* @param notificationId - Channel to join; also the event name the server emits.
|
|
28
|
+
* @param callback - Called with the notification payload.
|
|
29
|
+
* @returns Releases this subscription, and the socket once it is the last one.
|
|
15
30
|
*/
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
31
|
+
subscribe(notificationId: string, callback: EventCallback): () => void {
|
|
32
|
+
if (!notificationId) {
|
|
33
|
+
return () => {};
|
|
34
|
+
}
|
|
21
35
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
disconnect() {
|
|
26
|
-
if (this.socket) {
|
|
27
|
-
this.socket.disconnect()
|
|
28
|
-
this.socket = null
|
|
36
|
+
if (!this.socket) {
|
|
37
|
+
this.socket = io(this.url, { autoConnect: false, path: "/sockete" });
|
|
38
|
+
this.socket.connect();
|
|
29
39
|
}
|
|
30
|
-
}
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
isConnected(): boolean {
|
|
36
|
-
return !!this.socket?.connected
|
|
37
|
-
}
|
|
41
|
+
// Captured so a late unsubscribe cannot act on a socket that has since been
|
|
42
|
+
// replaced by a newer connection.
|
|
43
|
+
const socket = this.socket;
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
socket.on(notificationId, callback);
|
|
46
|
+
socket.emit("registerNotification", { notificationId });
|
|
47
|
+
this.subscriptions += 1;
|
|
48
|
+
|
|
49
|
+
let released = false;
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
if (released) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
released = true;
|
|
56
|
+
|
|
57
|
+
socket.off(notificationId, callback);
|
|
58
|
+
this.subscriptions -= 1;
|
|
59
|
+
|
|
60
|
+
if (this.subscriptions === 0 && this.socket === socket) {
|
|
61
|
+
socket.disconnect();
|
|
62
|
+
this.socket = null;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
44
65
|
}
|
|
45
66
|
|
|
46
67
|
/**
|
|
47
|
-
*
|
|
68
|
+
* Whether the shared connection is currently open.
|
|
69
|
+
* @returns True while connected.
|
|
48
70
|
*/
|
|
49
|
-
|
|
50
|
-
this.socket?.
|
|
71
|
+
isConnected(): boolean {
|
|
72
|
+
return Boolean(this.socket?.connected);
|
|
51
73
|
}
|
|
52
74
|
|
|
53
75
|
/**
|
|
54
|
-
*
|
|
76
|
+
* How many live subscriptions the connection is serving.
|
|
77
|
+
* @returns The current subscriber count.
|
|
55
78
|
*/
|
|
56
|
-
|
|
57
|
-
this.
|
|
79
|
+
subscriptionCount(): number {
|
|
80
|
+
return this.subscriptions;
|
|
58
81
|
}
|
|
59
82
|
}
|
|
60
83
|
|
|
61
|
-
export default CreatorSocket
|
|
84
|
+
export default CreatorSocket;
|
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
import { create } from "zustand"
|
|
2
|
-
import { persist } from "zustand/middleware"
|
|
3
|
-
import { Lesson } from "../components/LessonItem"
|
|
4
|
-
import { ParsedFile } from "../components/FileUploader"
|
|
5
|
-
import { TMessage } from "../components/Message"
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { persist } from "zustand/middleware";
|
|
3
|
+
import { Lesson } from "../components/LessonItem";
|
|
4
|
+
import { ParsedFile } from "../components/FileUploader";
|
|
5
|
+
import { TMessage } from "../components/Message";
|
|
6
6
|
// import { ParsedLink } from "../components/LinkUploader"
|
|
7
|
-
export type TDifficulty = "easy" | "beginner" | "intermediate" | "hard"
|
|
7
|
+
export type TDifficulty = "easy" | "beginner" | "intermediate" | "hard";
|
|
8
8
|
|
|
9
9
|
export type FormState = {
|
|
10
|
-
description: string
|
|
11
|
-
duration: number
|
|
12
|
-
hasContentIndex: boolean
|
|
13
|
-
contentIndex: string
|
|
14
|
-
purpose: string
|
|
15
|
-
difficulty: TDifficulty
|
|
16
|
-
slug: string
|
|
17
|
-
language?: string
|
|
18
|
-
isCompleted: boolean
|
|
19
|
-
variables: string[]
|
|
20
|
-
currentStep: string
|
|
21
|
-
title?: string
|
|
22
|
-
technologies?: string[]
|
|
23
|
-
}
|
|
10
|
+
description: string;
|
|
11
|
+
duration: number;
|
|
12
|
+
hasContentIndex: boolean;
|
|
13
|
+
contentIndex: string;
|
|
14
|
+
purpose: string;
|
|
15
|
+
difficulty: TDifficulty;
|
|
16
|
+
slug: string;
|
|
17
|
+
language?: string;
|
|
18
|
+
isCompleted: boolean;
|
|
19
|
+
variables: string[];
|
|
20
|
+
currentStep: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
technologies?: string[];
|
|
23
|
+
};
|
|
24
24
|
|
|
25
25
|
type Auth = {
|
|
26
|
-
bcToken: string
|
|
27
|
-
rigoToken: string
|
|
28
|
-
userId: string
|
|
29
|
-
user: any
|
|
30
|
-
publicToken: string
|
|
31
|
-
}
|
|
26
|
+
bcToken: string;
|
|
27
|
+
rigoToken: string;
|
|
28
|
+
userId: string;
|
|
29
|
+
user: any;
|
|
30
|
+
publicToken: string;
|
|
31
|
+
};
|
|
32
32
|
export type Syllabus = {
|
|
33
|
-
lessons: Lesson[]
|
|
34
|
-
courseInfo: FormState
|
|
33
|
+
lessons: Lesson[];
|
|
34
|
+
courseInfo: FormState;
|
|
35
35
|
// messages: TMessage[]
|
|
36
|
-
}
|
|
36
|
+
};
|
|
37
37
|
|
|
38
38
|
type Consumables = {
|
|
39
|
-
[key: string]: number
|
|
40
|
-
}
|
|
39
|
+
[key: string]: number;
|
|
40
|
+
};
|
|
41
41
|
|
|
42
42
|
type TTechnology = {
|
|
43
|
-
slug: string
|
|
44
|
-
lang: string
|
|
45
|
-
}
|
|
43
|
+
slug: string;
|
|
44
|
+
lang: string;
|
|
45
|
+
};
|
|
46
46
|
|
|
47
47
|
type Store = {
|
|
48
|
-
auth: Auth
|
|
49
|
-
formState: FormState
|
|
50
|
-
setFormState: (formState: Partial<FormState>) => void
|
|
51
|
-
resetFormState: () => void
|
|
52
|
-
setAuth: (auth: Auth) => void
|
|
48
|
+
auth: Auth;
|
|
49
|
+
formState: FormState;
|
|
50
|
+
setFormState: (formState: Partial<FormState>) => void;
|
|
51
|
+
resetFormState: () => void;
|
|
52
|
+
setAuth: (auth: Auth) => void;
|
|
53
53
|
// syllabus: Syllabus
|
|
54
|
-
planToRedirect: string
|
|
55
|
-
setPlanToRedirect: (planToRedirect: string) => void
|
|
56
|
-
uploadedFiles: ParsedFile[]
|
|
57
|
-
setUploadedFiles: (uploadedFiles: ParsedFile[]) => void
|
|
58
|
-
messages: TMessage[]
|
|
54
|
+
planToRedirect: string;
|
|
55
|
+
setPlanToRedirect: (planToRedirect: string) => void;
|
|
56
|
+
uploadedFiles: ParsedFile[];
|
|
57
|
+
setUploadedFiles: (uploadedFiles: ParsedFile[]) => void;
|
|
58
|
+
messages: TMessage[];
|
|
59
59
|
setMessages: (
|
|
60
60
|
messages: TMessage[] | ((prev: TMessage[]) => TMessage[])
|
|
61
|
-
) => void
|
|
62
|
-
cleanHistory: () => void
|
|
63
|
-
history: Syllabus[]
|
|
64
|
-
technologies: TTechnology[]
|
|
65
|
-
setTechnologies: (technologies: TTechnology[]) => void
|
|
66
|
-
undo: () => void
|
|
67
|
-
push: (syllabus: Syllabus) => void
|
|
68
|
-
cleanAll: () => void
|
|
61
|
+
) => void;
|
|
62
|
+
cleanHistory: () => void;
|
|
63
|
+
history: Syllabus[];
|
|
64
|
+
technologies: TTechnology[];
|
|
65
|
+
setTechnologies: (technologies: TTechnology[]) => void;
|
|
66
|
+
undo: () => void;
|
|
67
|
+
push: (syllabus: Syllabus) => void;
|
|
68
|
+
cleanAll: () => void;
|
|
69
69
|
// setSyllabus: (syllabus: Partial<Syllabus>) => void
|
|
70
|
-
consumables: Consumables
|
|
71
|
-
setConsumables: (consumables: Partial<Consumables>) => void
|
|
72
|
-
mode: "student" | "teacher"
|
|
73
|
-
setMode: (mode: "student" | "teacher") => void
|
|
74
|
-
}
|
|
70
|
+
consumables: Consumables;
|
|
71
|
+
setConsumables: (consumables: Partial<Consumables>) => void;
|
|
72
|
+
mode: "student" | "teacher";
|
|
73
|
+
setMode: (mode: "student" | "teacher") => void;
|
|
74
|
+
};
|
|
75
75
|
|
|
76
76
|
const useStore = create<Store>()(
|
|
77
77
|
persist(
|
|
@@ -117,7 +117,7 @@ const useStore = create<Store>()(
|
|
|
117
117
|
typeof messages === "function"
|
|
118
118
|
? messages(state.messages)
|
|
119
119
|
: messages,
|
|
120
|
-
}))
|
|
120
|
+
}));
|
|
121
121
|
},
|
|
122
122
|
setFormState: (formState: Partial<FormState>) =>
|
|
123
123
|
set((state) => ({ formState: { ...state.formState, ...formState } })),
|
|
@@ -154,18 +154,18 @@ const useStore = create<Store>()(
|
|
|
154
154
|
push: (syllabus: Syllabus) => {
|
|
155
155
|
set((state) => ({
|
|
156
156
|
history: [...state.history, syllabus],
|
|
157
|
-
}))
|
|
157
|
+
}));
|
|
158
158
|
},
|
|
159
159
|
|
|
160
160
|
undo: () => {
|
|
161
161
|
set((state) => {
|
|
162
162
|
return {
|
|
163
163
|
history: state.history.slice(0, -1),
|
|
164
|
-
}
|
|
165
|
-
})
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
166
|
},
|
|
167
167
|
cleanHistory: () => {
|
|
168
|
-
set(() => ({ history: [] }))
|
|
168
|
+
set(() => ({ history: [] }));
|
|
169
169
|
},
|
|
170
170
|
cleanAll: () => {
|
|
171
171
|
set({
|
|
@@ -193,20 +193,20 @@ const useStore = create<Store>()(
|
|
|
193
193
|
],
|
|
194
194
|
currentStep: "description",
|
|
195
195
|
},
|
|
196
|
-
})
|
|
196
|
+
});
|
|
197
197
|
},
|
|
198
198
|
consumables: {},
|
|
199
199
|
setConsumables: (consumables: Partial<Consumables>) =>
|
|
200
200
|
set((state) => {
|
|
201
201
|
const sanitized: Consumables = Object.fromEntries(
|
|
202
202
|
Object.entries(consumables).map(([k, v]) => [k, v ?? 0])
|
|
203
|
-
)
|
|
203
|
+
);
|
|
204
204
|
return {
|
|
205
205
|
consumables: {
|
|
206
206
|
...state.consumables,
|
|
207
207
|
...sanitized,
|
|
208
208
|
},
|
|
209
|
-
}
|
|
209
|
+
};
|
|
210
210
|
}),
|
|
211
211
|
|
|
212
212
|
setAuth: (auth: Auth) => set({ auth }),
|
|
@@ -215,8 +215,25 @@ const useStore = create<Store>()(
|
|
|
215
215
|
}),
|
|
216
216
|
{
|
|
217
217
|
name: "syllabus-storage",
|
|
218
|
+
/**
|
|
219
|
+
* Never resume a page load already claiming the wizard is finished.
|
|
220
|
+
*
|
|
221
|
+
* `formState.isCompleted` drives an effect in App.tsx that fires course
|
|
222
|
+
* generation. Persisting it meant that a generation which failed without
|
|
223
|
+
* clearing the flag came back on every reload and fired again, so the user
|
|
224
|
+
* could not escape the loading screen without wiping localStorage.
|
|
225
|
+
*
|
|
226
|
+
* Handled on rehydration rather than with `partialize` because that only
|
|
227
|
+
* governs what gets written: users whose storage already holds a stuck
|
|
228
|
+
* `true` need it cleared on the way in.
|
|
229
|
+
*/
|
|
230
|
+
onRehydrateStorage: () => (state) => {
|
|
231
|
+
if (state?.formState.isCompleted) {
|
|
232
|
+
state.formState.isCompleted = false;
|
|
233
|
+
}
|
|
234
|
+
},
|
|
218
235
|
}
|
|
219
236
|
)
|
|
220
|
-
)
|
|
237
|
+
);
|
|
221
238
|
|
|
222
|
-
export default useStore
|
|
239
|
+
export default useStore;
|