Add speech-topic policy from tomorrow and a morning family talk.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -315,11 +315,17 @@ const ru = {
|
||||
rulesStudentsColor: 'Цвет учеников',
|
||||
rulesStaffForm: 'Форма сотрудников',
|
||||
rulesStaffColor: 'Цвет сотрудников',
|
||||
rulesRoleSummary: '{role}: {form}, {color}',
|
||||
rulesRoleSummary: '{role}: {form}, {color}; {speech}',
|
||||
rulesApply: 'Применить',
|
||||
rulesLoadFailed: 'Не удалось загрузить правила одежды.',
|
||||
rulesSaveFailed: 'Не удалось сохранить правила.',
|
||||
rulesPickHint: 'Правила действуют на всю школу.',
|
||||
rulesStudentsSpeech: 'Речь учеников',
|
||||
rulesStaffSpeech: 'Речь сотрудников',
|
||||
speechPolicyFree: 'свободно',
|
||||
speechPolicyNoRude: 'без грубого',
|
||||
speechPolicyStudyOnly: 'только учёба',
|
||||
speechErrorUnknownPolicy: 'Такой политики речи нет.',
|
||||
dressFormRegular: 'обычная',
|
||||
dressFormShort: 'короткая',
|
||||
dressFormStrict: 'строгая',
|
||||
@@ -688,11 +694,17 @@ const en: Messages = {
|
||||
rulesStudentsColor: 'Student colours',
|
||||
rulesStaffForm: 'Staff form',
|
||||
rulesStaffColor: 'Staff colours',
|
||||
rulesRoleSummary: '{role}: {form}, {color}',
|
||||
rulesRoleSummary: '{role}: {form}, {color}; {speech}',
|
||||
rulesApply: 'Apply',
|
||||
rulesLoadFailed: 'Could not load dress rules.',
|
||||
rulesSaveFailed: 'Could not save the rules.',
|
||||
rulesPickHint: 'Rules apply to the whole school.',
|
||||
rulesStudentsSpeech: 'Student speech',
|
||||
rulesStaffSpeech: 'Staff speech',
|
||||
speechPolicyFree: 'free',
|
||||
speechPolicyNoRude: 'no rude talk',
|
||||
speechPolicyStudyOnly: 'study only',
|
||||
speechErrorUnknownPolicy: 'That speech policy is not in the catalog.',
|
||||
dressFormRegular: 'regular',
|
||||
dressFormShort: 'short',
|
||||
dressFormStrict: 'strict',
|
||||
|
||||
@@ -762,6 +762,28 @@ export async function setDressRules(
|
||||
});
|
||||
}
|
||||
|
||||
export interface SpeechRules {
|
||||
readonly students: string;
|
||||
readonly staff: string;
|
||||
readonly pendingStudents: string | null;
|
||||
readonly pendingStaff: string | null;
|
||||
}
|
||||
|
||||
export async function fetchSpeechRules(schoolId: number): Promise<SpeechRules> {
|
||||
return request<SpeechRules>(`/api/schools/${schoolId}/speech-rules`);
|
||||
}
|
||||
|
||||
export async function setSpeechRules(
|
||||
schoolId: number,
|
||||
body: { readonly students?: string; readonly staff?: string },
|
||||
): Promise<SpeechRules> {
|
||||
return request<SpeechRules>(`/api/schools/${schoolId}/speech-rules`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export interface TimetableLesson {
|
||||
readonly classId: string;
|
||||
readonly classYear: number;
|
||||
|
||||
@@ -5,8 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
fetchDressRules,
|
||||
fetchSpeechRules,
|
||||
setDressRules,
|
||||
setSpeechRules,
|
||||
type DressRules,
|
||||
type SpeechRules,
|
||||
} from '../net/api.ts';
|
||||
import { getLocale, setLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
@@ -17,7 +20,9 @@ vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
fetchDressRules: vi.fn(),
|
||||
fetchSpeechRules: vi.fn(),
|
||||
setDressRules: vi.fn(),
|
||||
setSpeechRules: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -32,12 +37,24 @@ function rules(): DressRules {
|
||||
};
|
||||
}
|
||||
|
||||
function speech(): SpeechRules {
|
||||
return {
|
||||
students: 'free',
|
||||
staff: 'free',
|
||||
pendingStudents: null,
|
||||
pendingStaff: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DressRulesPanel', () => {
|
||||
beforeEach(() => {
|
||||
setLocale('en');
|
||||
vi.mocked(fetchDressRules).mockReset();
|
||||
vi.mocked(fetchSpeechRules).mockReset();
|
||||
vi.mocked(setDressRules).mockReset();
|
||||
vi.mocked(setSpeechRules).mockReset();
|
||||
vi.mocked(fetchDressRules).mockResolvedValue(rules());
|
||||
vi.mocked(fetchSpeechRules).mockResolvedValue(speech());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -49,7 +66,7 @@ describe('DressRulesPanel', () => {
|
||||
const panel = new DressRulesPanel();
|
||||
document.body.append(panel.element);
|
||||
panel.show(3);
|
||||
await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(fetchSpeechRules).toHaveBeenCalled());
|
||||
|
||||
const colorSelect = panel.element.querySelector('select.input');
|
||||
if (!(colorSelect instanceof HTMLSelectElement)) {
|
||||
@@ -66,7 +83,13 @@ describe('DressRulesPanel', () => {
|
||||
const panel = new DressRulesPanel();
|
||||
document.body.append(panel.element);
|
||||
panel.show(3);
|
||||
await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(fetchSpeechRules).toHaveBeenCalled());
|
||||
const apply = panel.element.querySelector('button.button');
|
||||
if (!(apply instanceof HTMLButtonElement)) {
|
||||
throw new Error('apply button is missing');
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(apply.disabled).toBe(false));
|
||||
|
||||
const formSelect = panel.element.querySelector('fieldset select.input');
|
||||
if (!(formSelect instanceof HTMLSelectElement)) {
|
||||
@@ -74,11 +97,6 @@ describe('DressRulesPanel', () => {
|
||||
}
|
||||
|
||||
formSelect.value = 'strict';
|
||||
const apply = panel.element.querySelector('button.button');
|
||||
if (!(apply instanceof HTMLButtonElement)) {
|
||||
throw new Error('apply button is missing');
|
||||
}
|
||||
|
||||
apply.click();
|
||||
const banner = panel.element.querySelector('.staffing__error');
|
||||
if (!(banner instanceof HTMLElement)) {
|
||||
@@ -88,4 +106,40 @@ describe('DressRulesPanel', () => {
|
||||
await vi.waitFor(() => expect(banner.hidden).toBe(false));
|
||||
expect(banner.textContent).toBe(t('dressErrorUnknownForm'));
|
||||
});
|
||||
|
||||
it('places speech comboboxes next to dress with a from-tomorrow caption', async () => {
|
||||
const panel = new DressRulesPanel();
|
||||
document.body.append(panel.element);
|
||||
panel.reveal();
|
||||
panel.show(3);
|
||||
await vi.waitFor(() => expect(fetchSpeechRules).toHaveBeenCalled());
|
||||
|
||||
expect(panel.element.textContent).toContain(t('rulesPendingTitle'));
|
||||
const studentsFieldset = panel.element.querySelector('fieldset.rules__fieldset');
|
||||
if (!(studentsFieldset instanceof HTMLFieldSetElement)) {
|
||||
throw new Error('students fieldset is missing');
|
||||
}
|
||||
|
||||
const selects = [...studentsFieldset.querySelectorAll('select.input')];
|
||||
expect(selects).toHaveLength(3);
|
||||
const speechSelect = studentsFieldset.querySelector('select[data-speech="students"]');
|
||||
if (!(speechSelect instanceof HTMLSelectElement)) {
|
||||
throw new Error('student speech select is missing');
|
||||
}
|
||||
|
||||
expect(selects[2]).toBe(speechSelect);
|
||||
expect([...speechSelect.options].map((option) => option.value)).toEqual([
|
||||
'free',
|
||||
'noRude',
|
||||
'studyOnly',
|
||||
]);
|
||||
expect(studentsFieldset.textContent).toContain(t('rulesStudentsSpeech'));
|
||||
|
||||
const staffSpeech = panel.element.querySelector('select[data-speech="staff"]');
|
||||
if (!(staffSpeech instanceof HTMLSelectElement)) {
|
||||
throw new Error('staff speech select is missing');
|
||||
}
|
||||
|
||||
expect(staffSpeech.closest('fieldset.rules__fieldset')?.querySelectorAll('select.input')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchDressRules, setDressRules, type DressRulePair, type DressRules } from '../net/api.ts';
|
||||
import { fetchDressRules, fetchSpeechRules, setDressRules, setSpeechRules, type DressRulePair, type DressRules, type SpeechRules } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import {
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
dressFormLabel,
|
||||
dressFormOptions,
|
||||
dressRulesError,
|
||||
speechPolicyLabel,
|
||||
speechPolicyOptions,
|
||||
} from './dressRulesUi.ts';
|
||||
import { fillSelect } from './staffingUi.ts';
|
||||
|
||||
@@ -26,10 +28,20 @@ export class DressRulesPanel {
|
||||
private readonly studentsFormSelect = el('select', { class: 'input people__input' });
|
||||
private readonly studentsColorLabel = el('span', { class: 'people__label' });
|
||||
private readonly studentsColorSelect = el('select', { class: 'input people__input' });
|
||||
private readonly studentsSpeechLabel = el('span', { class: 'people__label' });
|
||||
private readonly studentsSpeechSelect = el('select', {
|
||||
class: 'input people__input',
|
||||
dataset: { speech: 'students' },
|
||||
});
|
||||
private readonly staffFormLabel = el('span', { class: 'people__label' });
|
||||
private readonly staffFormSelect = el('select', { class: 'input people__input' });
|
||||
private readonly staffColorLabel = el('span', { class: 'people__label' });
|
||||
private readonly staffColorSelect = el('select', { class: 'input people__input' });
|
||||
private readonly staffSpeechLabel = el('span', { class: 'people__label' });
|
||||
private readonly staffSpeechSelect = el('select', {
|
||||
class: 'input people__input',
|
||||
dataset: { speech: 'staff' },
|
||||
});
|
||||
private readonly applyButton = el('button', { class: 'button', type: 'button' });
|
||||
|
||||
private readonly studentsLegend: HTMLElement;
|
||||
@@ -37,6 +49,7 @@ export class DressRulesPanel {
|
||||
|
||||
private schoolId: number | null = null;
|
||||
private rules: DressRules | null = null;
|
||||
private speech: SpeechRules | null = null;
|
||||
private loadToken = 0;
|
||||
private busy = false;
|
||||
|
||||
@@ -70,6 +83,12 @@ export class DressRulesPanel {
|
||||
this.studentsColorLabel,
|
||||
this.studentsColorSelect,
|
||||
),
|
||||
el(
|
||||
'label',
|
||||
{ class: 'people__field' },
|
||||
this.studentsSpeechLabel,
|
||||
this.studentsSpeechSelect,
|
||||
),
|
||||
),
|
||||
el(
|
||||
'fieldset',
|
||||
@@ -77,6 +96,7 @@ export class DressRulesPanel {
|
||||
el('legend', { class: 'people__section-title', text: '' }),
|
||||
el('label', { class: 'people__field' }, this.staffFormLabel, this.staffFormSelect),
|
||||
el('label', { class: 'people__field' }, this.staffColorLabel, this.staffColorSelect),
|
||||
el('label', { class: 'people__field' }, this.staffSpeechLabel, this.staffSpeechSelect),
|
||||
),
|
||||
this.applyButton,
|
||||
),
|
||||
@@ -100,15 +120,19 @@ export class DressRulesPanel {
|
||||
this.pendingHint.textContent = t('rulesPendingHint');
|
||||
this.studentsFormLabel.textContent = t('rulesStudentsForm');
|
||||
this.studentsColorLabel.textContent = t('rulesStudentsColor');
|
||||
this.studentsSpeechLabel.textContent = t('rulesStudentsSpeech');
|
||||
this.staffFormLabel.textContent = t('rulesStaffForm');
|
||||
this.staffColorLabel.textContent = t('rulesStaffColor');
|
||||
this.staffSpeechLabel.textContent = t('rulesStaffSpeech');
|
||||
this.applyButton.textContent = t('rulesApply');
|
||||
this.studentsLegend.textContent = t('rulesStudentsRole');
|
||||
this.staffLegend.textContent = t('rulesStaffRole');
|
||||
fillSelect(this.studentsFormSelect, dressFormOptions(), this.studentsFormSelect.value);
|
||||
fillSelect(this.studentsColorSelect, dressColorOptions(), this.studentsColorSelect.value);
|
||||
fillSelect(this.studentsSpeechSelect, speechPolicyOptions(), this.studentsSpeechSelect.value);
|
||||
fillSelect(this.staffFormSelect, dressFormOptions(), this.staffFormSelect.value);
|
||||
fillSelect(this.staffColorSelect, dressColorOptions(), this.staffColorSelect.value);
|
||||
fillSelect(this.staffSpeechSelect, speechPolicyOptions(), this.staffSpeechSelect.value);
|
||||
this.paint();
|
||||
}
|
||||
|
||||
@@ -116,6 +140,7 @@ export class DressRulesPanel {
|
||||
if (this.schoolId !== schoolId) {
|
||||
this.schoolId = schoolId;
|
||||
this.rules = null;
|
||||
this.speech = null;
|
||||
this.clearError();
|
||||
}
|
||||
|
||||
@@ -138,12 +163,13 @@ export class DressRulesPanel {
|
||||
|
||||
const token = ++this.loadToken;
|
||||
try {
|
||||
const rules = await fetchDressRules(schoolId);
|
||||
const [rules, speech] = await Promise.all([fetchDressRules(schoolId), fetchSpeechRules(schoolId)]);
|
||||
if (token !== this.loadToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rules = rules;
|
||||
this.speech = speech;
|
||||
this.paint();
|
||||
} catch {
|
||||
if (token !== this.loadToken) {
|
||||
@@ -156,9 +182,10 @@ export class DressRulesPanel {
|
||||
|
||||
private paint(): void {
|
||||
const rules = this.rules;
|
||||
this.applyButton.disabled = this.busy || rules === null;
|
||||
const speech = this.speech;
|
||||
this.applyButton.disabled = this.busy || rules === null || speech === null;
|
||||
|
||||
if (rules === null) {
|
||||
if (rules === null || speech === null) {
|
||||
this.currentStudents.textContent = '—';
|
||||
this.currentStaff.textContent = '—';
|
||||
return;
|
||||
@@ -168,11 +195,13 @@ export class DressRulesPanel {
|
||||
role: t('rulesStudentsRole'),
|
||||
form: dressFormLabel(rules.students.form),
|
||||
color: dressColorLabel(rules.students.color),
|
||||
speech: speechPolicyLabel(speech.students),
|
||||
});
|
||||
this.currentStaff.textContent = t('rulesRoleSummary', {
|
||||
role: t('rulesStaffRole'),
|
||||
form: dressFormLabel(rules.staff.form),
|
||||
color: dressColorLabel(rules.staff.color),
|
||||
speech: speechPolicyLabel(speech.staff),
|
||||
});
|
||||
|
||||
const pendingStudents = rules.pendingStudents ?? rules.students;
|
||||
@@ -181,12 +210,19 @@ export class DressRulesPanel {
|
||||
fillSelect(this.studentsColorSelect, dressColorOptions(), pendingStudents.color);
|
||||
fillSelect(this.staffFormSelect, dressFormOptions(), pendingStaff.form);
|
||||
fillSelect(this.staffColorSelect, dressColorOptions(), pendingStaff.color);
|
||||
fillSelect(
|
||||
this.studentsSpeechSelect,
|
||||
speechPolicyOptions(),
|
||||
speech.pendingStudents ?? speech.students,
|
||||
);
|
||||
fillSelect(this.staffSpeechSelect, speechPolicyOptions(), speech.pendingStaff ?? speech.staff);
|
||||
}
|
||||
|
||||
private async apply(): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
const rules = this.rules;
|
||||
if (schoolId === null || rules === null || this.busy) {
|
||||
const speech = this.speech;
|
||||
if (schoolId === null || rules === null || speech === null || this.busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,7 +237,21 @@ export class DressRulesPanel {
|
||||
body.staff = staff;
|
||||
}
|
||||
|
||||
if (body.students === undefined && body.staff === undefined) {
|
||||
const speechBody: { students?: string; staff?: string } = {};
|
||||
if (this.studentsSpeechSelect.value !== (speech.pendingStudents ?? speech.students)) {
|
||||
speechBody.students = this.studentsSpeechSelect.value;
|
||||
}
|
||||
|
||||
if (this.staffSpeechSelect.value !== (speech.pendingStaff ?? speech.staff)) {
|
||||
speechBody.staff = this.staffSpeechSelect.value;
|
||||
}
|
||||
|
||||
if (
|
||||
body.students === undefined
|
||||
&& body.staff === undefined
|
||||
&& speechBody.students === undefined
|
||||
&& speechBody.staff === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -209,7 +259,14 @@ export class DressRulesPanel {
|
||||
this.clearError();
|
||||
this.paint();
|
||||
try {
|
||||
this.rules = await setDressRules(schoolId, body);
|
||||
if (body.students !== undefined || body.staff !== undefined) {
|
||||
this.rules = await setDressRules(schoolId, body);
|
||||
}
|
||||
|
||||
if (speechBody.students !== undefined || speechBody.staff !== undefined) {
|
||||
this.speech = await setSpeechRules(schoolId, speechBody);
|
||||
}
|
||||
|
||||
this.paint();
|
||||
} catch (error) {
|
||||
this.showError(dressRulesError(error));
|
||||
|
||||
@@ -51,7 +51,30 @@ export function dressRulesError(error: unknown): string {
|
||||
return t('dressErrorUnknownForm');
|
||||
case 'unknown-color':
|
||||
return t('dressErrorUnknownColor');
|
||||
case 'unknown-speech':
|
||||
return t('speechErrorUnknownPolicy');
|
||||
default:
|
||||
return error.message.length > 0 ? error.message : t('rulesSaveFailed');
|
||||
}
|
||||
}
|
||||
|
||||
export const SPEECH_POLICIES = ['free', 'noRude', 'studyOnly'] as const;
|
||||
|
||||
export type SpeechPolicy = (typeof SPEECH_POLICIES)[number];
|
||||
|
||||
export function speechPolicyOptions(): readonly { value: SpeechPolicy; label: string }[] {
|
||||
return SPEECH_POLICIES.map((value) => ({ value, label: speechPolicyLabel(value) }));
|
||||
}
|
||||
|
||||
export function speechPolicyLabel(policy: string): string {
|
||||
switch (policy) {
|
||||
case 'free':
|
||||
return t('speechPolicyFree');
|
||||
case 'noRude':
|
||||
return t('speechPolicyNoRude');
|
||||
case 'studyOnly':
|
||||
return t('speechPolicyStudyOnly');
|
||||
default:
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
fetchPerson,
|
||||
fetchGameStatus,
|
||||
fetchDressRules,
|
||||
fetchSpeechRules,
|
||||
fetchStaffing,
|
||||
fetchTimetable,
|
||||
hireStaff,
|
||||
@@ -27,6 +28,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
fetchStaffing: vi.fn(),
|
||||
fetchTimetable: vi.fn(),
|
||||
fetchDressRules: vi.fn(),
|
||||
fetchSpeechRules: vi.fn(),
|
||||
fetchPerson: vi.fn(),
|
||||
fetchGameStatus: vi.fn().mockResolvedValue({
|
||||
tick: 0,
|
||||
@@ -224,6 +226,7 @@ describe('ManagementPanel uncovered', () => {
|
||||
vi.mocked(fetchStaffing).mockReset();
|
||||
vi.mocked(fetchTimetable).mockReset();
|
||||
vi.mocked(fetchDressRules).mockReset();
|
||||
vi.mocked(fetchSpeechRules).mockReset();
|
||||
vi.mocked(fetchStaffing).mockResolvedValue({
|
||||
...staffing(),
|
||||
applicants: [],
|
||||
@@ -245,6 +248,12 @@ describe('ManagementPanel uncovered', () => {
|
||||
pendingStudents: null,
|
||||
pendingStaff: null,
|
||||
});
|
||||
vi.mocked(fetchSpeechRules).mockResolvedValue({
|
||||
students: 'free',
|
||||
staff: 'free',
|
||||
pendingStudents: null,
|
||||
pendingStaff: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -271,6 +280,7 @@ describe('ManagementPanel rules tab', () => {
|
||||
vi.mocked(fetchStaffing).mockReset();
|
||||
vi.mocked(fetchTimetable).mockReset();
|
||||
vi.mocked(fetchDressRules).mockReset();
|
||||
vi.mocked(fetchSpeechRules).mockReset();
|
||||
vi.mocked(fetchStaffing).mockResolvedValue(staffing());
|
||||
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
|
||||
vi.mocked(fetchDressRules).mockResolvedValue({
|
||||
@@ -279,6 +289,12 @@ describe('ManagementPanel rules tab', () => {
|
||||
pendingStudents: null,
|
||||
pendingStaff: null,
|
||||
});
|
||||
vi.mocked(fetchSpeechRules).mockResolvedValue({
|
||||
students: 'free',
|
||||
staff: 'free',
|
||||
pendingStudents: null,
|
||||
pendingStaff: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
Reference in New Issue
Block a user