Classroom support strategies CRUD
This commit is contained in:
parent
5e9b4995ab
commit
40e60165d4
@ -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`.
|
||||
|
||||
@ -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<string, unknown> | null = null;
|
||||
mock.method(db.content_catalog, 'findOne', (async (options: { where?: Record<string, unknown> }) => {
|
||||
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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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<Record<string, unknown>> = [];
|
||||
|
||||
mock.method(db.content_catalog, 'findOne', (async () => null) as typeof db.content_catalog.findOne);
|
||||
mock.method(db.content_catalog, 'create', (async (payload: Record<string, unknown>) => {
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
@ -48,7 +48,7 @@ describe('UserProgressService drilled scope persistence guard', () => {
|
||||
permissions: [],
|
||||
},
|
||||
});
|
||||
let createdPayload: Record<string, unknown> | null = null;
|
||||
let createdPayload: Record<string, unknown> | 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<string, unknown>;
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -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/`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
]);
|
||||
});
|
||||
|
||||
@ -103,8 +103,8 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <IndexRedirect /> },
|
||||
...extraShellRoutes,
|
||||
...moduleRoutes,
|
||||
...extraShellRoutes,
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<readonly Strategy[]>(
|
||||
CONTENT_CATALOG_TYPES.classroomStrategies,
|
||||
[],
|
||||
@ -31,7 +96,33 @@ export function useClassroomSupportPage(now: Date = new Date()): ClassroomSuppor
|
||||
const [zoneFilter, setZoneFilter] = useState<ClassroomSupportZoneFilter>('all');
|
||||
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
||||
const [selectedStrategy, setSelectedStrategy] = useState<Strategy | null>(null);
|
||||
const [strategyDraft, setStrategyDraft] = useState<ClassroomStrategyDraft | null>(null);
|
||||
const [strategyDraftMode, setStrategyDraftMode] = useState<'create' | 'edit' | null>(null);
|
||||
const [strategyDraftError, setStrategyDraftError] = useState<string | null>(null);
|
||||
const [strategySaveMessage, setStrategySaveMessage] = useState<string | null>(null);
|
||||
const [pendingDeleteStrategy, setPendingDeleteStrategy] = useState<Strategy | null>(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<readonly Strategy[]>(
|
||||
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<ClassroomStrategyDraft>) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<ClassroomStrategyDraft>) => void;
|
||||
readonly cancelStrategyDraft: () => void;
|
||||
readonly saveStrategyDraft: () => void;
|
||||
readonly requestDeleteStrategy: (strategy: Strategy) => void;
|
||||
readonly cancelDeleteStrategy: () => void;
|
||||
readonly confirmDeleteStrategy: () => void;
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
export { fileDownloadUrl, uploadFile } from '@/shared/api/files';
|
||||
export { fileAssetUrl, fileDownloadUrl, uploadFile } from '@/shared/api/files';
|
||||
|
||||
@ -31,6 +31,8 @@ describe('staff attendance mappers', () => {
|
||||
note: 'Traffic delay',
|
||||
userName: 'Jordan Lee',
|
||||
userRole: 'Teacher',
|
||||
campusId: 'campus-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -42,7 +42,11 @@ const AppLayout: React.FC = () => {
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<TopBar {...topBarProps} />
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8">
|
||||
<main
|
||||
aria-label="Main content"
|
||||
className="flex-1 overflow-y-auto p-4 outline-none md:p-6 lg:p-8"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Outlet context={shellOutletContext} />
|
||||
</div>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
<div className="h-40 overflow-hidden relative">
|
||||
<img src={strategy.image} alt={strategy.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
||||
<img src={fileAssetUrl(strategy.image)} alt={strategy.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/80 via-transparent to-transparent" />
|
||||
{canPersistFavorite && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleFavorite(strategy.id);
|
||||
}}
|
||||
aria-label={isFavorite ? 'Remove strategy from favorites' : 'Save strategy to favorites'}
|
||||
className="absolute top-2 right-2 z-10 w-8 h-8 bg-slate-900/60 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-slate-900/80 transition-colors p-0"
|
||||
>
|
||||
{isFavorite ? (
|
||||
<Bookmark size={16} className="text-amber-400 fill-amber-400" />
|
||||
) : (
|
||||
<BookmarkPlus size={16} className="text-slate-300" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<div className="absolute right-2 top-2 z-10 flex gap-1.5">
|
||||
{canManageStrategy && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEditStrategy(strategy);
|
||||
}}
|
||||
disabled={isSavingStrategy}
|
||||
aria-label={`Edit ${strategy.title}`}
|
||||
className="h-8 w-8 rounded-full bg-slate-900/60 p-0 text-slate-200 backdrop-blur-sm hover:bg-slate-900/80"
|
||||
>
|
||||
<Edit3 size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDeleteStrategy(strategy);
|
||||
}}
|
||||
disabled={isSavingStrategy}
|
||||
aria-label={`Delete ${strategy.title}`}
|
||||
className="h-8 w-8 rounded-full bg-slate-900/60 p-0 text-red-300 backdrop-blur-sm hover:bg-red-950/80 hover:text-red-200"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canPersistFavorite && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleFavorite(strategy.id);
|
||||
}}
|
||||
aria-label={isFavorite ? 'Remove strategy from favorites' : 'Save strategy to favorites'}
|
||||
className="h-8 w-8 rounded-full bg-slate-900/60 p-0 backdrop-blur-sm hover:bg-slate-900/80"
|
||||
>
|
||||
{isFavorite ? (
|
||||
<Bookmark size={16} className="text-amber-400 fill-amber-400" />
|
||||
) : (
|
||||
<BookmarkPlus size={16} className="text-slate-300" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-2 left-2 flex gap-1.5">
|
||||
<span className={`px-2 py-0.5 rounded-lg text-[10px] font-semibold border ${CLASSROOM_SUPPORT_CATEGORY_COLOR_CLASSES[strategy.category]}`}>
|
||||
{strategy.category.replace('-', ' ')}
|
||||
|
||||
@ -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()}
|
||||
>
|
||||
<div className="h-52 overflow-hidden relative rounded-t-2xl">
|
||||
<img src={strategy.image} alt={strategy.title} className="w-full h-full object-cover" />
|
||||
<img src={fileAssetUrl(strategy.image)} alt={strategy.title} className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-800/90 via-transparent to-transparent" />
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@ -43,8 +43,12 @@ export function ClassroomStrategyGrid({ page }: ClassroomStrategyGridProps) {
|
||||
strategy={strategy}
|
||||
isFavorite={page.favoriteStrategyIds.has(strategy.id)}
|
||||
canPersistFavorite={page.canPersistFavorites}
|
||||
canManageStrategy={page.canManageStrategies}
|
||||
isSavingStrategy={page.isSavingStrategies}
|
||||
onSelect={page.selectStrategy}
|
||||
onToggleFavorite={page.toggleFavorite}
|
||||
onEditStrategy={page.startEditStrategy}
|
||||
onDeleteStrategy={page.requestDeleteStrategy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -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 (
|
||||
<section className="rounded-2xl border border-slate-700/50 bg-slate-900/45 overflow-hidden">
|
||||
<div className="flex flex-col gap-3 border-b border-slate-700/50 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Manage Strategy Cards</h3>
|
||||
<p className="text-sm text-slate-400">Organization-level classroom support content</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={page.startCreateStrategy}
|
||||
disabled={page.isSavingStrategies}
|
||||
leadingIcon={<Plus size={16} />}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
>
|
||||
Add Strategy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-5">
|
||||
{draft && (
|
||||
<div className="rounded-xl border border-slate-700/60 bg-slate-950/35 p-4">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 className="text-base font-semibold text-white">
|
||||
{page.strategyDraftMode === 'create' ? 'New Strategy' : 'Edit Strategy'}
|
||||
</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={page.cancelStrategyDraft}
|
||||
leadingIcon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<label className="space-y-2 text-sm font-medium text-slate-300">
|
||||
Title
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) => updateTextField('title', event.target.value)}
|
||||
placeholder="Strategy title"
|
||||
/>
|
||||
</label>
|
||||
<ImageUpload
|
||||
value={draft.image || null}
|
||||
onChange={(privateUrl) => page.updateStrategyDraft({ image: privateUrl ?? '' })}
|
||||
table="classroom-support"
|
||||
field="strategy-images"
|
||||
label="Strategy image"
|
||||
/>
|
||||
<label className="space-y-2 text-sm font-medium text-slate-300 lg:col-span-2">
|
||||
Description
|
||||
<Textarea
|
||||
value={draft.description}
|
||||
onChange={(event) => updateTextField('description', event.target.value)}
|
||||
placeholder="Short card description"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm font-medium text-slate-300 lg:col-span-2">
|
||||
Implementation Tip
|
||||
<Textarea
|
||||
value={draft.implementationTip}
|
||||
onChange={(event) => updateTextField('implementationTip', event.target.value)}
|
||||
placeholder="Practical implementation detail"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm font-medium text-slate-300">
|
||||
Category
|
||||
<NativeSelect
|
||||
value={draft.category}
|
||||
onChange={(event) => page.updateStrategyDraft({ category: toStrategyCategory(event.target.value) })}
|
||||
>
|
||||
{CLASSROOM_SUPPORT_CATEGORY_FILTERS
|
||||
.filter((category) => category.value !== 'all')
|
||||
.map((category) => (
|
||||
<option key={category.value} value={category.value}>
|
||||
{category.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm font-medium text-slate-300">
|
||||
Age Group
|
||||
<NativeSelect
|
||||
value={draft.ageGroup}
|
||||
onChange={(event) => page.updateStrategyDraft({ ageGroup: toStrategyAgeGroup(event.target.value) })}
|
||||
>
|
||||
{CLASSROOM_SUPPORT_AGE_FILTERS
|
||||
.filter((age) => age.value !== 'all')
|
||||
.map((age) => (
|
||||
<option key={age.value} value={age.value}>
|
||||
{age.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm font-medium text-slate-300">
|
||||
Zone
|
||||
<NativeSelect
|
||||
value={draft.zone}
|
||||
onChange={(event) => page.updateStrategyDraft({ zone: toStrategyZone(event.target.value) })}
|
||||
>
|
||||
{CLASSROOM_SUPPORT_ZONE_FILTERS
|
||||
.filter((zone) => zone.value !== 'all')
|
||||
.map((zone) => (
|
||||
<option key={zone.value} value={zone.value}>
|
||||
{zone.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{page.strategyDraftError && (
|
||||
<p className="mt-4 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm font-medium text-red-300">
|
||||
{page.strategyDraftError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={page.saveStrategyDraft}
|
||||
loading={page.isSavingStrategies}
|
||||
leadingIcon={<Save size={16} />}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white"
|
||||
>
|
||||
Save Strategy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Boolean(page.strategyManagementError) && (
|
||||
<p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm font-medium text-red-300">
|
||||
Classroom support cards could not be saved.
|
||||
</p>
|
||||
)}
|
||||
{page.strategySaveMessage && (
|
||||
<p className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm font-medium text-emerald-300">
|
||||
{page.strategySaveMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import type { ClassroomSupportPage } from '@/business/classroom-support/types';
|
||||
import { ConfirmationDialog } from '@/components/common/ConfirmationDialog';
|
||||
import { ClassroomSupportFilters } from '@/components/classroom-support/ClassroomSupportFilters';
|
||||
import { ClassroomSupportHeader } from '@/components/classroom-support/ClassroomSupportHeader';
|
||||
import { ClassroomSupportManagementPanel } from '@/components/classroom-support/ClassroomSupportManagementPanel';
|
||||
import { ClassroomSupportTryToday } from '@/components/classroom-support/ClassroomSupportTryToday';
|
||||
import { ClassroomStrategyDetailModal } from '@/components/classroom-support/ClassroomStrategyDetailModal';
|
||||
import { ClassroomStrategyGrid } from '@/components/classroom-support/ClassroomStrategyGrid';
|
||||
@ -18,6 +20,7 @@ export function ClassroomSupportView({ page }: ClassroomSupportViewProps) {
|
||||
isLoading={page.isLoading}
|
||||
error={page.error}
|
||||
/>
|
||||
<ClassroomSupportManagementPanel page={page} />
|
||||
<ClassroomSupportFilters
|
||||
filters={page.filters}
|
||||
favoriteCount={page.favoriteCount}
|
||||
@ -38,6 +41,22 @@ export function ClassroomSupportView({ page }: ClassroomSupportViewProps) {
|
||||
onToggleFavorite={page.toggleFavorite}
|
||||
onClose={page.closeStrategy}
|
||||
/>
|
||||
<ConfirmationDialog
|
||||
open={Boolean(page.pendingDeleteStrategy)}
|
||||
title="Delete classroom strategy?"
|
||||
description={
|
||||
page.pendingDeleteStrategy
|
||||
? `This will remove "${page.pendingDeleteStrategy.title}" from the organization classroom support library.`
|
||||
: 'This will remove the classroom support strategy.'
|
||||
}
|
||||
confirmLabel="Delete strategy"
|
||||
loadingLabel="Deleting..."
|
||||
loading={page.isSavingStrategies}
|
||||
disabled={!page.pendingDeleteStrategy}
|
||||
tone="danger"
|
||||
onCancel={page.cancelDeleteStrategy}
|
||||
onConfirm={page.confirmDeleteStrategy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
115
frontend/src/components/common/ConfirmationDialog.tsx
Normal file
115
frontend/src/components/common/ConfirmationDialog.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ConfirmationDialogTone = 'danger' | 'warning' | 'info';
|
||||
|
||||
interface ConfirmationDialogProps {
|
||||
readonly open: boolean;
|
||||
readonly title: string;
|
||||
readonly description: ReactNode;
|
||||
readonly confirmLabel: string;
|
||||
readonly cancelLabel?: string;
|
||||
readonly loading?: boolean;
|
||||
readonly loadingLabel?: string;
|
||||
readonly disabled?: boolean;
|
||||
readonly tone?: ConfirmationDialogTone;
|
||||
readonly icon?: ReactNode;
|
||||
readonly onCancel: () => void;
|
||||
readonly onConfirm: () => void;
|
||||
}
|
||||
|
||||
const toneClasses: Record<ConfirmationDialogTone, {
|
||||
readonly panelAccent: string;
|
||||
readonly iconWrap: string;
|
||||
readonly confirmButton: string;
|
||||
}> = {
|
||||
danger: {
|
||||
panelAccent: 'from-red-500/12 via-rose-500/8 to-transparent border-red-500/20',
|
||||
iconWrap: 'border-red-500/30 bg-gradient-to-br from-red-500/25 to-rose-600/15 text-red-200 shadow-red-500/20',
|
||||
confirmButton: 'bg-gradient-to-r from-red-500 to-rose-600 text-white hover:from-red-400 hover:to-rose-500 shadow-lg shadow-red-500/20',
|
||||
},
|
||||
warning: {
|
||||
panelAccent: 'from-amber-500/12 via-orange-500/8 to-transparent border-amber-500/20',
|
||||
iconWrap: 'border-amber-500/30 bg-gradient-to-br from-amber-400/25 to-orange-500/15 text-amber-200 shadow-amber-500/20',
|
||||
confirmButton: 'bg-gradient-to-r from-amber-400 to-orange-500 text-slate-950 hover:from-amber-300 hover:to-orange-400 shadow-lg shadow-amber-500/20',
|
||||
},
|
||||
info: {
|
||||
panelAccent: 'from-blue-500/12 via-cyan-500/8 to-transparent border-blue-500/20',
|
||||
iconWrap: 'border-blue-500/30 bg-gradient-to-br from-blue-500/25 to-cyan-500/15 text-blue-200 shadow-blue-500/20',
|
||||
confirmButton: 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:from-blue-400 hover:to-cyan-400 shadow-lg shadow-blue-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
export function ConfirmationDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
cancelLabel = 'Cancel',
|
||||
loading = false,
|
||||
loadingLabel,
|
||||
disabled = false,
|
||||
tone = 'danger',
|
||||
icon,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ConfirmationDialogProps) {
|
||||
const fallbackIcon = tone === 'info' ? <Info size={22} /> : <AlertTriangle size={22} />;
|
||||
const classes = toneClasses[tone];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !loading) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="overflow-hidden rounded-2xl border-slate-700/60 bg-slate-900/95 p-0 text-white shadow-2xl shadow-black/40 backdrop-blur-xl sm:max-w-lg">
|
||||
<DialogHeader className={cn('gap-0 border-b bg-gradient-to-br p-6 text-left', classes.panelAccent)}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={cn('flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border shadow-lg', classes.iconWrap)}>
|
||||
{icon ?? fallbackIcon}
|
||||
</div>
|
||||
<div className="space-y-2 pt-0.5">
|
||||
<DialogTitle className="text-xl font-semibold leading-tight text-white">{title}</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-6 text-slate-300">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-3 border-t border-slate-800/80 bg-slate-950/35 p-5 sm:space-x-0">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="h-11 rounded-xl border border-slate-700/70 bg-slate-800/70 px-5 text-slate-200 hover:bg-slate-700/80"
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={disabled || loading}
|
||||
loading={loading}
|
||||
loadingLabel={loadingLabel ?? confirmLabel}
|
||||
className={cn(classes.confirmButton, 'h-11 rounded-xl px-5 disabled:cursor-not-allowed disabled:opacity-60')}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { ImagePlus, Loader2 } from 'lucide-react';
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { uploadFile, fileDownloadUrl } from '@/business/files/api';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { fileAssetUrl, uploadFile } from '@/business/files/api';
|
||||
import { getErrorMessage } from '@/shared/errors/errorMessages';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@ -54,8 +55,13 @@ export function ImageUpload({
|
||||
shape === 'circle' ? 'rounded-full' : 'rounded-xl',
|
||||
)}
|
||||
>
|
||||
{value ? (
|
||||
<img src={fileDownloadUrl(value)} alt={label} className="h-full w-full object-cover" />
|
||||
{uploading ? (
|
||||
<Skeleton
|
||||
aria-label={`${label} upload in progress`}
|
||||
className="h-full w-full rounded-none bg-slate-700/70"
|
||||
/>
|
||||
) : value ? (
|
||||
<img src={fileAssetUrl(value)} alt={label} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<ImagePlus size={20} />
|
||||
)}
|
||||
@ -75,8 +81,7 @@ export function ImageUpload({
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="h-auto px-3 py-1.5 text-xs"
|
||||
>
|
||||
{uploading && <Loader2 size={14} className="mr-1.5 animate-spin" />}
|
||||
{value ? 'Replace' : 'Upload'}
|
||||
{uploading ? 'Uploading...' : value ? 'Replace' : 'Upload'}
|
||||
</Button>
|
||||
{value && (
|
||||
<Button
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { ArrowLeft, Calendar, UserCheck, Users } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useShellOutletContext } from '@/app/shellOutletContext';
|
||||
import { useCampusAttendancePage } from '@/business/campus-attendance/hooks';
|
||||
import {
|
||||
useAttendanceDetailsChildCampuses,
|
||||
useCampusAttendancePage,
|
||||
} from '@/business/campus-attendance/hooks';
|
||||
import { formatAttendanceDate } from '@/business/campus-attendance/selectors';
|
||||
import { useStaffAttendanceRecords } from '@/business/staff-attendance/hooks';
|
||||
import { CampusAttendanceLoadingState } from '@/components/campus-attendance/CampusAttendanceStatus';
|
||||
@ -17,7 +19,6 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { useScopeContext } from '@/contexts/scope-context';
|
||||
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
|
||||
import { getScopeChildren } from '@/shared/api/scope';
|
||||
import type { StaffAttendanceStatus } from '@/shared/types/staffAttendance';
|
||||
import type { TenantLevel } from '@/shared/types/scope';
|
||||
|
||||
@ -58,11 +59,7 @@ export default function CampusAttendanceDetailsPage() {
|
||||
{ limit: 500 },
|
||||
state.roleAccess.canReadStaffReports,
|
||||
);
|
||||
const childCampusesQuery = useQuery({
|
||||
queryKey: ['attendance-details-child-campuses', level, tenantId],
|
||||
enabled: level === 'school' && Boolean(tenantId),
|
||||
queryFn: () => getScopeChildren('school', tenantId ?? '', { limit: 500 }),
|
||||
});
|
||||
const childCampusesQuery = useAttendanceDetailsChildCampuses(level, tenantId);
|
||||
|
||||
if (state.loading || childCampusesQuery.isLoading) {
|
||||
return <CampusAttendanceLoadingState />;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fileDownloadUrl, uploadFile } from '@/shared/api/files';
|
||||
import { fileAssetUrl, fileDownloadUrl, uploadFile } from '@/shared/api/files';
|
||||
import { apiFormRequest, createApiUrl } from '@/shared/api/httpClient';
|
||||
|
||||
vi.mock('@/shared/api/httpClient', () => ({
|
||||
@ -27,4 +27,12 @@ describe('files API', () => {
|
||||
);
|
||||
expect(createApiUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves uploaded private URLs and leaves public image URLs intact', () => {
|
||||
expect(fileAssetUrl('classroom-support/strategy-images/card.png')).toBe(
|
||||
'http://localhost:8080/file/download?privateUrl=classroom-support%2Fstrategy-images%2Fcard.png',
|
||||
);
|
||||
expect(fileAssetUrl('/images/card.png')).toBe('/images/card.png');
|
||||
expect(fileAssetUrl('https://cdn.example.com/card.png')).toBe('https://cdn.example.com/card.png');
|
||||
});
|
||||
});
|
||||
|
||||
@ -36,3 +36,17 @@ export async function uploadFile(
|
||||
export function fileDownloadUrl(privateUrl: string): string {
|
||||
return fileUrl(`/file/download?privateUrl=${encodeURIComponent(privateUrl)}`);
|
||||
}
|
||||
|
||||
export function fileAssetUrl(value: string): string {
|
||||
if (
|
||||
value.startsWith('/')
|
||||
|| value.startsWith('http://')
|
||||
|| value.startsWith('https://')
|
||||
|| value.startsWith('data:')
|
||||
|| value.startsWith('blob:')
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return fileDownloadUrl(value);
|
||||
}
|
||||
|
||||
@ -53,6 +53,21 @@ describe('user progress API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('upserts classroom strategy favorites with the typed progress payload', () => {
|
||||
const request: UserProgressMutationDto = {
|
||||
progress_type: 'classroom_strategy_favorite',
|
||||
item_id: 'first-then-board',
|
||||
value: 'saved',
|
||||
};
|
||||
|
||||
void upsertUserProgress(request);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('/user_progress', {
|
||||
method: 'POST',
|
||||
body: { data: request },
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes progress by item through the typed DELETE endpoint', () => {
|
||||
void deleteUserProgressByItem('sign_learned', 'help');
|
||||
|
||||
|
||||
@ -10,6 +10,14 @@ export function isForbiddenError(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 403;
|
||||
}
|
||||
|
||||
export function isAuthExpiredError(error: unknown): boolean {
|
||||
return error instanceof AuthExpiredError;
|
||||
}
|
||||
|
||||
export function isUnauthorizedError(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 401;
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown, fallbackMessage = DEFAULT_ERROR_MESSAGE): string {
|
||||
if (error instanceof AuthExpiredError) {
|
||||
return AUTH_EXPIRED_ERROR_MESSAGE;
|
||||
|
||||
@ -11,10 +11,15 @@ const TEST_USER = {
|
||||
password: 'flatlogicUser123!',
|
||||
};
|
||||
|
||||
async function authenticateViaPage(page: Page): Promise<void> {
|
||||
const CLASS_SCOPE_TEST_USER = {
|
||||
email: 'teacher@flatlogic.com',
|
||||
password: 'flatlogicUser123!',
|
||||
};
|
||||
|
||||
async function authenticateViaPage(page: Page, user = TEST_USER): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.getByPlaceholder('you@school.edu').fill(TEST_USER.email);
|
||||
await page.getByPlaceholder('Enter your password').fill(TEST_USER.password);
|
||||
await page.getByPlaceholder('you@school.edu').fill(user.email);
|
||||
await page.getByPlaceholder('Enter your password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Sign In', exact: true }).click();
|
||||
await page.waitForURL('/', { timeout: 10000 });
|
||||
}
|
||||
@ -133,7 +138,7 @@ test.describe('seeded content catalog integration', () => {
|
||||
});
|
||||
|
||||
test('renders static classroom timer content', async ({ page }) => {
|
||||
await authenticateViaPage(page);
|
||||
await authenticateViaPage(page, CLASS_SCOPE_TEST_USER);
|
||||
await page.goto('/classroom-timer');
|
||||
|
||||
await expect(page.getByText('Loading classroom timer content...')).toHaveCount(0);
|
||||
|
||||
11
frontend/tests/e2e/smoke.e2e.ts
Normal file
11
frontend/tests/e2e/smoke.e2e.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('frontend smoke', () => {
|
||||
test('renders the login page without a backend session', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible();
|
||||
await expect(page.getByPlaceholder('you@school.edu')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Sign In', exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user