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
159 changes: 121 additions & 38 deletions frontend/src/ts/components/modals/WordFilterModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ import type { LayoutObject } from "@monkeytype/schemas/layouts";

import { tryCatch } from "@monkeytype/util/trycatch";
import { createForm } from "@tanstack/solid-form";
import { createSignal, JSXElement, Setter } from "solid-js";
import {
createMemo,
createResource,
createSignal,
JSXElement,
Setter,
} from "solid-js";

import { LanguageList } from "../../constants/languages";
import { LayoutsList } from "../../constants/layouts";
import { createDebouncedSignal } from "../../hooks/createDebouncedSignal";
import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar";
import { hideModal } from "../../states/modals";
import {
Expand Down Expand Up @@ -104,6 +111,74 @@ const presetOptions = Object.entries(presets).map(([id, preset]) => ({
text: preset.display,
}));

type FilterFormValues = {
include: string;
exclude: string;
minLength: string;
maxLength: string;
regex: string;
exactMatch: boolean;
};

type FilterResult = { words: string[] } | { error: string };

function filterWordList(
value: FilterFormValues,
words: string[],
): FilterResult {
const exactMatchOnly = value.exactMatch;

// Source - https://stackoverflow.com/a/874742
// Retrieved 2026-06-23, License - CC BY-SA 3.0
// Separates string into regex expression
let reglit = new RegExp("");
try {
const flags = value.regex.replace(/.*\/([gimy]*)$/, "$1");
const pattern = value.regex.replace(new RegExp(`^/(.*?)/${flags}$`), "$1");
reglit = new RegExp(pattern, flags);
} catch (error) {
if (error instanceof SyntaxError) {
return { error: "Invaid Regex Expression" };
}
return { error: String(error) };
}

let filterin = Misc.escapeRegExp(value.include.trim());
filterin = filterin.replace(/\s+/gi, "|");

if (exactMatchOnly && filterin === "") {
return { error: "Include field is required for exact match" };
}
const regincl = exactMatchOnly
? new RegExp(`^[${filterin}]+$`, "i")
: new RegExp(filterin, "i");

let filterout = Misc.escapeRegExp(value.exclude.trim());
filterout = filterout.replace(/\s+/gi, "|");
const regexcl = new RegExp(filterout, "i");

const max = value.maxLength === "" ? 999 : parseInt(value.maxLength);
const min = value.minLength === "" ? 1 : parseInt(value.minLength);

const filteredWords: string[] = [];
for (const word of words) {
const testincl = regincl.test(word);
const testexcl =
exactMatchOnly || filterout === "" ? false : regexcl.test(word);
const testlit = exactMatchOnly ? true : reglit.test(word);
if (
testincl &&
!testexcl &&
testlit &&
word.length <= max &&
word.length >= min
) {
filteredWords.push(word);
}
}
return { words: filteredWords };
}

export function WordFilterModal(props: {
setChainedData: Setter<CustomTextIncomingData>;
}): JSXElement {
Expand All @@ -120,29 +195,13 @@ export function WordFilterModal(props: {
exclude: "",
minLength: "",
maxLength: "",
regex: "",
exactMatch: false,
},
onSubmit: async ({ value }) => {
setLoading(true);
showLoaderBar();
try {
const exactMatchOnly = value.exactMatch;
let filterin = Misc.escapeRegExp(value.include.trim());
filterin = filterin.replace(/\s+/gi, "|");

if (exactMatchOnly && filterin === "") {
showNoticeNotification("Include field is required for exact match");
return;
}

const regincl = exactMatchOnly
? new RegExp(`^[${filterin}]+$`, "i")
: new RegExp(filterin, "i");

let filterout = Misc.escapeRegExp(value.exclude.trim());
filterout = filterout.replace(/\s+/gi, "|");
const regexcl = new RegExp(filterout, "i");

const { data: languageWordList, error } = await tryCatch(
JSONData.getLanguage(language() as Language),
);
Expand All @@ -152,28 +211,18 @@ export function WordFilterModal(props: {
return;
}

const max = value.maxLength === "" ? 999 : parseInt(value.maxLength);
const min = value.minLength === "" ? 1 : parseInt(value.minLength);

const filteredWords: string[] = [];
for (const word of languageWordList.words) {
const test1 = regincl.test(word);
const test2 = exactMatchOnly ? false : regexcl.test(word);
if (
((test1 && !test2) || (test1 && filterout === "")) &&
word.length <= max &&
word.length >= min
) {
filteredWords.push(word);
}
const result = filterWordList(value, languageWordList.words);
if ("error" in result) {
showNoticeNotification(result.error);
return;
}

if (filteredWords.length === 0) {
if (result.words.length === 0) {
showNoticeNotification("No words found");
return;
}
props.setChainedData({
splitText: filteredWords,
splitText: result.words,
set: submitAction === "set",
});
hideModal("WordFilter");
Expand All @@ -186,6 +235,22 @@ export function WordFilterModal(props: {

const isExactMatch = form.useStore((s) => s.values.exactMatch);

const [languageWords] = createResource(language, async (lang) => {
const { data } = await tryCatch(JSONData.getLanguage(lang as Language));
return data?.words ?? null;
});

const formValues = form.useStore((s) => s.values);

// debounce the preview so it doesn't refilter the whole word list on every keystroke
const debouncedValues = createDebouncedSignal(formValues, 250);

const matchResult = createMemo<FilterResult | null>(() => {
const words = languageWords();
if (words === null || words === undefined) return null;
return filterWordList(debouncedValues(), words);
});

const applyPreset = async () => {
const presetToApply = presets[preset()];
if (presetToApply === undefined) {
Expand All @@ -205,6 +270,7 @@ export function WordFilterModal(props: {
if (presetToApply.exactMatch === true) {
form.setFieldValue("exactMatch", true);
form.setFieldValue("exclude", "");
form.setFieldValue("regex", "");
} else {
form.setFieldValue("exactMatch", false);
if (presetToApply.getExcludeString !== undefined) {
Expand Down Expand Up @@ -239,9 +305,9 @@ export function WordFilterModal(props: {
/>
</LabeledField>
<div class="text-xs text-sub">
You can manually filter words by length, words or characters
(separated by spaces) on the left side. On the right side you can
generate filters based on a preset and selected layout.
You can manually filter words by length, regular expressions, words,
or characters (separated by spaces) on the left side. On the right
side you can generate filters based on a preset and selected layout.
</div>

<div class="grid grid-cols-1 gap-4 md:grid-cols-[1fr_auto_1fr]">
Expand All @@ -258,6 +324,15 @@ export function WordFilterModal(props: {
</form.Field>
</LabeledField>
</div>

<LabeledField label="regex">
<form.Field name="regex">
{(field) => (
<InputField field={field} disabled={isExactMatch()} />
)}
</form.Field>
</LabeledField>

<LabeledField label="include">
<form.Field name="include">
{(field) => <InputField field={field} />}
Expand All @@ -268,6 +343,7 @@ export function WordFilterModal(props: {
onChange: ({ value }) => {
if (value) {
form.setFieldValue("exclude", "");
form.setFieldValue("regex", "");
}
return undefined;
},
Expand Down Expand Up @@ -321,7 +397,14 @@ export function WordFilterModal(props: {
/>
</div>
</div>

<div class="text-center text-xs text-sub">
{(() => {
const result = matchResult();
if (result === null) return "loading words...";
if ("error" in result) return result.error;
return `${result.words.length} words found`;
})()}
</div>
<div class="text-xs text-sub">
{
'"Set" replaces the current custom word list with the filter result, "Add" appends the filter result to the current custom word list.'
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/ts/hooks/createDebouncedSignal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Accessor, createEffect, createSignal, onCleanup } from "solid-js";
import { debounce } from "throttle-debounce";

/**
* Derives a debounced accessor from a reactive source. The returned accessor
* updates at most once per `delay` ms after the source stops changing.
*/
export function createDebouncedSignal<T>(
source: Accessor<T>,
delay: number,
): Accessor<T> {
const [value, setValue] = createSignal(source());

const update = debounce(delay, (next: T) => setValue(() => next));
onCleanup(() => update.cancel());

createEffect(() => update(source()));

return value;
}
Loading