@nualang/nualang-ui-components 0.1.1435 → 0.1.1436

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,6 +55,10 @@ export default function FeedbackCard({
55
55
  userData = [],
56
56
  hasSubmission = false,
57
57
  aiGrade = {},
58
+ // Discuss AI feedback, already combined with the all-AI switch. It stops new
59
+ // AI feedback being generated; assessments already saved stay visible, since
60
+ // a teacher may have set them.
61
+ isDiscussAIFeedbackEnabled = true,
58
62
  onGenerateFeedback = () => {},
59
63
  onSubmitFeedback = () => {},
60
64
  setSelectedRecording = () => {},
@@ -201,6 +205,7 @@ export default function FeedbackCard({
201
205
  }
202
206
  }, [submitTrigger]);
203
207
  const handleGenerateAI = async () => {
208
+ if (!isDiscussAIFeedbackEnabled) return;
204
209
  setAiJustGenerated(true);
205
210
  setIsGeneratingFeedback(true);
206
211
  setIsGeneratingIndividual(true);
@@ -566,7 +571,7 @@ export default function FeedbackCard({
566
571
  disabled: !hasSubmission || isGeneratingFeedback,
567
572
  onClick: handleOpenRecordingDialog,
568
573
  children: t("view_recording")
569
- }), /*#__PURE__*/_jsx(Button, {
574
+ }), isDiscussAIFeedbackEnabled && /*#__PURE__*/_jsx(Button, {
570
575
  variant: "contained",
571
576
  color: "secondary",
572
577
  disabled: !hasSubmission || isGeneratingFeedback,
@@ -23,7 +23,7 @@ function DynamicAIWarning({
23
23
  return /*#__PURE__*/_jsxs(ResponsiveDialog, {
24
24
  open: open,
25
25
  handleClose: handleClose,
26
- submitText: t("classroom_settings"),
26
+ submitText: t("ai_settings"),
27
27
  dialogTitle: dialogTitle,
28
28
  children: [/*#__PURE__*/_jsxs(Box, {
29
29
  sx: {
@@ -168,7 +168,7 @@ function DynamicAIWarning({
168
168
  fontWeight: "bold"
169
169
  },
170
170
  onClick: submit,
171
- children: t("classroom_settings")
171
+ children: t("ai_settings")
172
172
  }), " ", "for this class."]
173
173
  })]
174
174
  }), /*#__PURE__*/_jsx(Divider, {
@@ -28,6 +28,9 @@ export default function GroupFeedbackDialog({
28
28
  t = x => x,
29
29
  handleBatchGradeConversations = () => {},
30
30
  aiGrade = {},
31
+ // Discuss AI feedback, already combined with the all-AI switch, so the
32
+ // generate controls disappear when either is off.
33
+ isDiscussAIFeedbackEnabled = true,
31
34
  selectedGroup = {},
32
35
  setSelectedRecording = () => {},
33
36
  selectedRecording = {},
@@ -200,7 +203,7 @@ export default function GroupFeedbackDialog({
200
203
  }).filter(member => member.name !== "Unknown");
201
204
  };
202
205
  const handleGenerateFeedback = async discussionData => {
203
- if (!handleBatchGradeConversations) return;
206
+ if (!isDiscussAIFeedbackEnabled || !handleBatchGradeConversations) return;
204
207
  setIsGeneratingIndividual(true);
205
208
  try {
206
209
  const joinedMembersWithNames = getJoinedMembersNames(discussionData.joinedMembers || [], userData);
@@ -243,7 +246,7 @@ export default function GroupFeedbackDialog({
243
246
  customFeedback: customFeedbackText
244
247
  };
245
248
  });
246
- if (conversationsToGrade.length > 0 && handleBatchGradeConversations) {
249
+ if (isDiscussAIFeedbackEnabled && conversationsToGrade.length > 0 && handleBatchGradeConversations) {
247
250
  setIsGeneratingAll(true);
248
251
  setGenerateAllUsed(true);
249
252
  try {
@@ -546,7 +549,7 @@ export default function GroupFeedbackDialog({
546
549
  sx: {
547
550
  flex: 1
548
551
  }
549
- }), /*#__PURE__*/_jsx(Button, {
552
+ }), isDiscussAIFeedbackEnabled && /*#__PURE__*/_jsx(Button, {
550
553
  variant: "contained",
551
554
  color: "secondary",
552
555
  endIcon: /*#__PURE__*/_jsx(AutoFixHighIcon, {}),
@@ -603,6 +606,7 @@ export default function GroupFeedbackDialog({
603
606
  existingFeedback: feedbackByMeetingId[feedbackMeetingId] || {},
604
607
  onGenerateFeedback: () => openCustomPromptForSingle(discussionData),
605
608
  onSubmitFeedback: handleSubmitFeedback,
609
+ isDiscussAIFeedbackEnabled: isDiscussAIFeedbackEnabled,
606
610
  setSelectedRecording: setSelectedRecording,
607
611
  selectedRecording: selectedRecording,
608
612
  s3Url: s3Url,
@@ -0,0 +1,39 @@
1
+ import { render } from "@testing-library/react";
2
+ import { describe, it, expect } from "vitest";
3
+ import { createTheme, ThemeProvider } from "@mui/material/styles";
4
+ import GroupedFeedbackDialog from "./GroupedFeedbackDialog";
5
+ import { WithData } from "./GroupedFeedbackDialog.stories";
6
+
7
+ // This repo has no jest-dom matchers and no auto-cleanup, so assertions are
8
+ // plain and each test unmounts - matching AISettings.test.jsx.
9
+ import { jsx as _jsx } from "react/jsx-runtime";
10
+ const setup = (props = {}) => render(/*#__PURE__*/_jsx(ThemeProvider, {
11
+ theme: createTheme(),
12
+ children: /*#__PURE__*/_jsx(GroupedFeedbackDialog, {
13
+ ...WithData.args,
14
+ ...props
15
+ })
16
+ }));
17
+ describe("GroupedFeedbackDialog AI controls", () => {
18
+ it("offers feedback generation when the classroom allows AI", () => {
19
+ const {
20
+ queryAllByText,
21
+ unmount
22
+ } = setup();
23
+ expect(queryAllByText("generate_feedback").length).toBeGreaterThan(0);
24
+ unmount();
25
+ });
26
+ it("removes every generate control when the classroom limits AI", () => {
27
+ // Teachers can still write and submit their own feedback - only the AI
28
+ // route away from the page is closed.
29
+ const {
30
+ queryAllByText,
31
+ unmount
32
+ } = setup({
33
+ isDiscussAIFeedbackEnabled: false
34
+ });
35
+ expect(queryAllByText("generate_feedback").length).toBe(0);
36
+ expect(queryAllByText("submit_feedback").length).toBeGreaterThan(0);
37
+ unmount();
38
+ });
39
+ });
@@ -145,7 +145,6 @@ export default function QuickCreateClassroomDialog({
145
145
  visibility: "private",
146
146
  vchatEnabled: true,
147
147
  vchatEnabledInSettings: true,
148
- isDynamicResponsesEnabled: "enabled",
149
148
  enrolmentKey: generateRandomEnrolmentKey(),
150
149
  allowedDomains: [],
151
150
  collaborators: [],
@@ -29,8 +29,8 @@ import ReportProblem from "../../Misc/ReportProblem/ReportProblem";
29
29
  import NualaSignInImage from "../../img/nuala_sign_in.svg";
30
30
  import { startJoyrideWhenReady } from "../../utils/joyride";
31
31
  import { collectCorrections } from "../../utils/correction";
32
- import { getDynamicResponsesSetting } from "../../utils/dynamicResponses";
33
32
  import { getBotContentTemplateType } from "../../utils";
33
+ import { AI_ENABLED, courseAIValue, isCourseAIEnabled } from "../../utils/aiControls";
34
34
  import { useTheme } from "@mui/material/styles";
35
35
  import config from "../../config";
36
36
  import { useNavigate } from "react-router";
@@ -288,11 +288,17 @@ function Bot({
288
288
  } = getRecognitionParams();
289
289
  toggleListen(learnLang, speechContexts, model, undefined, autoStopOptions);
290
290
  };
291
- const isDynamicResponsesClassroomEnabled = getDynamicResponsesSetting({
292
- botSetting: isDynamicResponsesEnabled,
293
- parentClassroom,
294
- courseSettings
295
- });
291
+
292
+ // The classroom's all-AI and chatbot switches, and the course's, can each
293
+ // silence dynamic responses whatever the bot's own setting. Only when they
294
+ // allow AI does the bot's setting decide - except for courses saved as
295
+ // "enabled", which older classroom records can still carry.
296
+ let isDynamicResponsesClassroomEnabled = isDynamicResponsesEnabled;
297
+ if (!isCourseAIEnabled(parentClassroom, courseSettings)) {
298
+ isDynamicResponsesClassroomEnabled = "disable";
299
+ } else if (courseAIValue(courseSettings) === AI_ENABLED) {
300
+ isDynamicResponsesClassroomEnabled = "enable";
301
+ }
296
302
  const exerciseState = useExerciseState({
297
303
  t,
298
304
  open,
@@ -0,0 +1,235 @@
1
+ import { useEffect, useRef } from "react";
2
+ import * as Yup from "yup";
3
+ import { Alert, Box, FormControl, FormControlLabel, FormLabel, Grid, IconButton, Radio, RadioGroup, Tooltip, Typography } from "@mui/material";
4
+ import HelpIcon from "@mui/icons-material/Help";
5
+ import SearchIcon from "@mui/icons-material/Search";
6
+ import EastIcon from "@mui/icons-material/East";
7
+ import { AI_DISABLED, AI_ENABLED, AI_FIELDS, aiLockedBy, classroomAIValue, courseAIValue } from "../../../../utils/aiControls";
8
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
+ export const AI_INFO_URL = "https://www.waysidepublishing.com/digital-solutions/ai-safety-and-trust";
10
+
11
+ // One Allowed / Limited choice for one of AI_FIELDS.
12
+ function AISwitch({
13
+ t,
14
+ name,
15
+ label,
16
+ description,
17
+ info,
18
+ value,
19
+ disabled,
20
+ dataCy,
21
+ handleChange,
22
+ handleBlur
23
+ }) {
24
+ return /*#__PURE__*/_jsxs(FormControl, {
25
+ component: "fieldset",
26
+ children: [/*#__PURE__*/_jsxs(FormLabel, {
27
+ component: "legend",
28
+ children: [t(label), info && /*#__PURE__*/_jsx(Tooltip, {
29
+ title: t(info),
30
+ placement: "right",
31
+ children: /*#__PURE__*/_jsx(IconButton, {
32
+ sx: {
33
+ padding: 0,
34
+ marginLeft: 1
35
+ },
36
+ children: /*#__PURE__*/_jsx(HelpIcon, {
37
+ fontSize: "small"
38
+ })
39
+ })
40
+ })]
41
+ }), /*#__PURE__*/_jsx(Typography, {
42
+ color: "textSecondary",
43
+ children: t(description)
44
+ }), /*#__PURE__*/_jsxs(RadioGroup, {
45
+ row: true,
46
+ "aria-label": name,
47
+ name: name,
48
+ value: value,
49
+ onChange: handleChange,
50
+ onBlur: handleBlur,
51
+ children: [/*#__PURE__*/_jsx(FormControlLabel, {
52
+ value: AI_ENABLED,
53
+ control: /*#__PURE__*/_jsx(Radio, {}),
54
+ label: t("ai_allowed"),
55
+ disabled: disabled,
56
+ "data-cy": `allow-${dataCy}-in-settings`
57
+ }), /*#__PURE__*/_jsx(FormControlLabel, {
58
+ value: AI_DISABLED,
59
+ control: /*#__PURE__*/_jsx(Radio, {}),
60
+ label: t("ai_limited"),
61
+ disabled: disabled,
62
+ "data-cy": `limit-${dataCy}-in-settings`
63
+ })]
64
+ })]
65
+ });
66
+ }
67
+
68
+ // The classroom's AI section: a switch for all AI, and one each for chatbot
69
+ // dynamic responses and Discuss AI feedback. Per-course chatbot overrides stay
70
+ // in Course Settings, so this section only reports them.
71
+ function AISettings({
72
+ t = text => text,
73
+ values,
74
+ handleChange,
75
+ handleBlur,
76
+ courses = [],
77
+ courseSettings = {},
78
+ scrollToDynamicResponses = false,
79
+ setScrollToDynamicResponses,
80
+ // The saved classroom record, which the API stamps with `aiLockedBy` when
81
+ // the classroom's customer has limited AI. `values` is only the form state.
82
+ classroom
83
+ }) {
84
+ const aiControlRef = useRef(null);
85
+ useEffect(() => {
86
+ // Guarded: the notice can fire before this section has mounted.
87
+ if (scrollToDynamicResponses && aiControlRef.current) {
88
+ aiControlRef.current.scrollIntoView({
89
+ behavior: "smooth",
90
+ block: "center"
91
+ });
92
+ setScrollToDynamicResponses && setScrollToDynamicResponses(false);
93
+ }
94
+ }, [scrollToDynamicResponses]);
95
+ const isAllLimited = classroomAIValue(values) === AI_DISABLED;
96
+ const isAllLocked = Boolean(aiLockedBy(classroom));
97
+
98
+ // While all AI is off, a feature shows as limited - which is what students
99
+ // get - but keeps its own value for when all AI is turned back on.
100
+ const featureValue = field => isAllLimited ? AI_DISABLED : classroomAIValue(values, field);
101
+ const isChatbotLimited = featureValue(AI_FIELDS.chatbot) === AI_DISABLED;
102
+
103
+ // Courses that have turned the chatbot off for themselves. Read from the
104
+ // saved classroom, so this is what students currently get.
105
+ const overriddenCourses = courses.filter(course => courseAIValue(courseSettings?.[course.courseId]) === AI_DISABLED);
106
+ const switchProps = {
107
+ t,
108
+ handleChange,
109
+ handleBlur
110
+ };
111
+ return /*#__PURE__*/_jsxs(Grid, {
112
+ container: true,
113
+ spacing: 2,
114
+ ref: aiControlRef,
115
+ children: [/*#__PURE__*/_jsxs(Grid, {
116
+ size: 12,
117
+ children: [/*#__PURE__*/_jsx(Typography, {
118
+ color: "textSecondary",
119
+ gutterBottom: true,
120
+ children: t("ai_settings_desc")
121
+ }), /*#__PURE__*/_jsxs(Box, {
122
+ component: "a",
123
+ href: AI_INFO_URL,
124
+ target: "_blank",
125
+ rel: "noopener noreferrer",
126
+ sx: {
127
+ display: "inline-flex",
128
+ alignItems: "center",
129
+ textDecoration: "none",
130
+ color: "primary.main"
131
+ },
132
+ children: [/*#__PURE__*/_jsx(SearchIcon, {
133
+ sx: {
134
+ mr: 0.5
135
+ },
136
+ fontSize: "small"
137
+ }), /*#__PURE__*/_jsx(Typography, {
138
+ variant: "body2",
139
+ children: t("ai_learn_more")
140
+ }), /*#__PURE__*/_jsx(EastIcon, {
141
+ sx: {
142
+ ml: 0.5
143
+ },
144
+ fontSize: "small"
145
+ })]
146
+ })]
147
+ }), /*#__PURE__*/_jsxs(Grid, {
148
+ size: 12,
149
+ children: [/*#__PURE__*/_jsx(AISwitch, {
150
+ ...switchProps,
151
+ name: AI_FIELDS.all,
152
+ label: "limit_ai",
153
+ description: "limit_ai_desc",
154
+ info: "limit_ai_info",
155
+ value: classroomAIValue(values),
156
+ disabled: isAllLocked,
157
+ dataCy: "ai"
158
+ }), isAllLocked && /*#__PURE__*/_jsx(Alert, {
159
+ severity: "info",
160
+ "data-cy": "ai-locked-notice",
161
+ sx: {
162
+ mt: 1
163
+ },
164
+ children: t("ai_locked_notice")
165
+ }), !isAllLocked && isAllLimited && /*#__PURE__*/_jsx(Alert, {
166
+ severity: "info",
167
+ "data-cy": "ai-limited-notice",
168
+ sx: {
169
+ mt: 1
170
+ },
171
+ children: t("ai_limited_classroom_notice")
172
+ })]
173
+ }), /*#__PURE__*/_jsx(Grid, {
174
+ size: 12,
175
+ children: /*#__PURE__*/_jsxs(Box, {
176
+ sx: {
177
+ pl: 4,
178
+ display: "grid",
179
+ gap: 2
180
+ },
181
+ children: [/*#__PURE__*/_jsxs(Box, {
182
+ children: [/*#__PURE__*/_jsx(AISwitch, {
183
+ ...switchProps,
184
+ name: AI_FIELDS.chatbot,
185
+ label: "chatbot_ai",
186
+ description: "chatbot_ai_desc",
187
+ info: "dynamic_responses_info",
188
+ value: featureValue(AI_FIELDS.chatbot),
189
+ disabled: isAllLimited || Boolean(aiLockedBy(classroom, AI_FIELDS.chatbot)),
190
+ dataCy: "chatbot-ai"
191
+ }), !isAllLocked && Boolean(aiLockedBy(classroom, AI_FIELDS.chatbot)) && /*#__PURE__*/_jsx(Alert, {
192
+ severity: "info",
193
+ "data-cy": "ai-partly-locked-notice",
194
+ sx: {
195
+ mt: 1
196
+ },
197
+ children: t("ai_partly_locked_notice")
198
+ }), !isChatbotLimited && overriddenCourses.length > 0 && /*#__PURE__*/_jsxs(Alert, {
199
+ severity: "info",
200
+ "data-cy": "ai-course-overrides-notice",
201
+ sx: {
202
+ mt: 1
203
+ },
204
+ children: [t("ai_course_overrides_notice"), ": ", overriddenCourses.map(course => course.courseName).join(", ")]
205
+ })]
206
+ }), /*#__PURE__*/_jsxs(Box, {
207
+ children: [/*#__PURE__*/_jsx(AISwitch, {
208
+ ...switchProps,
209
+ name: AI_FIELDS.discuss,
210
+ label: "discuss_ai",
211
+ description: "discuss_ai_desc",
212
+ value: featureValue(AI_FIELDS.discuss),
213
+ disabled: isAllLimited || Boolean(aiLockedBy(classroom, AI_FIELDS.discuss)),
214
+ dataCy: "discuss-ai"
215
+ }), !isAllLocked && Boolean(aiLockedBy(classroom, AI_FIELDS.discuss)) && /*#__PURE__*/_jsx(Alert, {
216
+ severity: "info",
217
+ "data-cy": "ai-partly-locked-notice",
218
+ sx: {
219
+ mt: 1
220
+ },
221
+ children: t("ai_partly_locked_notice")
222
+ })]
223
+ })]
224
+ })
225
+ })]
226
+ });
227
+ }
228
+ AISettings.propTypes = {};
229
+ AISettings.label = "ai_settings";
230
+ AISettings.validationSchema = Yup.object().shape({
231
+ isAIEnabled: Yup.string(),
232
+ isDynamicResponsesEnabled: Yup.string(),
233
+ isDiscussAIFeedbackEnabled: Yup.string()
234
+ });
235
+ export default AISettings;
@@ -0,0 +1,237 @@
1
+ import { render, fireEvent, within } from "@testing-library/react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { createTheme, ThemeProvider } from "@mui/material/styles";
4
+ import AISettings, { AI_INFO_URL } from "./AISettings";
5
+
6
+ // This repo has no jest-dom matchers and no auto-cleanup, so assertions are
7
+ // plain and each test unmounts - matching UpdateClassroom.test.jsx.
8
+ import { jsx as _jsx } from "react/jsx-runtime";
9
+ const courses = [{
10
+ courseId: "c1",
11
+ courseName: "Spanish 1"
12
+ }, {
13
+ courseId: "c2",
14
+ courseName: "Spanish 2"
15
+ }];
16
+ const setup = (props = {}) => {
17
+ const utils = render(/*#__PURE__*/_jsx(ThemeProvider, {
18
+ theme: createTheme(),
19
+ children: /*#__PURE__*/_jsx(AISettings, {
20
+ values: {},
21
+ courses: courses,
22
+ courseSettings: {},
23
+ handleChange: () => {},
24
+ ...props
25
+ })
26
+ }));
27
+ // One radio group per switch, named after its field.
28
+ const group = name => within(utils.getByRole("radiogroup", {
29
+ name
30
+ }));
31
+ const radio = (name, label) => group(name).getByLabelText(label);
32
+ return {
33
+ ...utils,
34
+ radio
35
+ };
36
+ };
37
+ const SWITCHES = ["isAIEnabled", "isDynamicResponsesEnabled", "isDiscussAIFeedbackEnabled"];
38
+ describe("AISettings", () => {
39
+ it("renders all three switches, allowed by default", () => {
40
+ const {
41
+ radio,
42
+ unmount
43
+ } = setup();
44
+ SWITCHES.forEach(name => {
45
+ expect(radio(name, "ai_allowed").checked).toBe(true);
46
+ expect(radio(name, "ai_allowed").disabled).toBe(false);
47
+ });
48
+ unmount();
49
+ });
50
+ it("shows both features as limited and fixed while all AI is limited", () => {
51
+ const {
52
+ radio,
53
+ queryByText,
54
+ unmount
55
+ } = setup({
56
+ values: {
57
+ isAIEnabled: "disabled",
58
+ isDynamicResponsesEnabled: "enabled",
59
+ isDiscussAIFeedbackEnabled: "enabled"
60
+ }
61
+ });
62
+ expect(radio("isAIEnabled", "ai_limited").checked).toBe(true);
63
+ ["isDynamicResponsesEnabled", "isDiscussAIFeedbackEnabled"].forEach(name => {
64
+ expect(radio(name, "ai_limited").checked).toBe(true);
65
+ expect(radio(name, "ai_allowed").disabled).toBe(true);
66
+ });
67
+ expect(queryByText("ai_limited_classroom_notice")).not.toBe(null);
68
+ unmount();
69
+ });
70
+ it("limits one feature on its own", () => {
71
+ const {
72
+ radio,
73
+ queryByText,
74
+ unmount
75
+ } = setup({
76
+ values: {
77
+ isDynamicResponsesEnabled: "disabled"
78
+ }
79
+ });
80
+ expect(radio("isAIEnabled", "ai_allowed").checked).toBe(true);
81
+ expect(radio("isDynamicResponsesEnabled", "ai_limited").checked).toBe(true);
82
+ expect(radio("isDiscussAIFeedbackEnabled", "ai_allowed").checked).toBe(true);
83
+ expect(queryByText("ai_limited_classroom_notice")).toBe(null);
84
+ unmount();
85
+ });
86
+ it("reports changes through handleChange so Formik owns the value", () => {
87
+ const handleChange = vi.fn();
88
+ const {
89
+ radio,
90
+ unmount
91
+ } = setup({
92
+ handleChange
93
+ });
94
+ fireEvent.click(radio("isDiscussAIFeedbackEnabled", "ai_limited"));
95
+ expect(handleChange).toHaveBeenCalled();
96
+ unmount();
97
+ });
98
+ it("names the courses that have limited their chatbot", () => {
99
+ const {
100
+ getByText,
101
+ unmount
102
+ } = setup({
103
+ courseSettings: {
104
+ c1: {
105
+ isDynamicResponsesEnabled: "disabled"
106
+ },
107
+ c2: {
108
+ isDynamicResponsesEnabled: "default"
109
+ }
110
+ }
111
+ });
112
+ const notice = getByText(/ai_course_overrides_notice/);
113
+ expect(notice.textContent).toContain("Spanish 1");
114
+ expect(notice.textContent).not.toContain("Spanish 2");
115
+ unmount();
116
+ });
117
+ it("hides the course overrides once the classroom's chatbot is limited", () => {
118
+ // The classroom already covers every course, so listing overrides would
119
+ // imply they still make a difference.
120
+ [{
121
+ isAIEnabled: "disabled"
122
+ }, {
123
+ isDynamicResponsesEnabled: "disabled"
124
+ }].forEach(values => {
125
+ const {
126
+ queryByText,
127
+ unmount
128
+ } = setup({
129
+ values,
130
+ courseSettings: {
131
+ c1: {
132
+ isDynamicResponsesEnabled: "disabled"
133
+ }
134
+ }
135
+ });
136
+ expect(queryByText(/ai_course_overrides_notice/)).toBe(null);
137
+ unmount();
138
+ });
139
+ });
140
+ it("links out to the same AI explainer the notice points at", () => {
141
+ const {
142
+ getByText,
143
+ unmount
144
+ } = setup();
145
+ expect(getByText("ai_learn_more").closest("a").getAttribute("href")).toBe(AI_INFO_URL);
146
+ unmount();
147
+ });
148
+ it("locks every switch when the customer has switched all AI off", () => {
149
+ const {
150
+ radio,
151
+ queryByText,
152
+ unmount
153
+ } = setup({
154
+ values: {
155
+ isAIEnabled: "disabled"
156
+ },
157
+ classroom: {
158
+ aiLockedBy: {
159
+ isAIEnabled: "customer"
160
+ }
161
+ }
162
+ });
163
+ SWITCHES.forEach(name => {
164
+ expect(radio(name, "ai_allowed").disabled).toBe(true);
165
+ expect(radio(name, "ai_limited").disabled).toBe(true);
166
+ });
167
+ expect(queryByText("ai_locked_notice")).not.toBe(null);
168
+ // One notice that says who did it, not a second implying the teacher did.
169
+ expect(queryByText("ai_limited_classroom_notice")).toBe(null);
170
+ unmount();
171
+ });
172
+ it("locks only the feature the customer has limited", () => {
173
+ const {
174
+ radio,
175
+ queryByText,
176
+ unmount
177
+ } = setup({
178
+ values: {
179
+ isDiscussAIFeedbackEnabled: "disabled"
180
+ },
181
+ classroom: {
182
+ aiLockedBy: {
183
+ isDiscussAIFeedbackEnabled: "customer"
184
+ }
185
+ }
186
+ });
187
+ expect(radio("isDiscussAIFeedbackEnabled", "ai_allowed").disabled).toBe(true);
188
+ expect(radio("isDynamicResponsesEnabled", "ai_allowed").disabled).toBe(false);
189
+ expect(radio("isAIEnabled", "ai_allowed").disabled).toBe(false);
190
+ expect(queryByText("ai_partly_locked_notice")).not.toBe(null);
191
+ expect(queryByText("ai_locked_notice")).toBe(null);
192
+ unmount();
193
+ });
194
+ it("reads the older single-level lock as a full lock", () => {
195
+ const {
196
+ radio,
197
+ queryByText,
198
+ unmount
199
+ } = setup({
200
+ classroom: {
201
+ aiLockedBy: "customer"
202
+ }
203
+ });
204
+ expect(radio("isDynamicResponsesEnabled", "ai_allowed").disabled).toBe(true);
205
+ expect(queryByText("ai_locked_notice")).not.toBe(null);
206
+ unmount();
207
+ });
208
+ it("leaves every switch editable when nothing above the classroom has locked it", () => {
209
+ const {
210
+ radio,
211
+ queryByText,
212
+ unmount
213
+ } = setup({
214
+ classroom: {}
215
+ });
216
+ SWITCHES.forEach(name => {
217
+ expect(radio(name, "ai_allowed").disabled).toBe(false);
218
+ });
219
+ expect(queryByText("ai_locked_notice")).toBe(null);
220
+ expect(queryByText("ai_partly_locked_notice")).toBe(null);
221
+ unmount();
222
+ });
223
+ it("scrolls itself into view when the AI notice sends a teacher here", () => {
224
+ const scrollIntoView = vi.fn();
225
+ window.HTMLElement.prototype.scrollIntoView = scrollIntoView;
226
+ const setScrollToDynamicResponses = vi.fn();
227
+ const {
228
+ unmount
229
+ } = setup({
230
+ scrollToDynamicResponses: true,
231
+ setScrollToDynamicResponses
232
+ });
233
+ expect(scrollIntoView).toHaveBeenCalled();
234
+ expect(setScrollToDynamicResponses).toHaveBeenCalledWith(false);
235
+ unmount();
236
+ });
237
+ });
@@ -1,11 +1,10 @@
1
1
  import { useRef, useEffect } from "react";
2
- import { Grid, FormControl, FormControlLabel, FormLabel, Radio, RadioGroup, Typography, TextField, IconButton } from "@mui/material";
2
+ import { Grid, FormControl, FormControlLabel, FormLabel, Radio, RadioGroup, Typography, TextField } from "@mui/material";
3
3
  import * as Yup from "yup";
4
4
  import Tooltip from "@mui/material/Tooltip";
5
5
  import ChipInput from "../../../../Forms/ChipInput/ChipInput";
6
6
  import { domainPattern } from "../../../../utils/index";
7
7
  import { containsInvalidSymbols, containsInvalidCollaboratorSymbols } from "../../../../utils/index";
8
- import HelpIcon from "@mui/icons-material/Help";
9
8
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
10
9
  function ClassroomSettings({
11
10
  t,
@@ -19,8 +18,6 @@ function ClassroomSettings({
19
18
  isUpdateClassroom = false,
20
19
  scrollToDiscuss = false,
21
20
  setScrollToDiscuss,
22
- scrollToDynamicResponses = false,
23
- setScrollToDynamicResponses,
24
21
  isVideoChatEnabled,
25
22
  collaboratorLabels
26
23
  }) {
@@ -29,11 +26,9 @@ function ClassroomSettings({
29
26
  enrolmentKey,
30
27
  allowedDomains,
31
28
  collaborators,
32
- vchatEnabledInSettings,
33
- isDynamicResponsesEnabled
29
+ vchatEnabledInSettings
34
30
  } = values;
35
31
  const vchatEnabledInSettingsRef = useRef(null);
36
- const dynamicResponsesRef = useRef(null);
37
32
  const generateRandomEnrolmentKey = () => {
38
33
  return String(Math.floor(Math.random() * 90000) + 10000);
39
34
  };
@@ -54,15 +49,6 @@ function ClassroomSettings({
54
49
  setScrollToDiscuss(false);
55
50
  }
56
51
  }, [scrollToDiscuss]);
57
- useEffect(() => {
58
- if (scrollToDynamicResponses) {
59
- dynamicResponsesRef.current.scrollIntoView({
60
- behavior: "smooth",
61
- block: "center"
62
- });
63
- setScrollToDynamicResponses(false);
64
- }
65
- }, [scrollToDynamicResponses]);
66
52
  const isInviteFeatureDisabled = classroomId && classroomId.toLowerCase().includes("wayside");
67
53
  return /*#__PURE__*/_jsxs(_Fragment, {
68
54
  children: [/*#__PURE__*/_jsx(Typography, {
@@ -279,54 +265,6 @@ function ClassroomSettings({
279
265
  })]
280
266
  })]
281
267
  })
282
- }), /*#__PURE__*/_jsx(Grid, {
283
- ref: dynamicResponsesRef,
284
- size: {
285
- xs: 12,
286
- md: 12
287
- },
288
- children: /*#__PURE__*/_jsxs(FormControl, {
289
- component: "fieldset",
290
- children: [/*#__PURE__*/_jsxs(FormLabel, {
291
- component: "legend",
292
- children: [t("dynamic_response"), /*#__PURE__*/_jsx(Tooltip, {
293
- title: t("dynamic_responses_info"),
294
- placement: "right",
295
- children: /*#__PURE__*/_jsx(IconButton, {
296
- sx: {
297
- padding: 0,
298
- marginLeft: 1
299
- },
300
- children: /*#__PURE__*/_jsx(HelpIcon, {
301
- fontSize: "small"
302
- })
303
- })
304
- })]
305
- }), /*#__PURE__*/_jsx(Typography, {
306
- color: "textSecondary",
307
- children: t("dynamic_response_desc")
308
- }), /*#__PURE__*/_jsxs(RadioGroup, {
309
- row: true,
310
- "aria-label": "isDynamicResponsesEnabled",
311
- name: "isDynamicResponsesEnabled",
312
- value: isDynamicResponsesEnabled || "enabled",
313
- onChange: handleChange,
314
- onBlur: handleBlur,
315
- helperText: touched.isDynamicResponsesEnabled ? errors.isDynamicResponsesEnabled : "",
316
- error: touched.isDynamicResponsesEnabled && Boolean(errors.isDynamicResponsesEnabled),
317
- children: [/*#__PURE__*/_jsx(FormControlLabel, {
318
- value: "enabled",
319
- control: /*#__PURE__*/_jsx(Radio, {}),
320
- label: t("enabled"),
321
- "data-cy": "enable-dynamic-responses-in-settings"
322
- }), /*#__PURE__*/_jsx(FormControlLabel, {
323
- value: "disabled",
324
- control: /*#__PURE__*/_jsx(Radio, {}),
325
- label: t("disabled"),
326
- "data-cy": "disable-dynamic-responses-in-settings"
327
- })]
328
- })]
329
- })
330
268
  })]
331
269
  }), /*#__PURE__*/_jsx("br", {}), /*#__PURE__*/_jsx(Typography, {
332
270
  children: t("add_collaborators")
@@ -3,6 +3,7 @@ import { Grid, FormControl, List, ListItem, ListItemButton, ListItemText, Box, A
3
3
  import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
4
4
  import ExpandLessIcon from "@mui/icons-material/ExpandLess";
5
5
  import HelpIcon from "@mui/icons-material/Help";
6
+ import { AI_DEFAULT, AI_DISABLED, courseAIValue, isClassroomChatbotAIEnabled } from "../../../../utils/aiControls";
6
7
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
7
8
  function Settings({
8
9
  t,
@@ -12,11 +13,13 @@ function Settings({
12
13
  handleBlur,
13
14
  courseId,
14
15
  setFieldValue,
15
- classroomIsDynamicResponsesEnabled
16
+ classroomAISettings
16
17
  }) {
17
18
  const {
18
19
  courseSettings
19
20
  } = values;
21
+ // Courses only hold chatbots, so this per-course switch is the chatbot one.
22
+ const classroomAIAllowed = isClassroomChatbotAIEnabled(classroomAISettings);
20
23
  const thisCoursesSettings = courseSettings && courseId in courseSettings ? courseSettings[courseId] : {};
21
24
  return /*#__PURE__*/_jsxs(Grid, {
22
25
  container: true,
@@ -180,7 +183,7 @@ function Settings({
180
183
  component: "fieldset",
181
184
  children: [/*#__PURE__*/_jsxs(FormLabel, {
182
185
  component: "legend",
183
- children: [t("dynamic_response"), /*#__PURE__*/_jsx(Tooltip, {
186
+ children: [t("chatbot_ai"), /*#__PURE__*/_jsx(Tooltip, {
184
187
  title: t("dynamic_responses_info"),
185
188
  placement: "right",
186
189
  children: /*#__PURE__*/_jsx(IconButton, {
@@ -195,12 +198,12 @@ function Settings({
195
198
  })]
196
199
  }), /*#__PURE__*/_jsx(Typography, {
197
200
  color: "textSecondary",
198
- children: t("dynamic_response_desc")
201
+ children: t("limit_ai_course_desc")
199
202
  }), /*#__PURE__*/_jsxs(RadioGroup, {
200
203
  row: true,
201
- "aria-label": "Dynamic Responses",
204
+ "aria-label": "Limit AI",
202
205
  name: `courseSettings.${courseId}.isDynamicResponsesEnabled`,
203
- value: classroomIsDynamicResponsesEnabled === "disabled" ? "disabled" : thisCoursesSettings.isDynamicResponsesEnabled || "default",
206
+ value: classroomAIAllowed ? courseAIValue(thisCoursesSettings) : AI_DISABLED,
204
207
  onChange: event => {
205
208
  const newCourseSettings = {
206
209
  ...courseSettings,
@@ -215,14 +218,14 @@ function Settings({
215
218
  helperText: touched.visibility ? errors.visibility : "",
216
219
  error: touched.visibility && Boolean(errors.visibility),
217
220
  children: [/*#__PURE__*/_jsx(FormControlLabel, {
218
- value: "default",
221
+ value: AI_DEFAULT,
219
222
  control: /*#__PURE__*/_jsx(Radio, {}),
220
223
  label: t("course_default"),
221
- disabled: classroomIsDynamicResponsesEnabled === "disabled"
224
+ disabled: !classroomAIAllowed
222
225
  }), /*#__PURE__*/_jsx(FormControlLabel, {
223
- value: "disabled",
226
+ value: AI_DISABLED,
224
227
  control: /*#__PURE__*/_jsx(Radio, {}),
225
- label: t("disabled")
228
+ label: t("ai_limited")
226
229
  })]
227
230
  })]
228
231
  })
@@ -239,7 +242,7 @@ function Course({
239
242
  handleChange,
240
243
  handleBlur,
241
244
  setFieldValue,
242
- classroomIsDynamicResponsesEnabled
245
+ classroomAISettings
243
246
  }) {
244
247
  const [open, setOpen] = useState(false);
245
248
  return /*#__PURE__*/_jsxs(List, {
@@ -298,7 +301,7 @@ function Course({
298
301
  handleBlur: handleBlur,
299
302
  courseId: course.courseId,
300
303
  setFieldValue: setFieldValue,
301
- classroomIsDynamicResponsesEnabled: classroomIsDynamicResponsesEnabled
304
+ classroomAISettings: classroomAISettings
302
305
  })
303
306
  })
304
307
  })]
@@ -313,7 +316,7 @@ function CourseSettings({
313
316
  handleChange,
314
317
  handleBlur,
315
318
  setFieldValue,
316
- classroomIsDynamicResponsesEnabled
319
+ classroomAISettings
317
320
  }) {
318
321
  return /*#__PURE__*/_jsx(_Fragment, {
319
322
  children: courses.length > 0 ? /*#__PURE__*/_jsxs(_Fragment, {
@@ -329,7 +332,7 @@ function CourseSettings({
329
332
  handleChange: handleChange,
330
333
  handleBlur: handleBlur,
331
334
  setFieldValue: setFieldValue,
332
- classroomIsDynamicResponsesEnabled: classroomIsDynamicResponsesEnabled
335
+ classroomAISettings: classroomAISettings
333
336
  }))]
334
337
  }) : /*#__PURE__*/_jsx(Typography, {
335
338
  children: t("classroom_courses_not_found_desc")
@@ -1,6 +1,7 @@
1
+ import AISettings from "./AISettings/AISettings";
1
2
  import ClassroomInformation from "./ClassroomInformation/ClassroomInformation";
2
3
  import ClassroomSettings from "./ClassroomSettings/ClassroomSettings";
3
4
  import Customization from "./Customization/Customization";
4
5
  import Confirmation from "./Confirmation/Confirmation";
5
6
  import CourseSettings from "./CourseSettings/CourseSettings";
6
- export { ClassroomInformation, ClassroomSettings, Customization, Confirmation, CourseSettings };
7
+ export { AISettings, ClassroomInformation, ClassroomSettings, Customization, Confirmation, CourseSettings };
@@ -2,7 +2,8 @@ import { useEffect, useRef } from "react";
2
2
  import { Divider, Button, Grid, Card, CardActions, CardContent, CardHeader, Typography } from "@mui/material";
3
3
  import { makeStyles } from "tss-react/mui";
4
4
  import { Formik } from "formik";
5
- import { ClassroomInformation, ClassroomSettings, Customization, CourseSettings } from "../CreateClassroom/Steps";
5
+ import { AISettings, ClassroomInformation, ClassroomSettings, Customization, CourseSettings } from "../CreateClassroom/Steps";
6
+ import { AI_DEFAULT, AI_FIELDS, aiLockedBy, classroomAIFields, classroomAIValue } from "../../utils/aiControls";
6
7
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
8
  const useStyles = makeStyles()(theme => ({
8
9
  padding: {
@@ -70,9 +71,15 @@ export default function UpdateClassroom({
70
71
  bannerBuilderColor,
71
72
  bannerBuilderIcon,
72
73
  tags,
73
- courseSettings,
74
- isDynamicResponsesEnabled
74
+ courseSettings
75
75
  } = initialValues;
76
+
77
+ // The classroom's AI switches as saved: all AI, chatbot and Discuss.
78
+ const aiValues = Object.fromEntries(Object.values(AI_FIELDS).map(field => [field, classroomAIValue(initialValues, field)]));
79
+
80
+ // Set by the API when the classroom's customer has switched all AI off.
81
+ // Nothing saved here could change that, so the section is read-only.
82
+ const isAILocked = Boolean(aiLockedBy(initialValues));
76
83
  const courseIds = courses.map(course => course.courseId);
77
84
  let validatedCourseSettings;
78
85
  if (courseSettings === undefined) {
@@ -82,7 +89,7 @@ export default function UpdateClassroom({
82
89
  translationEnabled: "default",
83
90
  wordBankEnabled: "default",
84
91
  shuffleEnabled: "default",
85
- isDynamicResponsesEnabled: "default"
92
+ isDynamicResponsesEnabled: AI_DEFAULT
86
93
  };
87
94
  return obj;
88
95
  }, {});
@@ -97,7 +104,7 @@ export default function UpdateClassroom({
97
104
  translationEnabled: "default",
98
105
  wordBankEnabled: "default",
99
106
  shuffleEnabled: "default",
100
- isDynamicResponsesEnabled: "default"
107
+ isDynamicResponsesEnabled: AI_DEFAULT
101
108
  };
102
109
  }
103
110
  });
@@ -185,8 +192,7 @@ export default function UpdateClassroom({
185
192
  allowedDomains,
186
193
  collaborators,
187
194
  vchatEnabled,
188
- vchatEnabledInSettings,
189
- isDynamicResponsesEnabled
195
+ vchatEnabledInSettings
190
196
  },
191
197
  enableReinitialize: enableReinitialize,
192
198
  validationSchema: ClassroomSettings.validationSchema,
@@ -231,8 +237,6 @@ export default function UpdateClassroom({
231
237
  values: values,
232
238
  scrollToDiscuss: scrollToDiscuss,
233
239
  setScrollToDiscuss: setScrollToDiscuss,
234
- scrollToDynamicResponses: scrollToDynamicResponses,
235
- setScrollToDynamicResponses: setScrollToDynamicResponses,
236
240
  isVideoChatEnabled: isVideoChatEnabled,
237
241
  collaboratorLabels: collaboratorLabels,
238
242
  ...otherProps
@@ -252,6 +256,67 @@ export default function UpdateClassroom({
252
256
  })
253
257
  })
254
258
  })
259
+ }), /*#__PURE__*/_jsx("br", {}), /*#__PURE__*/_jsx(Formik, {
260
+ initialValues: aiValues,
261
+ enableReinitialize: enableReinitialize,
262
+ validationSchema: AISettings.validationSchema
263
+ // Switches the customer has locked are left out, so the API's
264
+ // override is never saved over the teacher's own choice.
265
+ ,
266
+ onSubmit: (values, actions) => onSubmit(classroomAIFields(values, initialValues), actions),
267
+ render: ({
268
+ handleChange,
269
+ handleSubmit,
270
+ isSubmitting,
271
+ dirty,
272
+ values,
273
+ ...otherProps
274
+ }) => /*#__PURE__*/_jsx("form", {
275
+ onSubmit: handleSubmit,
276
+ children: /*#__PURE__*/_jsx(Grid, {
277
+ container: true,
278
+ sx: {
279
+ width: "100%"
280
+ },
281
+ spacing: 1,
282
+ children: /*#__PURE__*/_jsx(Grid, {
283
+ size: 12,
284
+ children: /*#__PURE__*/_jsxs(Card, {
285
+ children: [/*#__PURE__*/_jsx(CardHeader, {
286
+ title: /*#__PURE__*/_jsx(Typography, {
287
+ variant: "h5",
288
+ component: "h2",
289
+ children: t(AISettings.label)
290
+ }),
291
+ className: classes.padding
292
+ }), /*#__PURE__*/_jsx(Divider, {}), /*#__PURE__*/_jsx(CardContent, {
293
+ className: classes.formContent,
294
+ children: /*#__PURE__*/_jsx(AISettings, {
295
+ t: t,
296
+ values: values,
297
+ handleChange: handleChange,
298
+ courses: courses,
299
+ courseSettings: validatedCourseSettings,
300
+ classroom: initialValues,
301
+ scrollToDynamicResponses: scrollToDynamicResponses,
302
+ setScrollToDynamicResponses: setScrollToDynamicResponses,
303
+ ...otherProps
304
+ })
305
+ }), /*#__PURE__*/_jsx(Divider, {}), /*#__PURE__*/_jsx(CardActions, {
306
+ className: classes.padding,
307
+ children: /*#__PURE__*/_jsx(Button, {
308
+ type: "submit",
309
+ color: "primary",
310
+ variant: "contained",
311
+ disabled: isSubmitting || !dirty || isClassroomArchived || isAILocked,
312
+ "data-cy": "save-ai-settings",
313
+ children: t("save_changes")
314
+ })
315
+ })]
316
+ })
317
+ })
318
+ })
319
+ })
255
320
  }), /*#__PURE__*/_jsx("br", {}), /*#__PURE__*/_jsx(Formik, {
256
321
  initialValues: {
257
322
  picture,
@@ -362,7 +427,7 @@ export default function UpdateClassroom({
362
427
  values: values,
363
428
  handleChange: handleChange,
364
429
  setFieldValue: setFieldValue,
365
- classroomIsDynamicResponsesEnabled: isDynamicResponsesEnabled,
430
+ classroomAISettings: initialValues,
366
431
  ...otherProps
367
432
  })
368
433
  }), /*#__PURE__*/_jsx(Divider, {}), /*#__PURE__*/_jsx(CardActions, {
@@ -35,6 +35,9 @@ const MeetingPromptsList = withStyles(({
35
35
  handleCreateFeedback,
36
36
  handleBatchGradeConversations,
37
37
  aiGrade,
38
+ // Discuss AI feedback, already combined with the all-AI switch, so the
39
+ // generate controls below come down when either is off.
40
+ isDiscussAIFeedbackEnabled = true,
38
41
  meetingRecsData,
39
42
  resetVideoState,
40
43
  setSelectedRecording,
@@ -490,6 +493,7 @@ const MeetingPromptsList = withStyles(({
490
493
  t: t,
491
494
  handleBatchGradeConversations: handleBatchGradeConversations,
492
495
  aiGrade: aiGrade,
496
+ isDiscussAIFeedbackEnabled: isDiscussAIFeedbackEnabled,
493
497
  selectedGroup: selectedGroup,
494
498
  setSelectedRecording: setSelectedRecording,
495
499
  selectedRecording: selectedRecording,
@@ -1485,7 +1485,7 @@ export default function useExerciseState({
1485
1485
  setIsWordBankAttemptChanged(false);
1486
1486
  };
1487
1487
  const formatBotMessages = async botMessages => {
1488
- const updatedConversationHistory = conversationHistory.slice(1);
1488
+ const updatedConversationHistory = (conversationHistory || []).slice(1);
1489
1489
  const responses = await Promise.all(botMessages.map(async m => {
1490
1490
  let conversationHistoryMessage = updatedConversationHistory.find(historyMsg => historyMsg.content?.trim() === m.text?.trim());
1491
1491
  const updatedMessage = {
@@ -1719,7 +1719,7 @@ export default function useExerciseState({
1719
1719
  if (response && response.reply) {
1720
1720
  setBotText("");
1721
1721
  setBotVars(response.vars);
1722
- setConversationHistory(response.conversationHistory);
1722
+ setConversationHistory(response.conversationHistory || []);
1723
1723
  handleReset();
1724
1724
  if (clearAttemptAudio) {
1725
1725
  clearAttemptAudio();
@@ -0,0 +1,63 @@
1
+ // The classroom's AI switches: one for all AI, and one each for chatbot
2
+ // dynamic responses and Discuss AI feedback. A feature is on only when the
3
+ // all-AI switch and its own switch are both on. Everything downstream asks
4
+ // these helpers rather than reading a field directly.
5
+ //
6
+ // Each field is "enabled" or "disabled", and an absent value means on -
7
+ // classrooms predate the controls. `isDynamicResponsesEnabled` is the original
8
+ // chatbot field, so classrooms saved before the other two keep their setting.
9
+
10
+ export const AI_ENABLED = "enabled";
11
+ export const AI_DISABLED = "disabled";
12
+ export const AI_DEFAULT = "default";
13
+ export const AI_FIELDS = {
14
+ all: "isAIEnabled",
15
+ chatbot: "isDynamicResponsesEnabled",
16
+ discuss: "isDiscussAIFeedbackEnabled"
17
+ };
18
+ const read = (record, field) => record && typeof record === "object" ? record[field] : undefined;
19
+
20
+ // Anything other than an explicit "disabled" counts as on, so an unset or
21
+ // unrecognised value never silently turns AI off.
22
+ const isOn = value => value !== AI_DISABLED;
23
+
24
+ // Courses only hold the chatbot switch, so this is the one field to read.
25
+ const readCourse = courseSettings => read(courseSettings, AI_FIELDS.chatbot);
26
+
27
+ // The all-AI switch on its own.
28
+ export const isClassroomAIEnabled = classroom => isOn(read(classroom, AI_FIELDS.all));
29
+ export const isClassroomChatbotAIEnabled = classroom => isClassroomAIEnabled(classroom) && isOn(read(classroom, AI_FIELDS.chatbot));
30
+ export const isClassroomDiscussAIFeedbackEnabled = classroom => isClassroomAIEnabled(classroom) && isOn(read(classroom, AI_FIELDS.discuss));
31
+
32
+ // Chatbot dynamic responses for one course in the classroom. A course can only
33
+ // narrow the classroom's answer, never re-enable it.
34
+ export const isCourseAIEnabled = (classroom, courseSettings) => isClassroomChatbotAIEnabled(classroom) && readCourse(courseSettings) !== AI_DISABLED;
35
+
36
+ // The value a classroom radio should show for one of AI_FIELDS.
37
+ export const classroomAIValue = (classroom, field = AI_FIELDS.all) => read(classroom, field) || AI_ENABLED;
38
+
39
+ // The value a per-course radio should show.
40
+ export const courseAIValue = courseSettings => readCourse(courseSettings) || AI_DEFAULT;
41
+
42
+ // Which level above the classroom has locked a switch, if any. The API sets
43
+ // `aiLockedBy` per field; a lock on all AI locks every switch. An older API
44
+ // sent one level for everything, which is still read as a full lock.
45
+ export const aiLockedBy = (classroom, field = AI_FIELDS.all) => {
46
+ const locks = read(classroom, "aiLockedBy");
47
+ if (!locks) return null;
48
+ if (typeof locks === "string") return locks;
49
+ return locks[field] || locks[AI_FIELDS.all] || null;
50
+ };
51
+
52
+ // The switches to save: only the ones the teacher changed, so a switch still
53
+ // on the customer's default keeps following it. Locked ones are left out too:
54
+ // their value is the API's override, and saving it would overwrite the
55
+ // teacher's own choice.
56
+ export const classroomAIFields = (values, classroom) => Object.values(AI_FIELDS).reduce((fields, field) => {
57
+ const value = classroomAIValue(values, field);
58
+ const isUnchanged = value === classroomAIValue(classroom, field);
59
+ return aiLockedBy(classroom, field) || isUnchanged ? fields : {
60
+ ...fields,
61
+ [field]: value
62
+ };
63
+ }, {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nualang/nualang-ui-components",
3
- "version": "0.1.1435",
3
+ "version": "0.1.1436",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [