diff --git a/frontend/__tests__/controllers/preset-controller.spec.ts b/frontend/__tests__/controllers/preset-controller.spec.ts index ad250c5f494e..4b23d4cb3b4f 100644 --- a/frontend/__tests__/controllers/preset-controller.spec.ts +++ b/frontend/__tests__/controllers/preset-controller.spec.ts @@ -87,8 +87,14 @@ describe("PresetController", () => { //THEN expect(tagsClearMock).toHaveBeenCalled(); - expect(tagsSetMock).toHaveBeenNthCalledWith(1, "tagOne", true, false); - expect(tagsSetMock).toHaveBeenNthCalledWith(2, "tagTwo", true, false); + expect(tagsSetMock).toHaveBeenNthCalledWith(1, { + tagId: "tagOne", + active: true, + }); + expect(tagsSetMock).toHaveBeenNthCalledWith(2, { + tagId: "tagTwo", + active: true, + }); expect(tagsSaveActiveMock).toHaveBeenCalled(); }); @@ -141,8 +147,14 @@ describe("PresetController", () => { //THEN expect(tagsClearMock).toHaveBeenCalled(); - expect(tagsSetMock).toHaveBeenNthCalledWith(1, "tagOne", true, false); - expect(tagsSetMock).toHaveBeenNthCalledWith(2, "tagTwo", true, false); + expect(tagsSetMock).toHaveBeenNthCalledWith(1, { + tagId: "tagOne", + active: true, + }); + expect(tagsSetMock).toHaveBeenNthCalledWith(2, { + tagId: "tagTwo", + active: true, + }); expect(tagsSaveActiveMock).toHaveBeenCalled(); }); diff --git a/frontend/src/ts/collections/results.ts b/frontend/src/ts/collections/results.ts index a224008a34d4..a9146207224c 100644 --- a/frontend/src/ts/collections/results.ts +++ b/frontend/src/ts/collections/results.ts @@ -30,6 +30,7 @@ import { baseKey } from "../queries/utils/keys"; import { isAuthenticated } from "../states/core"; import { getLastResult, setLastResult } from "../states/snapshot"; import { + getActiveTagsOnce, reconcileLocalTagPB, saveLocalTagPB, __nonReactive as tagsNonReactive, @@ -586,13 +587,13 @@ export async function getUserAverage10( //exit early if there is no user. Don't init the result collection if (!isAuthenticated()) return { wpm: 0, acc: 0 }; + const tagIds = (await getActiveTagsOnce()).map((it) => it._id); + const result = await queryOnce((q) => q .from({ //we use sub-query to filter first and then aggregate - last10: buildSettingsResultsQuery(options, { - tagIds: tagsNonReactive.getActiveTags().map((it) => it._id), - }) + last10: buildSettingsResultsQuery(options, { tagIds }) .orderBy(({ r }) => r.timestamp, "desc") .limit(10), }) @@ -609,11 +610,10 @@ export async function getUserDailyBest( ): Promise<{ wpm: number; acc: number }> { //exit early if there is no user. Don't init the result collection if (!isAuthenticated()) return { wpm: 0, acc: 0 }; + const tagIds = (await getActiveTagsOnce()).map((it) => it._id); const result = await queryOnce(() => - buildSettingsResultsQuery(options, { - tagIds: tagsNonReactive.getActiveTags().map((it) => it._id), - }) + buildSettingsResultsQuery(options, { tagIds }) .where(({ r }) => gte(r.timestamp, Date.now() - 24 * 60 * 60 * 1000)) .orderBy(({ r }) => r.wpm, "desc") .limit(1) diff --git a/frontend/src/ts/collections/tags.ts b/frontend/src/ts/collections/tags.ts index 5142acebbae3..a101c873bece 100644 --- a/frontend/src/ts/collections/tags.ts +++ b/frontend/src/ts/collections/tags.ts @@ -3,6 +3,8 @@ import { queryCollectionOptions } from "@tanstack/query-db-collection"; import { createCollection, createOptimisticAction, + eq, + queryOnce, useLiveQuery, } from "@tanstack/solid-db"; import { z } from "zod"; @@ -20,8 +22,9 @@ import { } from "@monkeytype/schemas/shared"; import { Difficulty } from "@monkeytype/schemas/configs"; import { Language } from "@monkeytype/schemas/languages"; -import { applyIdWorkaround, tempId } from "./utils/misc"; +import { applyIdWorkaround, isTempId, tempId } from "./utils/misc"; import { fetchUserFromApi } from "../ape/user"; +import { updateTagsInFilterStorage } from "../states/result-filters"; export type TagItem = UserTag & { active: boolean }; @@ -60,6 +63,16 @@ export function useTagsLiveQuery() { }); } +// oxlint-disable-next-line typescript/explicit-function-return-type +export async function getActiveTagsOnce() { + return queryOnce((q) => { + return q + .from({ tag: tagsCollection }) + .where(({ tag }) => eq(tag.active, true)) + .orderBy(({ tag }) => tag.name, "asc"); + }); +} + type ActionType = { insertTag: { name: string; @@ -74,6 +87,18 @@ type ActionType = { deleteTag: { tagId: string; }; + toggleTagActive: { + tagId: string; + noSave?: boolean; + }; + setTagActive: { + tagId: string; + active: boolean; + noSave?: boolean; + }; + clearActiveTags: { + noSave?: boolean; + }; }; const actions = { @@ -100,6 +125,11 @@ const actions = { }; tagsCollection.utils.writeInsert(newTag); + updateTagsInFilterStorage( + [...tagsCollection.values()] + .filter((it) => !isTempId(it._id)) + .map((it) => it._id), + ); }, }), updateTagName: createOptimisticAction({ @@ -166,6 +196,46 @@ const actions = { throw new Error(`Failed to delete tag: ${response.body.message}`); } tagsCollection.utils.writeDelete(tagId); + updateTagsInFilterStorage( + [...tagsCollection.values()].map((it) => it._id), + ); + }, + }), + toggleTagActive: createOptimisticAction({ + onMutate: ({ tagId, noSave }) => { + const tag = tagsCollection.get(tagId); + if (tag === undefined) return; + tagsCollection.utils.writeUpdate({ ...tag, active: !tag.active }); + if (!noSave) saveActiveToLocalStorage(); + }, + mutationFn: async () => { + return; + }, + }), + setTagActive: createOptimisticAction({ + onMutate: ({ tagId, active, noSave }) => { + const tag = tagsCollection.get(tagId); + if (tag === undefined) return; + tagsCollection.utils.writeUpdate({ ...tag, active }); + if (!noSave) saveActiveToLocalStorage(); + }, + mutationFn: async () => { + return; + }, + }), + clearActiveTags: createOptimisticAction({ + onMutate: ({ noSave }) => { + tagsCollection.utils.writeBatch(() => { + tagsCollection.forEach((tag) => { + if (tag.active) { + tagsCollection.utils.writeUpdate({ ...tag, active: false }); + } + }); + }); + if (!noSave) saveActiveToLocalStorage(); + }, + mutationFn: async () => { + return; }, }), }; @@ -200,6 +270,27 @@ export async function deleteTag( await transaction.isPersisted.promise; } +export async function toggleTagActive( + params: ActionType["toggleTagActive"], +): Promise { + const transaction = actions.toggleTagActive(params); + await transaction.isPersisted.promise; +} + +export async function setTagActive( + params: ActionType["setTagActive"], +): Promise { + const transaction = actions.setTagActive(params); + await transaction.isPersisted.promise; +} + +export async function clearActiveTags( + params: ActionType["clearActiveTags"] = {}, +): Promise { + const transaction = actions.clearActiveTags(params); + await transaction.isPersisted.promise; +} + function getTags(): TagItem[] { return [...tagsCollection.values()].sort((a, b) => a.name.localeCompare(b.name), @@ -230,35 +321,6 @@ export function saveActiveToLocalStorage(): void { activeTagsLS.set(activeIds); } -export function toggleTagActive(tagId: string, nosave = false): void { - const tag = tagsCollection.get(tagId); - if (tag === undefined) return; - tagsCollection.utils.writeUpdate({ ...tag, active: !tag.active }); - if (!nosave) saveActiveToLocalStorage(); -} - -export function setTagActive( - tagId: string, - state: boolean, - nosave = false, -): void { - const tag = tagsCollection.get(tagId); - if (tag === undefined) return; - tagsCollection.utils.writeUpdate({ ...tag, active: state }); - if (!nosave) saveActiveToLocalStorage(); -} - -export function clearActiveTags(nosave = false): void { - tagsCollection.utils.writeBatch(() => { - tagsCollection.forEach((tag) => { - if (tag.active) { - tagsCollection.utils.writeUpdate({ ...tag, active: false }); - } - }); - }); - if (!nosave) saveActiveToLocalStorage(); -} - // --- Personal bests --- export function getLocalTagPB( diff --git a/frontend/src/ts/collections/utils/misc.ts b/frontend/src/ts/collections/utils/misc.ts index 876dbff7aadc..1340b46e3a15 100644 --- a/frontend/src/ts/collections/utils/misc.ts +++ b/frontend/src/ts/collections/utils/misc.ts @@ -1,5 +1,11 @@ +const tempIdPrefix = "temp_"; + export function tempId(): string { - return `temp_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; + return `${tempIdPrefix}${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; +} + +export function isTempId(id: string): boolean { + return id.startsWith(tempIdPrefix); } /** diff --git a/frontend/src/ts/commandline/lists/tags.ts b/frontend/src/ts/commandline/lists/tags.ts index 209ae2047a30..fc00f1d94eaa 100644 --- a/frontend/src/ts/commandline/lists/tags.ts +++ b/frontend/src/ts/commandline/lists/tags.ts @@ -41,7 +41,7 @@ function update(): void { icon: "fa-times", sticky: true, exec: async (): Promise => { - clearActiveTags(); + await clearActiveTags(); if ( Config.paceCaret === "average" || Config.paceCaret === "tagPb" || @@ -62,7 +62,7 @@ function update(): void { return __nonReactive.getTag(tag._id)?.active ?? false; }, exec: async (): Promise => { - toggleTagActive(tag._id); + await toggleTagActive({ tagId: tag._id }); if ( Config.paceCaret === "average" || diff --git a/frontend/src/ts/components/pages/account/Filters.tsx b/frontend/src/ts/components/pages/account/Filters.tsx index 62785f0d40c2..5f0ae2065b6e 100644 --- a/frontend/src/ts/components/pages/account/Filters.tsx +++ b/frontend/src/ts/components/pages/account/Filters.tsx @@ -19,6 +19,7 @@ import defaultResultFilters from "../../../constants/default-result-filters"; import { SimpleModal } from "../../../elements/simple-modal"; import { FaSolidIcon } from "../../../types/font-awesome"; import { IsValidResponse } from "../../../types/validation"; +import { cn } from "../../../utils/cn"; import { createErrorMessage } from "../../../utils/error"; import { getLanguageDisplayString, @@ -141,6 +142,7 @@ export function Filters(props: { text: string; group: T; format?: (value: K) => string; + class?: string; }): JSXElement => { // Isolate this group's data to prevent unnecessary updates const groupData = createMemo(() => props.filters[options.group]); @@ -159,7 +161,7 @@ export function Filters(props: { ); return ( -
+

props.onChangeFilters(noFilters())} class="mb-4 w-full" /> -
+
@@ -334,20 +336,25 @@ export function Filters(props: { - - tag === "none" - ? "no tag" - : (tags().find((it) => it._id === tag)?.name ?? tag) - } - /> + 0}> + + tag === "none" + ? "no tag" + : (tags().find((it) => it._id === tag)?.name ?? tag) + } + /> + val === "none" ? "no funbox" : replaceUnderscoresWithSpaces(val) } @@ -356,6 +363,7 @@ export function Filters(props: { icon="fa-globe-americas" text="language" group="language" + class="col-span-2" format={getLanguageDisplayString} />
diff --git a/frontend/src/ts/components/pages/settings/custom-setting/Tags.tsx b/frontend/src/ts/components/pages/settings/custom-setting/Tags.tsx index 74fdf79658bb..ea7d15628d96 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/Tags.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/Tags.tsx @@ -36,7 +36,7 @@ export function Tags(): JSXElement { text={tag.name} active={tag.active} onClick={() => { - toggleTagActive(tag._id); + void toggleTagActive({ tagId: tag._id }); }} />