@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.
- package/dist/Forms/Settings/Settings.js +89 -88
- package/dist/Forms/Settings/Settings.test.js +65 -0
- package/dist/Forms/Settings/Tabs/ClassroomAISettings/ClassroomAISettings.js +535 -0
- package/dist/Forms/Settings/Tabs/ClassroomAISettings/ClassroomAISettings.test.js +400 -0
- package/dist/Forms/Settings/Tabs/ClassroomAISettings/package.json +6 -0
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
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
|
|
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__*/
|
|
123
|
+
children: /*#__PURE__*/_jsx(ResponsiveTabs, {
|
|
75
124
|
t: t,
|
|
76
|
-
tabs:
|
|
125
|
+
tabs: tabs,
|
|
77
126
|
initialValue: initialTabValue,
|
|
78
127
|
scrollable: true,
|
|
79
|
-
children:
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
128
|
-
|
|
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
|
+
});
|