@nualang/nualang-ui-components 0.1.1440 → 0.1.1441

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.
@@ -4,8 +4,9 @@ import General from "./Tabs/General/General";
4
4
  import Display from "./Tabs/Display/Display";
5
5
  import Subscription from "./Tabs/Subscription/Subscription";
6
6
  import EmailNotifications from "./Tabs/EmailNotifications/EmailNotifications";
7
+ import ClassroomAISettings from "./Tabs/ClassroomAISettings/ClassroomAISettings";
7
8
  import { checkIsGeneratedEmail } from "../../utils";
8
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
9
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
9
10
  export default function Settings({
10
11
  t = text => text,
11
12
  generalOnSubmit,
@@ -32,107 +33,107 @@ export default function Settings({
32
33
  username,
33
34
  verificationStatus,
34
35
  gameAward,
35
- submitAvatar
36
+ submitAvatar,
37
+ isCreator = false,
38
+ // Props for the AI settings tab (see ClassroomAISettings). Teachers only.
39
+ classroomAISettings
36
40
  }) {
37
- let tabs;
38
41
  const {
39
42
  email
40
43
  } = generalInitialValues;
41
44
  const isGenerated = checkIsGeneratedEmail(email);
42
- if (subscription.isPaidUser || subscription.isUpgradePossible) {
43
- tabs = [{
44
- label: t("general"),
45
- id: "General"
46
- }, {
47
- label: t("display"),
48
- id: "Display"
49
- }, {
50
- label: t("subscription"),
51
- id: "Subscription"
52
- }, !isGenerated ? {
53
- label: t("email_notifications"),
54
- id: "EmailNotifications"
55
- } : null];
56
- } else {
57
- tabs = [{
58
- label: t("general"),
59
- id: "General"
60
- }, {
61
- label: t("display"),
62
- id: "Display"
63
- }];
64
- }
65
- let initialTabValue;
45
+ const hasSubscriptionTabs = subscription.isPaidUser || subscription.isUpgradePossible;
46
+
47
+ // Each tab with its panel, so the two can't fall out of step when a tab is
48
+ // left out. ResponsiveTabs matches them by position.
49
+ const panels = [{
50
+ label: t("general"),
51
+ id: "General",
52
+ content: /*#__PURE__*/_jsx(General, {
53
+ "data-cy": "settings_general_tab",
54
+ t: t,
55
+ enableReinitialize: enableReinitialize,
56
+ initialValues: generalInitialValues,
57
+ onSubmit: generalOnSubmit,
58
+ languages: languages,
59
+ forLanguages: forLanguages,
60
+ initialAvatarAttributes: initialAvatarAttributes,
61
+ saveAvatarToDynamo: saveAvatarToDynamo,
62
+ verificationStatus: verificationStatus,
63
+ gameAward: gameAward,
64
+ username: username,
65
+ submitAvatar: submitAvatar
66
+ })
67
+ }, {
68
+ label: t("display"),
69
+ id: "Display",
70
+ content: /*#__PURE__*/_jsx(Display, {
71
+ "data-cy": "settings_display_tab",
72
+ t: t,
73
+ toggleTheme: toggleTheme
74
+ })
75
+ }, hasSubscriptionTabs && {
76
+ label: t("subscription"),
77
+ id: "Subscription",
78
+ content: /*#__PURE__*/_jsx(Subscription, {
79
+ "data-cy": "settings_subscription_tab",
80
+ t: t,
81
+ subscription: subscription,
82
+ cancelSubscription: cancelSubscription,
83
+ groupSubscriptions: groupSubscriptions,
84
+ managePlanButtonText: managePlanButtonText,
85
+ handleManagePlan: handleManagePlan,
86
+ handleLeaveGroup: handleLeaveGroup,
87
+ leaveGroupButtonText: leaveGroupButtonText,
88
+ username: username
89
+ })
90
+ }, hasSubscriptionTabs && !isGenerated && {
91
+ label: t("email_notifications"),
92
+ id: "EmailNotifications",
93
+ content: /*#__PURE__*/_jsx(EmailNotifications, {
94
+ "data-cy": "settings_email_notifications_tab",
95
+ t: t,
96
+ EmailNotificationEvents: EmailNotificationEvents,
97
+ handleSubmit: emailNotificationOnSubmit,
98
+ userEmailNotificationSettingsObject: userEmailNotificationSettingsObject,
99
+ checkBoxState: checkBoxState
100
+ })
101
+ }, isCreator && classroomAISettings && {
102
+ label: t("ai_settings"),
103
+ id: "AISettings",
104
+ content: /*#__PURE__*/_jsx(ClassroomAISettings, {
105
+ "data-cy": "settings_ai_settings_tab",
106
+ t: t,
107
+ ...classroomAISettings
108
+ })
109
+ }].filter(Boolean);
110
+ const tabs = panels.map(({
111
+ label,
112
+ id
113
+ }) => ({
114
+ label,
115
+ id
116
+ }));
117
+ let initialTabValue = 0;
66
118
  if (window.location.hash) {
67
- const selectedTab = window.location.hash;
68
- initialTabValue = tabs.findIndex(tab => tab.id === selectedTab.replace("#", ""));
69
- } else {
70
- initialTabValue = 0;
119
+ const selectedTab = window.location.hash.replace("#", "");
120
+ initialTabValue = Math.max(tabs.findIndex(tab => tab.id === selectedTab), 0);
71
121
  }
72
- const filteredTabs = tabs.filter(tab => tab !== null);
73
122
  return /*#__PURE__*/_jsx(_Fragment, {
74
- children: /*#__PURE__*/_jsxs(ResponsiveTabs, {
123
+ children: /*#__PURE__*/_jsx(ResponsiveTabs, {
75
124
  t: t,
76
- tabs: filteredTabs,
125
+ tabs: tabs,
77
126
  initialValue: initialTabValue,
78
127
  scrollable: true,
79
- children: [/*#__PURE__*/_jsx(Box, {
80
- sx: {
81
- py: 1
82
- },
83
- children: /*#__PURE__*/_jsx(General, {
84
- "data-cy": "settings_general_tab",
85
- t: t,
86
- enableReinitialize: enableReinitialize,
87
- initialValues: generalInitialValues,
88
- onSubmit: generalOnSubmit,
89
- languages: languages,
90
- forLanguages: forLanguages,
91
- initialAvatarAttributes: initialAvatarAttributes,
92
- saveAvatarToDynamo: saveAvatarToDynamo,
93
- verificationStatus: verificationStatus,
94
- gameAward: gameAward,
95
- username: username,
96
- submitAvatar: submitAvatar
97
- })
98
- }), /*#__PURE__*/_jsx(Box, {
99
- sx: {
100
- py: 1
101
- },
102
- children: /*#__PURE__*/_jsx(Display, {
103
- "data-cy": "settings_display_tab",
104
- t: t,
105
- toggleTheme: toggleTheme
106
- })
107
- }), /*#__PURE__*/_jsx(Box, {
108
- sx: {
109
- py: 1
110
- },
111
- children: /*#__PURE__*/_jsx(Subscription, {
112
- "data-cy": "settings_subscription_tab",
113
- t: t,
114
- subscription: subscription,
115
- cancelSubscription: cancelSubscription,
116
- groupSubscriptions: groupSubscriptions,
117
- managePlanButtonText: managePlanButtonText,
118
- handleManagePlan: handleManagePlan,
119
- handleLeaveGroup: handleLeaveGroup,
120
- leaveGroupButtonText: leaveGroupButtonText,
121
- username: username
122
- })
123
- }), !isGenerated && /*#__PURE__*/_jsx(Box, {
128
+ children: panels.map(({
129
+ id,
130
+ content
131
+ }) => /*#__PURE__*/_jsx(Box, {
124
132
  sx: {
125
133
  py: 1
126
134
  },
127
- children: /*#__PURE__*/_jsx(EmailNotifications, {
128
- "data-cy": "settings_email_notifications_tab",
129
- t: t,
130
- EmailNotificationEvents: EmailNotificationEvents,
131
- handleSubmit: emailNotificationOnSubmit,
132
- userEmailNotificationSettingsObject: userEmailNotificationSettingsObject,
133
- checkBoxState: checkBoxState
134
- })
135
- })]
135
+ children: content
136
+ }, id))
136
137
  })
137
138
  });
138
139
  }
@@ -0,0 +1,65 @@
1
+ import { render, fireEvent } from "@testing-library/react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { MemoryRouter } from "react-router";
4
+ import { createTheme, ThemeProvider } from "@mui/material/styles";
5
+ import Settings from "./Settings";
6
+
7
+ // This repo has no jest-dom matchers and no auto-cleanup, so assertions are
8
+ // plain and each test unmounts.
9
+ import { jsx as _jsx } from "react/jsx-runtime";
10
+ const setup = (props = {}) => render(/*#__PURE__*/_jsx(MemoryRouter, {
11
+ children: /*#__PURE__*/_jsx(ThemeProvider, {
12
+ theme: createTheme(),
13
+ children: /*#__PURE__*/_jsx(Settings, {
14
+ generalInitialValues: {
15
+ email: "teacher@example.com"
16
+ },
17
+ generalOnSubmit: () => {},
18
+ classroomAISettings: {
19
+ classrooms: [{
20
+ classroomId: "r1",
21
+ classroomName: "Room 1"
22
+ }],
23
+ onSave: vi.fn()
24
+ },
25
+ ...props
26
+ })
27
+ })
28
+ }));
29
+ describe("Settings AI settings tab", () => {
30
+ it("is shown to teachers only", () => {
31
+ const teacher = setup({
32
+ isCreator: true
33
+ });
34
+ expect(teacher.queryByRole("tab", {
35
+ name: "ai_settings"
36
+ })).not.toBe(null);
37
+ teacher.unmount();
38
+ const student = setup({
39
+ isCreator: false
40
+ });
41
+ expect(student.queryByRole("tab", {
42
+ name: "ai_settings"
43
+ })).toBe(null);
44
+ student.unmount();
45
+ });
46
+ it("opens the AI settings panel when there are no subscription tabs", () => {
47
+ const {
48
+ getByRole,
49
+ getByText,
50
+ unmount
51
+ } = setup({
52
+ isCreator: true,
53
+ subscription: {
54
+ isUpgradePossible: false,
55
+ isPaidUser: false
56
+ }
57
+ });
58
+ fireEvent.click(getByRole("tab", {
59
+ name: "ai_settings"
60
+ }));
61
+ expect(getByText("ai_overview_desc")).toBeTruthy();
62
+ expect(getByText("Room 1")).toBeTruthy();
63
+ unmount();
64
+ });
65
+ });
@@ -0,0 +1,535 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Alert, Box, Button, Card, CardContent, CardHeader, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, IconButton, Menu, MenuItem, Switch, Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tabs, Tooltip, Typography } from "@mui/material";
3
+ import { alpha } from "@mui/material/styles";
4
+ import LockIcon from "@mui/icons-material/Lock";
5
+ import InfoIcon from "@mui/icons-material/Info";
6
+ import HelpIcon from "@mui/icons-material/Help";
7
+ import { AI_DISABLED, AI_ENABLED, AI_FIELDS, aiLockedBy, classroomAIValue, courseAIValue } from "../../../../utils/aiControls";
8
+
9
+ // The three columns, in the same order and with the same labels and help
10
+ // text as the classroom's own AI settings.
11
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
12
+ const COLUMNS = [{
13
+ field: AI_FIELDS.all,
14
+ label: "limit_ai",
15
+ description: "limit_ai_desc",
16
+ info: "limit_ai_info",
17
+ dataCy: "ai"
18
+ }, {
19
+ field: AI_FIELDS.chatbot,
20
+ label: "chatbot_ai",
21
+ description: "chatbot_ai_desc",
22
+ info: "dynamic_responses_info",
23
+ dataCy: "chatbot-ai"
24
+ }, {
25
+ field: AI_FIELDS.discuss,
26
+ label: "discuss_ai",
27
+ description: "discuss_ai_desc",
28
+ dataCy: "discuss-ai"
29
+ }];
30
+
31
+ // The all-AI column overrides the other two, so it is shaded to stand out. In
32
+ // the classroom this is shown by nesting the other switches under it.
33
+ const mainColumnSx = field => field === AI_FIELDS.all ? {
34
+ bgcolor: theme => alpha(theme.palette.primary.main, 0.08)
35
+ } : {};
36
+
37
+ // Saves for "apply to all" go a few at a time, not all at once.
38
+ const APPLY_ALL_BATCH = 5;
39
+ const isArchived = classroom => classroom.visibility === "archived";
40
+
41
+ // The classroom's lock notice, as in its own settings: one for a lock on all
42
+ // AI, another when only some switches are locked.
43
+ const lockNotice = classroom => {
44
+ if (aiLockedBy(classroom, AI_FIELDS.all)) return "ai_locked_notice";
45
+ const isPartlyLocked = [AI_FIELDS.chatbot, AI_FIELDS.discuss].some(field => aiLockedBy(classroom, field));
46
+ return isPartlyLocked ? "ai_partly_locked_notice" : null;
47
+ };
48
+ const isAllLimited = classroom => classroomAIValue(classroom, AI_FIELDS.all) === AI_DISABLED;
49
+
50
+ // Why a switch can't be changed, if it can't. Matches the classroom's own
51
+ // settings: a locked switch is fixed, and a feature is fixed while all AI is
52
+ // off.
53
+ const fixedReason = (classroom, field) => {
54
+ if (isArchived(classroom)) return "archived";
55
+ if (aiLockedBy(classroom, field)) return "locked";
56
+ if (field !== AI_FIELDS.all && isAllLimited(classroom)) return "all_limited";
57
+ return null;
58
+ };
59
+
60
+ // While all AI is off a feature shows as off, which is what students get.
61
+ const shownValue = (classroom, field) => field !== AI_FIELDS.all && isAllLimited(classroom) ? AI_DISABLED : classroomAIValue(classroom, field);
62
+
63
+ // Courses in the classroom that have turned the chatbot off for themselves.
64
+ const overriddenCourseCount = classroom => {
65
+ const courseSettings = classroom.courseSettings || {};
66
+ const courseIds = Array.isArray(classroom.courses) ? classroom.courses : Object.keys(courseSettings);
67
+ return courseIds.filter(courseId => courseAIValue(courseSettings[courseId]) === AI_DISABLED).length;
68
+ };
69
+
70
+ // Whether a switch's local value still beats the classroom's own. The
71
+ // classroom wins once it shows the saved value, when the switch is locked, or
72
+ // when it has changed since a save that has finished. A refetch that still has
73
+ // the old value doesn't drop a save in progress.
74
+ const isOverridden = (classroom, field, entry) => {
75
+ if (!classroom || !entry) return false;
76
+ if (aiLockedBy(classroom, field)) return false;
77
+ const current = classroomAIValue(classroom, field);
78
+ if (current === entry.value) return false;
79
+ return !(entry.settled && current !== entry.base);
80
+ };
81
+ const overriddenEntries = (classroom, entries = {}) => Object.entries(entries).filter(([field, entry]) => isOverridden(classroom, field, entry));
82
+
83
+ // The account Settings tab that lists every classroom the teacher created or
84
+ // co-teaches, with its AI switches. Each switch saves on its own through `onSave`, and
85
+ // each column can be set for every classroom at once.
86
+ export default function ClassroomAISettings({
87
+ t = text => text,
88
+ classrooms = [],
89
+ isLoading = false,
90
+ isError = false,
91
+ // (classroomId, fields) => Promise. `fields` holds only what changed.
92
+ onSave,
93
+ onOpenClassroom
94
+ }) {
95
+ // Values saved here, keyed by classroomId then field. Each is
96
+ // { value, base, settled, token }: `base` is the classroom's value when the
97
+ // save started, and `settled` is set once onSave resolves.
98
+ const [pending, setPending] = useState({});
99
+ const pendingRef = useRef(pending);
100
+ pendingRef.current = pending;
101
+ const lastToken = useRef(0);
102
+ const [saving, setSaving] = useState({});
103
+ // True for the whole of an "apply to all", including between batches.
104
+ const [isApplyingAll, setIsApplyingAll] = useState(false);
105
+ const [menu, setMenu] = useState(null);
106
+ const [confirm, setConfirm] = useState(null);
107
+ const [result, setResult] = useState(null);
108
+ // Archived classrooms are in their own view so they don't clutter the list.
109
+ const [view, setView] = useState("active");
110
+ const rows = classrooms.map(classroom => ({
111
+ ...classroom,
112
+ ...Object.fromEntries(overriddenEntries(classroom, pending[classroom.classroomId]).map(([field, entry]) => [field, entry.value]))
113
+ }));
114
+
115
+ // Drop local values the classrooms have caught up with or replaced.
116
+ useEffect(() => {
117
+ setPending(prev => {
118
+ let changed = false;
119
+ const next = {};
120
+ Object.entries(prev).forEach(([classroomId, entries]) => {
121
+ const classroom = classrooms.find(c => c.classroomId === classroomId);
122
+ const kept = overriddenEntries(classroom, entries);
123
+ if (kept.length !== Object.keys(entries).length) changed = true;
124
+ if (kept.length > 0) next[classroomId] = Object.fromEntries(kept);
125
+ });
126
+ return changed ? next : prev;
127
+ });
128
+ }, [classrooms]);
129
+ const archivedRows = rows.filter(isArchived);
130
+ const activeRows = rows.filter(classroom => !isArchived(classroom));
131
+ const isArchivedView = view === "archived" && archivedRows.length > 0;
132
+ const shownRows = isArchivedView ? archivedRows : activeRows;
133
+ const savingKey = (classroomId, field) => `${classroomId}:${field}`;
134
+
135
+ // Sets one field's local value from the current one. Returning undefined
136
+ // removes it, leaving the classroom's other fields alone.
137
+ const updateEntry = (classroomId, field, update) => setPending(prev => {
138
+ const {
139
+ [field]: entry,
140
+ ...others
141
+ } = prev[classroomId] || {};
142
+ const next = update(entry);
143
+ return {
144
+ ...prev,
145
+ [classroomId]: next ? {
146
+ ...others,
147
+ [field]: next
148
+ } : others
149
+ };
150
+ });
151
+ const save = async (classroom, field, value) => {
152
+ const {
153
+ classroomId
154
+ } = classroom;
155
+ const key = savingKey(classroomId, field);
156
+ const source = classrooms.find(c => c.classroomId === classroomId) || classroom;
157
+ // An earlier save the classroom hasn't caught up with yet, to put back if
158
+ // this one fails.
159
+ const previous = pendingRef.current[classroomId]?.[field];
160
+ lastToken.current += 1;
161
+ const token = lastToken.current;
162
+ const isThisSave = entry => entry && entry.token === token;
163
+ setSaving(prev => ({
164
+ ...prev,
165
+ [key]: true
166
+ }));
167
+ updateEntry(classroomId, field, () => ({
168
+ value,
169
+ base: classroomAIValue(source, field),
170
+ settled: false,
171
+ token
172
+ }));
173
+ try {
174
+ await onSave(classroomId, {
175
+ [field]: value
176
+ });
177
+ updateEntry(classroomId, field, entry => isThisSave(entry) ? {
178
+ ...entry,
179
+ settled: true
180
+ } : entry);
181
+ return true;
182
+ } catch (error) {
183
+ console.error(error);
184
+ // Put the switch back to what it showed before.
185
+ updateEntry(classroomId, field, entry => isThisSave(entry) ? previous : entry);
186
+ return false;
187
+ } finally {
188
+ setSaving(prev => {
189
+ const {
190
+ [key]: _done,
191
+ ...rest
192
+ } = prev; // eslint-disable-line no-unused-vars
193
+ return rest;
194
+ });
195
+ }
196
+ };
197
+ const handleToggle = async (classroom, field, checked) => {
198
+ setResult(null);
199
+ const ok = await save(classroom, field, checked ? AI_ENABLED : AI_DISABLED);
200
+ if (!ok) {
201
+ setResult({
202
+ severity: "error",
203
+ message: `${t("ai_overview_save_failed")}: ${classroom.classroomName}`
204
+ });
205
+ }
206
+ };
207
+
208
+ // Classrooms "apply to all" would change: not fixed, and not already set.
209
+ const applyAllTargets = (field, value) => rows.filter(classroom => !fixedReason(classroom, field) && classroomAIValue(classroom, field) !== value);
210
+ const applyAll = async ({
211
+ field,
212
+ value
213
+ }) => {
214
+ setConfirm(null);
215
+ setResult(null);
216
+ const targets = applyAllTargets(field, value);
217
+ const failed = [];
218
+ setIsApplyingAll(true);
219
+ try {
220
+ for (let i = 0; i < targets.length; i += APPLY_ALL_BATCH) {
221
+ const batch = targets.slice(i, i + APPLY_ALL_BATCH);
222
+ // eslint-disable-next-line no-await-in-loop
223
+ const results = await Promise.all(batch.map(classroom => save(classroom, field, value)));
224
+ results.forEach((ok, index) => {
225
+ if (!ok) failed.push(batch[index].classroomName);
226
+ });
227
+ }
228
+ } finally {
229
+ setIsApplyingAll(false);
230
+ }
231
+ const savedCount = targets.length - failed.length;
232
+ setResult(failed.length > 0 ? {
233
+ severity: "error",
234
+ message: `${t("ai_overview_apply_all_failed")}: ${failed.join(", ")}`
235
+ } : {
236
+ severity: "success",
237
+ message: `${t("ai_overview_apply_all_saved")}: ${savedCount}`
238
+ });
239
+ };
240
+ const openConfirm = (field, value) => {
241
+ setMenu(null);
242
+ setConfirm({
243
+ field,
244
+ value,
245
+ count: applyAllTargets(field, value).length
246
+ });
247
+ };
248
+
249
+ // While anything saves, no switch can be changed, so an edit can't overlap a
250
+ // queued "apply to all" save.
251
+ const isApplying = isApplyingAll || Object.keys(saving).length > 0;
252
+ const renderHeader = ({
253
+ field,
254
+ label,
255
+ description,
256
+ info,
257
+ dataCy
258
+ }) => /*#__PURE__*/_jsxs(TableCell, {
259
+ align: "center",
260
+ sx: mainColumnSx(field),
261
+ children: [/*#__PURE__*/_jsxs(Box, {
262
+ sx: {
263
+ display: "inline-flex",
264
+ alignItems: "center",
265
+ fontWeight: field === AI_FIELDS.all ? "bold" : undefined
266
+ },
267
+ children: [t(label), /*#__PURE__*/_jsx(Tooltip, {
268
+ title: /*#__PURE__*/_jsxs(_Fragment, {
269
+ children: [/*#__PURE__*/_jsx(Typography, {
270
+ variant: "body2",
271
+ children: t(description)
272
+ }), info && /*#__PURE__*/_jsx(Typography, {
273
+ variant: "body2",
274
+ sx: {
275
+ mt: 1
276
+ },
277
+ children: t(info)
278
+ })]
279
+ }),
280
+ children: /*#__PURE__*/_jsx(IconButton, {
281
+ sx: {
282
+ padding: 0,
283
+ marginLeft: 1
284
+ },
285
+ "aria-label": t(description),
286
+ "data-cy": `overview-help-${dataCy}`,
287
+ children: /*#__PURE__*/_jsx(HelpIcon, {
288
+ fontSize: "small"
289
+ })
290
+ })
291
+ })]
292
+ }), !isArchivedView && /*#__PURE__*/_jsx(Box, {
293
+ children: /*#__PURE__*/_jsx(Button, {
294
+ size: "small",
295
+ disabled: isApplying,
296
+ onClick: event => setMenu({
297
+ anchor: event.currentTarget,
298
+ field
299
+ }),
300
+ "data-cy": `overview-apply-all-${dataCy}`,
301
+ children: t("ai_overview_apply_all")
302
+ })
303
+ })]
304
+ }, field);
305
+ const renderSwitch = (classroom, {
306
+ field,
307
+ label,
308
+ dataCy
309
+ }) => {
310
+ const reason = fixedReason(classroom, field);
311
+ const isSaving = Boolean(saving[savingKey(classroom.classroomId, field)]);
312
+ const control = /*#__PURE__*/_jsx(Switch, {
313
+ checked: shownValue(classroom, field) !== AI_DISABLED,
314
+ onChange: event => handleToggle(classroom, field, event.target.checked),
315
+ disabled: Boolean(reason) || isApplying,
316
+ slotProps: {
317
+ input: {
318
+ "aria-label": `${t(label)} - ${classroom.classroomName}`
319
+ }
320
+ },
321
+ "data-cy": `overview-${dataCy}-${classroom.classroomId}`
322
+ });
323
+ return /*#__PURE__*/_jsxs(Box, {
324
+ sx: {
325
+ display: "inline-flex",
326
+ alignItems: "center"
327
+ },
328
+ children: [control, reason === "locked" && /*#__PURE__*/_jsx(Tooltip, {
329
+ title: t(lockNotice(classroom)),
330
+ children: /*#__PURE__*/_jsx(LockIcon, {
331
+ fontSize: "small",
332
+ color: "action",
333
+ "data-cy": `overview-locked-${dataCy}-${classroom.classroomId}`
334
+ })
335
+ }), isSaving && /*#__PURE__*/_jsx(CircularProgress, {
336
+ size: 16,
337
+ sx: {
338
+ ml: 1
339
+ }
340
+ })]
341
+ });
342
+ };
343
+ let body;
344
+ if (isLoading) {
345
+ body = /*#__PURE__*/_jsx(Box, {
346
+ sx: {
347
+ display: "flex",
348
+ justifyContent: "center",
349
+ p: 3
350
+ },
351
+ children: /*#__PURE__*/_jsx(CircularProgress, {})
352
+ });
353
+ } else if (isError) {
354
+ body = /*#__PURE__*/_jsx(Alert, {
355
+ severity: "error",
356
+ children: t("ai_overview_load_failed")
357
+ });
358
+ } else if (rows.length === 0) {
359
+ body = /*#__PURE__*/_jsx(Typography, {
360
+ color: "textSecondary",
361
+ "data-cy": "ai-overview-empty",
362
+ children: t("ai_overview_empty")
363
+ });
364
+ } else if (shownRows.length === 0) {
365
+ body = /*#__PURE__*/_jsx(Typography, {
366
+ color: "textSecondary",
367
+ "data-cy": "ai-overview-no-active",
368
+ children: t("ai_overview_no_active")
369
+ });
370
+ } else {
371
+ body = /*#__PURE__*/_jsx(TableContainer, {
372
+ sx: {
373
+ overflowX: "auto"
374
+ },
375
+ children: /*#__PURE__*/_jsxs(Table, {
376
+ size: "small",
377
+ "aria-label": t("ai_settings"),
378
+ children: [/*#__PURE__*/_jsx(TableHead, {
379
+ children: /*#__PURE__*/_jsxs(TableRow, {
380
+ children: [/*#__PURE__*/_jsx(TableCell, {
381
+ children: t("classroom")
382
+ }), COLUMNS.map(renderHeader), /*#__PURE__*/_jsx(TableCell, {})]
383
+ })
384
+ }), /*#__PURE__*/_jsx(TableBody, {
385
+ children: shownRows.map(classroom => {
386
+ const overrides = overriddenCourseCount(classroom);
387
+ const notice = lockNotice(classroom);
388
+ return /*#__PURE__*/_jsxs(TableRow, {
389
+ "data-cy": `ai-overview-row-${classroom.classroomId}`,
390
+ children: [/*#__PURE__*/_jsxs(TableCell, {
391
+ children: [/*#__PURE__*/_jsx(Typography, {
392
+ variant: "body2",
393
+ children: classroom.classroomName
394
+ }), isArchived(classroom) && /*#__PURE__*/_jsx(Chip, {
395
+ size: "small",
396
+ label: t("archived"),
397
+ sx: {
398
+ mt: 0.5
399
+ }
400
+ }), overrides > 0 && /*#__PURE__*/_jsxs(Box, {
401
+ sx: {
402
+ display: "flex",
403
+ alignItems: "center",
404
+ mt: 0.5
405
+ },
406
+ "data-cy": `overview-course-overrides-${classroom.classroomId}`,
407
+ children: [/*#__PURE__*/_jsx(InfoIcon, {
408
+ fontSize: "small",
409
+ color: "info",
410
+ sx: {
411
+ mr: 0.5
412
+ }
413
+ }), /*#__PURE__*/_jsx(Typography, {
414
+ variant: "caption",
415
+ color: "textSecondary",
416
+ children: `${t("ai_overview_course_overrides")}: ${overrides}`
417
+ })]
418
+ }), notice && /*#__PURE__*/_jsx(Alert, {
419
+ severity: "info",
420
+ icon: /*#__PURE__*/_jsx(LockIcon, {
421
+ fontSize: "small"
422
+ }),
423
+ sx: {
424
+ mt: 0.5,
425
+ py: 0,
426
+ maxWidth: 360
427
+ },
428
+ "data-cy": `overview-locked-notice-${classroom.classroomId}`,
429
+ children: /*#__PURE__*/_jsx(Typography, {
430
+ variant: "caption",
431
+ children: t(notice)
432
+ })
433
+ })]
434
+ }), COLUMNS.map(column => /*#__PURE__*/_jsx(TableCell, {
435
+ align: "center",
436
+ sx: mainColumnSx(column.field),
437
+ children: renderSwitch(classroom, column)
438
+ }, column.field)), /*#__PURE__*/_jsx(TableCell, {
439
+ align: "right",
440
+ children: onOpenClassroom && /*#__PURE__*/_jsx(Button, {
441
+ size: "small",
442
+ onClick: () => onOpenClassroom(classroom.classroomId),
443
+ children: t("ai_overview_open_classroom")
444
+ })
445
+ })]
446
+ }, classroom.classroomId);
447
+ })
448
+ })]
449
+ })
450
+ });
451
+ }
452
+ return /*#__PURE__*/_jsxs(Card, {
453
+ "data-cy": "ai-overview",
454
+ children: [/*#__PURE__*/_jsx(CardHeader, {
455
+ title: t("ai_settings"),
456
+ sx: {
457
+ px: 3,
458
+ py: 2
459
+ }
460
+ }), /*#__PURE__*/_jsx(Divider, {}), /*#__PURE__*/_jsxs(CardContent, {
461
+ sx: {
462
+ p: 3
463
+ },
464
+ children: [/*#__PURE__*/_jsx(Typography, {
465
+ color: "textSecondary",
466
+ sx: {
467
+ mb: 2
468
+ },
469
+ children: t("ai_overview_desc")
470
+ }), result && /*#__PURE__*/_jsx(Alert, {
471
+ severity: result.severity,
472
+ onClose: () => setResult(null),
473
+ sx: {
474
+ mb: 2
475
+ },
476
+ "data-cy": "ai-overview-result",
477
+ children: result.message
478
+ }), archivedRows.length > 0 && /*#__PURE__*/_jsxs(Tabs, {
479
+ value: isArchivedView ? "archived" : "active",
480
+ onChange: (_event, value) => setView(value),
481
+ sx: {
482
+ mb: 2
483
+ },
484
+ children: [/*#__PURE__*/_jsx(Tab, {
485
+ value: "active",
486
+ label: `${t("active")} (${activeRows.length})`,
487
+ "data-cy": "ai-overview-active-tab"
488
+ }), /*#__PURE__*/_jsx(Tab, {
489
+ value: "archived",
490
+ label: `${t("archived")} (${archivedRows.length})`,
491
+ "data-cy": "ai-overview-archived-tab"
492
+ })]
493
+ }), body]
494
+ }), /*#__PURE__*/_jsxs(Menu, {
495
+ anchorEl: menu?.anchor,
496
+ open: Boolean(menu),
497
+ onClose: () => setMenu(null),
498
+ children: [/*#__PURE__*/_jsx(MenuItem, {
499
+ onClick: () => openConfirm(menu.field, AI_ENABLED),
500
+ "data-cy": "overview-apply-all-enable",
501
+ children: t("ai_overview_enable_all")
502
+ }), /*#__PURE__*/_jsx(MenuItem, {
503
+ onClick: () => openConfirm(menu.field, AI_DISABLED),
504
+ "data-cy": "overview-apply-all-disable",
505
+ children: t("ai_overview_disable_all")
506
+ })]
507
+ }), /*#__PURE__*/_jsxs(Dialog, {
508
+ open: Boolean(confirm),
509
+ onClose: () => setConfirm(null),
510
+ children: [/*#__PURE__*/_jsx(DialogTitle, {
511
+ children: t("ai_overview_apply_all")
512
+ }), /*#__PURE__*/_jsxs(DialogContent, {
513
+ children: [/*#__PURE__*/_jsx(DialogContentText, {
514
+ children: confirm && `${t(COLUMNS.find(c => c.field === confirm.field).label)}: ${t(confirm.value === AI_ENABLED ? "ai_allowed" : "ai_limited")}. ${t("ai_overview_apply_all_count")}: ${confirm.count}`
515
+ }), /*#__PURE__*/_jsx(DialogContentText, {
516
+ sx: {
517
+ mt: 1
518
+ },
519
+ children: t("ai_overview_apply_all_skips")
520
+ })]
521
+ }), /*#__PURE__*/_jsxs(DialogActions, {
522
+ children: [/*#__PURE__*/_jsx(Button, {
523
+ onClick: () => setConfirm(null),
524
+ children: t("cancel")
525
+ }), /*#__PURE__*/_jsx(Button, {
526
+ variant: "contained",
527
+ disabled: !confirm || confirm.count === 0,
528
+ onClick: () => applyAll(confirm),
529
+ "data-cy": "overview-apply-all-confirm",
530
+ children: t("confirm")
531
+ })]
532
+ })]
533
+ })]
534
+ });
535
+ }
@@ -0,0 +1,400 @@
1
+ import { act, render, fireEvent, waitFor } from "@testing-library/react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { createTheme, ThemeProvider } from "@mui/material/styles";
4
+ import ClassroomAISettings from "./ClassroomAISettings";
5
+
6
+ // This repo has no jest-dom matchers and no auto-cleanup, so assertions are
7
+ // plain and each test unmounts - matching AISettings.test.jsx.
8
+ import { jsx as _jsx } from "react/jsx-runtime";
9
+ const classrooms = [{
10
+ classroomId: "r1",
11
+ classroomName: "Room 1"
12
+ }, {
13
+ classroomId: "r2",
14
+ classroomName: "Room 2",
15
+ isDynamicResponsesEnabled: "disabled"
16
+ }, {
17
+ classroomId: "r3",
18
+ classroomName: "Room 3",
19
+ isDynamicResponsesEnabled: "disabled",
20
+ aiLockedBy: {
21
+ isDynamicResponsesEnabled: "customer"
22
+ }
23
+ }, {
24
+ classroomId: "r4",
25
+ classroomName: "Room 4",
26
+ visibility: "archived"
27
+ }];
28
+ const deferred = () => {
29
+ let resolve;
30
+ let reject;
31
+ const promise = new Promise((res, rej) => {
32
+ resolve = res;
33
+ reject = rej;
34
+ });
35
+ return {
36
+ promise,
37
+ resolve,
38
+ reject
39
+ };
40
+ };
41
+ const setup = (props = {}) => {
42
+ const onSave = props.onSave || vi.fn().mockResolvedValue({});
43
+ const ui = rows => /*#__PURE__*/_jsx(ThemeProvider, {
44
+ theme: createTheme(),
45
+ children: /*#__PURE__*/_jsx(ClassroomAISettings, {
46
+ ...props,
47
+ classrooms: rows,
48
+ onSave: onSave
49
+ })
50
+ });
51
+ const utils = render(ui(props.classrooms || classrooms));
52
+ const toggle = (label, name) => utils.getByRole("switch", {
53
+ name: `${label} - ${name}`
54
+ });
55
+ // New classroom records from the parent, as after a refetch.
56
+ const rerenderWith = rows => utils.rerender(ui(rows));
57
+ return {
58
+ ...utils,
59
+ onSave,
60
+ toggle,
61
+ rerenderWith
62
+ };
63
+ };
64
+ describe("ClassroomAISettings", () => {
65
+ it("shows each classroom's switches, with unset ones on", () => {
66
+ const {
67
+ toggle,
68
+ unmount
69
+ } = setup();
70
+ expect(toggle("limit_ai", "Room 1").checked).toBe(true);
71
+ expect(toggle("chatbot_ai", "Room 1").checked).toBe(true);
72
+ expect(toggle("chatbot_ai", "Room 2").checked).toBe(false);
73
+ unmount();
74
+ });
75
+ it("fixes locked switches and says why", () => {
76
+ const {
77
+ toggle,
78
+ unmount
79
+ } = setup();
80
+ expect(toggle("chatbot_ai", "Room 3").disabled).toBe(true);
81
+ expect(toggle("limit_ai", "Room 3").disabled).toBe(false);
82
+ const notice = document.querySelector('[data-cy="overview-locked-notice-r3"]');
83
+ expect(notice.textContent).toBe("ai_partly_locked_notice");
84
+ expect(document.querySelector('[data-cy="overview-locked-notice-r1"]')).toBe(null);
85
+ unmount();
86
+ });
87
+ it("uses the full lock notice when all AI is locked", () => {
88
+ const {
89
+ unmount
90
+ } = setup({
91
+ classrooms: [{
92
+ classroomId: "r1",
93
+ classroomName: "Room 1",
94
+ aiLockedBy: {
95
+ isAIEnabled: "customer"
96
+ }
97
+ }]
98
+ });
99
+ const notice = document.querySelector('[data-cy="overview-locked-notice-r1"]');
100
+ expect(notice.textContent).toBe("ai_locked_notice");
101
+ unmount();
102
+ });
103
+ it("keeps archived classrooms in their own view", () => {
104
+ const {
105
+ toggle,
106
+ queryByRole,
107
+ getByText,
108
+ unmount
109
+ } = setup();
110
+ expect(queryByRole("switch", {
111
+ name: "limit_ai - Room 4"
112
+ })).toBe(null);
113
+ fireEvent.click(getByText("archived (1)"));
114
+ expect(toggle("limit_ai", "Room 4").disabled).toBe(true);
115
+ expect(queryByRole("switch", {
116
+ name: "limit_ai - Room 1"
117
+ })).toBe(null);
118
+ // Nothing to apply to in the archived view.
119
+ expect(document.querySelector('[data-cy="overview-apply-all-ai"]')).toBe(null);
120
+ unmount();
121
+ });
122
+ it("has no tabs when nothing is archived", () => {
123
+ const {
124
+ queryByText,
125
+ unmount
126
+ } = setup({
127
+ classrooms: classrooms.slice(0, 3)
128
+ });
129
+ expect(queryByText("active (3)")).toBe(null);
130
+ unmount();
131
+ });
132
+ it("says when every classroom is archived", () => {
133
+ const {
134
+ getByText,
135
+ unmount
136
+ } = setup({
137
+ classrooms: [classrooms[3]]
138
+ });
139
+ expect(getByText("ai_overview_no_active")).toBeTruthy();
140
+ unmount();
141
+ });
142
+ it("has help on each column", () => {
143
+ const {
144
+ unmount
145
+ } = setup();
146
+ ["ai", "chatbot-ai", "discuss-ai"].forEach(dataCy => {
147
+ expect(document.querySelector(`[data-cy="overview-help-${dataCy}"]`)).toBeTruthy();
148
+ });
149
+ unmount();
150
+ });
151
+ it("shows features as off and fixed while all AI is off", () => {
152
+ const {
153
+ toggle,
154
+ unmount
155
+ } = setup({
156
+ classrooms: [{
157
+ classroomId: "r1",
158
+ classroomName: "Room 1",
159
+ isAIEnabled: "disabled"
160
+ }]
161
+ });
162
+ expect(toggle("chatbot_ai", "Room 1").checked).toBe(false);
163
+ expect(toggle("chatbot_ai", "Room 1").disabled).toBe(true);
164
+ unmount();
165
+ });
166
+ it("saves only the switch that changed", async () => {
167
+ const {
168
+ toggle,
169
+ onSave,
170
+ unmount
171
+ } = setup();
172
+ fireEvent.click(toggle("discuss_ai", "Room 1"));
173
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
174
+ expect(onSave).toHaveBeenCalledWith("r1", {
175
+ isDiscussAIFeedbackEnabled: "disabled"
176
+ });
177
+ unmount();
178
+ });
179
+ it("puts a switch back and says so when saving fails", async () => {
180
+ const onSave = vi.fn().mockRejectedValue(new Error("500"));
181
+ const {
182
+ toggle,
183
+ findByText,
184
+ unmount
185
+ } = setup({
186
+ onSave
187
+ });
188
+ vi.spyOn(console, "error").mockImplementation(() => {});
189
+ fireEvent.click(toggle("chatbot_ai", "Room 1"));
190
+ await findByText("ai_overview_save_failed: Room 1");
191
+ expect(toggle("chatbot_ai", "Room 1").checked).toBe(true);
192
+ unmount();
193
+ });
194
+ it("applies to all, skipping fixed classrooms and ones already set", async () => {
195
+ const {
196
+ getByText,
197
+ onSave,
198
+ findByText,
199
+ unmount
200
+ } = setup();
201
+ fireEvent.click(document.querySelector('[data-cy="overview-apply-all-chatbot-ai"]'));
202
+ fireEvent.click(getByText("ai_overview_disable_all"));
203
+ getByText(/ai_overview_apply_all_count: 1/);
204
+ fireEvent.click(document.querySelector('[data-cy="overview-apply-all-confirm"]'));
205
+ await findByText("ai_overview_apply_all_saved: 1");
206
+ // Room 2 is already off, Room 3 is locked and Room 4 is archived.
207
+ expect(onSave).toHaveBeenCalledTimes(1);
208
+ expect(onSave).toHaveBeenCalledWith("r1", {
209
+ isDynamicResponsesEnabled: "disabled"
210
+ });
211
+ unmount();
212
+ });
213
+ it("lists the classrooms that failed in apply to all", async () => {
214
+ const onSave = vi.fn(classroomId => classroomId === "r2" ? Promise.reject(new Error("500")) : Promise.resolve({}));
215
+ vi.spyOn(console, "error").mockImplementation(() => {});
216
+ const {
217
+ getByText,
218
+ findByText,
219
+ unmount
220
+ } = setup({
221
+ onSave
222
+ });
223
+ fireEvent.click(document.querySelector('[data-cy="overview-apply-all-ai"]'));
224
+ fireEvent.click(getByText("ai_overview_disable_all"));
225
+ fireEvent.click(document.querySelector('[data-cy="overview-apply-all-confirm"]'));
226
+ await findByText("ai_overview_apply_all_failed: Room 2");
227
+ unmount();
228
+ });
229
+ describe("after a save", () => {
230
+ const room = (fields = {}) => [{
231
+ classroomId: "r1",
232
+ classroomName: "Room 1",
233
+ ...fields
234
+ }];
235
+ const chatbot = "chatbot_ai";
236
+ it("shows a newer value once the save has been acknowledged", async () => {
237
+ const save = deferred();
238
+ const onSave = vi.fn(() => save.promise);
239
+ const {
240
+ toggle,
241
+ rerenderWith,
242
+ unmount
243
+ } = setup({
244
+ classrooms: room(),
245
+ onSave
246
+ });
247
+ fireEvent.click(toggle(chatbot, "Room 1"));
248
+ await act(async () => save.resolve({}));
249
+ rerenderWith(room({
250
+ isDynamicResponsesEnabled: "disabled"
251
+ }));
252
+ expect(toggle(chatbot, "Room 1").checked).toBe(false);
253
+ rerenderWith(room({
254
+ isDynamicResponsesEnabled: "enabled"
255
+ }));
256
+ expect(toggle(chatbot, "Room 1").checked).toBe(true);
257
+ unmount();
258
+ });
259
+ it("shows a newer value when acknowledged before the save resolves", async () => {
260
+ const save = deferred();
261
+ const onSave = vi.fn(() => save.promise);
262
+ const {
263
+ toggle,
264
+ rerenderWith,
265
+ unmount
266
+ } = setup({
267
+ classrooms: room(),
268
+ onSave
269
+ });
270
+ fireEvent.click(toggle(chatbot, "Room 1"));
271
+ rerenderWith(room({
272
+ isDynamicResponsesEnabled: "disabled"
273
+ }));
274
+ await act(async () => save.resolve({}));
275
+ rerenderWith(room({
276
+ isDynamicResponsesEnabled: "enabled"
277
+ }));
278
+ expect(toggle(chatbot, "Room 1").checked).toBe(true);
279
+ unmount();
280
+ });
281
+ it("keeps a save in progress through a refetch with the old value", async () => {
282
+ const save = deferred();
283
+ const onSave = vi.fn(() => save.promise);
284
+ const {
285
+ toggle,
286
+ rerenderWith,
287
+ unmount
288
+ } = setup({
289
+ classrooms: room(),
290
+ onSave
291
+ });
292
+ fireEvent.click(toggle(chatbot, "Room 1"));
293
+ rerenderWith(room());
294
+ expect(toggle(chatbot, "Room 1").checked).toBe(false);
295
+ await act(async () => save.resolve({}));
296
+ expect(toggle(chatbot, "Room 1").checked).toBe(false);
297
+ unmount();
298
+ });
299
+ it("shows a customer lock over a saved value", async () => {
300
+ const {
301
+ toggle,
302
+ rerenderWith,
303
+ unmount
304
+ } = setup({
305
+ classrooms: room({
306
+ isDynamicResponsesEnabled: "disabled"
307
+ })
308
+ });
309
+ fireEvent.click(toggle(chatbot, "Room 1"));
310
+ await waitFor(() => expect(toggle(chatbot, "Room 1").disabled).toBe(false));
311
+ expect(toggle(chatbot, "Room 1").checked).toBe(true);
312
+ rerenderWith(room({
313
+ isDynamicResponsesEnabled: "disabled",
314
+ aiLockedBy: {
315
+ isDynamicResponsesEnabled: "customer"
316
+ }
317
+ }));
318
+ expect(toggle(chatbot, "Room 1").checked).toBe(false);
319
+ expect(toggle(chatbot, "Room 1").disabled).toBe(true);
320
+ unmount();
321
+ });
322
+ it("keeps an earlier saved value when a later save fails", async () => {
323
+ const onSave = vi.fn().mockResolvedValueOnce({}).mockRejectedValueOnce(new Error("500"));
324
+ vi.spyOn(console, "error").mockImplementation(() => {});
325
+ const {
326
+ toggle,
327
+ findByText,
328
+ unmount
329
+ } = setup({
330
+ classrooms: room(),
331
+ onSave
332
+ });
333
+ fireEvent.click(toggle(chatbot, "Room 1"));
334
+ await waitFor(() => expect(toggle(chatbot, "Room 1").disabled).toBe(false));
335
+ expect(toggle(chatbot, "Room 1").checked).toBe(false);
336
+ fireEvent.click(toggle(chatbot, "Room 1"));
337
+ await findByText("ai_overview_save_failed: Room 1");
338
+ expect(toggle(chatbot, "Room 1").checked).toBe(false);
339
+ unmount();
340
+ });
341
+ });
342
+ it("keeps every switch fixed until apply to all has finished", async () => {
343
+ const six = [1, 2, 3, 4, 5, 6].map(n => ({
344
+ classroomId: `r${n}`,
345
+ classroomName: `Room ${n}`
346
+ }));
347
+ const saves = {};
348
+ const onSave = vi.fn(classroomId => {
349
+ saves[classroomId] = deferred();
350
+ return saves[classroomId].promise;
351
+ });
352
+ vi.spyOn(console, "error").mockImplementation(() => {});
353
+ const {
354
+ toggle,
355
+ getByText,
356
+ findByText,
357
+ queryByRole,
358
+ unmount
359
+ } = setup({
360
+ classrooms: six,
361
+ onSave
362
+ });
363
+ fireEvent.click(document.querySelector('[data-cy="overview-apply-all-chatbot-ai"]'));
364
+ fireEvent.click(getByText("ai_overview_disable_all"));
365
+ fireEvent.click(document.querySelector('[data-cy="overview-apply-all-confirm"]'));
366
+
367
+ // The first batch is saving; Room 6 is queued and can't be changed. The
368
+ // dialog hides the rest of the page until it has closed.
369
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(5));
370
+ await waitFor(() => expect(queryByRole("dialog")).toBe(null));
371
+ expect(toggle("chatbot_ai", "Room 6").disabled).toBe(true);
372
+ expect(toggle("discuss_ai", "Room 6").disabled).toBe(true);
373
+ expect(toggle("limit_ai", "Room 1").disabled).toBe(true);
374
+
375
+ // One of the first batch fails; the second batch starts.
376
+ await act(async () => {
377
+ ["r1", "r3", "r4", "r5"].forEach(id => saves[id].resolve({}));
378
+ saves.r2.reject(new Error("500"));
379
+ });
380
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(6));
381
+ expect(toggle("chatbot_ai", "Room 6").disabled).toBe(true);
382
+ expect(toggle("limit_ai", "Room 1").disabled).toBe(true);
383
+ await act(async () => saves.r6.resolve({}));
384
+ await findByText("ai_overview_apply_all_failed: Room 2");
385
+ expect(toggle("chatbot_ai", "Room 6").disabled).toBe(false);
386
+ expect(toggle("chatbot_ai", "Room 2").disabled).toBe(false);
387
+ expect(toggle("chatbot_ai", "Room 2").checked).toBe(true);
388
+ unmount();
389
+ });
390
+ it("says when there are no classrooms", () => {
391
+ const {
392
+ getByText,
393
+ unmount
394
+ } = setup({
395
+ classrooms: []
396
+ });
397
+ expect(getByText("ai_overview_empty")).toBeTruthy();
398
+ unmount();
399
+ });
400
+ });
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "ClassroomAISettings",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "main": "./ClassroomAISettings.js"
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nualang/nualang-ui-components",
3
- "version": "0.1.1440",
3
+ "version": "0.1.1441",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [