Skip to content

Commit 9ea7662

Browse files
committed
add embed query parameter for practice quiz (elearning v2025)
1 parent b5e1c7b commit 9ea7662

4 files changed

Lines changed: 197 additions & 35 deletions

File tree

AGENTS.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# AGENTS.md
2+
3+
This repository is a **pnpm + Turborepo monorepo** for **KlickerUZH v3**.
4+
5+
## Repo layout
6+
7+
- `apps/*` — deployable apps/services (frontends, backend services, Azure Functions)
8+
- `packages/*` — shared libraries (e.g. `graphql`, `prisma`, `util`)
9+
- `cypress/` — end-to-end tests
10+
11+
## Tooling / prerequisites
12+
13+
- **Node.js 20** (see `package.json` `engines` / `volta`)
14+
- **pnpm 10.x** (repo is pinned to `pnpm@10.15.0`)
15+
16+
## Common commands (run from repo root)
17+
18+
- Install dependencies: `pnpm install`
19+
- Format: `pnpm run format` / Check: `pnpm run format:check`
20+
- Lint: `pnpm run lint`
21+
- Typecheck (CI-style): `pnpm run check`
22+
- Build: `pnpm run build`
23+
24+
## Running a single workspace
25+
26+
Use filters:
27+
28+
- `pnpm --filter <workspace> <script>` (example: `pnpm --filter @klicker-uzh/graphql build`)
29+
30+
## Dev notes
31+
32+
Some dev scripts load secrets via `./util/_run_with_infisical.sh` (e.g. `pnpm run dev`). Avoid starting long-running dev servers or anything requiring credentials unless explicitly requested.
33+
34+
## Apps & URLs
35+
36+
Local development uses Traefik to route `*.klicker.com` hostnames to apps running on the host (see `util/traefik/rules_docker.yaml` / `util/traefik/rules_wsl.yaml`).
37+
38+
| URL | App / code | Local port | Notes |
39+
| ------------------------------------------- | ------------------------------------------- | ---------: | -------------------------------- |
40+
| https://pwa.klicker.com | Student PWA (`apps/frontend-pwa`) | 3001 | |
41+
| https://manage.klicker.com | Lecturer UI (`apps/frontend-manage`) | 3002 | |
42+
| https://control.klicker.com | Mobile controller (`apps/frontend-control`) | 3003 | |
43+
| https://chat.klicker.com | Chat UI (`apps/chat`) | 3004 | |
44+
| https://auth.klicker.com | Auth UI (`apps/auth`) | 3010 | |
45+
| https://api.klicker.com | Backend / GraphQL (`apps/backend-docker`) | 3000 | GraphQL endpoint: `/api/graphql` |
46+
| https://assessment.klicker.com | Assessment PWA (same service as PWA) | 3001 | |
47+
| https://assessment-api.klicker.com | Assessment API (same service as API) | 3000 | |
48+
| https://response-api.klicker.com | Response API (`apps/response-api`) | 7078 | |
49+
| https://response-api-assessment.klicker.com | Response API (assessment) | 7078 | |
50+
51+
If Traefik is not used, the apps are still reachable directly via `http://localhost:<port>`, but the `*.klicker.com` domains better mirror production cookie/domain behavior.
52+
53+
## Factory skills (AI assistance)
54+
55+
This repo includes Factory skills under `.factory/skills/` (mirrored in `.github/skills/`).
56+
57+
- `agent-browser` — browser automation for UI verification (see `.factory/skills/agent-browser/SKILL.md`).
58+
- `web-design-guidelines` — UI/UX/accessibility review (see `.factory/skills/web-design-guidelines/SKILL.md`).
59+
- `vercel-react-best-practices` — React/Next performance guidance (see `.factory/skills/vercel-react-best-practices/SKILL.md`).
60+
61+
### Using agent-browser to verify changes
62+
63+
Use `agent-browser` when changes affect browser behavior and need real UI validation, especially:
64+
65+
- Auth/login/redirect/cookie-domain changes
66+
- UI/UX changes (layout, navigation, forms)
67+
- Cross-app flows (e.g. manage ↔ auth, pwa ↔ auth)
68+
- Browser-only behavior (localStorage, service worker/PWA, media queries)
69+
70+
**Screenshots are required** (snapshots alone miss visual regressions). Take a screenshot before and after the action you’re verifying and review it with the `Read` tool.
71+
72+
Prereqs: the app is running locally in **dev mode** and the database is **seeded**.
73+
74+
If `agent-browser` is missing entirely:
75+
76+
```bash
77+
npm i -g agent-browser
78+
```
79+
80+
If the browser executable is missing on a new machine, run once:
81+
82+
```bash
83+
agent-browser install
84+
```
85+
86+
For automated runs, **do not use Edu-ID** (it will not work with `agent-browser`). Always use **Delegated login**:
87+
88+
- Username: `lecturer`
89+
- Password: `abcd`
90+
91+
For Student PWA testing in local seeded dev environments, you can log in with the seeded participants (see `packages/prisma-data/src/data/seedTEST.ts`):
92+
93+
- Usernames: `testuser1``testuser50` (enrolled in course **Testkurs**)
94+
- Password: `abcdabcd`
95+
96+
Additional seeded participants exist (`testuser51``testuser52`), but they are not enrolled in any course by default.
97+
98+
### Example: delegated login (Manage)
99+
100+
Note: on the first screen, **“Delegated Access” is disabled until the Terms checkbox is checked**.
101+
102+
```bash
103+
agent-browser open https://manage.klicker.com
104+
agent-browser screenshot /tmp/klicker-manage-01.png --full
105+
agent-browser snapshot -i -c
106+
107+
# 1) Check the Terms checkbox (if needed)
108+
# 2) Click “Delegated Access”
109+
# 3) Fill username/password and submit
110+
# (Use the @e* refs from the latest snapshot output)
111+
112+
agent-browser wait --load domcontentloaded
113+
agent-browser screenshot /tmp/klicker-manage-02.png --full
114+
agent-browser get url
115+
agent-browser close
116+
```
117+
118+
These credentials are intended for local seeded dev environments only.
119+
120+
## Change hygiene
121+
122+
- Keep changes small and follow existing patterns in the touched app/package.
123+
- Don’t add/update dependencies (or churn lockfiles) unless required for the task.
124+
- Never commit secrets (tokens, `.env` files, credentials).

