feat(settings): allow partial presets (@amarnathsama, @miodec, @fehmer) (#5813)

This commit is contained in:
amarnathsama 2024-09-13 01:12:29 +05:30 committed by GitHub
parent dd93fdbf02
commit 8a6c81669e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 898 additions and 86 deletions

View file

@ -24,9 +24,15 @@ describe("PresetController", () => {
_id: new ObjectId(),
uid: "123456789",
name: "test2",
config: { language: "polish" },
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
};
//@ts-expect-error
getPresetsMock.mockResolvedValue([presetOne, presetTwo]);
//WHEN
@ -47,7 +53,13 @@ describe("PresetController", () => {
{
_id: presetTwo._id.toHexString(),
name: "test2",
config: { language: "polish" },
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
},
],
});
@ -81,7 +93,7 @@ describe("PresetController", () => {
addPresetMock.mockReset();
});
it("should add the users preset", async () => {
it("should add the users full preset", async () => {
//GIVEN
addPresetMock.mockResolvedValue({ presetId: "1" });
@ -110,6 +122,65 @@ describe("PresetController", () => {
config: { language: "english", tags: ["one", "two"] },
});
});
it("should add the users partial preset", async () => {
//GIVEN
addPresetMock.mockResolvedValue({ presetId: "1" });
//WHEN
const { body } = await mockApp
.post("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
name: "new",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
})
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Preset created",
data: { presetId: "1" },
});
expect(addPresetMock).toHaveBeenCalledWith("123456789", {
name: "new",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
});
});
it("should fail for no setting groups in partial presets", async () => {
//WHEN
const { body } = await mockApp
.post("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
name: "update",
settingGroups: [],
config: {},
})
.expect(422);
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [
`"settingGroups" Array must contain at least 1 element(s)`,
],
});
expect(addPresetMock).not.toHaveBeenCalled();
});
it("should not fail with emtpy config", async () => {
//GIVEN
@ -149,7 +220,7 @@ describe("PresetController", () => {
});
expect(addPresetMock).not.toHaveBeenCalled();
});
it("should not fail with invalid preset", async () => {
it("should fail with invalid preset", async () => {
//WHEN
const { body } = await mockApp
.post("/presets")
@ -178,6 +249,32 @@ describe("PresetController", () => {
],
});
expect(addPresetMock).not.toHaveBeenCalled();
});
it("should fail with duplicate group settings in partial preset", async () => {
//WHEN
const { body } = await mockApp
.post("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
name: "new",
settingGroups: ["hideElements", "hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
})
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [`"settingGroups" No duplicates allowed.`],
});
expect(addPresetMock).not.toHaveBeenCalled();
});
});
@ -220,6 +317,46 @@ describe("PresetController", () => {
config: { language: "english", tags: ["one", "two"] },
});
});
it("should update the users partial preset", async () => {
//GIVEN
editPresetMock.mockResolvedValue({} as any);
//WHEN
const { body } = await mockApp
.patch("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
_id: "1",
name: "new",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
})
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Preset updated",
data: null,
});
expect(editPresetMock).toHaveBeenCalledWith("123456789", {
_id: "1",
name: "new",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
});
});
it("should not fail with emtpy config", async () => {
//GIVEN
@ -256,15 +393,11 @@ describe("PresetController", () => {
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [
`"_id" Required`,
`"name" Required`,
`"config" Required`,
],
validationErrors: [`"_id" Required`, `"name" Required`],
});
expect(editPresetMock).not.toHaveBeenCalled();
});
it("should not fail with invalid preset", async () => {
it("should fail with invalid preset", async () => {
//WHEN
const { body } = await mockApp
.patch("/presets")
@ -274,6 +407,7 @@ describe("PresetController", () => {
_id: "1",
name: "update",
extra: "extra",
settingGroups: ["mappers"],
config: {
extra: "extra",
autoSwitchTheme: "yes",
@ -286,6 +420,7 @@ describe("PresetController", () => {
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [
`"settingGroups.0" Invalid enum value. Expected 'test' | 'behavior' | 'input' | 'sound' | 'caret' | 'appearance' | 'theme' | 'hideElements' | 'ads' | 'hidden', received 'mappers'`,
`"config.autoSwitchTheme" Expected boolean, received string`,
`"config.confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`,
`"config" Unrecognized key(s) in object: 'extra'`,
@ -293,6 +428,33 @@ describe("PresetController", () => {
],
});
expect(editPresetMock).not.toHaveBeenCalled();
});
it("should fail with duplicate group settings in partial preset", async () => {
//WHEN
const { body } = await mockApp
.patch("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
_id: "1",
name: "new",
settingGroups: ["hideElements", "hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
})
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [`"settingGroups" No duplicates allowed.`],
});
expect(editPresetMock).not.toHaveBeenCalled();
});
});

View file

@ -13,8 +13,12 @@ describe("PresetDal", () => {
});
const second = await PresetDal.addPreset(uid, {
name: "second",
settingGroups: ["hideElements"],
config: {
ads: "result",
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
});
await PresetDal.addPreset("unknown", { name: "unknown", config: {} });
@ -36,7 +40,13 @@ describe("PresetDal", () => {
_id: new ObjectId(second.presetId),
uid: uid,
name: "second",
config: { ads: "result" },
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
}),
])
);
@ -160,7 +170,7 @@ describe("PresetDal", () => {
);
});
it("should edit with name only", async () => {
it("should edit with name only - full preset", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = (
@ -174,7 +184,6 @@ describe("PresetDal", () => {
await PresetDal.editPreset(uid, {
_id: first,
name: "newName",
config: {},
});
expect(await PresetDal.getPresets(uid)).toEqual(
expect.arrayContaining([
@ -187,6 +196,44 @@ describe("PresetDal", () => {
])
);
});
it("should edit with name only - partial preset", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = (
await PresetDal.addPreset(uid, {
name: "first",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
})
).presetId;
//WHEN empty
await PresetDal.editPreset(uid, {
_id: first,
name: "newName",
});
expect(await PresetDal.getPresets(uid)).toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: new ObjectId(first),
uid: uid,
name: "newName",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
}),
])
);
});
it("should not edit present not matching uid", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
@ -219,6 +266,85 @@ describe("PresetDal", () => {
])
);
});
it("should edit when partial is edited to full", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = (
await PresetDal.addPreset(uid, {
name: "first",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
})
).presetId;
//WHEN
await PresetDal.editPreset(uid, {
_id: first,
name: "newName",
settingGroups: null,
config: { ads: "off" },
});
//THEN
expect(await PresetDal.getPresets(uid)).toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: new ObjectId(first),
uid: uid,
name: "newName",
config: { ads: "off" },
settingGroups: null,
}),
])
);
});
it("should edit when full is edited to partial", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = (
await PresetDal.addPreset(uid, {
name: "first",
config: {
ads: "off",
},
})
).presetId;
//WHEN
await PresetDal.editPreset(uid, {
_id: first,
name: "newName",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
});
//THEN
expect(await PresetDal.getPresets(uid)).toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: new ObjectId(first),
uid: uid,
name: "newName",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
}),
])
);
});
});
describe("removePreset", () => {

View file

@ -7,7 +7,7 @@ import {
import * as PresetDAL from "../../dal/preset";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { replaceObjectId } from "../../utils/misc";
import { Preset } from "@monkeytype/contracts/schemas/presets";
import { EditPresetRequest } from "@monkeytype/contracts/schemas/presets";
export async function getPresets(
req: MonkeyTypes.Request2
@ -35,7 +35,7 @@ export async function addPreset(
}
export async function editPreset(
req: MonkeyTypes.Request2<undefined, Preset>
req: MonkeyTypes.Request2<undefined, EditPresetRequest>
): Promise<MonkeyResponse2> {
const { uid } = req.ctx.decodedToken;

View file

@ -1,7 +1,11 @@
import MonkeyError from "../utils/error";
import * as db from "../init/db";
import { ObjectId, type Filter, Collection, type WithId } from "mongodb";
import { Preset } from "@monkeytype/contracts/schemas/presets";
import {
EditPresetRequest,
Preset,
} from "@monkeytype/contracts/schemas/presets";
import { omit } from "lodash";
const MAX_PRESETS = 10;
@ -56,15 +60,21 @@ export async function addPreset(
};
}
export async function editPreset(uid: string, preset: Preset): Promise<void> {
const config = preset.config;
const presetUpdates =
config !== undefined && config !== null && Object.keys(config).length > 0
? { name: preset.name, config }
: { name: preset.name };
export async function editPreset(
uid: string,
preset: EditPresetRequest
): Promise<void> {
const update: Partial<Omit<Preset, "_id">> = omit(preset, "_id");
if (
preset.config === undefined ||
preset.config === null ||
Object.keys(preset.config).length === 0
) {
delete update.config;
}
await getPresetsCollection().updateOne(getPresetKeyFilter(uid, preset._id), {
$set: presetUpdates,
$set: update,
});
}

View file

@ -1,6 +1,9 @@
import { migrateConfig } from "../../src/ts/utils/config";
import DefaultConfig from "../../src/ts/constants/default-config";
import { PartialConfig } from "@monkeytype/contracts/schemas/configs";
import {
PartialConfig,
ShowAverageSchema,
} from "@monkeytype/contracts/schemas/configs";
describe("config.ts", () => {
describe("migrateConfig", () => {

View file

@ -953,14 +953,30 @@
</dialog>
<dialog id="editPresetModal" class="modalWrapper hidden">
<form class="modal">
<div class="title"></div>
<input type="text" title="presets" />
<label class="checkbox">
<div class="title popupTitle"></div>
<div class="group">
<div class="presetNameTitle">name</div>
<input type="text" title="presets" />
</div>
<label class="changePresetToCurrentCheckbox checkbox">
<input type="checkbox" />
Change preset to current settings
</label>
<div class="text"></div>
<button type="submit">add</button>
<div class="inputs">
<div class="presetType group" data-id="presetType">
<div class="title">Preset Type</div>
<div class="presetTypeButtonGroup">
<button value="full" type="button">full</button>
<button value="partial" type="button">partial</button>
</div>
</div>
<div class="partialPresetGroups group">
<div class="title">partial groups</div>
<div class="checkboxList"></div>
</div>
</div>
<div class="text deletePrompt"></div>
<button class="submit" type="submit">add</button>
</form>
</dialog>
<dialog id="shareCustomThemeModal" class="modalWrapper hidden">

View file

@ -1478,6 +1478,62 @@ body.darkMode {
#editPresetModal {
.modal {
max-width: 450px;
gap: 1rem;
.presetNameTitle {
font-size: 0.75rem;
color: var(--sub-color);
text-transform: lowercase;
}
}
.group {
display: grid;
gap: 0.5rem;
align-items: center;
width: 100%;
}
input[type="text"] {
width: 100%;
}
.inputs {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
font-size: 0.75rem;
.title {
color: var(--sub-color);
text-transform: lowercase;
}
.partialPresetGroups {
.checkboxList {
display: grid;
gap: 1rem;
grid-template-columns: 1fr 1fr;
.title {
color: var(--text-color);
text-transform: lowercase;
}
.checkboxTitlePair {
display: flex;
gap: 0.5rem;
button {
flex-grow: 1;
}
}
}
}
.presetType {
.presetTypeButtonGroup {
display: flex;
width: 100%;
gap: 0.5rem;
button {
flex-grow: 1;
}
}
}
}
}

View file

@ -2064,8 +2064,8 @@ export async function loadFromLocalStorage(): Promise<void> {
loadDone();
}
export function getConfigChanges(): MonkeyTypes.PresetConfig {
const configChanges = {} as MonkeyTypes.PresetConfig;
export function getConfigChanges(): MonkeyTypes.ConfigChanges {
const configChanges: MonkeyTypes.ConfigChanges = {};
typedKeys(config)
.filter((key) => {
return config[key] !== DefaultConfig[key];

View file

@ -1,8 +1,9 @@
import { Preset } from "@monkeytype/contracts/schemas/presets";
import * as UpdateConfig from "../config";
import * as DB from "../db";
import * as Notifications from "../elements/notifications";
import * as TestLogic from "../test/test-logic";
import { migrateConfig } from "../utils/config";
import { migrateConfig, replaceLegacyValues } from "../utils/config";
import * as TagController from "./tag-controller";
export async function apply(_id: string): Promise<void> {
@ -10,19 +11,29 @@ export async function apply(_id: string): Promise<void> {
if (!snapshot) return;
const presetToApply = snapshot.presets?.find((preset) => preset._id === _id);
if (presetToApply === undefined) {
Notifications.add("Preset not found", 0);
return;
}
await UpdateConfig.apply(migrateConfig(presetToApply.config));
TagController.clear(true);
if (presetToApply.config.tags) {
for (const tagId of presetToApply.config.tags) {
TagController.set(tagId, true, false);
if (isPartialPreset(presetToApply)) {
const combinedConfig = {
...UpdateConfig.getConfigChanges(),
...replaceLegacyValues(presetToApply.config),
};
await UpdateConfig.apply(migrateConfig(combinedConfig));
} else {
await UpdateConfig.apply(migrateConfig(presetToApply.config));
}
if (
!isPartialPreset(presetToApply) ||
presetToApply.settingGroups?.includes("behavior")
) {
TagController.clear(true);
if (presetToApply.config.tags) {
for (const tagId of presetToApply.config.tags) {
TagController.set(tagId, true, false);
}
TagController.saveActiveToLocalStorage();
}
TagController.saveActiveToLocalStorage();
}
TestLogic.restart();
Notifications.add("Preset applied", 1, {
@ -30,3 +41,21 @@ export async function apply(_id: string): Promise<void> {
});
UpdateConfig.saveFullConfigToLocalStorage();
}
function isPartialPreset(preset: MonkeyTypes.SnapshotPreset): boolean {
return preset.settingGroups !== undefined && preset.settingGroups !== null;
}
export async function getPreset(_id: string): Promise<Preset | undefined> {
const snapshot = DB.getSnapshot();
if (!snapshot) {
return;
}
const preset = snapshot.presets?.find((preset) => preset._id === _id);
if (preset === undefined) {
Notifications.add("Preset not found", 0);
return;
}
return preset;
}

View file

@ -6,6 +6,27 @@ import * as Settings from "../pages/settings";
import * as Notifications from "../elements/notifications";
import * as ConnectionState from "../states/connection";
import AnimatedModal from "../utils/animated-modal";
import {
ActiveSettingGroups,
ActiveSettingGroupsSchema,
PresetSettingGroup,
PresetSettingGroupSchema,
PresetType,
PresetTypeSchema,
} from "@monkeytype/contracts/schemas/presets";
import { getPreset } from "../controllers/preset-controller";
import defaultConfig from "../constants/default-config";
const state = {
presetType: "full" as PresetType,
checkboxes: new Map(
PresetSettingGroupSchema.options.map((key: PresetSettingGroup) => [
key,
true,
])
),
setPresetToCurrent: false,
};
export function show(action: string, id?: string, name?: string): void {
if (!ConnectionState.get()) {
@ -19,22 +40,33 @@ export function show(action: string, id?: string, name?: string): void {
focusFirstInput: true,
beforeAnimation: async () => {
$("#editPresetModal .modal .text").addClass("hidden");
addCheckBoxes();
if (action === "add") {
$("#editPresetModal .modal").attr("data-action", "add");
$("#editPresetModal .modal .title").html("Add new preset");
$("#editPresetModal .modal button").html(`add`);
$("#editPresetModal .modal .popupTitle").html("Add new preset");
$("#editPresetModal .modal .submit").html(`add`);
$("#editPresetModal .modal input").val("");
$("#editPresetModal .modal input").removeClass("hidden");
$("#editPresetModal .modal label").addClass("hidden");
$(
"#editPresetModal .modal label.changePresetToCurrentCheckbox"
).addClass("hidden");
$("#editPresetModal .modal .inputs").removeClass("hidden");
$("#editPresetModal .modal .presetType").removeClass("hidden");
$("#editPresetModal .modal .presetNameTitle").removeClass("hidden");
state.presetType = "full";
} else if (action === "edit" && id !== undefined && name !== undefined) {
$("#editPresetModal .modal").attr("data-action", "edit");
$("#editPresetModal .modal").attr("data-preset-id", id);
$("#editPresetModal .modal .title").html("Edit preset");
$("#editPresetModal .modal button").html(`save`);
$("#editPresetModal .modal .popupTitle").html("Edit preset");
$("#editPresetModal .modal .submit").html(`save`);
$("#editPresetModal .modal input").val(name);
$("#editPresetModal .modal input").removeClass("hidden");
$("#editPresetModal .modal label input").prop("checked", false);
$("#editPresetModal .modal label").removeClass("hidden");
$(
"#editPresetModal .modal label.changePresetToCurrentCheckbox"
).removeClass("hidden");
$("#editPresetModal .modal .presetNameTitle").removeClass("hidden");
state.setPresetToCurrent = false;
await updateEditPresetUI();
} else if (
action === "remove" &&
id !== undefined &&
@ -42,19 +74,132 @@ export function show(action: string, id?: string, name?: string): void {
) {
$("#editPresetModal .modal").attr("data-action", "remove");
$("#editPresetModal .modal").attr("data-preset-id", id);
$("#editPresetModal .modal .title").html("Delete preset");
$("#editPresetModal .modal button").html("delete");
$("#editPresetModal .modal .popupTitle").html("Delete preset");
$("#editPresetModal .modal .submit").html("delete");
$("#editPresetModal .modal input").addClass("hidden");
$("#editPresetModal .modal label").addClass("hidden");
$(
"#editPresetModal .modal label.changePresetToCurrentCheckbox"
).addClass("hidden");
$("#editPresetModal .modal .text").removeClass("hidden");
$("#editPresetModal .modal .text").text(
$("#editPresetModal .modal .deletePrompt").text(
`Are you sure you want to delete the preset ${name}?`
);
$("#editPresetModal .modal .inputs").addClass("hidden");
$("#editPresetModal .modal .presetType").addClass("hidden");
$("#editPresetModal .modal .presetNameTitle").addClass("hidden");
}
updateUI();
},
});
}
async function initializeEditState(id: string): Promise<void> {
for (const key of state.checkboxes.keys()) {
state.checkboxes.set(key, false);
}
const edittedPreset = await getPreset(id);
if (edittedPreset === undefined) {
Notifications.add("Preset not found", -1);
return;
}
if (
edittedPreset.settingGroups === undefined ||
edittedPreset.settingGroups === null
) {
state.presetType = "full";
for (const key of state.checkboxes.keys()) {
state.checkboxes.set(key, true);
}
} else {
state.presetType = "partial";
edittedPreset.settingGroups.forEach((currentActiveSettingGroup) =>
state.checkboxes.set(currentActiveSettingGroup, true)
);
}
state.setPresetToCurrent = false;
updateUI();
}
function addCheckboxListeners(): void {
PresetSettingGroupSchema.options.forEach(
(settingGroup: PresetSettingGroup) => {
const checkboxInput = $(
`#editPresetModal .modal .checkboxList .checkboxTitlePair[data-id="${settingGroup}"] input`
);
checkboxInput.on("change", (e) => {
state.checkboxes.set(
settingGroup,
checkboxInput.prop("checked") as boolean
);
});
}
);
const presetToCurrentCheckbox = $(
`#editPresetModal .modal .changePresetToCurrentCheckbox input`
);
presetToCurrentCheckbox.on("change", async () => {
state.setPresetToCurrent = presetToCurrentCheckbox.prop("checked");
await updateEditPresetUI();
});
}
function addCheckBoxes(): void {
function camelCaseToSpaced(input: string): string {
return input.replace(/([a-z])([A-Z])/g, "$1 $2");
}
const settingGroupListEl = $(
"#editPresetModal .modal .inputs .checkboxList"
).empty();
PresetSettingGroupSchema.options.forEach((currSettingGroup) => {
const currSettingGroupTitle = camelCaseToSpaced(currSettingGroup);
const settingGroupCheckbox: string = `<label class="checkboxTitlePair" data-id="${currSettingGroup}">
<input type="checkbox" />
<div class="title">${currSettingGroupTitle}</div>
</label>`;
settingGroupListEl.append(settingGroupCheckbox);
});
for (const key of state.checkboxes.keys()) {
state.checkboxes.set(key, true);
}
addCheckboxListeners();
}
function updateUI(): void {
PresetSettingGroupSchema.options.forEach(
(settingGroup: PresetSettingGroup) => {
$(
`#editPresetModal .modal .checkboxList .checkboxTitlePair[data-id="${settingGroup}"] input`
).prop("checked", state.checkboxes.get(settingGroup));
}
);
$(`#editPresetModal .modal .presetType button`).removeClass("active");
$(
`#editPresetModal .modal .presetType button[value="${state.presetType}"]`
).addClass("active");
$(`#editPresetModal .modal .partialPresetGroups`).removeClass("hidden");
if (state.presetType === "full") {
$(`#editPresetModal .modal .partialPresetGroups`).addClass("hidden");
}
}
async function updateEditPresetUI(): Promise<void> {
$("#editPresetModal .modal label.changePresetToCurrentCheckbox input").prop(
"checked",
state.setPresetToCurrent
);
if (state.setPresetToCurrent) {
const presetId = $("#editPresetModal .modal").attr(
"data-preset-id"
) as string;
await initializeEditState(presetId);
$("#editPresetModal .modal .inputs").removeClass("hidden");
$("#editPresetModal .modal .presetType").removeClass("hidden");
} else {
$("#editPresetModal .modal .inputs").addClass("hidden");
$("#editPresetModal .modal .presetType").addClass("hidden");
}
}
function hide(): void {
void modal.hide();
}
@ -71,28 +216,47 @@ async function apply(): Promise<void> {
"checked"
);
let configChanges: MonkeyTypes.ConfigChanges = {};
const snapshotPresets = DB.getSnapshot()?.presets ?? [];
if ((updateConfig && action === "edit") || action === "add") {
configChanges = Config.getConfigChanges();
const tags = DB.getSnapshot()?.tags ?? [];
const activeTagIds: string[] = tags
.filter((tag: MonkeyTypes.UserTag) => tag.active)
.map((tag: MonkeyTypes.UserTag) => tag._id);
configChanges.tags = activeTagIds;
if (action === undefined) {
return;
}
const snapshotPresets = DB.getSnapshot()?.presets ?? [];
const noPartialGroupSelected: boolean =
["add", "edit"].includes(action) &&
state.presetType === "partial" &&
Array.from(state.checkboxes.values()).every((val: boolean) => !val);
if (noPartialGroupSelected) {
Notifications.add(
"At least one setting group must be active while saving partial presets",
0
);
return;
}
const noPresetName: boolean =
["add", "edit"].includes(action) &&
presetName.replace(/^_+|_+$/g, "").length === 0; //all whitespace names are rejected
if (noPresetName) {
Notifications.add("Preset name cannot be empty", 0);
return;
}
hide();
Loader.show();
if (action === "add") {
const configChanges = getConfigChanges();
const activeSettingGroups = getActiveSettingGroupsFromState();
const response = await Ape.presets.add({
body: { name: presetName, config: configChanges },
body: {
name: presetName,
config: configChanges,
...(state.presetType === "partial" && {
settingGroups: activeSettingGroups,
}),
},
});
if (response.status !== 200 || response.body.data === null) {
@ -108,16 +272,32 @@ async function apply(): Promise<void> {
snapshotPresets.push({
name: presetName,
config: configChanges,
...(state.presetType === "partial" && {
settingGroups: activeSettingGroups,
}),
display: propPresetName,
_id: response.body.data.presetId,
} as MonkeyTypes.SnapshotPreset);
}
} else if (action === "edit") {
const preset = snapshotPresets.filter(
(preset: MonkeyTypes.SnapshotPreset) => preset._id === presetId
)[0] as MonkeyTypes.SnapshotPreset;
if (preset === undefined) {
Notifications.add("Preset not found", -1);
return;
}
const configChanges = getConfigChanges();
const activeSettingGroups: ActiveSettingGroups | null =
state.presetType === "partial" ? getActiveSettingGroupsFromState() : null;
const response = await Ape.presets.save({
body: {
_id: presetId,
name: presetName,
config: configChanges,
...(updateConfig && {
config: configChanges,
settingGroups: activeSettingGroups,
}),
},
});
@ -125,13 +305,16 @@ async function apply(): Promise<void> {
Notifications.add("Failed to edit preset: " + response.body.message, -1);
} else {
Notifications.add("Preset updated", 1);
const preset = snapshotPresets.filter(
(preset: MonkeyTypes.SnapshotPreset) => preset._id === presetId
)[0] as MonkeyTypes.SnapshotPreset;
preset.name = presetName;
preset.display = presetName.replace(/_/g, " ");
if (updateConfig) {
preset.config = configChanges;
if (state.presetType === "partial") {
preset.settingGroups = getActiveSettingGroupsFromState();
} else {
preset.settingGroups = null;
}
}
}
} else if (action === "remove") {
@ -158,12 +341,197 @@ async function apply(): Promise<void> {
Loader.hide();
}
function getSettingGroup(configFieldName: string): PresetSettingGroup {
const themeSettings = [
"theme",
"themeLight",
"themeDark",
"autoSwitchTheme",
"customTheme",
"customThemeColors",
"favThemes",
"flipTestColors",
"colorfulMode",
"randomTheme",
"customBackground",
"customBackgroundSize",
"customBackgroundFilter",
];
const hideElementsSettings = [
"showKeyTips",
"capsLockWarning",
"showOutOfFocusWarning",
"showAverage",
];
const caretSettings = [
"smoothCaret",
"caretStyle",
"paceCaretStyle",
"paceCaret",
"paceCaretCustomSpeed",
"repeatedPace",
];
const behaviorSettings = [
"quickRestart",
"difficulty",
"blindMode",
"funbox",
"alwaysShowWordsHistory",
"singleListCommandLine",
"minWpm",
"minWpmCustomSpeed",
"minAcc",
"minAccCustom",
"repeatQuotes",
"customLayoutfluid",
"minBurst",
"minBurstCustomSpeed",
"britishEnglish",
"tags",
];
const testSettings = [
"punctuation",
"words",
"time",
"numbers",
"mode",
"quoteLength",
"language",
"burstHeatmap",
];
const appearanceSettings = [
"fontSize",
"timerStyle",
"liveSpeedStyle",
"liveAccStyle",
"liveBurstStyle",
"timerColor",
"timerOpacity",
"showAllLines",
"keymapMode",
"keymapStyle",
"keymapLegendStyle",
"keymapLayout",
"keymapShowTopRow",
"keymapSize",
"fontFamily",
"smoothLineScroll",
"alwaysShowDecimalPlaces",
"startGraphsAtZero",
"highlightMode",
"tapeMode",
"typingSpeedUnit",
"maxLineWidth",
];
const inputSettings = [
"freedomMode",
"quickEnd",
"layout",
"confidenceMode",
"indicateTypos",
"stopOnError",
"hideExtraLetters",
"strictSpace",
"oppositeShiftMode",
"lazyMode",
];
const soundSettings = ["playSoundOnError", "playSoundOnClick", "soundVolume"];
const hiddenSettings = ["accountChart", "monkey", "monkeyPowerLevel"];
const adsSettings = ["ads"];
if (themeSettings.includes(configFieldName)) {
return "theme";
} else if (hideElementsSettings.includes(configFieldName)) {
return "hideElements";
} else if (caretSettings.includes(configFieldName)) {
return "caret";
} else if (behaviorSettings.includes(configFieldName)) {
return "behavior";
} else if (testSettings.includes(configFieldName)) {
return "test";
} else if (appearanceSettings.includes(configFieldName)) {
return "appearance";
} else if (inputSettings.includes(configFieldName)) {
return "input";
} else if (soundSettings.includes(configFieldName)) {
return "sound";
} else if (hiddenSettings.includes(configFieldName)) {
return "hidden";
} else if (adsSettings.includes(configFieldName)) {
return "ads";
}
throw new Error(`${configFieldName} setting not part of any setting group`);
}
function getPartialConfigChanges(
configChanges: MonkeyTypes.ConfigChanges
): MonkeyTypes.ConfigChanges {
const activeConfigChanges: MonkeyTypes.ConfigChanges = {};
Object.keys(defaultConfig)
.filter(
(settingName) =>
state.checkboxes.get(getSettingGroup(settingName)) === true
)
.forEach((settingName) => {
//@ts-expect-error this is fine
activeConfigChanges[settingName] =
//@ts-expect-error this is fine
configChanges[settingName] !== undefined
? //@ts-expect-error this is fine
configChanges[settingName]
: //@ts-expect-error this is fine
defaultConfig[settingName];
});
return activeConfigChanges;
}
function getActiveSettingGroupsFromState(): ActiveSettingGroups {
return ActiveSettingGroupsSchema.parse(
Array.from(state.checkboxes.entries())
.filter(([, value]) => value)
.map(([key]) => key)
);
}
function getConfigChanges(): MonkeyTypes.ConfigChanges {
const activeConfigChanges =
state.presetType === "partial"
? getPartialConfigChanges(Config.getConfigChanges())
: Config.getConfigChanges();
const tags = DB.getSnapshot()?.tags ?? [];
const activeTagIds: string[] = tags
.filter((tag: MonkeyTypes.UserTag) => tag.active)
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
.map((tag: MonkeyTypes.UserTag) => tag._id);
const setTags: boolean =
state.presetType === "full" || state.checkboxes.get("behavior") === true;
return {
...activeConfigChanges,
...(setTags && {
tags: activeTagIds,
}),
};
}
async function setup(modalEl: HTMLElement): Promise<void> {
modalEl.addEventListener("submit", (e) => {
e.preventDefault();
void apply();
});
PresetTypeSchema.options.forEach((presetType) => {
const presetOption = modalEl.querySelector(
`.presetType button[value="${presetType}"]`
);
if (presetOption === null) return;
presetOption.addEventListener("click", () => {
state.presetType = presetType;
updateUI();
});
});
}
const modal = new AnimatedModal({
dialogId: "editPresetModal",
setup: async (modalEl): Promise<void> => {
modalEl.addEventListener("submit", (e) => {
e.preventDefault();
void apply();
});
},
setup,
});

View file

@ -179,10 +179,6 @@ declare namespace MonkeyTypes {
hasCSS?: boolean;
};
type PresetConfig = {
tags: string[];
} & import("@monkeytype/contracts/schemas/configs").Config;
type SnapshotPreset =
import("@monkeytype/contracts/schemas/presets").Preset & {
display: string;
@ -197,9 +193,9 @@ declare namespace MonkeyTypes {
_id: string;
} & RawCustomTheme;
type ConfigChanges = {
tags?: string[];
} & Partial<import("@monkeytype/contracts/schemas/configs").Config>;
type ConfigChanges = Partial<
import("@monkeytype/contracts/schemas/configs").Config
>;
type LeaderboardMemory = {
time: {

View file

@ -26,7 +26,7 @@ function mergeWithDefaultConfig(config: PartialConfig): Config {
return mergedConfig;
}
function replaceLegacyValues(
export function replaceLegacyValues(
configObj: ConfigSchemas.PartialConfig
): ConfigSchemas.PartialConfig {
//@ts-expect-error

View file

@ -7,7 +7,7 @@ import {
MonkeyResponseSchema,
responseWithData,
} from "./schemas/api";
import { PresetSchema } from "./schemas/presets";
import { EditPresetRequestSchema, PresetSchema } from "./schemas/presets";
import { IdSchema } from "./schemas/util";
export const GetPresetResponseSchema = responseWithData(z.array(PresetSchema));
@ -60,7 +60,7 @@ export const presetsContract = c.router(
description: "Update an existing preset for the current user.",
method: "PATCH",
path: "",
body: PresetSchema.strict(),
body: EditPresetRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},

View file

@ -8,11 +8,57 @@ export const PresetNameSchema = z
.max(16);
export type PresentName = z.infer<typeof PresetNameSchema>;
export const PresetTypeSchema = z.enum(["full", "partial"]);
export type PresetType = z.infer<typeof PresetTypeSchema>;
export const PresetSettingGroupSchema = z.enum([
"test",
"behavior",
"input",
"sound",
"caret",
"appearance",
"theme",
"hideElements",
"ads",
"hidden",
]);
export type PresetSettingGroup = z.infer<typeof PresetSettingGroupSchema>;
export const ActiveSettingGroupsSchema = z
.array(PresetSettingGroupSchema)
.min(1)
.superRefine((settingList, ctx) => {
PresetSettingGroupSchema.options.forEach(
(presetSettingGroup: PresetSettingGroup) => {
const duplicateElemExits: boolean =
settingList.filter(
(settingGroup: PresetSettingGroup) =>
settingGroup === presetSettingGroup
).length > 1;
if (duplicateElemExits) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `No duplicates allowed.`,
});
}
}
);
});
export type ActiveSettingGroups = z.infer<typeof ActiveSettingGroupsSchema>;
export const PresetSchema = z.object({
_id: IdSchema,
name: PresetNameSchema,
settingGroups: ActiveSettingGroupsSchema.nullable().optional(),
config: PartialConfigSchema.extend({
tags: z.array(TagSchema).optional(),
}),
});
export type Preset = z.infer<typeof PresetSchema>;
export const EditPresetRequestSchema = PresetSchema.partial({
config: true,
settingGroups: true,
});
export type EditPresetRequest = z.infer<typeof EditPresetRequestSchema>;