diff --git a/CHANGELOG.md b/CHANGELOG.md index f98b55b..518c192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.5] - 2026-02-09 + +### Added + +- Class-aware state foundations: `Classroom` model support, class utilities (`lib/classes.ts`), and class-focused storage/test coverage. +- Shared `ClassSelector` component for class selection across students, generator, play, breakout rooms, and projects. +- Unified quiz editor test coverage for form + import workflows (`components/quizzes/__tests__/quiz-editor-form.test.tsx`). +- Shared select wrapper tests for popup alignment defaults (`components/ui/__tests__/select.test.tsx`). + +### Changed + +- Student workflow expanded to class-first management: create/select classes, import full class rosters, and manage students in the active class. +- Generator, quiz play, breakout groups, and project lists now run with active-class scoping. +- Quiz editing/import flow consolidated into `QuizEditorForm`; legacy `quiz-import-card` removed. +- Quiz question list card behavior refined for better overflow/height handling. +- Shared `SelectContent` now defaults to popper-style content-fit behavior and supports explicit trigger-aligned positioning via `alignItemWithTrigger={true}` where needed. +- App version updated to `1.1.5` in `package.json`. +- Sidebar version label now reads version dynamically from `package.json` via server layout prop wiring. +- Dependency/version refresh from the `main..HEAD` baseline commits (including `package.json` and `bun.lock` updates from `e9d61c2`). + +### Tests + +- Expanded reducer/storage/type-guard coverage for class-aware persistence and migration paths. +- Added class utility tests (`lib/__tests__/classes.test.ts`) and updated student/reducer expectations. +- Added select alignment behavior tests in `components/ui/__tests__/select.test.tsx`. + +### Documentation + +- Updated README and project docs for class-scoped workflows and unified quiz editing/import. +- Updated component docs to describe select popup auto-sizing defaults and trigger-alignment override. + ## [1.1.4] - 2026-02-05 ### Changed diff --git a/app/breakout-rooms/page.tsx b/app/breakout-rooms/page.tsx index 2b2ac2f..c317ad4 100644 --- a/app/breakout-rooms/page.tsx +++ b/app/breakout-rooms/page.tsx @@ -14,7 +14,7 @@ export const metadata: Metadata = { */ export default function Page() { return ( -
+
); diff --git a/app/generator/page.tsx b/app/generator/page.tsx index 0263e42..b2bbe4c 100644 --- a/app/generator/page.tsx +++ b/app/generator/page.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import { Metadata } from 'next'; import GeneratorCard from '@/components/generator/generator-card'; @@ -15,7 +17,7 @@ export const metadata: Metadata = { */ export default function Page() { return ( -
+
} />
); diff --git a/app/layout.tsx b/app/layout.tsx index 40f8491..28c4285 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,10 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; +import { cookies } from 'next/headers'; import './globals.css'; +import packageJson from '@/package.json'; import AppShell from '@/components/app-shell'; import Footer from '@/components/footer'; @@ -57,11 +59,20 @@ export const metadata: Metadata = { * Renders the shared HTML shell and providers for all application routes. * Wrap page content in this layout so theme, state, sidebar, and footer stay consistent. */ -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { + const appVersion = packageJson.version; + const cookieStore = await cookies(); + const sidebarCookieValue = + cookieStore.get('teacherbuddy_sidebar_state')?.value ?? + cookieStore.get('sidebar_state')?.value ?? + cookieStore.get('teacherbuddy:sidebar_state')?.value; + const defaultSidebarOpen = + sidebarCookieValue === undefined ? true : sidebarCookieValue === 'true'; + return ( @@ -91,7 +102,12 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`}> - }>{children} + }> + {children} + diff --git a/app/play/page.tsx b/app/play/page.tsx index d146b70..b28287a 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -14,7 +14,7 @@ export const metadata: Metadata = { */ export default function Page() { return ( -
+
} />
); diff --git a/app/projects/page.tsx b/app/projects/page.tsx index fb6ee3e..b278730 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -15,8 +15,8 @@ export const metadata: Metadata = { export default function Page() { return (
- +
); } diff --git a/app/quizzes/page.tsx b/app/quizzes/page.tsx index 43197d2..7c9fa91 100644 --- a/app/quizzes/page.tsx +++ b/app/quizzes/page.tsx @@ -13,9 +13,5 @@ export const metadata: Metadata = { * Provides the editor workflow with a skeleton shown before hydration. */ export default function Page() { - return ( -
- } /> -
- ); + return } />; } diff --git a/components/app-shell.tsx b/components/app-shell.tsx index acda6d1..088bc76 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -30,11 +30,17 @@ import Header from './header'; /** * Global app shell that renders the sidebar, header, and page content. * Styled to match the design-6 command center aesthetic with phase-colored navigation. + * Provide `appVersion` from server layout so the sidebar version stays in sync with package metadata. + * Provide `defaultSidebarOpen` from server cookie state so refreshes preserve sidebar preference. */ export default function AppShell({ + appVersion, + defaultSidebarOpen, children, footer, }: { + appVersion: string; + defaultSidebarOpen: boolean; children: React.ReactNode; footer?: React.ReactNode; }) { @@ -46,7 +52,7 @@ export default function AppShell({ }; return ( - + @@ -86,7 +92,7 @@ export default function AppShell({
- v1.1.4 + v{appVersion} Classroom @@ -100,7 +106,7 @@ export default function AppShell({ meta={meta} info={{ currentPath: pathname, pages: PAGE_INFOS }} /> -
+
{children}
{footer ?? null} diff --git a/components/play/quiz-play-card.tsx b/components/play/quiz-play-card.tsx index 44bd489..6268aff 100644 --- a/components/play/quiz-play-card.tsx +++ b/components/play/quiz-play-card.tsx @@ -138,25 +138,25 @@ export default function QuizPlayCard({
-
+
diff --git a/components/projects/project-list-builder.tsx b/components/projects/project-list-builder.tsx index e689117..fdfa835 100644 --- a/components/projects/project-list-builder.tsx +++ b/components/projects/project-list-builder.tsx @@ -503,7 +503,7 @@ export default function ProjectListBuilder() {
diff --git a/components/quizzes/quiz-editor-form.tsx b/components/quizzes/quiz-editor-form.tsx index 52c13f3..d659a04 100644 --- a/components/quizzes/quiz-editor-form.tsx +++ b/components/quizzes/quiz-editor-form.tsx @@ -484,8 +484,8 @@ export default function QuizEditorForm({ quiz, quizId }: QuizEditorFormProps) { return ( <> -
- +
+
+
{/* Key pattern: reset all form state when quiz changes */} student.id === editingStudentId, ) ?? null) : null; + const selectedEditClass = editClassId + ? (state.persisted.classes.find((entry) => entry.id === editClassId) ?? null) + : null; /** * Keeps the student list card height aligned to the form card on desktop widths. @@ -414,7 +417,9 @@ export default function StudentTable({ if (editError) setEditError(null); }}> - + + {selectedEditClass?.name ?? 'Select class'} + {state.persisted.classes.map((entry) => ( diff --git a/components/ui/__tests__/select.test.tsx b/components/ui/__tests__/select.test.tsx new file mode 100644 index 0000000..96799ec --- /dev/null +++ b/components/ui/__tests__/select.test.tsx @@ -0,0 +1,59 @@ +import { render, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +/** + * Renders an open select so popup-level behavior can be asserted in tests. + * + * @param alignItemWithTrigger Optional popup alignment override. + * @returns Promise resolving to the rendered popup element. + */ +async function renderOpenSelect( + alignItemWithTrigger?: boolean, +): Promise { + render( + , + ); + + let popup: HTMLElement | null = null; + await waitFor(() => { + popup = document.querySelector('[data-slot="select-content"]'); + expect(popup).not.toBeNull(); + }); + + if (!popup) { + throw new Error('Select popup did not render'); + } + + return popup; +} + +describe('SelectContent', () => { + it('defaults to popper-style popup behavior', async () => { + const popup = await renderOpenSelect(); + expect(popup).toHaveAttribute('data-align-trigger', 'false'); + }); + + it('supports explicit trigger-aligned popup behavior', async () => { + const popup = await renderOpenSelect(true); + expect(popup).toHaveAttribute('data-align-trigger', 'true'); + }); +}); diff --git a/components/ui/select.tsx b/components/ui/select.tsx index 66c97ec..e2f7cbf 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -57,6 +57,13 @@ function SelectTrigger({ ) } +/** + * Renders the positioned select popup and option list. + * By default, the popup uses popper-style positioning so menu height tracks + * content naturally while still respecting the shared max-height cap. + * Callers can opt into trigger-aligned positioning with + * `alignItemWithTrigger={true}` when needed. + */ function SelectContent({ className, children, @@ -64,7 +71,7 @@ function SelectContent({ sideOffset = 4, align = "center", alignOffset = 0, - alignItemWithTrigger = true, + alignItemWithTrigger = false, ...props }: SelectPrimitive.Popup.Props & Pick< @@ -87,9 +94,9 @@ function SelectContent({ className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-32 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 h-fit max-h-[min(18rem,var(--available-height))] w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", className )} {...props} > - + {alignItemWithTrigger ? : null} {children} - + {alignItemWithTrigger ? : null} diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx index 9608b1f..28dd347 100644 --- a/components/ui/sidebar.tsx +++ b/components/ui/sidebar.tsx @@ -29,7 +29,9 @@ import { } from '@/components/ui/tooltip'; import { useIsMobile } from '@/hooks/use-mobile'; -const SIDEBAR_COOKIE_NAME = 'sidebar_state'; +const SIDEBAR_COOKIE_NAME = 'teacherbuddy_sidebar_state'; +const SIDEBAR_LEGACY_COOKIE_NAME = 'sidebar_state'; +const SIDEBAR_LEGACY_COOKIE_NAME_COLON = 'teacherbuddy:sidebar_state'; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; const SIDEBAR_WIDTH = '16rem'; const SIDEBAR_WIDTH_MOBILE = '18rem'; @@ -39,7 +41,9 @@ const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; type SidebarContextProps = { state: 'expanded' | 'collapsed'; open: boolean; - setOpen: (open: boolean) => void; + setOpen: ( + open: boolean | ((open: boolean) => boolean), + ) => void; openMobile: boolean; setOpenMobile: (open: boolean) => void; isMobile: boolean; @@ -57,6 +61,17 @@ function useSidebar() { return context; } +/** + * Resolves the next sidebar open state from either a boolean or updater callback. + * Use this helper to keep `setOpen` behavior consistent with React state setters. + */ +function resolveSidebarOpenState( + value: boolean | ((value: boolean) => boolean), + current: boolean, +) { + return typeof value === 'function' ? value(current) : value; +} + function SidebarProvider({ defaultOpen = true, open: openProp, @@ -77,9 +92,15 @@ function SidebarProvider({ // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(defaultOpen); const open = openProp ?? _open; + const openRef = React.useRef(open); + + React.useEffect(() => { + openRef.current = open; + }, [open]); + const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === 'function' ? value(open) : value; + const openState = resolveSidebarOpenState(value, openRef.current); if (setOpenProp) { setOpenProp(openState); } else { @@ -87,9 +108,12 @@ function SidebarProvider({ } // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}; samesite=lax`; + // Remove deprecated cookie key to avoid stale state confusion. + document.cookie = `${SIDEBAR_LEGACY_COOKIE_NAME}=; path=/; max-age=0; samesite=lax`; + document.cookie = `${SIDEBAR_LEGACY_COOKIE_NAME_COLON}=; path=/; max-age=0; samesite=lax`; }, - [setOpenProp, open], + [setOpenProp], ); // Helper to toggle the sidebar. diff --git a/documentation/project-docs/components.md b/documentation/project-docs/components.md index 9d4ee8e..578c3d0 100644 --- a/documentation/project-docs/components.md +++ b/documentation/project-docs/components.md @@ -99,7 +99,7 @@ Base components in `components/ui/` use Tailwind and (where noted) Base UI / sha | `Button` | CVA variants; use `button-variants.ts` for server-safe variants. | | `Card`, `CardHeader`, etc. | Layout primitives. | | `Input`, `Textarea` | Form inputs. | -| `Select`, `Label`, `Field` | Form field wrappers. | +| `Select`, `Label`, `Field` | Form field wrappers. `SelectContent` defaults to popper-style content-fit behavior; pass `alignItemWithTrigger={true}` for trigger-aligned positioning. | | `Dialog`, `DialogContent`, `DialogTrigger`, etc. | Modal dialogs (used by PageInfoDialog, etc.). | | `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` | Tabbed content. | | `AlertDialog` | Confirmation dialogs. | @@ -124,6 +124,7 @@ import { buttonVariants } from '@/components/ui/button-variants'; Component tests: - `StudentForm` – `components/students/__tests__/student-form.test.tsx` +- `Select` – `components/ui/__tests__/select.test.tsx` - `QuizSelector` – `components/quizzes/__tests__/quiz-selector.test.tsx` - `PageInfoDialog` – `components/utility/__tests__/page-info-dialog.test.tsx` diff --git a/package.json b/package.json index dcb90ba..353a84a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "teacherbuddy", - "version": "1.1.4", + "version": "1.1.5", "private": true, "packageManager": "bun@1.3.4", "scripts": {