Enhance audience management features with new components and API integrations
- Added `AssignCoursesModal`, `AudienceMemberRow`, `AudienceTable`, and related components for improved audience management UI. - Implemented audience invite actions including resend and revoke functionality. - Updated audience API to support importing members and assigning courses. - Refactored audience data handling to include status and invite management. - Improved overall user experience with pagination and selection features in the audience table.
This commit is contained in:
@@ -66,12 +66,13 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id });
|
||||
await createProfileHook(user);
|
||||
}
|
||||
},
|
||||
update: {
|
||||
after: async (user) => {
|
||||
console.log('update user', user);
|
||||
console.log('[auth] databaseHooks.user.update.after: running', { userId: user.id });
|
||||
await syncUserWithProfile(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ async function isSelfHostedFirstSignup(): Promise<boolean> {
|
||||
* Self-hosted first signup: auto-verify email (no orgs exist yet, this user will create the only org).
|
||||
*/
|
||||
export const createProfileHook = async (user: User) => {
|
||||
console.debug('createProfileHook', user);
|
||||
console.log('[auth] createProfileHook: running', { userId: user.id });
|
||||
|
||||
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
|
||||
console.debug('existingProfile', existingProfile);
|
||||
|
||||
@@ -16,6 +16,7 @@ const afterHooks: Array<(context: AuthMiddlewareContext) => Promise<unknown>> =
|
||||
* This function will be called by better-auth after each request
|
||||
*/
|
||||
export const combineAfterHooks = async (context: AuthMiddlewareContext) => {
|
||||
console.log('[auth] combineAfterHooks: running');
|
||||
for (const hook of afterHooks) {
|
||||
try {
|
||||
await hook(context);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { User } from 'better-auth';
|
||||
import { db } from '@db/drizzle';
|
||||
import { enrollUsersInCourseGroups } from '@db/queries/group';
|
||||
import { getOrgCourseGroups } from '@db/queries/course';
|
||||
import { markUserAndProfileEmailVerified } from '@db/queries/auth';
|
||||
|
||||
const DEFAULT_STUDENT_ROLE_ID = 3;
|
||||
@@ -21,13 +23,25 @@ export async function ensureOrgMembership(
|
||||
): Promise<void> {
|
||||
const emailLower = email.toLowerCase();
|
||||
|
||||
const [existing] = await db
|
||||
const [existingByProfile] = await db
|
||||
.select()
|
||||
.from(schema.organizationmember)
|
||||
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.profileId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
if (existingByProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** Audience import pre-creates a row with org + email before profile exists; do not insert again. */
|
||||
const [existingByEmail] = await db
|
||||
.select()
|
||||
.from(schema.organizationmember)
|
||||
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.email, emailLower)))
|
||||
.limit(1);
|
||||
|
||||
if (existingByEmail?.profileId && existingByEmail.profileId !== userId) {
|
||||
console.error('ensureOrgMembership: org member email already linked to another profile');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,20 +52,33 @@ export async function ensureOrgMembership(
|
||||
and(
|
||||
eq(schema.organizationInvite.organizationId, orgId),
|
||||
eq(schema.organizationInvite.email, emailLower),
|
||||
eq(schema.organizationInvite.isRevoked, false)
|
||||
eq(schema.organizationInvite.isRevoked, false),
|
||||
isNull(schema.organizationInvite.acceptedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (invite) {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.organizationmember).values({
|
||||
organizationId: orgId,
|
||||
profileId: userId,
|
||||
email: emailLower,
|
||||
roleId: invite.roleId,
|
||||
verified: true
|
||||
});
|
||||
if (existingByEmail) {
|
||||
await tx
|
||||
.update(schema.organizationmember)
|
||||
.set({
|
||||
profileId: userId,
|
||||
email: emailLower,
|
||||
roleId: invite.roleId,
|
||||
verified: true
|
||||
})
|
||||
.where(eq(schema.organizationmember.id, existingByEmail.id));
|
||||
} else {
|
||||
await tx.insert(schema.organizationmember).values({
|
||||
organizationId: orgId,
|
||||
profileId: userId,
|
||||
email: emailLower,
|
||||
roleId: invite.roleId,
|
||||
verified: true
|
||||
});
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(schema.organizationInvite)
|
||||
@@ -65,10 +92,46 @@ export async function ensureOrgMembership(
|
||||
await markUserAndProfileEmailVerified(userId, tx);
|
||||
});
|
||||
|
||||
const metadata = invite.metadata as Record<string, unknown> | null;
|
||||
const courseIds = Array.isArray(metadata?.courseIds) ? (metadata.courseIds as string[]) : [];
|
||||
|
||||
if (courseIds.length > 0) {
|
||||
try {
|
||||
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
|
||||
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
|
||||
await enrollUsersInCourseGroups(validGroupIds, [{ profileId: userId, email: emailLower }], invite.roleId);
|
||||
} catch (error) {
|
||||
console.error('ensureOrgMembership course enrollment error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.debug('User joined org via invite:', orgId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingByEmail) {
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(schema.organizationAuthPolicy)
|
||||
.where(eq(schema.organizationAuthPolicy.organizationId, orgId))
|
||||
.limit(1);
|
||||
|
||||
const defaultRoleId = policy?.defaultRoleId ?? DEFAULT_STUDENT_ROLE_ID;
|
||||
const finalRoleId = roleId ?? defaultRoleId;
|
||||
|
||||
await db
|
||||
.update(schema.organizationmember)
|
||||
.set({
|
||||
profileId: userId,
|
||||
verified: true,
|
||||
roleId: finalRoleId
|
||||
})
|
||||
.where(eq(schema.organizationmember.id, existingByEmail.id));
|
||||
|
||||
console.debug('User linked to existing org membership row:', orgId);
|
||||
return;
|
||||
}
|
||||
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(schema.organizationAuthPolicy)
|
||||
@@ -94,6 +157,7 @@ export async function ensureOrgMembership(
|
||||
* This runs after profile creation and creates organization membership
|
||||
*/
|
||||
export const ssoProvisioningHook = async (user: User) => {
|
||||
console.log('[auth] ssoProvisioningHook: running', { userId: user.id });
|
||||
if (!user.email) {
|
||||
console.debug('No email for user, skipping SSO provisioning');
|
||||
return;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { hasVerifiedEmailProvider } from './create-profile';
|
||||
|
||||
export const syncUserWithProfile = async (user: User) => {
|
||||
console.debug('syncUserWithProfile', user);
|
||||
console.log('[auth] syncUserWithProfile: running', { userId: user?.id });
|
||||
|
||||
if (!user?.id) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
CREATE TYPE "public"."AUTOMATION_USAGE_CATEGORY" AS ENUM('read', 'write', 'publish');--> statement-breakpoint
|
||||
CREATE TYPE "public"."COURSE_IMPORT_DRAFT_STATUS" AS ENUM('DRAFT', 'PUBLISHED', 'ARCHIVED');--> statement-breakpoint
|
||||
CREATE TYPE "public"."COURSE_IMPORT_SOURCE_TYPE" AS ENUM('prompt', 'pdf', 'course');--> statement-breakpoint
|
||||
CREATE TYPE "public"."ORGANIZATION_API_KEY_TYPE" AS ENUM('mcp', 'api', 'zapier');--> statement-breakpoint
|
||||
CREATE TYPE "public"."SSO_PROVIDER" AS ENUM('OKTA', 'GOOGLE_WORKSPACE', 'AUTH0');--> statement-breakpoint
|
||||
CREATE TABLE "course_import_draft" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"created_by_profile_id" uuid NOT NULL,
|
||||
"source_type" "COURSE_IMPORT_SOURCE_TYPE" NOT NULL,
|
||||
"status" "COURSE_IMPORT_DRAFT_STATUS" DEFAULT 'DRAFT' NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"locale" "LOCALE" DEFAULT 'en' NOT NULL,
|
||||
"idempotency_key" varchar,
|
||||
"summary" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"draft" jsonb NOT NULL,
|
||||
"warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"sourceArtifacts" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"published_course_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organization_api_key" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"created_by_profile_id" uuid NOT NULL,
|
||||
"type" "ORGANIZATION_API_KEY_TYPE" NOT NULL,
|
||||
"label" varchar(120) NOT NULL,
|
||||
"secret_prefix" varchar(32) NOT NULL,
|
||||
"secret_hash" text NOT NULL,
|
||||
"scopes" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"expires_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organization_auth_policy" (
|
||||
"organization_id" uuid PRIMARY KEY NOT NULL,
|
||||
"force_sso" boolean DEFAULT false NOT NULL,
|
||||
"auto_join_sso_domains" boolean DEFAULT false NOT NULL,
|
||||
"break_glass_enabled" boolean DEFAULT true NOT NULL,
|
||||
"default_role_id" bigint DEFAULT '3' NOT NULL,
|
||||
"role_mapping" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organization_automation_usage" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"organization_api_key_id" uuid,
|
||||
"type" "ORGANIZATION_API_KEY_TYPE" NOT NULL,
|
||||
"action" varchar(120) NOT NULL,
|
||||
"category" "AUTOMATION_USAGE_CATEGORY" NOT NULL,
|
||||
"credits_consumed" integer DEFAULT 0 NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organization_sso_config" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"better_auth_provider_id" text NOT NULL,
|
||||
"provider" "SSO_PROVIDER" NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"domain" text NOT NULL,
|
||||
"is_active" boolean DEFAULT false NOT NULL,
|
||||
"created_by_profile_id" uuid NOT NULL,
|
||||
"updated_by_profile_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
CONSTRAINT "organization_sso_config_organization_id_unique" UNIQUE("organization_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organization_token_auth" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"signing_secret" text NOT NULL,
|
||||
"is_active" boolean DEFAULT false NOT NULL,
|
||||
"created_by_profile_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
CONSTRAINT "organization_token_auth_organization_id_unique" UNIQUE("organization_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sso_provider" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"issuer" text NOT NULL,
|
||||
"oidc_config" text,
|
||||
"saml_config" text,
|
||||
"user_id" uuid NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"organization_id" text,
|
||||
"domain" text NOT NULL,
|
||||
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tag" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"group_id" uuid NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"description" text,
|
||||
"color" varchar NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
CONSTRAINT "tag_org_slug_key" UNIQUE("organization_id","slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tag_assignment" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tag_id" uuid NOT NULL,
|
||||
"course_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
CONSTRAINT "tag_assignment_tag_course_key" UNIQUE("tag_id","course_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tag_group" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"description" text,
|
||||
"order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
CONSTRAINT "tag_group_org_slug_key" UNIQUE("organization_id","slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "organization" ALTER COLUMN "theme" SET DEFAULT 'blue';--> statement-breakpoint
|
||||
ALTER TABLE "option" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "organization" ADD COLUMN "disable_signup" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "organization" ADD COLUMN "disable_signup_message" text;--> statement-breakpoint
|
||||
ALTER TABLE "organization" ADD COLUMN "disable_email_password" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "organization" ADD COLUMN "disable_google_auth" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "question" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "question_answer" ADD COLUMN "answer_data" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "submission" ADD COLUMN "grading_state" varchar DEFAULT 'queued' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "submission" ADD COLUMN "overall_status" varchar DEFAULT 'manual_required' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_published_course_id_course_id_fk" FOREIGN KEY ("published_course_id") REFERENCES "public"."course"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_api_key" ADD CONSTRAINT "organization_api_key_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_api_key" ADD CONSTRAINT "organization_api_key_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_auth_policy" ADD CONSTRAINT "organization_auth_policy_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_auth_policy" ADD CONSTRAINT "organization_auth_policy_default_role_id_fkey" FOREIGN KEY ("default_role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_automation_usage" ADD CONSTRAINT "organization_automation_usage_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_automation_usage" ADD CONSTRAINT "organization_automation_usage_organization_api_key_id_organization_api_key_id_fk" FOREIGN KEY ("organization_api_key_id") REFERENCES "public"."organization_api_key"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_updated_by_profile_id_profile_id_fk" FOREIGN KEY ("updated_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_token_auth" ADD CONSTRAINT "organization_token_auth_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_token_auth" ADD CONSTRAINT "organization_token_auth_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tag" ADD CONSTRAINT "tag_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tag" ADD CONSTRAINT "tag_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."tag_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tag_assignment" ADD CONSTRAINT "tag_assignment_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tag_assignment" ADD CONSTRAINT "tag_assignment_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tag_group" ADD CONSTRAINT "tag_group_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_course_import_draft_org_id" ON "course_import_draft" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_course_import_draft_status" ON "course_import_draft" USING btree ("status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "course_import_draft_org_idempotency_key" ON "course_import_draft" USING btree ("organization_id","idempotency_key") WHERE "course_import_draft"."idempotency_key" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_api_key_org_id" ON "organization_api_key" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_api_key_type" ON "organization_api_key" USING btree ("type");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "organization_api_key_secret_hash_unique" ON "organization_api_key" USING btree ("secret_hash");--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_auth_policy_org_id" ON "organization_auth_policy" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_org_automation_usage_org_created_at" ON "organization_automation_usage" USING btree ("organization_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_org_automation_usage_key_created_at" ON "organization_automation_usage" USING btree ("organization_api_key_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_org_automation_usage_type_created_at" ON "organization_automation_usage" USING btree ("type","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_sso_config_org_id" ON "organization_sso_config" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_sso_config_domain" ON "organization_sso_config" USING btree ("domain");--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_sso_config_provider_id" ON "organization_sso_config" USING btree ("better_auth_provider_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_organization_token_auth_org_id" ON "organization_token_auth" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_tag_organization_id" ON "tag" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_tag_group_id" ON "tag" USING btree ("group_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_tag_assignment_tag_id" ON "tag_assignment" USING btree ("tag_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_tag_assignment_course_id" ON "tag_assignment" USING btree ("course_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_tag_group_organization_id" ON "tag_group" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "organizationmember_org_profile_unique" ON "organizationmember" USING btree ("organization_id","profile_id") WHERE "organizationmember"."profile_id" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "organizationmember_org_email_unique" ON "organizationmember" USING btree ("organization_id","email") WHERE "organizationmember"."email" IS NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1771176910627,
|
||||
"tag": "0006_modern_the_initiative",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1774154467188,
|
||||
"tag": "0007_rainy_vivisector",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as schema from '@db/schema';
|
||||
|
||||
import type { TProfile } from '@db/types';
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
|
||||
export const getProfileById = async (id: string) => {
|
||||
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, id)).limit(1);
|
||||
@@ -16,6 +16,12 @@ export const getProfileByEmail = async (email: string) => {
|
||||
return profile;
|
||||
};
|
||||
|
||||
export async function getProfilesByEmails(emails: string[]) {
|
||||
if (emails.length === 0) return [];
|
||||
|
||||
return db.select().from(schema.profile).where(inArray(schema.profile.email, emails));
|
||||
}
|
||||
|
||||
export const updateProfile = async (id: string, data: Partial<Omit<TProfile, 'id' | 'createdAt' | 'updatedAt'>>) => {
|
||||
const [updatedProfile] = await db.update(schema.profile).set(data).where(eq(schema.profile.id, id)).returning();
|
||||
|
||||
|
||||
@@ -879,3 +879,47 @@ export async function deleteCourseSection(sectionId: string, dbClient: DbOrTxCli
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets group IDs for multiple courses at once
|
||||
* @param courseIds Array of course IDs
|
||||
* @returns Array of { courseId, groupId } mappings
|
||||
*/
|
||||
export async function getCourseGroupIds(courseIds: string[]) {
|
||||
try {
|
||||
if (courseIds.length === 0) return [];
|
||||
|
||||
return db
|
||||
.select({ courseId: schema.course.id, groupId: schema.course.groupId })
|
||||
.from(schema.course)
|
||||
.where(inArray(schema.course.id, courseIds));
|
||||
} catch (error) {
|
||||
console.error('getCourseGroupIds error:', error);
|
||||
throw new Error(`Failed to get course group IDs: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets course group mappings for an organization, including course title
|
||||
* @param orgId Organization ID
|
||||
* @param courseIds Array of course IDs to filter
|
||||
* @returns Array of { courseId, courseTitle, groupId }
|
||||
*/
|
||||
export async function getOrgCourseGroups(orgId: string, courseIds: string[]) {
|
||||
try {
|
||||
if (courseIds.length === 0) return [];
|
||||
|
||||
return db
|
||||
.select({
|
||||
courseId: schema.course.id,
|
||||
courseTitle: schema.course.title,
|
||||
groupId: schema.course.groupId
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.where(and(eq(schema.group.organizationId, orgId), inArray(schema.course.id, courseIds)));
|
||||
} catch (error) {
|
||||
console.error('getOrgCourseGroups error:', error);
|
||||
throw new Error(`Failed to get org course groups: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { TNewGroup, TNewGroupmember } from '@db/types';
|
||||
import { and, eq, isNotNull, or } from 'drizzle-orm';
|
||||
import { and, eq, inArray, isNotNull, or } from 'drizzle-orm';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db } from '@db/drizzle';
|
||||
@@ -24,6 +24,91 @@ export async function addGroupMember(values: TNewGroupmember) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function addGroupMembers(values: TNewGroupmember[]) {
|
||||
if (values.length === 0) return [];
|
||||
try {
|
||||
return db.insert(schema.groupmember).values(values).returning();
|
||||
} catch (error) {
|
||||
console.error('addGroupMembers error:', error);
|
||||
throw new Error(`Failed to bulk add group members: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExistingGroupMembers(
|
||||
pairs: Array<{ groupId: string; profileId: string }>
|
||||
): Promise<Set<string>> {
|
||||
if (pairs.length === 0) return new Set();
|
||||
|
||||
try {
|
||||
const groupIds = [...new Set(pairs.map((p) => p.groupId))];
|
||||
const profileIds = [...new Set(pairs.map((p) => p.profileId))];
|
||||
|
||||
const rows = await db
|
||||
.select({ groupId: schema.groupmember.groupId, profileId: schema.groupmember.profileId })
|
||||
.from(schema.groupmember)
|
||||
.where(and(inArray(schema.groupmember.groupId, groupIds), inArray(schema.groupmember.profileId, profileIds)));
|
||||
|
||||
return new Set(rows.map((r) => `${r.groupId}:${r.profileId}`));
|
||||
} catch (error) {
|
||||
console.error('getExistingGroupMembers error:', error);
|
||||
throw new Error(
|
||||
`Failed to check existing group members: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll users in course groups, skipping any existing memberships.
|
||||
* Returns the count of newly inserted memberships.
|
||||
*/
|
||||
export async function enrollUsersInCourseGroups(
|
||||
groupIds: string[],
|
||||
users: Array<{ profileId: string; email?: string }>,
|
||||
roleId: number
|
||||
): Promise<number> {
|
||||
if (groupIds.length === 0 || users.length === 0) return 0;
|
||||
|
||||
const pairs = users.flatMap((u) => groupIds.map((groupId) => ({ groupId, profileId: u.profileId })));
|
||||
const existingSet = await getExistingGroupMembers(pairs);
|
||||
const emailByProfile = new Map(users.map((u) => [u.profileId, u.email]));
|
||||
|
||||
const toInsert = pairs
|
||||
.filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`))
|
||||
.map((p) => ({
|
||||
groupId: p.groupId,
|
||||
roleId,
|
||||
profileId: p.profileId,
|
||||
email: emailByProfile.get(p.profileId) ?? undefined
|
||||
}));
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await addGroupMembers(toInsert);
|
||||
}
|
||||
|
||||
return toInsert.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a profile is already a member of a specific group
|
||||
* @param groupId Group ID
|
||||
* @param profileId Profile ID
|
||||
* @returns true if the user is already a member
|
||||
*/
|
||||
export async function isGroupMember(groupId: string, profileId: string): Promise<boolean> {
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({ id: schema.groupmember.id })
|
||||
.from(schema.groupmember)
|
||||
.where(and(eq(schema.groupmember.groupId, groupId), eq(schema.groupmember.profileId, profileId)))
|
||||
.limit(1);
|
||||
|
||||
return !!existing;
|
||||
} catch (error) {
|
||||
console.error('isGroupMember error:', error);
|
||||
throw new Error(`Failed to check group membership: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user is a member of a course's group (any role)
|
||||
* @param courseId Course ID
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from '@db/types';
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import { and, desc, eq, gt, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '@db/drizzle';
|
||||
|
||||
export async function createOrganizationInvite(values: TNewOrganizationInvite): Promise<TOrganizationInvite> {
|
||||
@@ -26,6 +26,21 @@ export async function createOrganizationInvite(values: TNewOrganizationInvite):
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrganizationInvites(values: TNewOrganizationInvite[]): Promise<TOrganizationInvite[]> {
|
||||
if (values.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await db.insert(schema.organizationInvite).values(values).returning();
|
||||
} catch (error) {
|
||||
console.error('createOrganizationInvites error:', error);
|
||||
throw new Error(
|
||||
`Failed to create organization invites: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrganizationInviteAudit(
|
||||
values: TNewOrganizationInviteAudit
|
||||
): Promise<TOrganizationInviteAudit> {
|
||||
@@ -45,6 +60,23 @@ export async function createOrganizationInviteAudit(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrganizationInviteAudits(
|
||||
values: TNewOrganizationInviteAudit[]
|
||||
): Promise<TOrganizationInviteAudit[]> {
|
||||
if (values.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await db.insert(schema.organizationInviteAudit).values(values).returning();
|
||||
} catch (error) {
|
||||
console.error('createOrganizationInviteAudits error:', error);
|
||||
throw new Error(
|
||||
`Failed to create organization invite audit events: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeActiveOrganizationInvitesByEmails(
|
||||
organizationId: string,
|
||||
emails: string[],
|
||||
@@ -180,3 +212,96 @@ export async function hasActiveOrganizationInviteForEmail(organizationId: string
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type TLatestOrgInviteByEmail = {
|
||||
email: string;
|
||||
acceptedAt: string | null;
|
||||
isRevoked: boolean;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the latest invite per email (by created_at) for the given org and email list.
|
||||
*/
|
||||
export async function getLatestOrgInvitesByEmails(
|
||||
organizationId: string,
|
||||
emails: string[]
|
||||
): Promise<TLatestOrgInviteByEmail[]> {
|
||||
if (emails.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized = [...new Set(emails.map((e) => e.toLowerCase().trim()))].filter(Boolean);
|
||||
if (normalized.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
email: schema.organizationInvite.email,
|
||||
acceptedAt: schema.organizationInvite.acceptedAt,
|
||||
isRevoked: schema.organizationInvite.isRevoked,
|
||||
expiresAt: schema.organizationInvite.expiresAt,
|
||||
createdAt: schema.organizationInvite.createdAt
|
||||
})
|
||||
.from(schema.organizationInvite)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationInvite.organizationId, organizationId),
|
||||
inArray(schema.organizationInvite.email, normalized)
|
||||
)
|
||||
);
|
||||
|
||||
const latestByEmail = new Map<string, TLatestOrgInviteByEmail>();
|
||||
for (const row of rows) {
|
||||
const key = row.email.toLowerCase();
|
||||
const existing = latestByEmail.get(key);
|
||||
if (!existing || new Date(row.createdAt) > new Date(existing.createdAt)) {
|
||||
latestByEmail.set(key, row);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(latestByEmail.values());
|
||||
} catch (error) {
|
||||
console.error('getLatestOrgInvitesByEmails error:', error);
|
||||
throw new Error(
|
||||
`Failed to get latest organization invites by emails: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest organization_invite row for an org + email (by created_at), for metadata when resending.
|
||||
*/
|
||||
export async function getLatestOrganizationInviteRowByOrgAndEmail(
|
||||
organizationId: string,
|
||||
email: string
|
||||
): Promise<TOrganizationInvite | null> {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.organizationInvite)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationInvite.organizationId, organizationId),
|
||||
eq(schema.organizationInvite.email, normalized)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.organizationInvite.createdAt))
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
} catch (error) {
|
||||
console.error('getLatestOrganizationInviteRowByOrgAndEmail error:', error);
|
||||
throw new Error(
|
||||
`Failed to get latest organization invite: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export const createOrganizationMember = async (data: TNewOrganizationmember, dbC
|
||||
* @returns Array of created members
|
||||
*/
|
||||
export const createOrganizationMembers = async (data: TNewOrganizationmember[]) => {
|
||||
const members = await db.insert(schema.organizationmember).values(data).returning();
|
||||
const members = await db.insert(schema.organizationmember).values(data).onConflictDoNothing().returning();
|
||||
|
||||
return members;
|
||||
};
|
||||
@@ -345,34 +345,93 @@ export const getOrganizationTeam = async (orgId: string) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets organization audience (all organization members with student role)
|
||||
* @param orgId Organization ID
|
||||
* @returns Array of student profiles
|
||||
* Gets organization audience (all organization members with student role).
|
||||
* Includes invited members without a profile (LEFT JOIN profile).
|
||||
* Row id is organizationmember.id; use profileId for profile-backed actions when present.
|
||||
*/
|
||||
export const getOrganizationAudience = async (orgId: string) => {
|
||||
const result = await db
|
||||
.select({
|
||||
id: schema.profile.id,
|
||||
memberId: schema.organizationmember.id,
|
||||
profileId: schema.profile.id,
|
||||
fullname: schema.profile.fullname,
|
||||
email: schema.profile.email,
|
||||
email: sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`.as('email'),
|
||||
avatarUrl: schema.profile.avatarUrl,
|
||||
createdAt: schema.profile.createdAt
|
||||
profileCreatedAt: schema.profile.createdAt,
|
||||
memberCreatedAt: schema.organizationmember.createdAt
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.innerJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
|
||||
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
|
||||
.where(
|
||||
and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.roleId, ROLE.STUDENT))
|
||||
);
|
||||
|
||||
return result.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.fullname,
|
||||
email: profile.email || '',
|
||||
avatarUrl: profile.avatarUrl || '',
|
||||
createdAt: profile.createdAt ? new Date(profile.createdAt).toDateString() : ''
|
||||
}));
|
||||
return result.map((row) => {
|
||||
const email = row.email?.trim() ?? '';
|
||||
const name = row.fullname?.trim() || (email.includes('@') ? email.split('@')[0] : email) || '';
|
||||
const createdAtRaw = row.profileId ? row.profileCreatedAt : row.memberCreatedAt;
|
||||
const createdAt = createdAtRaw ? new Date(createdAtRaw).toDateString() : '';
|
||||
|
||||
return {
|
||||
id: row.memberId,
|
||||
profileId: row.profileId ?? null,
|
||||
name,
|
||||
email,
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
createdAt
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Student org member matched by profile email or organizationmember.email (for audience invite actions).
|
||||
*/
|
||||
export async function getStudentOrganizationMemberByOrgAndEmail(
|
||||
orgId: string,
|
||||
email: string
|
||||
): Promise<{ id: number; profileId: string | null; email: string } | null> {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: schema.organizationmember.id,
|
||||
profileId: schema.organizationmember.profileId,
|
||||
displayEmail: sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`.as(
|
||||
'displayEmail'
|
||||
)
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, orgId),
|
||||
eq(schema.organizationmember.roleId, ROLE.STUDENT),
|
||||
or(eq(schema.organizationmember.email, normalized), eq(schema.profile.email, normalized))
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
profileId: row.profileId,
|
||||
email: (row.displayEmail ?? normalized).trim()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getStudentOrganizationMemberByOrgAndEmail error:', error);
|
||||
throw new Error(
|
||||
`Failed to get organization student member: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets organizations with optional filters
|
||||
* @param filters Filter options (siteName, customDomain, isCustomDomainVerified)
|
||||
@@ -687,3 +746,33 @@ export const getActiveOrganizationPlan = async (orgId: string) => {
|
||||
|
||||
return plan ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets organization members by profile IDs
|
||||
* @param orgId Organization ID
|
||||
* @param profileIds Array of profile IDs to look up
|
||||
* @returns Array of { profileId, email } for matching members
|
||||
*/
|
||||
export async function getOrgMembersByProfileIds(orgId: string, profileIds: string[]) {
|
||||
try {
|
||||
if (profileIds.length === 0) return [];
|
||||
|
||||
return db
|
||||
.select({
|
||||
profileId: schema.organizationmember.profileId,
|
||||
email: schema.organizationmember.email
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, orgId),
|
||||
inArray(schema.organizationmember.profileId, profileIds)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('getOrgMembersByProfileIds error:', error);
|
||||
throw new Error(
|
||||
`Failed to get org members by profile IDs: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1475,7 +1475,10 @@ export const organizationmember = pgTable(
|
||||
index('idx_organizationmember_profile_org').on(table.profileId, table.organizationId),
|
||||
uniqueIndex('organizationmember_org_profile_unique')
|
||||
.on(table.organizationId, table.profileId)
|
||||
.where(sql`${table.profileId} IS NOT NULL`)
|
||||
.where(sql`${table.profileId} IS NOT NULL`),
|
||||
uniqueIndex('organizationmember_org_email_unique')
|
||||
.on(table.organizationId, table.email)
|
||||
.where(sql`${table.email} IS NOT NULL`)
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from './teacher-student-joined';
|
||||
export * from './teacher-student-buy-request';
|
||||
export * from './student-prove-payment';
|
||||
export * from './teacher-course-welcome';
|
||||
export * from './student-org-invite';
|
||||
|
||||
import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed';
|
||||
|
||||
@@ -28,6 +29,7 @@ import type { onPasswordResetEmail } from './on-password-reset';
|
||||
import type { studentCourseInviteEmail } from './student-course-invite';
|
||||
import type { studentCourseWelcomeEmail } from './student-course-welcome';
|
||||
import type { studentProvePaymentEmail } from './student-prove-payment';
|
||||
import type { studentOrgInviteEmail } from './student-org-invite';
|
||||
import type { teacherCourseWelcomeEmail } from './teacher-course-welcome';
|
||||
import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request';
|
||||
import type { teacherStudentJoinedEmail } from './teacher-student-joined';
|
||||
@@ -54,4 +56,5 @@ export type EmailSchemas = {
|
||||
teacherStudentBuyRequest: typeof teacherStudentBuyRequestEmail.template.schema;
|
||||
studentProvePayment: typeof studentProvePaymentEmail.template.schema;
|
||||
teacherCourseWelcome: typeof teacherCourseWelcomeEmail.template.schema;
|
||||
studentOrgInvite: typeof studentOrgInviteEmail.template.schema;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
|
||||
export const studentOrgInviteEmail = defineEmail({
|
||||
id: 'studentOrgInvite',
|
||||
subject: 'You have been invited to join as a student',
|
||||
schema: z.object({
|
||||
email: z.string().email(),
|
||||
orgName: z.string().min(1),
|
||||
inviteLink: z.url(),
|
||||
expiresAt: z.string().min(1),
|
||||
courseNames: z.string().optional()
|
||||
}),
|
||||
render: (fields) => {
|
||||
const courseLine = fields.courseNames
|
||||
? `<p>You have been given access to: <strong>${fields.courseNames}</strong>.</p>`
|
||||
: '';
|
||||
|
||||
const content = `
|
||||
<p>Hi there,</p>
|
||||
<p>You have been invited to join <strong>${fields.orgName}</strong> as a student.</p>
|
||||
${courseLine}
|
||||
<p>This invite expires on ${fields.expiresAt} (UTC).</p>
|
||||
<div>
|
||||
<a class="button" href="${fields.inviteLink}">Accept Invitation</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
}
|
||||
});
|
||||
@@ -2,18 +2,19 @@ import { env } from '../config/env';
|
||||
|
||||
export const EMAIL_IDS = [
|
||||
'forgotPassword',
|
||||
'welcome',
|
||||
'verifyEmail',
|
||||
'onPasswordReset',
|
||||
'inviteTeacher',
|
||||
'newsfeedPost',
|
||||
'newsfeedComment',
|
||||
'studentCourseWelcome',
|
||||
'newsfeedPost',
|
||||
'onPasswordReset',
|
||||
'studentCourseInvite',
|
||||
'teacherStudentJoined',
|
||||
'teacherStudentBuyRequest',
|
||||
'studentCourseWelcome',
|
||||
'studentOrgInvite',
|
||||
'studentProvePayment',
|
||||
'teacherCourseWelcome'
|
||||
'teacherCourseWelcome',
|
||||
'teacherStudentBuyRequest',
|
||||
'teacherStudentJoined',
|
||||
'verifyEmail',
|
||||
'welcome'
|
||||
] as const;
|
||||
|
||||
const DEFAULT_EMAIL_FROM = '"Best from ClassroomIO" <[email protected]>';
|
||||
|
||||
@@ -3,13 +3,14 @@ import type { AnswerData } from './answer-data';
|
||||
|
||||
export type ExerciseQuestionTypeKey = QuestionTypeKey;
|
||||
|
||||
export type ExerciseRendererMode = 'edit' | 'take' | 'preview' | 'view' | 'submission';
|
||||
export type ExerciseRendererMode = 'edit' | 'take' | 'preview' | 'review' | 'view' | 'submission';
|
||||
|
||||
export type ExerciseQuestionLabelKey =
|
||||
| 'common.option_prefix'
|
||||
| 'common.remove'
|
||||
| 'common.add_option'
|
||||
| 'common.correct_badge'
|
||||
| 'common.incorrect_badge'
|
||||
| 'common.not_set'
|
||||
| 'navigation.previous'
|
||||
| 'navigation.next'
|
||||
@@ -51,6 +52,8 @@ export type ExerciseQuestionLabelKey =
|
||||
| 'short_answer.edit.instructions_placeholder'
|
||||
| 'short_answer.take.placeholder'
|
||||
| 'short_answer.preview.helper'
|
||||
| 'short_answer.review.your_answer_label'
|
||||
| 'short_answer.review.accepted_label'
|
||||
| 'numeric.edit.correct_value_label'
|
||||
| 'numeric.edit.correct_value_info'
|
||||
| 'numeric.edit.tolerance_label'
|
||||
@@ -60,9 +63,13 @@ export type ExerciseQuestionLabelKey =
|
||||
| 'numeric.take.placeholder'
|
||||
| 'numeric.preview.correct_value_label'
|
||||
| 'numeric.preview.tolerance_label'
|
||||
| 'numeric.review.your_answer_label'
|
||||
| 'numeric.review.in_range'
|
||||
| 'numeric.review.out_of_range'
|
||||
| 'fill_blank.edit.accepted_answers_placeholder'
|
||||
| 'fill_blank.take.placeholder'
|
||||
| 'fill_blank.preview.accepted_answers_label'
|
||||
| 'fill_blank.review.your_answer_label'
|
||||
| 'file_upload.edit.accepted_types_placeholder'
|
||||
| 'file_upload.edit.max_size_placeholder'
|
||||
| 'file_upload.take.selected_file_label'
|
||||
@@ -164,6 +171,8 @@ export interface ExerciseRendererDefinition<TRenderer = unknown> {
|
||||
take: TRenderer;
|
||||
preview: TRenderer;
|
||||
submission: TRenderer;
|
||||
/** Read-only submission replay with correctness; defaults to `take` when omitted. */
|
||||
review?: TRenderer;
|
||||
}
|
||||
|
||||
export interface ExerciseQuestionRendererProps {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExerciseRendererDefinition, ExerciseRendererMode, ExerciseQuestionTypeKey } from './exercise-types';
|
||||
import type { ExerciseQuestionTypeKey, ExerciseRendererDefinition, ExerciseRendererMode } from './exercise-types';
|
||||
|
||||
export type ExerciseRendererRegistry<TRenderer> = Partial<
|
||||
Record<ExerciseQuestionTypeKey, ExerciseRendererDefinition<TRenderer>>
|
||||
@@ -18,6 +18,10 @@ export function getRendererForMode<TRenderer>(
|
||||
mode: ExerciseRendererMode,
|
||||
fallback: ExerciseRendererDefinition<TRenderer>
|
||||
): TRenderer {
|
||||
const rendererDefinition = getRendererDefinition(registry, questionType, fallback);
|
||||
const resolvedMode = mode === 'view' ? 'take' : mode;
|
||||
return getRendererDefinition(registry, questionType, fallback)[resolvedMode];
|
||||
if (resolvedMode === 'review') {
|
||||
return (rendererDefinition.review ?? rendererDefinition.take) as TRenderer;
|
||||
}
|
||||
return rendererDefinition[resolvedMode] as TRenderer;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,21 @@ describe('renderer registry helpers', () => {
|
||||
expect(getRendererForMode(REGISTRY, QUESTION_TYPE_KEY.ORDERING, 'view', FALLBACK_RENDERER)).toBe('ordering-take');
|
||||
});
|
||||
|
||||
it("resolves 'review' mode to 'take' when review is omitted", () => {
|
||||
expect(getRendererForMode(REGISTRY, QUESTION_TYPE_KEY.RADIO, 'review', FALLBACK_RENDERER)).toBe('radio-take');
|
||||
});
|
||||
|
||||
it("resolves 'review' mode using optional review slot when provided", () => {
|
||||
const withReview: typeof REGISTRY = {
|
||||
...REGISTRY,
|
||||
[QUESTION_TYPE_KEY.RADIO]: {
|
||||
...REGISTRY[QUESTION_TYPE_KEY.RADIO]!,
|
||||
review: 'radio-review'
|
||||
}
|
||||
};
|
||||
expect(getRendererForMode(withReview, QUESTION_TYPE_KEY.RADIO, 'review', FALLBACK_RENDERER)).toBe('radio-review');
|
||||
});
|
||||
|
||||
it('returns fallback renderer mode when type is unregistered', () => {
|
||||
expect(getRendererForMode(REGISTRY, QUESTION_TYPE_KEY.LINK, 'edit', FALLBACK_RENDERER)).toBe('fallback-edit');
|
||||
expect(getRendererForMode(REGISTRY, QUESTION_TYPE_KEY.LINK, 'take', FALLBACK_RENDERER)).toBe('fallback-take');
|
||||
|
||||
+60
-12
@@ -29,19 +29,31 @@
|
||||
|
||||
<Story name="Checkbox">
|
||||
{#snippet template()}
|
||||
<QuestionTypeModes question={CHECKBOX_FIXTURE.question} answer={CHECKBOX_FIXTURE.answer} />
|
||||
<QuestionTypeModes
|
||||
question={CHECKBOX_FIXTURE.question}
|
||||
answer={CHECKBOX_FIXTURE.answer}
|
||||
wrongAnswer={CHECKBOX_FIXTURE.wrongAnswer}
|
||||
/>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="FileUpload">
|
||||
{#snippet template()}
|
||||
<QuestionTypeModes question={FILE_UPLOAD_FIXTURE.question} answer={FILE_UPLOAD_FIXTURE.answer} />
|
||||
<QuestionTypeModes
|
||||
question={FILE_UPLOAD_FIXTURE.question}
|
||||
answer={FILE_UPLOAD_FIXTURE.answer}
|
||||
wrongAnswer={FILE_UPLOAD_FIXTURE.wrongAnswer}
|
||||
/>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="FillBlank">
|
||||
{#snippet template()}
|
||||
<QuestionTypeModes question={FILL_BLANK_FIXTURE.question} answer={FILL_BLANK_FIXTURE.answer} />
|
||||
<QuestionTypeModes
|
||||
question={FILL_BLANK_FIXTURE.question}
|
||||
answer={FILL_BLANK_FIXTURE.answer}
|
||||
wrongAnswer={FILL_BLANK_FIXTURE.wrongAnswer}
|
||||
/>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
@@ -62,13 +74,21 @@
|
||||
>
|
||||
<strong>Hidden – still in development.</strong> Not available in the exercise editor.
|
||||
</div>
|
||||
<QuestionTypeModes question={HOTSPOT_FIXTURE.question} answer={HOTSPOT_FIXTURE.answer} />
|
||||
<QuestionTypeModes
|
||||
question={HOTSPOT_FIXTURE.question}
|
||||
answer={HOTSPOT_FIXTURE.answer}
|
||||
wrongAnswer={HOTSPOT_FIXTURE.wrongAnswer}
|
||||
/>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Link">
|
||||
{#snippet template()}
|
||||
<QuestionTypeModes question={LINK_FIXTURE.question} answer={LINK_FIXTURE.answer} />
|
||||
<QuestionTypeModes
|
||||
question={LINK_FIXTURE.question}
|
||||
answer={LINK_FIXTURE.answer}
|
||||
wrongAnswer={LINK_FIXTURE.wrongAnswer}
|
||||
/>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
@@ -89,42 +109,70 @@
|
||||
>
|
||||
<strong>Hidden – still in development.</strong> Not available in the exercise editor.
|
||||
</div>
|
||||
<QuestionTypeModes question={MATCHING_FIXTURE.question} answer={MATCHING_FIXTURE.answer} />
|
||||
<QuestionTypeModes
|
||||
question={MATCHING_FIXTURE.question}
|
||||
answer={MATCHING_FIXTURE.answer}
|
||||
wrongAnswer={MATCHING_FIXTURE.wrongAnswer}
|
||||
/>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Numeric">
|
||||
{#snippet template()}
|
||||
<QuestionTypeModes question={NUMERIC_FIXTURE.question} answer={NUMERIC_FIXTURE.answer} />
|
||||
<QuestionTypeModes
|
||||
question={NUMERIC_FIXTURE.question}
|
||||
answer={NUMERIC_FIXTURE.answer}
|
||||
wrongAnswer={NUMERIC_FIXTURE.wrongAnswer}
|
||||
/>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Ordering">
|
||||
{#snippet template()}
|
||||
<QuestionTypeModes question={ORDERING_FIXTURE.question} answer={ORDERING_FIXTURE.answer} />
|
||||
<QuestionTypeModes
|
||||
question={ORDERING_FIXTURE.question}
|
||||
answer={ORDERING_FIXTURE.answer}
|
||||
wrongAnswer={ORDERING_FIXTURE.wrongAnswer}
|
||||
/>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Radio">
|
||||
{#snippet template()}
|
||||
<QuestionTypeModes question={RADIO_FIXTURE.question} answer={RADIO_FIXTURE.answer} />
|
||||
<QuestionTypeModes
|
||||
question={RADIO_FIXTURE.question}
|
||||
answer={RADIO_FIXTURE.answer}
|
||||
wrongAnswer={RADIO_FIXTURE.wrongAnswer}
|
||||
/>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="ShortAnswer">
|
||||
{#snippet template()}
|
||||
<QuestionTypeModes question={SHORT_ANSWER_FIXTURE.question} answer={SHORT_ANSWER_FIXTURE.answer} />
|
||||
<QuestionTypeModes
|
||||
question={SHORT_ANSWER_FIXTURE.question}
|
||||
answer={SHORT_ANSWER_FIXTURE.answer}
|
||||
wrongAnswer={SHORT_ANSWER_FIXTURE.wrongAnswer}
|
||||
/>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Textarea">
|
||||
{#snippet template()}
|
||||
<QuestionTypeModes question={TEXTAREA_FIXTURE.question} answer={TEXTAREA_FIXTURE.answer} />
|
||||
<QuestionTypeModes
|
||||
question={TEXTAREA_FIXTURE.question}
|
||||
answer={TEXTAREA_FIXTURE.answer}
|
||||
wrongAnswer={TEXTAREA_FIXTURE.wrongAnswer}
|
||||
/>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="TrueFalse">
|
||||
{#snippet template()}
|
||||
<QuestionTypeModes question={TRUE_FALSE_FIXTURE.question} answer={TRUE_FALSE_FIXTURE.answer} />
|
||||
<QuestionTypeModes
|
||||
question={TRUE_FALSE_FIXTURE.question}
|
||||
answer={TRUE_FALSE_FIXTURE.answer}
|
||||
wrongAnswer={TRUE_FALSE_FIXTURE.wrongAnswer}
|
||||
/>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export interface QuestionStoryFixture {
|
||||
question: Record<string, unknown>;
|
||||
/** Correct answer (`AnswerData` or legacy shapes supported by renderers). */
|
||||
answer?: unknown;
|
||||
/** Incorrect answer for Review Mode demos in Storybook (`AnswerData`). */
|
||||
wrongAnswer?: unknown;
|
||||
}
|
||||
|
||||
const OPTION_IMAGE_A = 'https://brand.cdn.clsrio.com/classroomio-lms-courses.png';
|
||||
@@ -33,7 +36,8 @@ export const RADIO_FIXTURE: QuestionStoryFixture = {
|
||||
{ id: 1003, label: 'Ruby', value: 'ruby' }
|
||||
]
|
||||
},
|
||||
answer: 1001
|
||||
answer: { type: 'RADIO', optionId: 1001 },
|
||||
wrongAnswer: { type: 'RADIO', optionId: 1002 }
|
||||
};
|
||||
|
||||
export const CHECKBOX_FIXTURE: QuestionStoryFixture = {
|
||||
@@ -63,7 +67,8 @@ export const CHECKBOX_FIXTURE: QuestionStoryFixture = {
|
||||
}
|
||||
]
|
||||
},
|
||||
answer: ['2001', '2003']
|
||||
answer: { type: 'CHECKBOX', optionIds: [2001, 2003] },
|
||||
wrongAnswer: { type: 'CHECKBOX', optionIds: [2002] }
|
||||
};
|
||||
|
||||
export const TEXTAREA_FIXTURE: QuestionStoryFixture = {
|
||||
@@ -93,7 +98,8 @@ export const TRUE_FALSE_FIXTURE: QuestionStoryFixture = {
|
||||
correctValue: true
|
||||
}
|
||||
},
|
||||
answer: true
|
||||
answer: { type: 'TRUE_FALSE', value: true },
|
||||
wrongAnswer: { type: 'TRUE_FALSE', value: false }
|
||||
};
|
||||
|
||||
export const SHORT_ANSWER_FIXTURE: QuestionStoryFixture = {
|
||||
@@ -103,10 +109,12 @@ export const SHORT_ANSWER_FIXTURE: QuestionStoryFixture = {
|
||||
title: 'Name one strategy for reducing flaky frontend tests. [SHORT_ANSWER]',
|
||||
questionType: 'SHORT_ANSWER',
|
||||
settings: {
|
||||
instructions: 'Answer in one sentence.'
|
||||
instructions: 'Answer in one sentence.',
|
||||
acceptedAnswers: 'use deterministic test fixtures'
|
||||
}
|
||||
},
|
||||
answer: 'Use deterministic test fixtures'
|
||||
answer: { type: 'SHORT_ANSWER', text: 'Use deterministic test fixtures' },
|
||||
wrongAnswer: { type: 'SHORT_ANSWER', text: 'Guess randomly every time' }
|
||||
};
|
||||
|
||||
export const NUMERIC_FIXTURE: QuestionStoryFixture = {
|
||||
@@ -117,7 +125,8 @@ export const NUMERIC_FIXTURE: QuestionStoryFixture = {
|
||||
questionType: 'NUMERIC',
|
||||
settings: { correctValue: 90, tolerance: 0 }
|
||||
},
|
||||
answer: 90
|
||||
answer: { type: 'NUMERIC', value: 90 },
|
||||
wrongAnswer: { type: 'NUMERIC', value: 42 }
|
||||
};
|
||||
|
||||
export const FILL_BLANK_FIXTURE: QuestionStoryFixture = {
|
||||
@@ -128,7 +137,8 @@ export const FILL_BLANK_FIXTURE: QuestionStoryFixture = {
|
||||
questionType: 'FILL_BLANK',
|
||||
settings: { acceptedAnswers: 'API,endpoint' }
|
||||
},
|
||||
answer: ['API']
|
||||
answer: { type: 'FILL_BLANK', values: ['API'] },
|
||||
wrongAnswer: { type: 'FILL_BLANK', values: ['SOAP'] }
|
||||
};
|
||||
|
||||
export const FILE_UPLOAD_FIXTURE: QuestionStoryFixture = {
|
||||
@@ -180,7 +190,8 @@ export const ORDERING_FIXTURE: QuestionStoryFixture = {
|
||||
items: ['Design schema', 'Implement renderer', 'Ship to production']
|
||||
}
|
||||
},
|
||||
answer: ['order-1', 'order-2', 'order-3']
|
||||
answer: { type: 'ORDERING', orderedValues: ['order-1', 'order-2', 'order-3'] },
|
||||
wrongAnswer: { type: 'ORDERING', orderedValues: ['order-3', 'order-1', 'order-2'] }
|
||||
};
|
||||
|
||||
export const HOTSPOT_FIXTURE: QuestionStoryFixture = {
|
||||
|
||||
@@ -9,7 +9,8 @@ export const QUESTION_LABELS = {
|
||||
'common.option_prefix': 'Option',
|
||||
'common.remove': 'Remove',
|
||||
'common.add_option': 'Add option',
|
||||
'common.correct_badge': '(Correct)',
|
||||
'common.correct_badge': 'Correct',
|
||||
'common.incorrect_badge': 'Incorrect',
|
||||
'common.not_set': 'Not set',
|
||||
'navigation.previous': 'Previous',
|
||||
'navigation.next': 'Next',
|
||||
@@ -51,6 +52,8 @@ export const QUESTION_LABELS = {
|
||||
'short_answer.edit.instructions_placeholder': 'Optional instructions',
|
||||
'short_answer.take.placeholder': 'Type your answer',
|
||||
'short_answer.preview.helper': 'Free-text answer question.',
|
||||
'short_answer.review.your_answer_label': 'Submitted:',
|
||||
'short_answer.review.accepted_label': 'Accepted answers',
|
||||
'numeric.edit.correct_value_label': 'Correct value',
|
||||
'numeric.edit.correct_value_info': 'Expected numeric answer to mark as correct.',
|
||||
'numeric.edit.tolerance_label': 'Tolerance',
|
||||
@@ -60,9 +63,13 @@ export const QUESTION_LABELS = {
|
||||
'numeric.take.placeholder': 'Enter a number',
|
||||
'numeric.preview.correct_value_label': 'Correct value',
|
||||
'numeric.preview.tolerance_label': 'Tolerance',
|
||||
'numeric.review.your_answer_label': 'Submitted:',
|
||||
'numeric.review.in_range': 'Within tolerance',
|
||||
'numeric.review.out_of_range': 'Outside tolerance',
|
||||
'fill_blank.edit.accepted_answers_placeholder': 'Accepted answers (comma separated)',
|
||||
'fill_blank.take.placeholder': 'Enter answers separated by commas',
|
||||
'fill_blank.preview.accepted_answers_label': 'Accepted answers',
|
||||
'fill_blank.review.your_answer_label': 'Submitted:',
|
||||
'file_upload.edit.accepted_types_placeholder': 'Select accepted file formats',
|
||||
'file_upload.edit.max_size_placeholder': 'Max size (MB)',
|
||||
'file_upload.take.selected_file_label': 'Selected file',
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
interface Props {
|
||||
question: Record;
|
||||
answer?: unknown;
|
||||
/** When set, renders an additional Review block with this incorrect `AnswerData`. */
|
||||
wrongAnswer?: unknown;
|
||||
}
|
||||
|
||||
type StoryQuestion = {
|
||||
@@ -15,7 +17,7 @@
|
||||
settings?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let { question, answer = null }: Props = $props();
|
||||
let { question, answer = null, wrongAnswer = undefined }: Props = $props();
|
||||
|
||||
function cloneQuestion(source: Record) {
|
||||
return JSON.parse(JSON.stringify(source));
|
||||
@@ -178,6 +180,42 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ui:rounded-md ui:border ui:p-4 ui:space-y-3">
|
||||
<p class="ui:text-sm ui:font-semibold">Review Mode</p>
|
||||
<p class="ui:text-muted-foreground ui:text-xs">
|
||||
Read-only submission replay with correctness (same as grading / finished exercise in the app).
|
||||
</p>
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
showContainer={false}
|
||||
contract={{
|
||||
mode: 'review',
|
||||
question,
|
||||
answer: viewAnswer,
|
||||
labels: QUESTION_LABELS,
|
||||
disabled: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if wrongAnswer !== undefined}
|
||||
<div class="ui:rounded-md ui:border ui:p-4 ui:space-y-3">
|
||||
<p class="ui:text-sm ui:font-semibold">Review Mode (incorrect)</p>
|
||||
<p class="ui:text-muted-foreground ui:text-xs">
|
||||
Same review UI when the submitted answer does not match the correct solution.
|
||||
</p>
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
showContainer={false}
|
||||
contract={{
|
||||
mode: 'review',
|
||||
question,
|
||||
answer: wrongAnswer,
|
||||
labels: QUESTION_LABELS,
|
||||
disabled: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="ui:rounded-md ui:border ui:p-4 ui:space-y-3">
|
||||
<p class="ui:text-sm ui:font-semibold">Submission Mode (12 respondents)</p>
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const FIELDS = ['class', 'listClass'] as string[];
|
||||
@@ -0,0 +1,63 @@
|
||||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import { MultiSelectList } from '@cio/ui';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { FIELDS } from './fields';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Molecules/MultiSelectList',
|
||||
component: MultiSelectList,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
controls: {
|
||||
include: FIELDS
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
});
|
||||
|
||||
const sampleItems = [
|
||||
{ id: 'a', label: 'Introduction' },
|
||||
{ id: 'b', label: 'Core concepts' },
|
||||
{ id: 'c', label: 'Practice set' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let selected = new SvelteSet<string>();
|
||||
|
||||
function toggle(id: string) {
|
||||
if (selected.has(id)) selected.delete(id);
|
||||
else selected.add(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Story name="Default">
|
||||
{#snippet template()}
|
||||
<div class="ui:w-full ui:max-w-md">
|
||||
<MultiSelectList
|
||||
heading="Select items"
|
||||
emptyMessage="No items to show."
|
||||
items={sampleItems}
|
||||
isSelected={(id) => selected.has(id)}
|
||||
onToggle={toggle}
|
||||
namePrefix="story-ms"
|
||||
/>
|
||||
<p class="ui:mt-3 ui:text-muted-foreground ui:text-sm">Selected: {[...selected].join(', ') || 'none'}</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Empty">
|
||||
{#snippet template()}
|
||||
<div class="ui:w-full ui:max-w-md">
|
||||
<MultiSelectList
|
||||
heading="Select items"
|
||||
emptyMessage="Nothing here yet."
|
||||
items={[]}
|
||||
isSelected={() => false}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -64,6 +64,16 @@ Components in the `custom` directory come from various sources or are built on t
|
||||
- `custom/image-cropper/` - Custom image cropping component
|
||||
- `custom/checkbox-field/` - Field component built on top of base Checkbox
|
||||
|
||||
### Exercise question (`src/custom/exercise-question/`)
|
||||
|
||||
Learner and author UIs for exercise questions (take, preview, review, submission, edit per question type).
|
||||
|
||||
- **`renderers/shared/`** — Cross-type presentation pieces reused by multiple question families (for example `labeled-value-row.svelte`, `mcq-preview-option-row.svelte`, `submission-response-pie-chart.svelte`). Mode entry files stay thin; add new shared pieces here when two or more types need the same markup.
|
||||
- **`renderers/<question-type>/`** — One folder per type (`radio`, `numeric`, …) containing `take.svelte`, `preview.svelte`, and optional `review.svelte`, `submission.svelte`, `edit.svelte`, plus type-specific fragments (for example `numeric-expected-fields.svelte`, `ordering-display.svelte`).
|
||||
- **`renderers/option-image.svelte`**, **`renderers/submission-utils.ts`** — Existing shared helpers at the `renderers/` root; keep importing from there unless consolidating in a later pass.
|
||||
|
||||
**Types with heavier or distinct UIs** (textarea editor, file upload, matching/hotspot authoring, link lists) intentionally keep separate `take` / `preview` implementations until a second consumer (for example a dedicated `review` mode) justifies extracting more shared fragments.
|
||||
|
||||
### Hooks (`src/hooks/`)
|
||||
|
||||
Reusable Svelte hooks are located in the `src/hooks/` directory. These are Svelte 5 runes-based utilities that can be used across components.
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
class={cn('ui:flex ui:my-4 ui:py-2 ui:flex-col', isSticky && 'ui:sticky ui:top-13 ui:z-10 ui:bg-white', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<div class="ui:flex ui:items-start ui:justify-between ui:gap-4">
|
||||
<div class="ui:flex ui:items-start ui:justify-between ui:gap-4 ui:flex-col ui:md:flex-row">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import { cn } from '../../tools';
|
||||
import { Button } from '../../base/button';
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
label?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { href, label, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Button variant="link" class={cn('ui:h-fit! ui:justify-start! ui:p-0', className)} {href}>
|
||||
<ArrowLeftIcon class="custom" />
|
||||
{#if label}
|
||||
<span class="ui:text-xs">{label}</span>
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -0,0 +1,4 @@
|
||||
import BackButton from './back-button.svelte';
|
||||
|
||||
export { BackButton };
|
||||
export { BackButton as default };
|
||||
@@ -12,6 +12,8 @@
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onchange?: (e: Event) => void;
|
||||
/** When set, the whole row handles the click (e.g. multi-select). Avoids nesting a native button inside the checkbox control. */
|
||||
onclick?: () => void;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
@@ -24,25 +26,70 @@
|
||||
disabled = false,
|
||||
className = '',
|
||||
onchange = () => {},
|
||||
onclick,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const rowClass = $derived(
|
||||
cn(
|
||||
'group ui:inline-flex ui:w-full ui:items-center ui:hover:bg-muted ui:rounded-md ui:p-2 ui:text-left',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
className
|
||||
)
|
||||
);
|
||||
|
||||
function handleRowClick() {
|
||||
if (disabled || onclick === undefined) return;
|
||||
onclick();
|
||||
}
|
||||
|
||||
function handleRowKeydown(event: KeyboardEvent) {
|
||||
if (disabled || onclick === undefined) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onclick();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'group ui:inline-flex ui:w-full ui:items-center',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Checkbox {name} {value} disabled={disabled || isEditable} bind:checked />
|
||||
{#if isEditable}
|
||||
{#if onclick !== undefined}
|
||||
<div
|
||||
class={rowClass}
|
||||
role="button"
|
||||
tabindex={disabled ? -1 : 0}
|
||||
aria-pressed={checked}
|
||||
aria-disabled={disabled ? true : undefined}
|
||||
onclick={handleRowClick}
|
||||
onkeydown={handleRowKeydown}
|
||||
>
|
||||
<Checkbox class="ui:pointer-events-none" {name} {value} disabled={disabled || isEditable} bind:checked />
|
||||
{#if isEditable}
|
||||
<!-- Isolate text field from the row button (rare: onclick + isEditable) -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="ui:w-2/4" onclick={(e) => e.stopPropagation()}>
|
||||
<InputField bind:value={label} placeholder="Your option" className="ui:ml-1" type="text" {onchange} />
|
||||
</div>
|
||||
{:else}
|
||||
<span class="ui:ml-2 ui:dark:text-white ui:text-sm">{label}</span>
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{:else if isEditable}
|
||||
<div class={rowClass}>
|
||||
<Checkbox {name} {value} disabled={disabled || isEditable} bind:checked />
|
||||
<div class="ui:w-2/4">
|
||||
<InputField bind:value={label} placeholder="Your option" className="ui:ml-1" type="text" {onchange} />
|
||||
</div>
|
||||
{:else}
|
||||
<span class="ui:ml-2 ui:dark:text-white">{label}</span>
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{:else}
|
||||
<label class={rowClass}>
|
||||
<Checkbox {name} {value} {disabled} bind:checked />
|
||||
<span class="ui:ml-2 ui:dark:text-white ui:text-sm">{label}</span>
|
||||
|
||||
{@render children?.()}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
@@ -40,6 +40,7 @@ export { default as CheckboxPreview } from './renderers/checkbox/preview.svelte'
|
||||
export { default as TextareaPreview } from './renderers/textarea/preview.svelte';
|
||||
export { default as MatchingPreview } from './renderers/matching/preview.svelte';
|
||||
export { default as OrderingPreview } from './renderers/ordering/preview.svelte';
|
||||
export { default as OrderingDisplay } from './renderers/ordering/ordering-display.svelte';
|
||||
export { default as HotspotPreview } from './renderers/hotspot/preview.svelte';
|
||||
|
||||
export { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte';
|
||||
|
||||
@@ -61,6 +61,14 @@ import { default as ShortAnswerSubmission } from './renderers/short-answer/submi
|
||||
import { default as TextareaSubmission } from './renderers/textarea/submission.svelte';
|
||||
import { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte';
|
||||
|
||||
import { default as RadioReview } from './renderers/radio/review.svelte';
|
||||
import { default as CheckboxReview } from './renderers/checkbox/review.svelte';
|
||||
import { default as NumericReview } from './renderers/numeric/review.svelte';
|
||||
import { default as ShortAnswerReview } from './renderers/short-answer/review.svelte';
|
||||
import { default as FillBlankReview } from './renderers/fill-blank/review.svelte';
|
||||
import { default as OrderingReview } from './renderers/ordering/review.svelte';
|
||||
import { default as TrueFalseReview } from './renderers/true-false/review.svelte';
|
||||
|
||||
type SharedRendererComponent = Component<ExerciseQuestionRendererProps>;
|
||||
|
||||
export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<SharedRendererComponent> = {
|
||||
@@ -68,13 +76,15 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<Share
|
||||
edit: RadioEdit,
|
||||
take: RadioTake,
|
||||
preview: RadioPreview,
|
||||
submission: RadioSubmission
|
||||
submission: RadioSubmission,
|
||||
review: RadioReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.CHECKBOX]: {
|
||||
edit: CheckboxEdit,
|
||||
take: CheckboxTake,
|
||||
preview: CheckboxPreview,
|
||||
submission: CheckboxSubmission
|
||||
submission: CheckboxSubmission,
|
||||
review: CheckboxReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.TEXTAREA]: {
|
||||
edit: TextareaEdit,
|
||||
@@ -86,25 +96,29 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<Share
|
||||
edit: TrueFalseEdit,
|
||||
take: TrueFalseTake,
|
||||
preview: TrueFalsePreview,
|
||||
submission: TrueFalseSubmission
|
||||
submission: TrueFalseSubmission,
|
||||
review: TrueFalseReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.SHORT_ANSWER]: {
|
||||
edit: ShortAnswerEdit,
|
||||
take: ShortAnswerTake,
|
||||
preview: ShortAnswerPreview,
|
||||
submission: ShortAnswerSubmission
|
||||
submission: ShortAnswerSubmission,
|
||||
review: ShortAnswerReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.NUMERIC]: {
|
||||
edit: NumericEdit,
|
||||
take: NumericTake,
|
||||
preview: NumericPreview,
|
||||
submission: NumericSubmission
|
||||
submission: NumericSubmission,
|
||||
review: NumericReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.FILL_BLANK]: {
|
||||
edit: FillBlankEdit,
|
||||
take: FillBlankTake,
|
||||
preview: FillBlankPreview,
|
||||
submission: FillBlankSubmission
|
||||
submission: FillBlankSubmission,
|
||||
review: FillBlankReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.FILE_UPLOAD]: {
|
||||
edit: FileUploadEdit,
|
||||
@@ -122,7 +136,8 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<Share
|
||||
edit: OrderingEdit,
|
||||
take: OrderingTake,
|
||||
preview: OrderingPreview,
|
||||
submission: OrderingSubmission
|
||||
submission: OrderingSubmission,
|
||||
review: OrderingReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.HOTSPOT]: {
|
||||
edit: HotspotEdit,
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import OptionImage from '../option-image.svelte';
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { getOptionImageUrl, hasOptionImages } from '../option-image-utils';
|
||||
import { cn } from '../../../../tools';
|
||||
import McqPreviewOptionRow from '../shared/mcq-preview-option-row.svelte';
|
||||
|
||||
let { question, labels }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const optionsHaveImages = $derived(hasOptionImages(question.options));
|
||||
</script>
|
||||
@@ -14,22 +11,7 @@
|
||||
<div class="ui:space-y-2">
|
||||
<ul class={optionsHaveImages ? 'ui:grid ui:grid-cols-2 ui:gap-3' : 'ui:space-y-1'}>
|
||||
{#each question.options ?? [] as option, index}
|
||||
{@const optionImageUrl = getOptionImageUrl(option)}
|
||||
<li class={cn(optionsHaveImages && 'ui:space-y-2 ui:rounded-md ui:p-2', 'ui:text-sm')}>
|
||||
<OptionImage
|
||||
src={optionImageUrl}
|
||||
alt={label('question.edit.image_alt')}
|
||||
variant="preview"
|
||||
hasAnyImageInOptions={optionsHaveImages}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{option.label || option.value || [label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')}
|
||||
{#if option.isCorrect}
|
||||
<span class="ui:ml-2 ui:text-xs ui:text-green-600">{label('common.correct_badge')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
<McqPreviewOptionRow {option} {index} optionImageUrl={getOptionImageUrl(option)} {optionsHaveImages} {labels} />
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { Checkbox } from '../../../../base/checkbox';
|
||||
import { Label } from '../../../../base/label';
|
||||
import OptionImage from '../option-image.svelte';
|
||||
import { getOptionImageUrl, hasOptionImages } from '../option-image-utils';
|
||||
import { getMcqReviewRowBorderClass, getOptionNumericId } from '../option-review-utils';
|
||||
import OptionCorrectnessBadges from '../shared/option-correctness-badges.svelte';
|
||||
import { cn } from '../../../../tools';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const selectedIds = $derived.by(() => {
|
||||
if (answer?.type !== 'CHECKBOX') return [] as number[];
|
||||
return answer.optionIds;
|
||||
});
|
||||
|
||||
const optionsHaveImages = $derived(hasOptionImages(question.options));
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-2">
|
||||
<div class={optionsHaveImages ? 'ui:grid ui:grid-cols-2 ui:gap-3' : 'ui:space-y-2'}>
|
||||
{#each question.options ?? [] as option, index}
|
||||
{@const optionId = `checkbox-review-${question.id ?? 'q'}-${index}`}
|
||||
{@const optionImageUrl = getOptionImageUrl(option)}
|
||||
{@const oid = getOptionNumericId(option)}
|
||||
{@const isSelected = oid !== null && selectedIds.includes(oid)}
|
||||
{@const isCorrect = option.isCorrect === true}
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'ui:rounded-md ui:border ui:p-2',
|
||||
optionsHaveImages ? 'ui:space-y-2' : 'ui:flex ui:items-center ui:gap-2',
|
||||
getMcqReviewRowBorderClass(isSelected, isCorrect)
|
||||
)}
|
||||
>
|
||||
<OptionImage
|
||||
src={optionImageUrl}
|
||||
alt={label('question.edit.image_alt')}
|
||||
variant="take"
|
||||
hasAnyImageInOptions={optionsHaveImages}
|
||||
/>
|
||||
|
||||
<div class="ui:flex ui:items-center ui:gap-2">
|
||||
<Checkbox id={optionId} checked={isSelected} disabled={true} />
|
||||
<Label class="ui:cursor-default ui:font-normal ui:w-full" for={optionId}>
|
||||
{option.label ||
|
||||
option.value ||
|
||||
[label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')}
|
||||
<OptionCorrectnessBadges {labels} showCorrect={isCorrect} showIncorrect={isSelected && !isCorrect} />
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import LabeledValueRow from '../shared/labeled-value-row.svelte';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const studentJoined = $derived.by(() => {
|
||||
if (answer?.type !== 'FILL_BLANK') return '';
|
||||
return answer.values
|
||||
.map((token) => String(token).trim())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
});
|
||||
|
||||
const acceptedRaw = $derived(String((question.settings?.acceptedAnswers as string | undefined) ?? ''));
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-2">
|
||||
<LabeledValueRow
|
||||
label={label('fill_blank.review.your_answer_label')}
|
||||
value={studentJoined || label('common.not_set')}
|
||||
/>
|
||||
<p class="ui:text-muted-foreground ui:text-sm">
|
||||
{label('fill_blank.preview.accepted_answers_label')}: {acceptedRaw || label('common.not_set')}
|
||||
</p>
|
||||
</div>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
|
||||
let { question, labels }: Pick<ExerciseQuestionRendererProps, 'question' | 'labels'> = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const correctValue = $derived(question.settings?.correctValue as number | undefined);
|
||||
const tolerance = $derived(question.settings?.tolerance as number | undefined);
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-muted-foreground ui:text-sm">
|
||||
{label('numeric.preview.correct_value_label')}: {correctValue ?? label('common.not_set')}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground ui:text-sm">{label('numeric.preview.tolerance_label')}: {tolerance ?? 0}</p>
|
||||
</div>
|
||||
@@ -1,17 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import NumericExpectedFields from './numeric-expected-fields.svelte';
|
||||
|
||||
let { question, labels }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const correctValue = $derived(question.settings?.correctValue as number | undefined);
|
||||
const tolerance = $derived(question.settings?.tolerance as number | undefined);
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-muted-foreground ui:text-sm">
|
||||
{label('numeric.preview.correct_value_label')}: {correctValue ?? label('common.not_set')}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground ui:text-sm">{label('numeric.preview.tolerance_label')}: {tolerance ?? 0}</p>
|
||||
</div>
|
||||
<NumericExpectedFields {question} {labels} />
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { cn } from '../../../../tools';
|
||||
import LabeledValueRow from '../shared/labeled-value-row.svelte';
|
||||
import NumericExpectedFields from './numeric-expected-fields.svelte';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const studentVal = $derived.by(() => {
|
||||
if (answer?.type !== 'NUMERIC' || typeof answer.value !== 'number' || !Number.isFinite(answer.value)) {
|
||||
return null;
|
||||
}
|
||||
return answer.value;
|
||||
});
|
||||
|
||||
const correct = $derived(question.settings?.correctValue as number | undefined);
|
||||
const tolerance = $derived((question.settings?.tolerance as number | undefined) ?? 0);
|
||||
|
||||
const inRange = $derived.by(() => {
|
||||
if (studentVal === null || correct === undefined) return null;
|
||||
return Math.abs(studentVal - correct) <= tolerance;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-2">
|
||||
<LabeledValueRow
|
||||
label={label('numeric.review.your_answer_label')}
|
||||
value={studentVal !== null ? String(studentVal) : label('common.not_set')}
|
||||
/>
|
||||
{#if studentVal !== null && correct !== undefined && inRange !== null}
|
||||
<p class={cn(inRange ? 'ui:text-xs ui:text-emerald-600' : 'ui:text-xs ui:text-red-600')}>
|
||||
{inRange ? label('numeric.review.in_range') : label('numeric.review.out_of_range')}
|
||||
</p>
|
||||
{/if}
|
||||
<NumericExpectedFields {question} {labels} />
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared helpers for MCQ-style review rows (radio, checkbox).
|
||||
*/
|
||||
|
||||
export function getOptionNumericId(option: { id?: number | string }): number | null {
|
||||
if (option.id === undefined || option.id === null) return null;
|
||||
const n = Number(option.id);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
export function getMcqReviewRowBorderClass(isSelected: boolean, isCorrect: boolean): string {
|
||||
if (isSelected && isCorrect) {
|
||||
return 'ui:border-emerald-500 ui:bg-emerald-50/40 dark:ui:bg-emerald-950/30';
|
||||
}
|
||||
if (isSelected && !isCorrect) {
|
||||
return 'ui:border-red-500 ui:bg-red-50/40 dark:ui:bg-red-950/30';
|
||||
}
|
||||
if (!isSelected && isCorrect) {
|
||||
return 'ui:border-emerald-300 ui:bg-emerald-50/20 dark:ui:bg-emerald-950/20';
|
||||
}
|
||||
return 'ui:border-muted';
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { applyOrderingAnswer, areSameOrdering, getOrderingItemsFromQuestion } from '../ordering-utils';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const expectedOrder = $derived.by(() => getOrderingItemsFromQuestion(question));
|
||||
const submittedOrder = $derived.by(() => applyOrderingAnswer(expectedOrder, answer));
|
||||
const hasSubmittedAnswer = $derived(answer?.type === 'ORDERING' ? answer.orderedValues.length > 0 : false);
|
||||
const isSubmittedOrderCorrect = $derived(areSameOrdering(expectedOrder, submittedOrder));
|
||||
|
||||
function getRowClass(index: number): string {
|
||||
if (!hasSubmittedAnswer) return 'ui:border-muted';
|
||||
|
||||
const expected = expectedOrder[index];
|
||||
const submitted = submittedOrder[index];
|
||||
const isMatch = expected && submitted ? expected.answerValue === submitted.answerValue : false;
|
||||
|
||||
return isMatch ? 'ui:border-emerald-400 ui:bg-emerald-50/30' : 'ui:border-amber-400 ui:bg-amber-50/30';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-3">
|
||||
{#if hasSubmittedAnswer}
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-muted-foreground ui:text-xs">{label('ordering.preview.submitted_label')}</p>
|
||||
{#each submittedOrder as item, index (item.id)}
|
||||
<div class={`ui:flex ui:items-center ui:gap-3 ui:rounded-md ui:border ui:p-2 ${getRowClass(index)}`}>
|
||||
<span class="ui:bg-muted ui:text-muted-foreground ui:rounded-full ui:px-2 ui:py-1 ui:text-xs"
|
||||
>{index + 1}</span
|
||||
>
|
||||
<span class="ui:text-sm">{item.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class={`ui:text-xs ${isSubmittedOrderCorrect ? 'ui:text-emerald-600' : 'ui:text-amber-600'}`}>
|
||||
{isSubmittedOrderCorrect ? label('ordering.preview.matches') : label('ordering.preview.differs')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-muted-foreground ui:text-xs">{label('ordering.preview.expected_label')}</p>
|
||||
{#each expectedOrder as item, index (item.id)}
|
||||
<div class="ui:bg-background ui:flex ui:items-center ui:gap-3 ui:rounded-md ui:border ui:p-2">
|
||||
<span class="ui:bg-muted ui:text-muted-foreground ui:rounded-full ui:px-2 ui:py-1 ui:text-xs">{index + 1}</span>
|
||||
<span class="ui:text-sm">{item.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,53 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { applyOrderingAnswer, areSameOrdering, getOrderingItemsFromQuestion } from '../ordering-utils';
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import OrderingDisplay from './ordering-display.svelte';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const expectedOrder = $derived.by(() => getOrderingItemsFromQuestion(question));
|
||||
const submittedOrder = $derived.by(() => applyOrderingAnswer(expectedOrder, answer));
|
||||
const hasSubmittedAnswer = $derived(answer?.type === 'ORDERING' ? answer.orderedValues.length > 0 : false);
|
||||
const isSubmittedOrderCorrect = $derived(areSameOrdering(expectedOrder, submittedOrder));
|
||||
|
||||
function getRowClass(index: number): string {
|
||||
if (!hasSubmittedAnswer) return 'ui:border-muted';
|
||||
|
||||
const expected = expectedOrder[index];
|
||||
const submitted = submittedOrder[index];
|
||||
const isMatch = expected && submitted ? expected.answerValue === submitted.answerValue : false;
|
||||
|
||||
return isMatch ? 'ui:border-emerald-400 ui:bg-emerald-50/30' : 'ui:border-amber-400 ui:bg-amber-50/30';
|
||||
}
|
||||
let props: ExerciseQuestionRendererProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-3">
|
||||
{#if hasSubmittedAnswer}
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-muted-foreground ui:text-xs">{label('ordering.preview.submitted_label')}</p>
|
||||
{#each submittedOrder as item, index (item.id)}
|
||||
<div class={`ui:flex ui:items-center ui:gap-3 ui:rounded-md ui:border ui:p-2 ${getRowClass(index)}`}>
|
||||
<span class="ui:bg-muted ui:text-muted-foreground ui:rounded-full ui:px-2 ui:py-1 ui:text-xs"
|
||||
>{index + 1}</span
|
||||
>
|
||||
<span class="ui:text-sm">{item.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class={`ui:text-xs ${isSubmittedOrderCorrect ? 'ui:text-emerald-600' : 'ui:text-amber-600'}`}>
|
||||
{isSubmittedOrderCorrect ? label('ordering.preview.matches') : label('ordering.preview.differs')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-muted-foreground ui:text-xs">{label('ordering.preview.expected_label')}</p>
|
||||
{#each expectedOrder as item, index (item.id)}
|
||||
<div class="ui:bg-background ui:flex ui:items-center ui:gap-3 ui:rounded-md ui:border ui:p-2">
|
||||
<span class="ui:bg-muted ui:text-muted-foreground ui:rounded-full ui:px-2 ui:py-1 ui:text-xs">{index + 1}</span>
|
||||
<span class="ui:text-sm">{item.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<OrderingDisplay {...props} answer={null} />
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import OrderingDisplay from './ordering-display.svelte';
|
||||
|
||||
let props: ExerciseQuestionRendererProps = $props();
|
||||
</script>
|
||||
|
||||
<OrderingDisplay {...props} />
|
||||
@@ -1,12 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import OptionImage from '../option-image.svelte';
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { getOptionImageUrl, hasOptionImages } from '../option-image-utils';
|
||||
import { cn } from '../../../../tools';
|
||||
import McqPreviewOptionRow from '../shared/mcq-preview-option-row.svelte';
|
||||
|
||||
let { question, labels }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const optionsHaveImages = $derived(hasOptionImages(question.options));
|
||||
</script>
|
||||
@@ -14,22 +11,7 @@
|
||||
<div class="ui:space-y-2">
|
||||
<ul class={optionsHaveImages ? 'ui:grid ui:grid-cols-2 ui:gap-3' : 'ui:space-y-1'}>
|
||||
{#each question.options ?? [] as option, index}
|
||||
{@const optionImageUrl = getOptionImageUrl(option)}
|
||||
<li class={cn(optionsHaveImages && 'ui:space-y-2 ui:rounded-md ui:p-2', 'ui:text-sm')}>
|
||||
<OptionImage
|
||||
src={optionImageUrl}
|
||||
alt={label('question.edit.image_alt')}
|
||||
variant="preview"
|
||||
hasAnyImageInOptions={optionsHaveImages}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{option.label || option.value || [label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')}
|
||||
{#if option.isCorrect}
|
||||
<span class="ui:ml-2 ui:text-xs ui:text-green-600">{label('common.correct_badge')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
<McqPreviewOptionRow {option} {index} optionImageUrl={getOptionImageUrl(option)} {optionsHaveImages} {labels} />
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import OptionImage from '../option-image.svelte';
|
||||
import { getOptionImageUrl, hasOptionImages } from '../option-image-utils';
|
||||
import { getMcqReviewRowBorderClass, getOptionNumericId } from '../option-review-utils';
|
||||
import OptionCorrectnessBadges from '../shared/option-correctness-badges.svelte';
|
||||
import { cn } from '../../../../tools';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const selectedOptionId = $derived.by(() => {
|
||||
if (answer?.type !== 'RADIO') return null;
|
||||
return answer.optionId;
|
||||
});
|
||||
|
||||
const optionsHaveImages = $derived(hasOptionImages(question.options));
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-2">
|
||||
<div class={optionsHaveImages ? 'ui:grid ui:grid-cols-2 ui:gap-3' : 'ui:space-y-2'}>
|
||||
{#each question.options ?? [] as option, index}
|
||||
{@const optionImageUrl = getOptionImageUrl(option)}
|
||||
{@const oid = getOptionNumericId(option)}
|
||||
{@const isSelected = oid !== null && selectedOptionId !== null && selectedOptionId === oid}
|
||||
{@const isCorrect = option.isCorrect === true}
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'ui:rounded-md ui:border ui:p-2',
|
||||
optionsHaveImages ? 'ui:space-y-2' : 'ui:flex ui:items-center ui:gap-2',
|
||||
getMcqReviewRowBorderClass(isSelected, isCorrect)
|
||||
)}
|
||||
>
|
||||
<OptionImage
|
||||
src={optionImageUrl}
|
||||
alt={label('question.edit.image_alt')}
|
||||
variant="take"
|
||||
hasAnyImageInOptions={optionsHaveImages}
|
||||
/>
|
||||
|
||||
<div class="ui:flex ui:min-w-0 ui:flex-1 ui:items-center ui:gap-2">
|
||||
<span
|
||||
class="ui:inline-flex ui:h-4 ui:w-4 ui:shrink-0 ui:rounded-full ui:border ui:border-muted-foreground"
|
||||
aria-hidden="true"
|
||||
style={`box-shadow: inset 0 0 0 3px ${isSelected ? 'var(--foreground)' : 'transparent'}`}
|
||||
></span>
|
||||
<div class="ui:text-sm ui:font-normal">
|
||||
{option.label ||
|
||||
option.value ||
|
||||
[label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')}
|
||||
<OptionCorrectnessBadges {labels} showCorrect={isCorrect} showIncorrect={isSelected && !isCorrect} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Chart from '../../../../base/chart';
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import type { ChartConfig } from '../../../../base/chart';
|
||||
|
||||
import SubmissionResponsePieChart from '../shared/submission-response-pie-chart.svelte';
|
||||
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
|
||||
|
||||
let {
|
||||
@@ -15,7 +16,7 @@
|
||||
label: getSubmissionLabel(labels, 'submission.chart.responses', 'Responses'),
|
||||
color: 'var(--chart-1)'
|
||||
}
|
||||
} satisfies Chart.ChartConfig);
|
||||
} satisfies ChartConfig);
|
||||
|
||||
const chartData = $derived.by(() => {
|
||||
const byOptionId = new Map<string, { key: string; label: string; responses: number }>();
|
||||
@@ -62,13 +63,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if chartData.length > 0}
|
||||
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full ui:flex-col ui:items-center">
|
||||
<Chart.PieChart data={chartData} key="key" label="label" value="responses" c="color" />
|
||||
<Chart.ChartLegend items={chartData.map((d) => ({ label: d.label, color: d.color, value: d.responses }))} />
|
||||
</Chart.Container>
|
||||
{:else}
|
||||
<p class="ui:text-muted-foreground ui:text-sm">
|
||||
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
|
||||
</p>
|
||||
{/if}
|
||||
<SubmissionResponsePieChart
|
||||
{chartData}
|
||||
{chartConfig}
|
||||
emptyLabel={getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '../../../../tools';
|
||||
|
||||
interface Props {
|
||||
/** Muted label (already translated by caller). */
|
||||
label: string;
|
||||
/** Main value text. */
|
||||
value: string;
|
||||
class?: string;
|
||||
valueClass?: string;
|
||||
}
|
||||
|
||||
let { label, value, class: className = '', valueClass = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<p class={cn('ui:text-sm', className)}>
|
||||
<span class="ui:text-muted-foreground">{label}</span>
|
||||
{' '}
|
||||
<span class={cn('ui:font-medium', valueClass)}>{value}</span>
|
||||
</p>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionLabels } from '@cio/question-types';
|
||||
import OptionImage from '../option-image.svelte';
|
||||
import { cn } from '../../../../tools';
|
||||
import OptionCorrectnessBadges from './option-correctness-badges.svelte';
|
||||
|
||||
interface Props {
|
||||
option: { label: string; value?: string; isCorrect?: boolean };
|
||||
index: number;
|
||||
optionImageUrl: string | null;
|
||||
optionsHaveImages: boolean;
|
||||
labels: ExerciseQuestionLabels | undefined;
|
||||
}
|
||||
|
||||
let { option, index, optionImageUrl, optionsHaveImages, labels }: Props = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
</script>
|
||||
|
||||
<li class={cn(optionsHaveImages && 'ui:space-y-2 ui:rounded-md ui:p-2', 'ui:text-sm')}>
|
||||
<OptionImage
|
||||
src={optionImageUrl}
|
||||
alt={label('question.edit.image_alt')}
|
||||
variant="preview"
|
||||
hasAnyImageInOptions={optionsHaveImages}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{option.label || option.value || [label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')}
|
||||
<OptionCorrectnessBadges {labels} showCorrect={option.isCorrect === true} />
|
||||
</div>
|
||||
</li>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionLabels } from '@cio/question-types';
|
||||
import { Badge } from '../../../../base/badge';
|
||||
|
||||
interface Props {
|
||||
labels: ExerciseQuestionLabels | undefined;
|
||||
showCorrect?: boolean;
|
||||
showIncorrect?: boolean;
|
||||
}
|
||||
|
||||
let { labels, showCorrect = false, showIncorrect = false }: Props = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
</script>
|
||||
|
||||
{#if showCorrect}
|
||||
<Badge variant="success" class="ui:ml-2">{label('common.correct_badge')}</Badge>
|
||||
{/if}
|
||||
{#if showIncorrect}
|
||||
<Badge variant="destructive" class="ui:ml-2">{label('common.incorrect_badge')}</Badge>
|
||||
{/if}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import * as Chart from '../../../../base/chart';
|
||||
import type { ChartConfig } from '../../../../base/chart';
|
||||
|
||||
import { CHART_COLORS } from '../submission-utils';
|
||||
|
||||
interface Row {
|
||||
key: string;
|
||||
label: string;
|
||||
responses: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
chartData: Row[];
|
||||
chartConfig: ChartConfig;
|
||||
emptyLabel: string;
|
||||
}
|
||||
|
||||
let { chartData, chartConfig, emptyLabel }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if chartData.length > 0}
|
||||
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full ui:flex-col ui:items-center">
|
||||
<Chart.PieChart data={chartData} key="key" label="label" value="responses" c="color" />
|
||||
<Chart.ChartLegend
|
||||
items={chartData.map((d) => ({
|
||||
label: d.label,
|
||||
color: d.color ?? CHART_COLORS[0],
|
||||
value: d.responses
|
||||
}))}
|
||||
/>
|
||||
</Chart.Container>
|
||||
{:else}
|
||||
<p class="ui:text-muted-foreground ui:text-sm">{emptyLabel}</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import LabeledValueRow from '../shared/labeled-value-row.svelte';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
function stripHtml(raw: string): string {
|
||||
return raw.replace(/<[^>]*>/g, '').trim();
|
||||
}
|
||||
|
||||
function normalizeToken(s: string): string {
|
||||
return s.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const studentText = $derived.by(() => {
|
||||
if (answer?.type !== 'SHORT_ANSWER') return '';
|
||||
return stripHtml(String(answer.text ?? ''));
|
||||
});
|
||||
|
||||
const acceptedList = $derived.by(() => {
|
||||
const raw = question.settings?.acceptedAnswers;
|
||||
if (typeof raw !== 'string') return [] as string[];
|
||||
return raw
|
||||
.split(',')
|
||||
.map((token) => normalizeToken(token))
|
||||
.filter(Boolean);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-2">
|
||||
<LabeledValueRow
|
||||
label={label('short_answer.review.your_answer_label')}
|
||||
value={studentText || label('common.not_set')}
|
||||
/>
|
||||
{#if acceptedList.length > 0}
|
||||
<p class="ui:text-muted-foreground ui:text-sm">
|
||||
{label('short_answer.review.accepted_label')}: {acceptedList.join(', ')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="ui:text-muted-foreground ui:text-sm">{label('short_answer.preview.helper')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { getTrueFalseCorrectIsTrue } from './true-false-correct';
|
||||
|
||||
let { question, labels }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const correctValue = $derived(
|
||||
(question.settings?.correctValue as boolean | undefined) === false
|
||||
? label('true_false.false_label')
|
||||
: label('true_false.true_label')
|
||||
getTrueFalseCorrectIsTrue(question.settings as Record<string, unknown> | undefined)
|
||||
? label('true_false.true_label')
|
||||
: label('true_false.false_label')
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { getTrueFalseCorrectIsTrue } from './true-false-correct';
|
||||
import TrueFalseReviewToggle from './true-false-review-toggle.svelte';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
|
||||
const correctIsTrue = $derived(getTrueFalseCorrectIsTrue(question.settings as Record<string, unknown> | undefined));
|
||||
|
||||
const studentValue = $derived.by(() => {
|
||||
if (answer?.type !== 'TRUE_FALSE') return '';
|
||||
return answer.value ? 'true' : 'false';
|
||||
});
|
||||
|
||||
const isCorrect = $derived.by(() => {
|
||||
if (answer?.type !== 'TRUE_FALSE') return null;
|
||||
return answer.value === correctIsTrue;
|
||||
});
|
||||
</script>
|
||||
|
||||
<TrueFalseReviewToggle {labels} {correctIsTrue} {studentValue} {isCorrect} />
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Matches preview/review: undefined `correctValue` defaults to true.
|
||||
*/
|
||||
export function getTrueFalseCorrectIsTrue(settings: Record<string, unknown> | undefined): boolean {
|
||||
return (settings?.correctValue as boolean | undefined) !== false;
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionLabels } from '@cio/question-types';
|
||||
import * as ToggleGroup from '../../../../base/toggle-group';
|
||||
import OptionCorrectnessBadges from '../shared/option-correctness-badges.svelte';
|
||||
|
||||
interface Props {
|
||||
labels: ExerciseQuestionLabels | undefined;
|
||||
correctIsTrue: boolean;
|
||||
studentValue: string;
|
||||
isCorrect: boolean | null;
|
||||
}
|
||||
|
||||
let { labels, correctIsTrue, studentValue, isCorrect }: Props = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-2">
|
||||
<ToggleGroup.Root type="single" value={studentValue} disabled={true} variant="outline">
|
||||
<ToggleGroup.Item
|
||||
value="true"
|
||||
class={correctIsTrue ? 'ui:border-emerald-500 ui:bg-emerald-50/40 dark:ui:bg-emerald-950/30' : ''}
|
||||
>
|
||||
{label('true_false.true_label')}
|
||||
<OptionCorrectnessBadges
|
||||
{labels}
|
||||
showCorrect={correctIsTrue}
|
||||
showIncorrect={studentValue === 'true' && isCorrect === false}
|
||||
/>
|
||||
</ToggleGroup.Item>
|
||||
<ToggleGroup.Item
|
||||
value="false"
|
||||
class={!correctIsTrue ? 'ui:border-emerald-500 ui:bg-emerald-50/40 dark:ui:bg-emerald-950/30' : ''}
|
||||
>
|
||||
{label('true_false.false_label')}
|
||||
<OptionCorrectnessBadges
|
||||
{labels}
|
||||
showCorrect={!correctIsTrue}
|
||||
showIncorrect={studentValue === 'false' && isCorrect === false}
|
||||
/>
|
||||
</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
import MultiSelectList from './multi-select-list.svelte';
|
||||
|
||||
export { MultiSelectList };
|
||||
export { MultiSelectList as default };
|
||||
export type { MultiSelectListItem } from './types';
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { CheckboxField } from '../checkbox-field';
|
||||
import { cn } from '../../tools';
|
||||
import type { MultiSelectListItem } from './types';
|
||||
|
||||
interface Props {
|
||||
heading: string;
|
||||
emptyMessage: string;
|
||||
items: MultiSelectListItem[];
|
||||
isSelected: (id: string) => boolean;
|
||||
onToggle: (id: string) => void;
|
||||
/** Prefix for each row's checkbox `name` (e.g. `assign-course` → `assign-course-<id>`). */
|
||||
namePrefix?: string;
|
||||
class?: string;
|
||||
/** Extra classes for the scrollable list container (`data-slot="multi-select-list-items"`). */
|
||||
listClass?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
heading,
|
||||
emptyMessage,
|
||||
items,
|
||||
isSelected,
|
||||
onToggle,
|
||||
namePrefix = 'multi-select',
|
||||
class: className,
|
||||
listClass
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="multi-select-list" class={cn('ui:border-border space-y-3 rounded-md border', className)}>
|
||||
<p class="ui:border-border border-b px-2 py-2 text-sm font-medium">
|
||||
{heading} ({items.length})
|
||||
</p>
|
||||
{#if items.length === 0}
|
||||
<p class="ui:text-muted-foreground px-2 pb-2 text-sm">{emptyMessage}</p>
|
||||
{:else}
|
||||
<div data-slot="multi-select-list-items" class={cn('max-h-60 space-y-2 overflow-y-auto px-2 pb-2', listClass)}>
|
||||
{#each items as item (item.id)}
|
||||
<CheckboxField
|
||||
name={`${namePrefix}-${item.id}`}
|
||||
label={item.label || item.id}
|
||||
checked={isSelected(item.id)}
|
||||
onclick={() => onToggle(item.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
export type MultiSelectListItem = { id: string; label: string };
|
||||
@@ -7,7 +7,7 @@
|
||||
rel="noopener noreferrer"
|
||||
class="ui:flex ui:items-center ui:justify-center ui:gap-2"
|
||||
>
|
||||
<img src="https://classroomio.com/logo-512.png" class="ui:w-7 ui:h-7" alt="ClassroomIO Logo" />
|
||||
<img src="/logo-512.png" class="ui:w-7 ui:h-7" alt="ClassroomIO Logo" />
|
||||
<span class="ui:text-foreground ui:text-md">ClassroomIO</span>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -66,4 +66,6 @@ export { RadioOptionCard, RadioOptionCardGroup, type RadioOptionCardOption } fro
|
||||
export { DocumentCard } from './custom/document-card';
|
||||
export * as ExerciseQuestion from './custom/exercise-question';
|
||||
export * as MediaPlayer from './custom/media-player';
|
||||
export { MultiSelectList, type MultiSelectListItem } from './custom/multi-select-list';
|
||||
export * as YoutubeLinkForm from './custom/youtube-link-form';
|
||||
export { BackButton } from './custom/back-button';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,24 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const ZImportAudienceMembers = z.object({
|
||||
recipientCsv: z.string().max(25000),
|
||||
courseIds: z.array(z.string().uuid()).optional(),
|
||||
allCourses: z.boolean().optional().default(false),
|
||||
sendEmail: z.boolean().default(true)
|
||||
});
|
||||
|
||||
export type TImportAudienceMembers = z.infer<typeof ZImportAudienceMembers>;
|
||||
|
||||
export const ZAssignAudienceCourses = z.object({
|
||||
profileIds: z.array(z.uuid()).min(1).max(500),
|
||||
courseIds: z.array(z.uuid()).min(1),
|
||||
sendEmail: z.boolean().default(true)
|
||||
});
|
||||
|
||||
export type TAssignAudienceCourses = z.infer<typeof ZAssignAudienceCourses>;
|
||||
|
||||
export const ZAudienceInviteByEmail = z.object({
|
||||
email: z.email()
|
||||
});
|
||||
|
||||
export type TAudienceInviteByEmail = z.infer<typeof ZAudienceInviteByEmail>;
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './audience';
|
||||
export * from './organization';
|
||||
export * from './quiz';
|
||||
export * from './exercise';
|
||||
|
||||
Reference in New Issue
Block a user