apps/frontend-pwa/src/components/Layout.tsx

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import MobileMenuBar from './common/MobileMenuBar'
1515
interface LayoutProps {
1616
children?: React.ReactNode
1717
displayName?: string
18+
embedded?: boolean
1819
course?:
1920
| Partial<Omit<Course, 'awards' | 'owner' | 'groupActivities'>>
2021
| (Omit<StudentCourse, 'owner'> & { owner: { shortname: string } })
@@ -36,6 +37,7 @@ interface LayoutProps {
3637
function Layout({
3738
children,
3839
displayName = 'KlickerUZH',
40+
embedded = false,
3941
course,
4042
mobileMenuItems,
4143
setActiveMobilePage,
@@ -45,6 +47,7 @@ function Layout({
4547
const { data: dataParticipant } = useQuery(SelfDocument, {
4648
variables: { liveQuizId },
4749
fetchPolicy: 'cache-and-network',
50+
skip: embedded,
4851
})
4952

5053
const pageInFrame =
@@ -70,40 +73,45 @@ function Layout({
7073
></meta>
7174
</Head>
7275

73-
<div className={twMerge('flex-none', className?.header)}>
74-
<Header
75-
participant={
76-
dataParticipant?.self &&
77-
(dataParticipant.self.role === UserRole.Participant || liveQuizId)
78-
? dataParticipant.self
79-
: undefined
80-
}
81-
title={displayName}
82-
course={course}
83-
liveQuizId={liveQuizId}
84-
/>
85-
</div>
76+
{!embedded && (
77+
<div className={twMerge('flex-none', className?.header)}>
78+
<Header
79+
participant={
80+
dataParticipant?.self &&
81+
(dataParticipant.self.role === UserRole.Participant || liveQuizId)
82+
? dataParticipant.self
83+
: undefined
84+
}
85+
title={displayName}
86+
course={course}
87+
liveQuizId={liveQuizId}
88+
/>
89+
</div>
90+
)}
8691

8792
<div
8893
className={twMerge(
89-
'flex min-h-0 flex-1 flex-col overflow-y-auto p-4',
90-
pageInFrame && 'px-0',
94+
'flex min-h-0 flex-1 flex-col overflow-y-auto',
95+
embedded ? 'p-0' : 'p-4',
96+
!embedded && pageInFrame && 'px-0',
9197
className?.body
9298
)}
9399
>
94100
{children}
95101
</div>
96102

97-
<div className="flex-none md:hidden">
98-
<MobileMenuBar
99-
menuItems={mobileMenuItems}
100-
onClick={(value) => setActiveMobilePage?.(value as any)}
101-
participantMissing={
102-
!dataParticipant?.self ||
103-
dataParticipant.self.role === UserRole.TemporaryParticipant
104-
}
105-
/>
106-
</div>
103+
{!embedded && (
104+
<div className="flex-none md:hidden">
105+
<MobileMenuBar
106+
menuItems={mobileMenuItems}
107+
onClick={(value) => setActiveMobilePage?.(value as any)}
108+
participantMissing={
109+
!dataParticipant?.self ||
110+
dataParticipant.self.role === UserRole.TemporaryParticipant
111+
}
112+
/>
113+
</div>
114+
)}
107115
</>
108116
)
109117
}

apps/frontend-pwa/src/components/practiceQuiz/PracticeQuiz.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ interface PracticeQuizProps {
4141
currentIx: number
4242
setCurrentIx: (ix: number) => void
4343
handleNextElement: () => void
44+
onAllStacksCompletion?: () => void
4445
showResetLocalStorage?: boolean
4546
previewOnly?: boolean
4647
}
@@ -50,6 +51,7 @@ function PracticeQuiz({
5051
currentIx,
5152
setCurrentIx,
5253
handleNextElement,
54+
onAllStacksCompletion,
5355
showResetLocalStorage = false,
5456
previewOnly = false,
5557
}: PracticeQuizProps) {
@@ -60,6 +62,16 @@ function PracticeQuiz({
6062
skip: previewOnly,
6163
})
6264

65+
const handleAllStacksCompletion = () => {
66+
if (onAllStacksCompletion) {
67+
onAllStacksCompletion()
68+
return
69+
}
70+
71+
// TODO: re-introduce summary page for practice quiz
72+
router.push(`/`)
73+
}
74+
6375
const [progressState, setProgressState] = useLocalStorage<
6476
Record<
6577
string,
@@ -173,10 +185,7 @@ function PracticeQuiz({
173185
!!dataParticipant?.self &&
174186
dataParticipant.self.role !== UserRole.TemporaryParticipant
175187
}
176-
onAllStacksCompletion={() =>
177-
// TODO: re-introduce summary page for practice quiz
178-
router.push(`/`)
179-
}
188+
onAllStacksCompletion={handleAllStacksCompletion}
180189
bookmarks={bookmarksData?.getBookmarksPracticeQuiz}
181190
previewOnly={previewOnly}
182191
/>

apps/frontend-pwa/src/pages/course/[courseId]/practiceQuizzes/[id].tsx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ function PracticeQuizPage({
2222
id,
2323
participantToken,
2424
cookiesAvailable,
25+
embedded,
2526
}: {
2627
courseId: string
2728
id: string
2829
participantToken?: string
2930
cookiesAvailable?: boolean
31+
embedded: boolean
3032
}) {
3133
const t = useTranslations()
3234
const [currentIx, setCurrentIx] = useState(-1)
@@ -42,14 +44,14 @@ function PracticeQuizPage({
4244

4345
if (loading)
4446
return (
45-
<Layout>
47+
<Layout embedded={embedded}>
4648
<Loader />
4749
</Layout>
4850
)
4951

5052
if (!data?.practiceQuiz) {
5153
return (
52-
<Layout>
54+
<Layout embedded={embedded}>
5355
<UserNotification
5456
type="error"
5557
message={t('pwa.practiceQuiz.notFound')}
@@ -59,7 +61,9 @@ function PracticeQuizPage({
5961
}
6062

6163
if (error) {
62-
return <Layout>{t('shared.generic.systemError')}</Layout>
64+
return (
65+
<Layout embedded={embedded}>{t('shared.generic.systemError')}</Layout>
66+
)
6367
}
6468

6569
// show notification with activity start date
@@ -69,6 +73,7 @@ function PracticeQuizPage({
6973
) {
7074
return (
7175
<Layout
76+
embedded={embedded}
7277
displayName={data.practiceQuiz.displayName}
7378
course={data.practiceQuiz.course ?? undefined}
7479
>
@@ -92,6 +97,7 @@ function PracticeQuizPage({
9297

9398
return (
9499
<Layout
100+
embedded={embedded}
95101
displayName={data.practiceQuiz.displayName}
96102
course={data.practiceQuiz.course ?? undefined}
97103
>
@@ -104,11 +110,20 @@ function PracticeQuizPage({
104110
currentIx={currentIx}
105111
setCurrentIx={setCurrentIx}
106112
handleNextElement={handleNextQuestion}
113+
onAllStacksCompletion={
114+
embedded
115+
? () => {
116+
setCurrentIx(-1)
117+
}
118+
: undefined
119+
}
107120
previewOnly={data.practiceQuiz.isOwner ?? undefined}
108121
/>
109-
<Footer
110-
browserLink={`${process.env.NEXT_PUBLIC_PWA_URL}/course/${courseId}/practiceQuizzes/${id}`}
111-
/>
122+
{!embedded && (
123+
<Footer
124+
browserLink={`${process.env.NEXT_PUBLIC_PWA_URL}/course/${courseId}/practiceQuizzes/${id}`}
125+
/>
126+
)}
112127
</Layout>
113128
)
114129
}
@@ -129,6 +144,10 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
129144

130145
const apolloClient = initializeApollo()
131146

147+
const embedParam = ctx.query.embed
148+
const embedValue = Array.isArray(embedParam) ? embedParam[0] : embedParam
149+
const embedded = embedValue === 'true' || embedValue === '1'
150+
132151
const { participantToken, cookiesAvailable } = await getParticipantToken({
133152
apolloClient,
134153
courseId: ctx.params.courseId,
@@ -142,6 +161,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
142161
cookiesAvailable,
143162
id: ctx.params.id,
144163
courseId: ctx.params.courseId,
164+
embedded,
145165
messages: (await import(`@klicker-uzh/i18n/messages/${ctx.locale}`))
146166
.default,
147167
},
@@ -152,6 +172,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
152172
props: {
153173
id: ctx.params.id,
154174
courseId: ctx.params.courseId,
175+
embedded,
155176
messages: (await import(`@klicker-uzh/i18n/messages/${ctx.locale}`))
156177
.default,
157178
},

0 commit comments

Comments
 (0)