From 40e60165d4b0a7f9b9ecd44518871b4811b4ddf6 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Wed, 17 Jun 2026 21:45:57 +0200 Subject: [PATCH] Classroom support strategies CRUD --- backend/docs/content-catalog.md | 9 +- backend/src/services/content_catalog.test.ts | 63 +++++ backend/src/services/content_catalog.ts | 23 +- .../src/services/content_catalog_seed.test.ts | 38 +++ backend/src/services/user_progress.test.ts | 17 +- .../src/shared/constants/content-catalog.ts | 2 +- .../docs/classroom-support-integration.md | 17 +- frontend/docs/content-catalog-integration.md | 2 + frontend/docs/test-coverage.md | 2 +- frontend/src/app/AppProviders.tsx | 7 +- frontend/src/app/appRoutes.test.ts | 4 +- frontend/src/app/appRoutes.tsx | 2 +- frontend/src/business/app-shell/hooks.ts | 28 ++- .../src/business/app-shell/selectors.test.ts | 1 + frontend/src/business/app-shell/selectors.ts | 3 + .../src/business/campus-attendance/hooks.ts | 11 + .../src/business/classroom-support/hooks.ts | 205 +++++++++++++++- .../src/business/classroom-support/types.ts | 29 +++ frontend/src/business/files/api.ts | 2 +- .../business/staff-attendance/mappers.test.ts | 2 + frontend/src/components/AppLayout.tsx | 6 +- .../ClassroomStrategyCard.tsx | 77 ++++-- .../ClassroomStrategyDetailModal.tsx | 3 +- .../ClassroomStrategyGrid.tsx | 4 + .../ClassroomSupportManagementPanel.tsx | 223 ++++++++++++++++++ .../ClassroomSupportView.tsx | 19 ++ .../components/common/ConfirmationDialog.tsx | 115 +++++++++ .../src/components/common/ImageUpload.tsx | 17 +- .../modules/CampusAttendanceDetailsPage.tsx | 13 +- frontend/src/shared/api/files.test.ts | 10 +- frontend/src/shared/api/files.ts | 14 ++ frontend/src/shared/api/userProgress.test.ts | 15 ++ frontend/src/shared/errors/errorMessages.ts | 8 + .../tests/e2e/content-catalog.seeded.e2e.ts | 13 +- frontend/tests/e2e/smoke.e2e.ts | 11 + 35 files changed, 928 insertions(+), 87 deletions(-) create mode 100644 frontend/src/components/classroom-support/ClassroomSupportManagementPanel.tsx create mode 100644 frontend/src/components/common/ConfirmationDialog.tsx create mode 100644 frontend/tests/e2e/smoke.e2e.ts diff --git a/backend/docs/content-catalog.md b/backend/docs/content-catalog.md index fd32f64..3d12329 100644 --- a/backend/docs/content-catalog.md +++ b/backend/docs/content-catalog.md @@ -27,13 +27,14 @@ DTO fields: `id`, `content_type`, `payload`, `updatedAt`. ## Access Rules - `GET /api/content-catalog/read/:contentType` requires an authenticated user and returns only records where `active = true`. - All `/api/content-catalog` management endpoints require `MANAGE_CONTENT_CATALOG`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users. Tenant/content-type scoping still constrains which row is edited. +- `classroom-strategies` is an organization-scoped catalog. Management requires `MANAGE_CONTENT_CATALOG` and an effective organization scope; school, campus, and class drill-down scopes can read it but cannot update the organization strategy library. ## Tenant Scope Content records can be tenant-scoped through nullable `organizationId`, `schoolId`, `campusId`, and `classId` columns: - Per-tenant types use the caller's own tenant (`getOwnTenant`) and exact owner matching. Dashboard content (`dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, and related dashboard assets) is seeded at organization, school, and campus scope so leadership dashboards work at every supported drill-down level. - School-scoped types read the caller's resolved school row. -- Org-scoped types read the caller's organization row. +- Org-scoped types read the caller's organization row. `classroom-strategies` is org-scoped: every organization gets one preset editable strategy library, while school, campus, and classroom views read that organization library instead of owning duplicate rows. - Shared/global types use all-null tenant ids. Management list excludes tenant-scoped content because those records are edited through dedicated per-type editors. @@ -48,6 +49,8 @@ Management list excludes tenant-scoped content because those records are edited - `create` looks up any existing row by `content_type` plus its exact tenant owner with `paranoid: false`. If a non-deleted row exists it throws `ValidationError`. If a soft-deleted row exists it is restored and updated inside `withTransaction`; otherwise a new row is created. - `update` and `delete` run inside `withTransaction` and throw `ValidationError('contentCatalogNotFound')` when the row is missing. `delete` sets `active = false` then soft-deletes (`destroy`). - `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record. +- For `classroom-strategies`, `findManagedByType`, `create`, `update`, and `delete` also require organization effective scope so organization leaders manage the shared strategy library and descendant scopes remain read-only. +- Managed `classroom-strategies` images are uploaded through the file subsystem first. The content payload stores the returned private URL; production uploads use the configured GCloud bucket path from `file.md`. - Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads. ### Seeded content types @@ -55,13 +58,15 @@ The seeder (`20260608103000-content-catalog.ts`) loads the following `content_ty The migration `20260614090000-drop-global-content-catalog-rows.ts` removes the former global static rows: `personality-*` and `classroom-timer-*`. The migration `20260616170000-backfill-dashboard-content-scopes.ts` backfills per-tenant dashboard catalog defaults for existing organization, school, and campus records. +New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, school creation presets school-scoped content, and campus creation presets only per-tenant campus content. This keeps the Classroom Support library shared at the organization level. + ### Content authoring rules - Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants. - Frontend constants stay limited to UI config, labels, query keys, timing values, presentation tokens, and product-static catalogs. - If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs. ## Tests -Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. +Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for `classroom-strategies`, and organization-only seeding for the preset Classroom Support library. ## Related - Frontend: `frontend/docs/content-catalog-integration.md`. diff --git a/backend/src/services/content_catalog.test.ts b/backend/src/services/content_catalog.test.ts index 25df7d1..dd31ce1 100644 --- a/backend/src/services/content_catalog.test.ts +++ b/backend/src/services/content_catalog.test.ts @@ -4,6 +4,8 @@ import assert from 'node:assert/strict'; import db from '@/db/models'; import ContentCatalogService from '@/services/content_catalog'; import { createGlobalAccessUser } from '@/test-utils'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; function catalogRecord(payload: unknown) { return { @@ -16,6 +18,24 @@ function catalogRecord(payload: unknown) { }; } +function organizationContentManager() { + return createGlobalAccessUser({ + app_role: { + name: ROLE_NAMES.SUPER_ADMIN, + scope: ROLE_SCOPES.SYSTEM, + globalAccess: true, + permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG }], + }, + activeScope: { + level: ROLE_SCOPES.ORGANIZATION, + organizationId: 'org-1', + schoolId: null, + campusId: null, + classId: null, + }, + }); +} + afterEach(() => { mock.restoreAll(); }); @@ -109,4 +129,47 @@ describe('ContentCatalogService tenant scoping', () => { classId: null, }); }); + + test('allows classroom strategy management from organization scope', async () => { + let capturedWhere: Record | null = null; + mock.method(db.content_catalog, 'findOne', (async (options: { where?: Record }) => { + capturedWhere = options.where ?? null; + return catalogRecord([]); + }) as typeof db.content_catalog.findOne); + + await ContentCatalogService.findManagedByType( + 'classroom-strategies', + organizationContentManager(), + ); + + assert.deepEqual(capturedWhere, { + content_type: 'classroom-strategies', + active: true, + organizationId: 'org-1', + }); + }); + + test('rejects classroom strategy management outside organization scope', async () => { + await assert.rejects( + () => ContentCatalogService.findManagedByType( + 'classroom-strategies', + createGlobalAccessUser({ + app_role: { + name: ROLE_NAMES.SUPER_ADMIN, + scope: ROLE_SCOPES.SYSTEM, + globalAccess: true, + permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG }], + }, + activeScope: { + level: ROLE_SCOPES.CAMPUS, + organizationId: 'org-1', + schoolId: 'school-1', + campusId: 'campus-1', + classId: null, + }, + }), + ), + { name: 'ForbiddenError' }, + ); + }); }); diff --git a/backend/src/services/content_catalog.ts b/backend/src/services/content_catalog.ts index d24bdc6..279b530 100644 --- a/backend/src/services/content_catalog.ts +++ b/backend/src/services/content_catalog.ts @@ -5,6 +5,7 @@ import { resolvePagination } from '@/shared/constants/pagination'; import ForbiddenError from '@/shared/errors/forbidden'; import ValidationError from '@/shared/errors/validation'; import { + getRoleScope, getOwnTenant, getOrganizationId, getSchoolId, @@ -13,12 +14,14 @@ import { tenantStamp, } from '@/services/shared/access'; import { + CLASSROOM_SUPPORT_CONTENT_TYPE, PER_TENANT_CONTENT_TYPES, SCHOOL_SCOPED_CONTENT_TYPES, ORG_SCOPED_CONTENT_TYPES, TENANT_SCOPED_CONTENT_TYPES, } from '@/shared/constants/content-catalog'; import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; +import { ROLE_SCOPES } from '@/shared/constants/roles'; import type { ContentCatalog } from '@/db/models/content_catalog'; import type { CurrentUser } from '@/db/api/types'; @@ -100,7 +103,7 @@ function assertCanManageContentCatalog(currentUser?: CurrentUser): void { * Edit authorization per content type. Tenant scoping constrains the row; the * right to edit content catalog data is a single effective permission. */ -function assertCanManageType(currentUser?: CurrentUser): void { +function assertCanManageType(contentType: string, currentUser?: CurrentUser): void { if ( !hasFeaturePermission( currentUser, @@ -109,6 +112,13 @@ function assertCanManageType(currentUser?: CurrentUser): void { ) { throw new ForbiddenError(); } + + if ( + contentType === CLASSROOM_SUPPORT_CONTENT_TYPE + && getRoleScope(currentUser) !== ROLE_SCOPES.ORGANIZATION + ) { + throw new ForbiddenError(); + } } function assertValidContentType(contentType: unknown): string { @@ -175,14 +185,15 @@ class ContentCatalogService { } static async findManagedByType(contentType: unknown, currentUser?: CurrentUser) { - assertCanManageContentCatalog(currentUser); + const normalizedContentType = assertValidContentType(contentType); + assertCanManageType(normalizedContentType, currentUser); - return this.findByType(contentType, currentUser); + return this.findByType(normalizedContentType, currentUser); } static async create(data: ContentCatalogInput, currentUser?: CurrentUser) { const contentType = assertValidContentType(data?.content_type); - assertCanManageType(currentUser); + assertCanManageType(contentType, currentUser); const payload = assertValidPayload(data?.payload); const tenantWhere = tenantWhereFor(contentType, currentUser); @@ -232,7 +243,7 @@ class ContentCatalogService { currentUser?: CurrentUser, ) { const normalizedContentType = assertValidContentType(contentType); - assertCanManageType(currentUser); + assertCanManageType(normalizedContentType, currentUser); const payload = assertValidPayload(data?.payload); @@ -263,7 +274,7 @@ class ContentCatalogService { static async delete(contentType: unknown, currentUser?: CurrentUser) { const normalizedContentType = assertValidContentType(contentType); - assertCanManageType(currentUser); + assertCanManageType(normalizedContentType, currentUser); return withTransaction(async (transaction) => { const record = await db.content_catalog.findOne({ diff --git a/backend/src/services/content_catalog_seed.test.ts b/backend/src/services/content_catalog_seed.test.ts index 56495a1..c5a083b 100644 --- a/backend/src/services/content_catalog_seed.test.ts +++ b/backend/src/services/content_catalog_seed.test.ts @@ -34,4 +34,42 @@ describe('seedDefaultContentForTenant', () => { assert.equal(quoteRow.classId, null); assert.equal(quoteRow.active, true); }); + + test('seeds classroom strategies only at organization scope', async () => { + const createdRows: Array> = []; + + mock.method(db.content_catalog, 'findOne', (async () => null) as typeof db.content_catalog.findOne); + mock.method(db.content_catalog, 'create', (async (payload: Record) => { + createdRows.push(payload); + return payload; + }) as typeof db.content_catalog.create); + + await seedDefaultContentForTenant({ + level: 'organization', + organizationId: 'org-1', + }); + await seedDefaultContentForTenant({ + level: 'school', + organizationId: 'org-1', + schoolId: 'school-1', + }); + await seedDefaultContentForTenant({ + level: 'campus', + organizationId: 'org-1', + schoolId: 'school-1', + campusId: 'campus-1', + }); + + const classroomRows = createdRows.filter((row) => + row.content_type === 'classroom-strategies', + ); + + assert.equal(classroomRows.length, 1); + assert.equal(classroomRows[0]?.organizationId, 'org-1'); + assert.equal(classroomRows[0]?.schoolId, null); + assert.equal(classroomRows[0]?.campusId, null); + assert.equal(classroomRows[0]?.classId, null); + assert.equal(classroomRows[0]?.active, true); + assert.ok(Array.isArray(classroomRows[0]?.payload)); + }); }); diff --git a/backend/src/services/user_progress.test.ts b/backend/src/services/user_progress.test.ts index 6f759f4..2150073 100644 --- a/backend/src/services/user_progress.test.ts +++ b/backend/src/services/user_progress.test.ts @@ -48,7 +48,7 @@ describe('UserProgressService drilled scope persistence guard', () => { permissions: [], }, }); - let createdPayload: Record | null = null; + let createdPayload: Record | undefined; mock.method(db.sequelize, 'transaction', async () => ({ commit: async () => undefined, @@ -76,14 +76,13 @@ describe('UserProgressService drilled scope persistence guard', () => { actor, ); - assert.notEqual(createdPayload, null); - const payload = createdPayload as Record; - assert.equal(payload.progress_type, USER_PROGRESS_TYPES.CLASSROOM_STRATEGY_FAVORITE); - assert.equal(payload.item_id, 'token-economy'); - assert.equal(payload.value, 'Token Economy System'); - assert.equal(payload.organizationId, 'org-1'); - assert.equal(payload.campusId, 'campus-1'); - assert.equal(payload.userId, 'user-1'); + assert.ok(createdPayload); + assert.equal(createdPayload.progress_type, USER_PROGRESS_TYPES.CLASSROOM_STRATEGY_FAVORITE); + assert.equal(createdPayload.item_id, 'token-economy'); + assert.equal(createdPayload.value, 'Token Economy System'); + assert.equal(createdPayload.organizationId, 'org-1'); + assert.equal(createdPayload.campusId, 'campus-1'); + assert.equal(createdPayload.userId, 'user-1'); assert.equal(result?.item_id, 'token-economy'); }); diff --git a/backend/src/shared/constants/content-catalog.ts b/backend/src/shared/constants/content-catalog.ts index 1627fe2..409c657 100644 --- a/backend/src/shared/constants/content-catalog.ts +++ b/backend/src/shared/constants/content-catalog.ts @@ -1,4 +1,4 @@ -/** Classroom Support — org-scoped but also editable by a campus director. */ +/** Classroom Support — org-scoped and managed from organization scope. */ export const CLASSROOM_SUPPORT_CONTENT_TYPE = 'classroom-strategies'; /** The safety/QBS quiz content type, dedicated per tenant (org/school/campus). */ diff --git a/frontend/docs/classroom-support-integration.md b/frontend/docs/classroom-support-integration.md index c3e0d62..8dbd5e0 100644 --- a/frontend/docs/classroom-support-integration.md +++ b/frontend/docs/classroom-support-integration.md @@ -13,11 +13,13 @@ View: - `frontend/src/components/frameworks/ClassroomSupport.tsx` - `frontend/src/components/classroom-support/ClassroomSupportView.tsx` - `frontend/src/components/classroom-support/ClassroomSupportHeader.tsx` +- `frontend/src/components/classroom-support/ClassroomSupportManagementPanel.tsx` - `frontend/src/components/classroom-support/ClassroomSupportTryToday.tsx` - `frontend/src/components/classroom-support/ClassroomSupportFilters.tsx` - `frontend/src/components/classroom-support/ClassroomStrategyGrid.tsx` - `frontend/src/components/classroom-support/ClassroomStrategyCard.tsx` - `frontend/src/components/classroom-support/ClassroomStrategyDetailModal.tsx` +- `frontend/src/components/common/ConfirmationDialog.tsx` Business logic: @@ -37,9 +39,14 @@ The page reads: - `GET /api/content-catalog/read/classroom-strategies` +Organization-level strategy managers update: + +- `PUT /api/content-catalog/classroom-strategies` +- `POST /api/file/upload/classroom-support/strategy-images` + The content payload is seeded in: -- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js` +- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts` Each strategy payload supports: @@ -49,13 +56,17 @@ Each strategy payload supports: - `category` - `ageGroup` - `zone` -- `image` +- `image` (uploaded file private URL for newly managed cards; seeded presets may use static asset paths) - `implementationTip` ## Behavior - `useClassroomSupportPage` loads strategies through the shared content catalog hook. - The app-shell scope selector exposes this module at organization, school, campus, and class effective tiers when the user has `READ_CLASSROOM`. Organization-level users can maintain classroom strategy content for descendant schools and campuses through the same backend-owned content catalog. +- The management panel is shown only when the effective tenant is an organization and the user has `MANAGE_CONTENT_CATALOG`. The backend enforces the same organization-scope rule for `classroom-strategies` management endpoints. +- Add, edit, and delete operations update the organization-scoped `classroom-strategies` payload. Strategy images are uploaded through the shared file subsystem before the content payload is saved, so production storage uses the configured bucket path and the payload stores the returned private URL. +- Delete uses the shared confirmation dialog so destructive classroom strategy actions stay consistent with project modal styling. +- Seeded strategy cards are preset for each organization through content catalog seeding. New school and campus tenants do not receive independent classroom-strategy rows; they read the organization library. - Favorite strategy IDs load and persist through `GET/POST/DELETE /api/user_progress` with `progress_type = classroom_strategy_favorite`. Favorite controls are enabled only when the user is viewing their own scope; parent users drilled into a child tenant do not load or write child-scope favorites. - Selectors handle search, category, age, zone, favorites-only filtering, and the daily strategy selection. - View components receive a prepared page model and do not call API/data access modules. @@ -67,4 +78,6 @@ Each strategy payload supports: - Do not add classroom strategy records to frontend constants. - Do not add frontend fallback strategy payloads. - Keep frontend constants limited to filter options, style classes, query-independent timing values, and UI labels. +- Keep favorites out of `content_catalog`; they are per-user rows in `user_progress`. +- Keep uploaded card images in the shared file subsystem (`classroom-support/strategy-images`) and store only the private URL in the strategy payload. - Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`. diff --git a/frontend/docs/content-catalog-integration.md b/frontend/docs/content-catalog-integration.md index d94ef1b..aaa8a96 100644 --- a/frontend/docs/content-catalog-integration.md +++ b/frontend/docs/content-catalog-integration.md @@ -108,6 +108,8 @@ QBS quiz title, weekly focus, key reminders, questions, answer choices, correct Classroom strategy titles, descriptions, images, categories, age groups, regulation zones, and implementation tips are part of the `classroom-strategies` content catalog payload. The frontend may keep filter labels and style tokens, but it must not keep strategy records or implementation copy in shared constants. +`classroom-strategies` is organization-scoped. Organization managers with `MANAGE_CONTENT_CATALOG` can add, edit, delete, and upload images for the shared strategy library; school, campus, and classroom views read the same organization-owned payload and can only persist per-user favorites when the user is in their own scope. + ## Editable Sign Language Content Sign records, teaching tips, video URLs, GIF URLs, step instructions, and page-level teaching reminders are part of the `sign-language-items` and `sign-language-page-content` content catalog payloads. The frontend renders these payloads and does not keep sign content or reminder copy in shared constants. diff --git a/frontend/docs/test-coverage.md b/frontend/docs/test-coverage.md index d8c68cc..fa30abd 100644 --- a/frontend/docs/test-coverage.md +++ b/frontend/docs/test-coverage.md @@ -79,7 +79,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and - `frontend/src/hooks/usePermissions.test.tsx` - `frontend/src/components/sign-in-modal/SignInForm.test.tsx` -These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, sign language selectors, top bar display selectors, user progress normalization, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance. +These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors including search over titles/descriptions and favorites-only filtering, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, sign language selectors, top bar display selectors, user progress normalization including Classroom Support favorite list/upsert API contracts, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance. The component and hook tests verify SignInForm rendering, loading states, user interactions, form submission, password visibility toggling, auth session initialization, sign-in/sign-out flows, AuthExpiredError handling, modal workflow state management, and permissions hook behavior including has(), hasAny(), hasAll() methods with globalAccess support and explicit personal-workflow exclusions. diff --git a/frontend/src/app/AppProviders.tsx b/frontend/src/app/AppProviders.tsx index 5188e28..b9cbbb0 100644 --- a/frontend/src/app/AppProviders.tsx +++ b/frontend/src/app/AppProviders.tsx @@ -17,10 +17,11 @@ import { ScopeProvider } from '@/contexts/ScopeProvider'; import { APP_DEFAULT_THEME } from '@/shared/constants/theme'; import { FORBIDDEN_ERROR_MESSAGE, + isAuthExpiredError, isForbiddenError, + isUnauthorizedError, } from '@/shared/errors/errorMessages'; import { AUTH_EXPIRED_EVENT } from '@/shared/constants/auth'; -import { ApiError, AuthExpiredError } from '@/shared/api/httpClient'; // Single handler so a backend 403 (permission denied) degrades to a toast // rather than a crash or silent failure, wherever it occurs. @@ -31,11 +32,11 @@ function notifyOnForbidden(error: unknown): void { } function shouldRetryQuery(failureCount: number, error: unknown): boolean { - if (error instanceof AuthExpiredError) { + if (isAuthExpiredError(error)) { return false; } - if (error instanceof ApiError && error.status === 401) { + if (isUnauthorizedError(error)) { return false; } diff --git a/frontend/src/app/appRoutes.test.ts b/frontend/src/app/appRoutes.test.ts index 4ca3849..fe42a6e 100644 --- a/frontend/src/app/appRoutes.test.ts +++ b/frontend/src/app/appRoutes.test.ts @@ -22,10 +22,10 @@ describe('app routes', () => { ?.map((route) => route.path) .filter((path): path is string => typeof path === 'string'); - // Module routes (in MODULES order) followed by the non-module shell routes - // (the self-service profile page). + // Module routes (in MODULES order) followed by non-module shell routes. expect(childPaths).toEqual([ ...MODULES.map((module) => module.routePath.slice(1)), + APP_ROUTE_PATHS.attendanceDetails.slice(1), APP_ROUTE_PATHS.profile.slice(1), ]); }); diff --git a/frontend/src/app/appRoutes.tsx b/frontend/src/app/appRoutes.tsx index ccb008d..2feda17 100644 --- a/frontend/src/app/appRoutes.tsx +++ b/frontend/src/app/appRoutes.tsx @@ -103,8 +103,8 @@ export const appRoutes: RouteObject[] = [ element: , children: [ { index: true, element: }, - ...extraShellRoutes, ...moduleRoutes, + ...extraShellRoutes, ], }, ], diff --git a/frontend/src/business/app-shell/hooks.ts b/frontend/src/business/app-shell/hooks.ts index 552d5af..54d5a75 100644 --- a/frontend/src/business/app-shell/hooks.ts +++ b/frontend/src/business/app-shell/hooks.ts @@ -116,19 +116,21 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { } } - const redirectPath = getScopedModuleRouteRedirectPath( - MODULES, - location.pathname, - options.user, - effectiveTier, - isDrilled, - ); - if (redirectPath && redirectPath !== location.pathname) { - navigate(redirectPath, { - replace: true, - state: { ...routeState, __scope: selectedTenant }, - }); - return; + if (scopeChanged) { + const redirectPath = getScopedModuleRouteRedirectPath( + MODULES, + location.pathname, + options.user, + effectiveTier, + isDrilled, + ); + if (redirectPath && redirectPath !== location.pathname) { + navigate(redirectPath, { + replace: true, + state: { ...routeState, __scope: selectedTenant }, + }); + return; + } } if (!sameScopeTenant(routeScopeTenant, selectedTenant)) { diff --git a/frontend/src/business/app-shell/selectors.test.ts b/frontend/src/business/app-shell/selectors.test.ts index b786dfe..abb2d93 100644 --- a/frontend/src/business/app-shell/selectors.test.ts +++ b/frontend/src/business/app-shell/selectors.test.ts @@ -115,6 +115,7 @@ describe('app-shell selectors', () => { expect(canAccessScopedModuleRoute(scopedModules, '/platform-dashboard', globalUser(), 'global', false)).toBe(true); expect(canAccessScopedModuleRoute(scopedModules, '/dashboard', globalUser(), 'global', false)).toBe(false); expect(canAccessScopedModuleRoute(scopedModules, '/zones-of-regulation', globalUser(), 'global', false)).toBe(false); + expect(canAccessScopedModuleRoute(scopedModules, '/director-dashboard', globalUser(), 'global', false)).toBe(true); expect(canAccessScopedModuleRoute(scopedModules, '/classroom-support', user(['READ_CLASSROOM']), 'organization', false)).toBe(true); expect(canAccessScopedModuleRoute(scopedModules, '/classroom-support', user(['READ_CLASSROOM']), 'school', false)).toBe(true); expect(canAccessScopedModuleRoute(scopedModules, '/qbs-safety', user(['READ_QBS']), 'organization', false)).toBe(true); diff --git a/frontend/src/business/app-shell/selectors.ts b/frontend/src/business/app-shell/selectors.ts index 0bcec8b..f5174bf 100644 --- a/frontend/src/business/app-shell/selectors.ts +++ b/frontend/src/business/app-shell/selectors.ts @@ -79,6 +79,9 @@ export function canAccessScopedModuleRoute( ): boolean { const module = getModuleByRoutePath(pathname); if (!module) return true; + if (module.id === 'director' && user?.app_role?.globalAccess && hasAnyPermission(user, module.permissions)) { + return true; + } return getScopedModules(modules, user, effectiveTier, isDrilled) .some((allowed) => allowed.id === module.id); } diff --git a/frontend/src/business/campus-attendance/hooks.ts b/frontend/src/business/campus-attendance/hooks.ts index 22ecea1..58fa860 100644 --- a/frontend/src/business/campus-attendance/hooks.ts +++ b/frontend/src/business/campus-attendance/hooks.ts @@ -113,6 +113,17 @@ export function useSaveCampusAttendanceSummary() { }); } +export function useAttendanceDetailsChildCampuses( + level: TenantLevel | undefined, + tenantId: string | undefined, +) { + return useQuery({ + queryKey: ['attendance-details-child-campuses', level, tenantId], + enabled: level === 'school' && Boolean(tenantId), + queryFn: () => getScopeChildren('school', tenantId ?? '', { limit: 500 }), + }); +} + export function useSaveStaffAttendanceRecord() { return useInvalidatingMutation({ mutationFn: (input: StaffAttendanceEntryDraft) => saveStaffAttendanceRecord( diff --git a/frontend/src/business/classroom-support/hooks.ts b/frontend/src/business/classroom-support/hooks.ts index 16a9b37..3a554ac 100644 --- a/frontend/src/business/classroom-support/hooks.ts +++ b/frontend/src/business/classroom-support/hooks.ts @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; @@ -8,18 +9,82 @@ import { } from '@/business/classroom-support/selectors'; import type { ClassroomSupportPage } from '@/business/classroom-support/types'; import { useClassroomStrategyFavorites } from '@/business/user-progress/hooks'; -import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { updateManagedContentCatalog } from '@/shared/api/contentCatalog'; +import { + CONTENT_CATALOG_QUERY_KEYS, + CONTENT_CATALOG_TYPES, +} from '@/shared/constants/contentCatalog'; import { useScopeContext } from '@/shared/app/scope-context'; +import { usePermissions } from '@/shared/app/usePermissions'; import type { ClassroomSupportAgeFilter, ClassroomSupportCategoryFilter, ClassroomSupportZoneFilter, } from '@/shared/constants/classroomSupport'; import type { Strategy } from '@/shared/types/app'; +import type { ClassroomStrategyDraft } from '@/business/classroom-support/types'; + +const DEFAULT_DRAFT: ClassroomStrategyDraft = { + id: null, + title: '', + description: '', + implementationTip: '', + category: 'visual-support', + ageGroup: 'All', + zone: 'green', + image: '', +}; + +function draftFromStrategy(strategy: Strategy): ClassroomStrategyDraft { + return { + id: strategy.id, + title: strategy.title, + description: strategy.description, + implementationTip: strategy.implementationTip ?? '', + category: strategy.category, + ageGroup: strategy.ageGroup, + zone: strategy.zone, + image: strategy.image, + }; +} + +function createStrategyId(): string { + return `strategy-${crypto.randomUUID()}`; +} + +function strategyFromDraft(draft: ClassroomStrategyDraft): Strategy { + return { + id: draft.id ?? createStrategyId(), + title: draft.title.trim(), + description: draft.description.trim(), + implementationTip: draft.implementationTip.trim() || undefined, + category: draft.category, + ageGroup: draft.ageGroup, + zone: draft.zone, + image: draft.image.trim(), + }; +} + +function validateStrategyDraft(draft: ClassroomStrategyDraft): string | null { + if (!draft.title.trim()) { + return 'Strategy title is required.'; + } + if (!draft.description.trim()) { + return 'Strategy description is required.'; + } + if (!draft.image.trim()) { + return 'Strategy image URL is required.'; + } + return null; +} export function useClassroomSupportPage(now: Date = new Date()): ClassroomSupportPage { - const { ownTenant, selectedTenant } = useScopeContext(); + const { effectiveTenant, ownTenant, selectedTenant } = useScopeContext(); + const permissions = usePermissions(); + const queryClient = useQueryClient(); const canPersistFavorites = canPersistPersonalScopeResults(ownTenant, selectedTenant); + const canManageStrategies = + effectiveTenant?.level === 'organization' && permissions.has('MANAGE_CONTENT_CATALOG'); const strategiesQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.classroomStrategies, [], @@ -31,7 +96,33 @@ export function useClassroomSupportPage(now: Date = new Date()): ClassroomSuppor const [zoneFilter, setZoneFilter] = useState('all'); const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); const [selectedStrategy, setSelectedStrategy] = useState(null); + const [strategyDraft, setStrategyDraft] = useState(null); + const [strategyDraftMode, setStrategyDraftMode] = useState<'create' | 'edit' | null>(null); + const [strategyDraftError, setStrategyDraftError] = useState(null); + const [strategySaveMessage, setStrategySaveMessage] = useState(null); + const [pendingDeleteStrategy, setPendingDeleteStrategy] = useState(null); const strategies = strategiesQuery.payload; + const existingStrategyIds = useMemo( + () => new Set(strategies.map((strategy) => strategy.id)), + [strategies], + ); + const visibleFavoriteStrategyIds = useMemo( + () => new Set([...favorites.favoriteStrategyIds].filter((id) => existingStrategyIds.has(id))), + [existingStrategyIds, favorites.favoriteStrategyIds], + ); + const saveStrategies = useMutation({ + mutationFn: async (nextStrategies: readonly Strategy[]) => + updateManagedContentCatalog( + CONTENT_CATALOG_TYPES.classroomStrategies, + { payload: nextStrategies }, + ), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.classroomStrategies], + }); + setStrategySaveMessage('Classroom support cards saved.'); + }, + }); const filters = useMemo( () => ({ searchQuery, @@ -43,8 +134,8 @@ export function useClassroomSupportPage(now: Date = new Date()): ClassroomSuppor [ageFilter, categoryFilter, searchQuery, showFavoritesOnly, zoneFilter], ); const filteredStrategies = useMemo( - () => filterClassroomSupportStrategies(strategies, filters, favorites.favoriteStrategyIds), - [favorites.favoriteStrategyIds, filters, strategies], + () => filterClassroomSupportStrategies(strategies, filters, visibleFavoriteStrategyIds), + [filters, strategies, visibleFavoriteStrategyIds], ); const tryTodayStrategy = useMemo( () => getClassroomSupportDailyStrategy(strategies, now), @@ -68,16 +159,110 @@ export function useClassroomSupportPage(now: Date = new Date()): ClassroomSuppor setSearchQuery(''); } + function startCreateStrategy() { + if (!canManageStrategies) { + return; + } + setStrategyDraft(DEFAULT_DRAFT); + setStrategyDraftMode('create'); + setStrategyDraftError(null); + setStrategySaveMessage(null); + } + + function startEditStrategy(strategy: Strategy) { + if (!canManageStrategies) { + return; + } + setStrategyDraft(draftFromStrategy(strategy)); + setStrategyDraftMode('edit'); + setStrategyDraftError(null); + setStrategySaveMessage(null); + } + + function updateStrategyDraft(updates: Partial) { + setStrategyDraft((current) => current ? { ...current, ...updates } : current); + setStrategyDraftError(null); + setStrategySaveMessage(null); + } + + function cancelStrategyDraft() { + setStrategyDraft(null); + setStrategyDraftMode(null); + setStrategyDraftError(null); + } + + function saveStrategyDraft() { + if (!canManageStrategies || !strategyDraft || !strategyDraftMode) { + return; + } + + const validationError = validateStrategyDraft(strategyDraft); + if (validationError) { + setStrategyDraftError(validationError); + return; + } + + const nextStrategy = strategyFromDraft(strategyDraft); + const nextStrategies = + strategyDraftMode === 'create' + ? [...strategies, nextStrategy] + : strategies.map((strategy) => strategy.id === nextStrategy.id ? nextStrategy : strategy); + + void saveStrategies.mutateAsync(nextStrategies).then(() => { + setStrategyDraft(null); + setStrategyDraftMode(null); + setStrategyDraftError(null); + }).catch(() => undefined); + } + + function requestDeleteStrategy(strategy: Strategy) { + if (!canManageStrategies) { + return; + } + + setPendingDeleteStrategy(strategy); + } + + function cancelDeleteStrategy() { + setPendingDeleteStrategy(null); + } + + function confirmDeleteStrategy() { + if (!canManageStrategies || !pendingDeleteStrategy) { + return; + } + + const id = pendingDeleteStrategy.id; + const nextStrategies = strategies.filter((strategy) => strategy.id !== id); + void saveStrategies.mutateAsync(nextStrategies).then(() => { + setPendingDeleteStrategy(null); + if (strategyDraft?.id === id) { + cancelStrategyDraft(); + } + if (selectedStrategy?.id === id) { + setSelectedStrategy(null); + } + }).catch(() => undefined); + } + return { strategies, filteredStrategies, - favoriteStrategyIds: favorites.favoriteStrategyIds, - favoriteCount: favorites.favoriteStrategyIds.size, + favoriteStrategyIds: visibleFavoriteStrategyIds, + favoriteCount: visibleFavoriteStrategyIds.size, canPersistFavorites, isSavingFavorite: favorites.isSaving, favoriteError: favorites.error, + canManageStrategies, + strategyDraft, + strategyDraftMode, + strategyDraftError, + strategySaveMessage, + isSavingStrategies: saveStrategies.isPending, + strategyManagementError: saveStrategies.error, filters, selectedStrategy, + pendingDeleteStrategy, tryTodayStrategy, isLoading: strategiesQuery.isLoading, error: strategiesQuery.error, @@ -90,5 +275,13 @@ export function useClassroomSupportPage(now: Date = new Date()): ClassroomSuppor selectStrategy: setSelectedStrategy, closeStrategy: () => setSelectedStrategy(null), clearSearch, + startCreateStrategy, + startEditStrategy, + updateStrategyDraft, + cancelStrategyDraft, + saveStrategyDraft, + requestDeleteStrategy, + cancelDeleteStrategy, + confirmDeleteStrategy, }; } diff --git a/frontend/src/business/classroom-support/types.ts b/frontend/src/business/classroom-support/types.ts index 58d5e58..50be949 100644 --- a/frontend/src/business/classroom-support/types.ts +++ b/frontend/src/business/classroom-support/types.ts @@ -5,6 +5,19 @@ import type { } from '@/shared/constants/classroomSupport'; import type { Strategy } from '@/shared/types/app'; +export interface ClassroomStrategyDraft { + readonly id: string | null; + readonly title: string; + readonly description: string; + readonly implementationTip: string; + readonly category: Strategy['category']; + readonly ageGroup: Strategy['ageGroup']; + readonly zone: Strategy['zone']; + readonly image: string; +} + +export type ClassroomStrategyDraftMode = 'create' | 'edit'; + export interface ClassroomSupportFilters { readonly searchQuery: string; readonly categoryFilter: ClassroomSupportCategoryFilter; @@ -21,8 +34,16 @@ export interface ClassroomSupportPage { readonly canPersistFavorites: boolean; readonly isSavingFavorite: boolean; readonly favoriteError: unknown; + readonly canManageStrategies: boolean; + readonly strategyDraft: ClassroomStrategyDraft | null; + readonly strategyDraftMode: ClassroomStrategyDraftMode | null; + readonly strategyDraftError: string | null; + readonly strategySaveMessage: string | null; + readonly isSavingStrategies: boolean; + readonly strategyManagementError: unknown; readonly filters: ClassroomSupportFilters; readonly selectedStrategy: Strategy | null; + readonly pendingDeleteStrategy: Strategy | null; readonly tryTodayStrategy: Strategy | null; readonly isLoading: boolean; readonly error: unknown; @@ -35,4 +56,12 @@ export interface ClassroomSupportPage { readonly selectStrategy: (strategy: Strategy) => void; readonly closeStrategy: () => void; readonly clearSearch: () => void; + readonly startCreateStrategy: () => void; + readonly startEditStrategy: (strategy: Strategy) => void; + readonly updateStrategyDraft: (updates: Partial) => void; + readonly cancelStrategyDraft: () => void; + readonly saveStrategyDraft: () => void; + readonly requestDeleteStrategy: (strategy: Strategy) => void; + readonly cancelDeleteStrategy: () => void; + readonly confirmDeleteStrategy: () => void; } diff --git a/frontend/src/business/files/api.ts b/frontend/src/business/files/api.ts index c6058bb..7952365 100644 --- a/frontend/src/business/files/api.ts +++ b/frontend/src/business/files/api.ts @@ -1 +1 @@ -export { fileDownloadUrl, uploadFile } from '@/shared/api/files'; +export { fileAssetUrl, fileDownloadUrl, uploadFile } from '@/shared/api/files'; diff --git a/frontend/src/business/staff-attendance/mappers.test.ts b/frontend/src/business/staff-attendance/mappers.test.ts index 3b34177..dba951f 100644 --- a/frontend/src/business/staff-attendance/mappers.test.ts +++ b/frontend/src/business/staff-attendance/mappers.test.ts @@ -31,6 +31,8 @@ describe('staff attendance mappers', () => { note: 'Traffic delay', userName: 'Jordan Lee', userRole: 'Teacher', + campusId: 'campus-1', + userId: 'user-1', }); }); diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 7adc78b..c2f3121 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -42,7 +42,11 @@ const AppLayout: React.FC = () => {
-
+
diff --git a/frontend/src/components/classroom-support/ClassroomStrategyCard.tsx b/frontend/src/components/classroom-support/ClassroomStrategyCard.tsx index 7ed681b..dd384a4 100644 --- a/frontend/src/components/classroom-support/ClassroomStrategyCard.tsx +++ b/frontend/src/components/classroom-support/ClassroomStrategyCard.tsx @@ -1,8 +1,9 @@ -import { Bookmark, BookmarkPlus } from 'lucide-react'; +import { Bookmark, BookmarkPlus, Edit3, Trash2 } from 'lucide-react'; import type { KeyboardEvent } from 'react'; import { Button } from '@/components/ui/button'; +import { fileAssetUrl } from '@/business/files/api'; import { CLASSROOM_SUPPORT_CATEGORY_COLOR_CLASSES, CLASSROOM_SUPPORT_ZONE_COLOR_CLASSES, @@ -13,16 +14,24 @@ interface ClassroomStrategyCardProps { readonly strategy: Strategy; readonly isFavorite: boolean; readonly canPersistFavorite: boolean; + readonly canManageStrategy: boolean; + readonly isSavingStrategy: boolean; readonly onSelect: (strategy: Strategy) => void; readonly onToggleFavorite: (id: string) => void; + readonly onEditStrategy: (strategy: Strategy) => void; + readonly onDeleteStrategy: (strategy: Strategy) => void; } export function ClassroomStrategyCard({ strategy, isFavorite, canPersistFavorite, + canManageStrategy, + isSavingStrategy, onSelect, onToggleFavorite, + onEditStrategy, + onDeleteStrategy, }: ClassroomStrategyCardProps) { function openStrategy() { onSelect(strategy); @@ -45,25 +54,55 @@ export function ClassroomStrategyCard({ className="absolute inset-0 z-0 w-full h-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50 focus-visible:ring-inset rounded-2xl" />
- {strategy.title} + {strategy.title}
- {canPersistFavorite && ( - - )} +
+ {canManageStrategy && ( + <> + + + + )} + {canPersistFavorite && ( + + )} +
{strategy.category.replace('-', ' ')} diff --git a/frontend/src/components/classroom-support/ClassroomStrategyDetailModal.tsx b/frontend/src/components/classroom-support/ClassroomStrategyDetailModal.tsx index 9f0974a..bab63d6 100644 --- a/frontend/src/components/classroom-support/ClassroomStrategyDetailModal.tsx +++ b/frontend/src/components/classroom-support/ClassroomStrategyDetailModal.tsx @@ -1,6 +1,7 @@ import { Bookmark, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { fileAssetUrl } from '@/business/files/api'; import { CLASSROOM_SUPPORT_CATEGORY_COLOR_CLASSES, } from '@/shared/constants/classroomSupport'; @@ -39,7 +40,7 @@ export function ClassroomStrategyDetailModal({ onClick={(event) => event.stopPropagation()} >
- {strategy.title} + {strategy.title}
diff --git a/frontend/src/components/classroom-support/ClassroomSupportManagementPanel.tsx b/frontend/src/components/classroom-support/ClassroomSupportManagementPanel.tsx new file mode 100644 index 0000000..73b21c4 --- /dev/null +++ b/frontend/src/components/classroom-support/ClassroomSupportManagementPanel.tsx @@ -0,0 +1,223 @@ +import { Plus, Save, X } from 'lucide-react'; + +import type { + ClassroomStrategyDraft, + ClassroomSupportPage, +} from '@/business/classroom-support/types'; +import { Button } from '@/components/ui/button'; +import { ImageUpload } from '@/components/common/ImageUpload'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; +import { Textarea } from '@/components/ui/textarea'; +import { + CLASSROOM_SUPPORT_AGE_FILTERS, + CLASSROOM_SUPPORT_CATEGORY_FILTERS, + CLASSROOM_SUPPORT_ZONE_FILTERS, +} from '@/shared/constants/classroomSupport'; +import type { Strategy } from '@/shared/types/app'; + +interface ClassroomSupportManagementPanelProps { + readonly page: ClassroomSupportPage; +} + +type DraftField = keyof ClassroomStrategyDraft; + +function toStrategyCategory(value: string): Strategy['category'] { + switch (value) { + case 'transition': + case 'sensory': + case 'communication': + case 'behavior': + case 'social': + return value; + case 'visual-support': + default: + return 'visual-support'; + } +} + +function toStrategyAgeGroup(value: string): Strategy['ageGroup'] { + switch (value) { + case 'K-2': + case '3-5': + case '6-8': + return value; + case 'All': + default: + return 'All'; + } +} + +function toStrategyZone(value: string): Strategy['zone'] { + switch (value) { + case 'blue': + case 'yellow': + case 'red': + return value; + case 'green': + default: + return 'green'; + } +} + +export function ClassroomSupportManagementPanel({ page }: ClassroomSupportManagementPanelProps) { + if (!page.canManageStrategies) { + return null; + } + + const draft = page.strategyDraft; + + function updateTextField(field: DraftField, value: string) { + page.updateStrategyDraft({ [field]: value }); + } + + return ( +
+
+
+

Manage Strategy Cards

+

Organization-level classroom support content

+
+ +
+ +
+ {draft && ( +
+
+

+ {page.strategyDraftMode === 'create' ? 'New Strategy' : 'Edit Strategy'} +

+ +
+ +
+ + page.updateStrategyDraft({ image: privateUrl ?? '' })} + table="classroom-support" + field="strategy-images" + label="Strategy image" + /> +