diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8159a93..ac4d04dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,5 +46,8 @@ jobs: - name: Repeating edit-scope tests run: npm run test:repeating-scope + - name: Join-class ref tests + run: npm run test:join-class + - name: Build run: npm run build diff --git a/.gitignore b/.gitignore index 448815d5..0f8167e6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules /server # local env files +.env .env.local .env.*.local diff --git a/package.json b/package.json index 2c3d0b75..87f5bc1e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:me-board": "node --experimental-strip-types scripts/test-me-board.mjs", "test:acting-as": "node --experimental-strip-types scripts/test-acting-as.mjs", "test:repeating-scope": "node --experimental-strip-types scripts/test-repeating-scope.mjs", + "test:join-class": "node --experimental-strip-types scripts/test-join-class.mjs", "deploy": "node deploy.js", "postbuild": "cross-env OS_TYPE=$(uname -s) npm-run-all --parallel copy-files echo-message", "copy-files": "npm run copy-win || npm run copy-nix", diff --git a/scripts/test-join-class.mjs b/scripts/test-join-class.mjs new file mode 100644 index 00000000..1adaae77 --- /dev/null +++ b/scripts/test-join-class.mjs @@ -0,0 +1,83 @@ +/** + * Unit tests for P4 join-ref → bare classId resolution (Add Class / Onboarding). + * Run: node --experimental-strip-types scripts/test-join-class.mjs + * (imports parseJoinRef from ../src/common/paths.ts) + */ +import { parseJoinRef } from "../src/common/paths.ts"; + +let failed = 0; + +function assert(cond, msg) { + if (!cond) { + failed++; + console.error("FAIL:", msg); + } else { + console.log("ok:", msg); + } +} + +function assertEq(actual, expected, label) { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (!ok) { + failed++; + console.error( + `FAIL: ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}` + ); + } else { + console.log(`ok: ${label}`); + } +} + +const org = "mvla.net"; +const orgAt = "@mvla.net"; + +console.log("--- parseJoinRef (P4 flat enroll) ---\n"); + +assertEq(parseJoinRef(null, org), null, "null → null"); +assertEq(parseJoinRef("", org), null, "empty → null"); +assertEq(parseJoinRef(" ", org), null, "whitespace → null"); + +assertEq( + parseJoinRef("abc123", org), + { classId: "abc123", needsTeacherLookup: true }, + "bare classId needs teacher dual-read" +); + +assertEq( + parseJoinRef("t@mvla.net/abc123", org), + { classId: "abc123", teacherEmail: "t@mvla.net", needsTeacherLookup: false }, + "email/classId → bare classId + teacher" +); + +assertEq( + parseJoinRef("t@mvla.net~abc123", org), + { classId: "abc123", teacherEmail: "t@mvla.net", needsTeacherLookup: false }, + "email~classId → bare classId + teacher" +); + +assertEq( + parseJoinRef("t~abc123", org), + { classId: "abc123", teacherEmail: "t@mvla.net", needsTeacherLookup: false }, + "local~classId expands org domain" +); + +assertEq( + parseJoinRef("t~abc123", orgAt), + { classId: "abc123", teacherEmail: "t@mvla.net", needsTeacherLookup: false }, + "local~classId with @orgDomain store form" +); + +assertEq( + parseJoinRef(" teacher@mvla.net/cls9 ", org), + { classId: "cls9", teacherEmail: "teacher@mvla.net", needsTeacherLookup: false }, + "trims whitespace around join ref" +); + +assert(parseJoinRef("only-id", org)?.needsTeacherLookup === true, "short ref flags lookup"); +assert(parseJoinRef("a@b.c/id", org)?.needsTeacherLookup === false, "prefixed ref skips lookup"); + +if (failed) { + console.error(`\n${failed} failure(s)`); + process.exit(1); +} +console.log("\nall join-class tests passed"); diff --git a/src/common/paths.ts b/src/common/paths.ts index d8c73798..f6764244 100644 --- a/src/common/paths.ts +++ b/src/common/paths.ts @@ -22,6 +22,22 @@ export interface ParsedClassPath { hasTeacherPrefix: boolean; } +/** + * P4 flat enroll: join-ref parts for Add Class / Onboarding. + * Prefer bare classId for enrollClass; dual-read when teacher is unknown. + */ +export interface JoinRefParts { + /** Bare classId for enrollClass({ classId }) / store.add_class. */ + classId: string; + /** Present when the join ref included a teacher email or local prefix. */ + teacherEmail?: string; + /** + * True for short bare classId refs — caller must dual-read (class_from_ref) + * to obtain teacher email before listing classes. + */ + needsTeacherLookup: boolean; +} + export interface ParsedTaskPath { teacherEmail?: string; classId: string; @@ -86,6 +102,34 @@ export function parseClassId( return null; } +/** + * P4: Parse a join ref (route or code→ref) into teacher + bare classId. + * Does not touch Firestore; async teacher lookup stays in AddClassForm. + * Accepts: classId | email/classId | local~classId (slash or ~). + * No nested classes/{email}/classes writers — bare classId enroll only. + */ +export function parseJoinRef( + ref: string | undefined | null, + orgDomain: string +): JoinRefParts | null { + if (!ref || typeof ref !== "string") return null; + const trimmed = ref.trim(); + if (!trimmed) return null; + + const parsed = parseClassId(trimmed, orgDomain); + if (!parsed?.classId) return null; + + if (!parsed.hasTeacherPrefix) { + return { classId: parsed.classId, needsTeacherLookup: true }; + } + + return { + classId: parsed.classId, + teacherEmail: parsed.teacherEmail, + needsTeacherLookup: false, + }; +} + /** * Parse task identity from slash id or ~ ref. * Accepts: classId/taskId | email/classId/taskId | local~classId~taskId | classId~taskId diff --git a/src/components/Portal/AddClassForm.vue b/src/components/Portal/AddClassForm.vue new file mode 100644 index 00000000..e7ba0c73 --- /dev/null +++ b/src/components/Portal/AddClassForm.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/src/views/Portal/AddClass.vue b/src/views/Portal/AddClass.vue index 954a48ed..4dbb5565 100644 --- a/src/views/Portal/AddClass.vue +++ b/src/views/Portal/AddClass.vue @@ -3,289 +3,31 @@ -
-
- {{ $store.loaded_email == teacher_email ? "Loaded" : "Loading" }} from your teacher's - class {{ code ? "code" : "ref" }} - Join a class with your teacher's details or - enter a class code - -
-
- - -
-
-
- {{ code || $route.params.ref || "" }} -
-
-
- {{ - class_obj && class_obj.is_joined && !adding - ? "You've already joined" - : "You'll be joining" - }} - {{ $store.class_text(class_obj) }} -
-
-
-
-
- If you're having trouble, ask your teacher to share their class code again using the - Share button on the class edit page - - Enter your teacher's email above to see their classes -
-
-
- -
- -
+ + - + diff --git a/src/views/Portal/Onboarding.vue b/src/views/Portal/Onboarding.vue index faf8223d..767d52d5 100644 --- a/src/views/Portal/Onboarding.vue +++ b/src/views/Portal/Onboarding.vue @@ -3,130 +3,30 @@ -
-
{{ name }} Let's get you set up with your first class
-
- - -
-
- You can add more classes later from the - Join a Class button on the left sidebar -
-
-
- -
- -
+ +