Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/monkey-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ jobs:
- 'frontend/static/themes/**'
- 'frontend/static/webfonts/**'
- 'frontend/static/challenges/**'
- 'frontend/static/sounds/**'

- name: Set up Node.js
uses: actions/setup-node@v4
Expand Down
10 changes: 9 additions & 1 deletion frontend/__tests__/controllers/preset-controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as Persistence from "../../src/ts/config/persistence";
import * as Notifications from "../../src/ts/states/notifications";
import * as TestLogic from "../../src/ts/test/test-logic";
import * as Tags from "../../src/ts/collections/tags";
import * as Presets from "../../src/ts/collections/presets";

describe("PresetController", () => {
describe("apply", () => {
Expand All @@ -20,6 +21,7 @@ describe("PresetController", () => {
//
}));
const dbGetSnapshotMock = vi.spyOn(DB, "getSnapshot");
const getPresetMock = vi.spyOn(Presets.__nonReactive, "getPreset");
const configApplyMock = vi.spyOn(Lifecycle, "applyConfig");
const configSaveFullConfigMock = vi.spyOn(
Persistence,
Expand All @@ -41,6 +43,7 @@ describe("PresetController", () => {
beforeEach(() => {
[
dbGetSnapshotMock,
getPresetMock,
configApplyMock,
configSaveFullConfigMock,
configGetConfigChangesMock,
Expand All @@ -51,6 +54,7 @@ describe("PresetController", () => {
tagsSaveActiveMock,
].forEach((it) => it.mockClear());

dbGetSnapshotMock.mockReturnValue({} as any);
configApplyMock.mockResolvedValue();
});

Expand Down Expand Up @@ -88,6 +92,9 @@ describe("PresetController", () => {
});

it("should ignore unknown preset", async () => {
//GIVEN
getPresetMock.mockReturnValue(undefined);

//WHEN
await PresetController.apply("unknown");
//THEN
Expand Down Expand Up @@ -143,7 +150,8 @@ describe("PresetController", () => {
_id: "1",
...partialPreset,
} as any;
dbGetSnapshotMock.mockReturnValue({ presets: [preset] } as any);
dbGetSnapshotMock.mockReturnValue({} as any);
getPresetMock.mockReturnValue(preset as any);
return preset;
};
});
Expand Down
51 changes: 51 additions & 0 deletions frontend/scripts/check-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { z } from "zod";
import { ChallengeSchema, Challenge } from "@monkeytype/schemas/challenges";
import { LayoutObject, LayoutObjectSchema } from "@monkeytype/schemas/layouts";
import { QuoteDataSchema, QuoteData } from "@monkeytype/schemas/quotes";
import { clickSoundConfig } from "../src/ts/constants/sounds";

class Problems<K extends string, T extends string> {
private type: string;
Expand Down Expand Up @@ -421,6 +422,54 @@ async function validateThemes(): Promise<void> {
}
}

async function validateSounds(): Promise<void> {
const problems = new Problems<string, "_additional">("Sounds", {
_additional:
"Sound files present but missing in frontend/src/ts/constants/sounds",
});

const soundFiles = new Set(
fs
.readdirSync("./static/sounds")
.filter((it) => it.startsWith("click"))
.flatMap((folder) =>
fs
.readdirSync(`./static/sounds/${folder}`)
.map((it) => `${folder}/${it}`),
),
);

//missing sound files

Object.entries(clickSoundConfig).forEach(([key, value]) => {
value
.map((file) => file.substring("../sounds/".length))
.filter((it) => !soundFiles.has(it))
.forEach((file) =>
problems.add(
"click" + key,
`missing file frontend/static/sounds/${file}`,
),
);
});

//additional files
const expectedSoundFiles = new Set(
Object.values(clickSoundConfig).flatMap((it) =>
it.map((file) => file.substring("../sounds/".length)),
),
);
[...soundFiles]
.filter((name) => !expectedSoundFiles.has(name))
.forEach((file) => problems.add("_additional", file));

console.log(problems.toString());

if (problems.hasError()) {
throw new Error("sounds with errors");
}
}

type Validator = () => Promise<void>;

async function main(): Promise<void> {
Expand All @@ -436,11 +485,13 @@ async function main(): Promise<void> {
challenges: [validateChallenges],
fonts: [validateFonts],
themes: [validateThemes],
sounds: [validateSounds],
others: [
validateChallenges,
validateLayouts,
validateFonts,
validateThemes,
validateSounds,
],
};

Expand Down
205 changes: 205 additions & 0 deletions frontend/src/ts/collections/presets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { Preset } from "@monkeytype/schemas/presets";
import { queryCollectionOptions } from "@tanstack/query-db-collection";
import {
createCollection,
createOptimisticAction,
useLiveQuery,
} from "@tanstack/solid-db";
import Ape from "../ape";
import { queryClient } from "../queries";
import { baseKey } from "../queries/utils/keys";
import { ConfigGroupName } from "@monkeytype/schemas/configs";
import { tempId } from "./utils/misc";

export type PresetItem = Preset;

const queryKeys = {
root: () => [...baseKey("presets", { isUserSpecific: true })],
};

// oxlint-disable-next-line typescript/explicit-function-return-type
export function usePresetsLiveQuery() {
return useLiveQuery((q) => {
return q
.from({ preset: presetsCollection })
.orderBy(({ preset }) => preset.name, "asc");
});
}

const presetsCollection = createCollection(
queryCollectionOptions({
staleTime: Infinity,
startSync: true,
queryKey: queryKeys.root(),

queryClient,
getKey: (it) => it._id,
queryFn: async () => {
return [] as PresetItem[];
},
}),
);

type ActionType = {
addPreset: {
name: string;
config: Preset["config"];
settingGroups: ConfigGroupName[] | undefined;
};
editPreset: {
presetId: string;
name: string;
config?: Preset["config"];
settingGroups?: ConfigGroupName[] | null;
};
deletePreset: {
presetId: string;
};
};

const actions = {
addPreset: createOptimisticAction<ActionType["addPreset"]>({
onMutate: ({ name, config, settingGroups }) => {
presetsCollection.insert({
_id: tempId(),
name: name.replace(/_/g, " "),
config,
settingGroups,
});
},
mutationFn: async ({ name, config, settingGroups }) => {
const response = await Ape.presets.add({
body: {
name: name.replace(/ /g, "_"),
config,
...(settingGroups !== undefined && { settingGroups }),
},
});
if (response.status !== 200) {
throw new Error(`Failed to add preset: ${response.body.message}`);
}

const newPreset = {
_id: response.body.data.presetId,
name: name.replace(/_/g, " "),
config,
settingGroups,
};

presetsCollection.utils.writeInsert(newPreset);
},
}),
editPreset: createOptimisticAction<ActionType["editPreset"]>({
onMutate: ({ presetId, name, config, settingGroups }) => {
presetsCollection.update(presetId, (preset) => {
preset.name = name.replace(/_/g, " ");

if (config !== undefined) {
preset.config = config;
}
if (settingGroups !== undefined) {
preset.settingGroups = settingGroups;
}
});
},
mutationFn: async ({ presetId, name, config, settingGroups }) => {
const existing = presetsCollection.get(presetId);

if (existing === undefined) {
throw new Error("Preset not found");
}

const response = await Ape.presets.save({
body: {
_id: presetId,
name: name.replace(/ /g, "_"),
...(config !== undefined && {
config: config,
settingGroups: settingGroups,
}),
},
});
if (response.status !== 200) {
throw new Error(`Failed to edit preset: ${response.body.message}`);
}

// if this is missing getPreset is out of sync
presetsCollection.utils.writeUpdate({
_id: presetId,
name: name.replace(/_/g, " "),
...(config !== undefined && { config }),
...(settingGroups !== undefined && { settingGroups }),
});
},
}),
deletePreset: createOptimisticAction<ActionType["deletePreset"]>({
onMutate: ({ presetId }) => {
presetsCollection.delete(presetId);
},
mutationFn: async ({ presetId }) => {
const response = await Ape.presets.delete({
params: { presetId },
});
if (response.status !== 200) {
throw new Error(`Failed to delete preset: ${response.body.message}`);
}
presetsCollection.utils.writeDelete(presetId);
},
}),
};

// --- Public API ---

function getPresets(): PresetItem[] {
return [...presetsCollection.values()].sort((a, b) =>
a.name.localeCompare(b.name),
);
}

function getPreset(id: string): PresetItem | undefined {
return presetsCollection.get(id);
}

export function fillPresetsCollection(presets: Preset[]): void {
const presetItems = presets.map((preset) => ({
_id: preset._id,
name: preset.name.replace(/_/g, " "),
config: preset.config,
settingGroups: preset.settingGroups,
}));

presetsCollection.utils.writeBatch(() => {
presetItems.forEach((item) => {
presetsCollection.utils.writeInsert(item);
});
});
}

export async function addPreset(
params: ActionType["addPreset"],
): Promise<void> {
const transaction = actions.addPreset(params);
await transaction.isPersisted.promise;
}

export async function editPreset(
params: ActionType["editPreset"],
): Promise<void> {
const transaction = actions.editPreset(params);
await transaction.isPersisted.promise;
}

export async function deletePreset(
params: ActionType["deletePreset"],
): Promise<void> {
const transaction = actions.deletePreset(params);
await transaction.isPersisted.promise;
}

/**
* Used for non reactive access. Do not use in Solid components.
*/
export const __nonReactive = {
getPresets,
getPreset,
};
12 changes: 5 additions & 7 deletions frontend/src/ts/commandline/lists/presets.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as DB from "../../db";
import * as ModesNotice from "../../elements/modes-notice";
import * as Settings from "../../pages/settings";
import * as PresetController from "../../controllers/preset-controller";
import * as EditPresetPopup from "../../modals/edit-preset";
import { isAuthenticated } from "../../states/core";
import { Command, CommandsSubgroup } from "../types";
import { __nonReactive } from "../../collections/presets";

const subgroup: CommandsSubgroup = {
title: "Presets...",
Expand All @@ -28,15 +28,13 @@ const commands: Command[] = [
];

function update(): void {
const snapshot = DB.getSnapshot();
const presets = __nonReactive.getPresets();
subgroup.list = [];
if (!snapshot?.presets || snapshot.presets.length === 0) return;
snapshot.presets.forEach((preset) => {
const dis = preset.display;

if (presets.length === 0) return;
presets.forEach((preset) => {
subgroup.list.push({
id: "applyPreset" + preset._id,
display: dis,
display: preset.name,
exec: async (): Promise<void> => {
Settings.setEventDisabled(true);
await PresetController.apply(preset._id);
Expand Down
Loading
Loading