diff --git a/frontend/src/ts/collections/presets.ts b/frontend/src/ts/collections/presets.ts new file mode 100644 index 000000000000..81f09b244704 --- /dev/null +++ b/frontend/src/ts/collections/presets.ts @@ -0,0 +1,203 @@ +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"; + +export type PresetItem = Preset & { display: string }; + +const queryKeys = { + root: () => [...baseKey("presets", { isUserSpecific: true })], +}; + +function toPresetItem(preset: Preset): PresetItem { + return { + ...preset, + display: preset.name.replaceAll("_", " "), + }; +} + +// oxlint-disable-next-line typescript/explicit-function-return-type +export function usePresetsLiveQuery() { + return useLiveQuery((q) => { + return q.from({ preset: presetsCollection }).select((p) => ({ ...p })); + }); +} + +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({ + onMutate: ({ name, config, settingGroups }) => { + presetsCollection.insert({ + _id: "temp-" + Date.now(), + name, + display: name.replaceAll("_", " "), + config, + settingGroups, + }); + }, + mutationFn: async ({ name, config, settingGroups }) => { + const response = await Ape.presets.add({ + body: { + name, + config, + ...(settingGroups !== undefined && { settingGroups }), + }, + }); + if (response.status !== 200) { + throw new Error(`Failed to add preset: ${response.body.message}`); + } + presetsCollection.utils.writeInsert( + toPresetItem({ + _id: response.body.data.presetId, + name: name, + settingGroups: settingGroups, + config: config, + }), + ); + }, + }), + editPreset: createOptimisticAction({ + onMutate: ({ presetId, name, config, settingGroups }) => { + presetsCollection.update(presetId, (preset) => { + preset.name = name; + preset.display = name.replaceAll("_", " "); + + 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, + ...(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, + display: name.replaceAll("_", " "), + ...(config !== undefined && { config }), + ...(settingGroups !== undefined && { settingGroups }), + }); + }, + }), + deletePreset: createOptimisticAction({ + 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 --- + +// todo: this might not be reactive +export function getPresets(): PresetItem[] { + return [...presetsCollection.values()]; +} + +// todo: this might not be reactive +export function getPreset(id: string): PresetItem | undefined { + return presetsCollection.get(id); +} + +export function fillPresetsCollection(presets: Preset[]): void { + const presetItems = presets + .map(toPresetItem) + .sort((a, b) => a.name.localeCompare(b.name)); + + presetsCollection.utils.writeBatch(() => { + presetsCollection.forEach((preset) => { + presetsCollection.utils.writeDelete(preset._id); + }); + presetItems.forEach((item) => { + presetsCollection.utils.writeInsert(item); + }); + }); +} + +export async function addPreset( + params: ActionType["addPreset"], +): Promise { + const transaction = actions.addPreset(params); + await transaction.isPersisted.promise; +} + +export async function editPreset( + params: ActionType["editPreset"], +): Promise { + const transaction = actions.editPreset(params); + await transaction.isPersisted.promise; +} + +export async function deletePreset( + params: ActionType["deletePreset"], +): Promise { + const transaction = actions.deletePreset(params); + await transaction.isPersisted.promise; +} diff --git a/frontend/src/ts/commandline/lists/presets.ts b/frontend/src/ts/commandline/lists/presets.ts index 8720745f0dfe..befcd30a5e97 100644 --- a/frontend/src/ts/commandline/lists/presets.ts +++ b/frontend/src/ts/commandline/lists/presets.ts @@ -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 { getPresets } from "../../collections/presets"; const subgroup: CommandsSubgroup = { title: "Presets...", @@ -28,10 +28,10 @@ const commands: Command[] = [ ]; function update(): void { - const snapshot = DB.getSnapshot(); + const presets = getPresets(); subgroup.list = []; - if (!snapshot?.presets || snapshot.presets.length === 0) return; - snapshot.presets.forEach((preset) => { + if (presets.length === 0) return; + presets.forEach((preset) => { const dis = preset.display; subgroup.list.push({ diff --git a/frontend/src/ts/constants/default-snapshot.ts b/frontend/src/ts/constants/default-snapshot.ts index 7a0ecec16eff..75d8f0b1725b 100644 --- a/frontend/src/ts/constants/default-snapshot.ts +++ b/frontend/src/ts/constants/default-snapshot.ts @@ -12,7 +12,6 @@ import { ModifiableTestActivityCalendar, TestActivityCalendar, } from "../elements/test-activity-calendar"; -import { Preset } from "@monkeytype/schemas/presets"; import { Language } from "@monkeytype/schemas/languages"; import { ConnectionStatus } from "@monkeytype/schemas/connections"; @@ -79,7 +78,6 @@ export type Snapshot = Omit< isPremium: boolean; streakHourOffset?: number; tags: SnapshotUserTag[]; - presets: SnapshotPreset[]; results?: SnapshotResult[]; xp: number; testActivity?: ModifiableTestActivityCalendar; @@ -87,10 +85,6 @@ export type Snapshot = Omit< connections: Record; }; -export type SnapshotPreset = Preset & { - display: string; -}; - const defaultSnap = { results: undefined, personalBests: { @@ -106,7 +100,6 @@ const defaultSnap = { isPremium: false, config: getDefaultConfig(), customThemes: [], - presets: [], tags: [], banned: undefined, verified: undefined, diff --git a/frontend/src/ts/controllers/preset-controller.ts b/frontend/src/ts/controllers/preset-controller.ts index fce7a6c02732..69f21a403a55 100644 --- a/frontend/src/ts/controllers/preset-controller.ts +++ b/frontend/src/ts/controllers/preset-controller.ts @@ -1,22 +1,20 @@ -import { Preset } from "@monkeytype/schemas/presets"; - import { Config } from "../config/store"; import { applyConfig } from "../config/lifecycle"; import * as DB from "../db"; -import { - showNoticeNotification, - showSuccessNotification, -} from "../states/notifications"; +import { showSuccessNotification } from "../states/notifications"; import * as TestLogic from "../test/test-logic"; import * as TagController from "./tag-controller"; -import { SnapshotPreset } from "../constants/default-snapshot"; import { saveFullConfigToLocalStorage } from "../config/persistence"; +import { + getPreset as getPresetFromCollection, + PresetItem, +} from "../collections/presets"; export async function apply(_id: string): Promise { const snapshot = DB.getSnapshot(); if (!snapshot) return; - const presetToApply = snapshot.presets?.find((preset) => preset._id === _id); + const presetToApply = getPresetFromCollection(_id); if (presetToApply === undefined) { return; } @@ -46,21 +44,7 @@ export async function apply(_id: string): Promise { showSuccessNotification("Preset applied", { durationMs: 2000 }); saveFullConfigToLocalStorage(); } -function isPartialPreset(preset: SnapshotPreset): boolean { - return preset.settingGroups !== undefined && preset.settingGroups !== null; -} -export async function getPreset(_id: string): Promise { - const snapshot = DB.getSnapshot(); - if (!snapshot) { - return; - } - - const preset = snapshot.presets?.find((preset) => preset._id === _id); - - if (preset === undefined) { - showNoticeNotification("Preset not found"); - return; - } - return preset; +function isPartialPreset(preset: PresetItem): boolean { + return preset.settingGroups !== undefined && preset.settingGroups !== null; } diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index d07e2f126c58..ae25e95967b3 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -23,7 +23,6 @@ import { import { getDefaultSnapshot, Snapshot, - SnapshotPreset, SnapshotResult, SnapshotUserTag, } from "./constants/default-snapshot"; @@ -42,6 +41,7 @@ import { import { XpBreakdown } from "@monkeytype/schemas/results"; import { setXpBarData } from "./states/header"; import { FunboxMetadata } from "@monkeytype/funbox"; +import { fillPresetsCollection } from "./collections/presets"; let dbSnapshot: Snapshot | undefined; const firstDayOfTheWeek = getFirstDayOfTheWeek(); @@ -231,27 +231,7 @@ export async function initSnapshot(): Promise { } }); - if (presetsData !== undefined && presetsData !== null) { - const presetsWithDisplay = presetsData.map((preset) => { - return { - ...preset, - display: preset.name.replace(/_/g, " "), - }; - }) as SnapshotPreset[]; - snap.presets = presetsWithDisplay; - - snap.presets = snap.presets?.sort( - (a: SnapshotPreset, b: SnapshotPreset) => { - if (a.name > b.name) { - return 1; - } else if (a.name < b.name) { - return -1; - } else { - return 0; - } - }, - ); - } + fillPresetsCollection(presetsData ?? []); snap.connections = convertConnections(connectionsData); diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts index 6b4d3f6e54d7..9feafe4b314c 100644 --- a/frontend/src/ts/modals/edit-preset.ts +++ b/frontend/src/ts/modals/edit-preset.ts @@ -1,4 +1,3 @@ -import Ape from "../ape"; import * as DB from "../db"; import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; import * as Settings from "../pages/settings"; @@ -13,7 +12,12 @@ import { PresetType, PresetTypeSchema, } from "@monkeytype/schemas/presets"; -import { getPreset } from "../controllers/preset-controller"; +import { + getPreset, + addPreset, + editPreset, + deletePreset, +} from "../collections/presets"; import { ConfigGroupName, ConfigGroupNameSchema, @@ -21,7 +25,6 @@ import { Config as ConfigType, } from "@monkeytype/schemas/configs"; import { getDefaultConfig } from "../constants/default-config"; -import { SnapshotPreset } from "../constants/default-snapshot"; import { ValidatedHtmlInputElement } from "../elements/input-validation"; import { ElementWithUtils } from "../utils/dom"; import { configMetadata } from "../config/metadata"; @@ -79,7 +82,7 @@ export function show(action: string, id?: string, name?: string): void { modalEl.qsr("label.changePresetToCurrentCheckbox").show(); modalEl.qsr(".presetNameTitle").show(); state.setPresetToCurrent = false; - await updateEditPresetUI(); + updateEditPresetUI(); } else if ( action === "remove" && id !== undefined && @@ -105,11 +108,11 @@ export function show(action: string, id?: string, name?: string): void { }); } -async function initializeEditState(id: string): Promise { +function initializeEditState(id: string): void { for (const key of state.checkboxes.keys()) { state.checkboxes.set(key, false); } - const edittedPreset = await getPreset(id); + const edittedPreset = getPreset(id); if (edittedPreset === undefined) { showErrorNotification("Preset not found"); return; @@ -147,9 +150,9 @@ function addCheckboxListeners(): void { const presetToCurrentCheckbox = modalEl.qsr( `.changePresetToCurrentCheckbox input`, ); - presetToCurrentCheckbox.on("change", async () => { + presetToCurrentCheckbox.on("change", () => { state.setPresetToCurrent = presetToCurrentCheckbox.isChecked() as boolean; - await updateEditPresetUI(); + updateEditPresetUI(); }); } @@ -201,14 +204,14 @@ function updateUI(): void { modalEl.qsr(".partialPresetGroups").hide(); } } -async function updateEditPresetUI(): Promise { +function updateEditPresetUI(): void { const modalEl = modal.getModal(); if (state.setPresetToCurrent) { modalEl .qsr("label.changePresetToCurrentCheckbox input") .setChecked(true); const presetId = modalEl.getAttribute("data-preset-id") as string; - await initializeEditState(presetId); + initializeEditState(presetId); modalEl.qsr(".inputs").show(); modalEl.qsr(".presetType").show(); } else { @@ -236,8 +239,6 @@ async function apply(): Promise { .qsr("label.changePresetToCurrentCheckbox input") .isChecked(); - const snapshotPresets = DB.getSnapshot()?.presets ?? []; - if (action === null || action === "") { return; } @@ -276,84 +277,43 @@ async function apply(): Promise { showLoaderBar(); - if (action === "add") { - const configChanges = getConfigChanges(); - const activeSettingGroups = getActiveSettingGroupsFromState(); - const response = await Ape.presets.add({ - body: { + try { + if (action === "add") { + const configChanges = getConfigChanges(); + const activeSettingGroups = getActiveSettingGroupsFromState(); + await addPreset({ name: presetName, config: configChanges, - ...(state.presetType === "partial" && { - settingGroups: activeSettingGroups, - }), - }, - }); - - if (response.status !== 200 || response.body.data === null) { - showErrorNotification("Failed to add preset: " + response.body.message); - } else { + settingGroups: + state.presetType === "partial" ? activeSettingGroups : undefined, + }); showSuccessNotification("Preset added", { durationMs: 2000 }); - snapshotPresets.push({ - name: presetName, - config: configChanges, - ...(state.presetType === "partial" && { - settingGroups: activeSettingGroups, - }), - display: presetName.replace(/_/g, " "), - _id: response.body.data.presetId, - } as SnapshotPreset); - } - } else if (action === "edit") { - const preset = snapshotPresets.find( - (preset: SnapshotPreset) => preset._id === presetId, - ) as SnapshotPreset; - if (preset === undefined) { - showErrorNotification("Preset not found"); - return; - } - const configChanges = getConfigChanges(); - const activeSettingGroups: ConfigGroupName[] | null = - state.presetType === "partial" ? getActiveSettingGroupsFromState() : null; - const response = await Ape.presets.save({ - body: { - _id: presetId, + } else if (action === "edit") { + const existing = getPreset(presetId); + if (existing === undefined) { + showErrorNotification("Preset not found"); + return; + } + const configChanges = getConfigChanges(); + const activeSettingGroups: ConfigGroupName[] | null = + state.presetType === "partial" + ? getActiveSettingGroupsFromState() + : null; + await editPreset({ + presetId, name: presetName, - ...(updateConfig && { - config: configChanges, - settingGroups: activeSettingGroups, - }), - }, - }); - - if (response.status !== 200) { - showErrorNotification("Failed to edit preset", { response }); - } else { + config: updateConfig ? configChanges : undefined, + settingGroups: updateConfig ? activeSettingGroups : undefined, + }); showSuccessNotification("Preset updated"); - - 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") { - const response = await Ape.presets.delete({ params: { presetId } }); - - if (response.status !== 200) { - showErrorNotification("Failed to remove preset", { response }); - } else { + } else if (action === "remove") { + await deletePreset({ presetId }); showSuccessNotification("Preset removed"); - snapshotPresets.forEach((preset: SnapshotPreset, index: number) => { - if (preset._id === presetId) { - snapshotPresets.splice(index, 1); - } - }); } + } catch (e) { + showErrorNotification( + e instanceof Error ? e.message : "Failed to update preset", + ); } void Settings.update(); diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts index 390b55f1597a..eaf70e56a5ce 100644 --- a/frontend/src/ts/pages/settings.ts +++ b/frontend/src/ts/pages/settings.ts @@ -33,7 +33,7 @@ import { } from "@monkeytype/schemas/configs"; import { getAllFunboxes, checkCompatibility } from "@monkeytype/funbox"; import { getActiveFunboxNames } from "../test/funbox/list"; -import { SnapshotPreset } from "../constants/default-snapshot"; +import { getPresets, PresetItem } from "../collections/presets"; import { LayoutsList } from "../constants/layouts"; import { DataArrayPartial, Optgroup, OptionOptional } from "slim-select/store"; import { ThemesList, ThemeWithName } from "../constants/themes"; @@ -558,7 +558,7 @@ function refreshPresetsSettingsSection(): void { const presetsEl = qs( ".pageSettings .section.presets .presetsList", )?.empty(); - DB.getSnapshot()?.presets?.forEach((preset: SnapshotPreset) => { + getPresets().forEach((preset: PresetItem) => { presetsEl?.appendHtml(`