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:
Rotimi Best
2026-03-22 18:42:23 +01:00
parent e9c4082f89
commit 3919ff6c2f
157 changed files with 13183 additions and 872 deletions
@@ -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