Skip to content

feat(filters): Add new tag filtering modes AND, EXACT & NOT (@TristanMarion) #6388

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
2 changes: 2 additions & 0 deletions backend/__tests__/api/controllers/user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1917,6 +1917,7 @@ describe("user controller test", () => {
tags: {
none: false,
},
tagsFilterMode: "or",
language: {
english: true,
},
Expand Down Expand Up @@ -1981,6 +1982,7 @@ describe("user controller test", () => {
'"numbers" Required',
'"date" Required',
'"tags" Required',
'"tagsFilterMode" Required',
'"language" Required',
'"funbox" Required',
],
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/html/pages/account.html
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,16 @@
<div class="title">
<i class="fas fa-tag"></i>
tags
<span
class="tagsFilterModeToggle"
data-balloon-pos="right"
data-balloon-length="xlarge"
data-balloon-break
aria-label="Tag filter mode:&#10;- OR (any selected tag must match)&#10;- AND (all selected tags must match)&#10;- EXACT (exactly the selected tags must match)"
>
<i class="fas fa-filter"></i>
<span class="mode-text">OR</span>
</span>
</div>
<div class="select filterGroup" group="tags"></div>
</div>
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/styles/account.scss
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,26 @@
margin-right: 0.5em;
}
}

&.tags {
.title {
align-items: baseline;

.tagsFilterModeToggle {
margin-left: 0.5rem;
cursor: pointer;
opacity: 0.5;
transition: 0.125s;
display: flex;
align-items: center;
font-size: 0.8em;

&:hover {
opacity: 1;
}
}
}
}
}

&.presetFilterButtons {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/ts/constants/default-result-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const object: ResultFilters = {
tags: {
none: true,
},
tagsFilterMode: "or",
language: {},
funbox: {
none: true,
Expand Down
37 changes: 35 additions & 2 deletions frontend/src/ts/elements/account/result-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export function mergeWithDefaultFilters(
merged[groupKey] = id;
} else if (groupKey === "name") {
merged[groupKey] = filters[groupKey] ?? defaultResultFilters[groupKey];
} else if (groupKey === "tagsFilterMode") {
Copy link
Contributor Author

@TristanMarion TristanMarion Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can merge this condition with the previous one as they contain the same code, let me know what you prefer

merged[groupKey] = filters[groupKey] ?? defaultResultFilters[groupKey];
} else {
// @ts-expect-error i cant figure this out
merged[groupKey] = {
Expand Down Expand Up @@ -289,7 +291,7 @@ export function updateActive(): void {

for (const group of Misc.typedKeys(getFilters())) {
// id and name field do not correspond to any ui elements, no need to update
if (group === "_id" || group === "name") {
if (group === "_id" || group === "name" || group === "tagsFilterMode") {
continue;
}

Expand Down Expand Up @@ -488,6 +490,19 @@ export function updateActive(): void {
}, 0);
}

function updateTagsFilterModeIcon(): void {
const toggleElement = $(".pageAccount .tagsFilterModeToggle");
const modeTextElement = toggleElement.find(".mode-text");

if (filters.tagsFilterMode === "and") {
modeTextElement.text("AND");
} else if (filters.tagsFilterMode === "exact") {
modeTextElement.text("EXACT");
} else {
modeTextElement.text("OR");
}
}

function toggle<G extends ResultFiltersGroup>(
group: G,
filter: ResultFiltersGroupItem<G>
Expand Down Expand Up @@ -857,7 +872,7 @@ export async function appendButtons(
html +=
"<select class='tagsSelect' group='tags' placeholder='select a tag' multiple>";

html += "<option value='all'>all</option>";
html += "<option value='all'>all (and no tag)</option>";
html += "<option value='none'>no tag</option>";

for (const tag of snapshot.tags) {
Expand Down Expand Up @@ -925,6 +940,20 @@ $(".group.presetFilterButtons .filterBtns").on(
}
);

$(document).on("click", ".pageAccount .tagsFilterModeToggle", () => {
// Cycle between "or" -> "and" -> "exact" -> "or"
if (filters.tagsFilterMode === "or") {
filters.tagsFilterMode = "and";
} else if (filters.tagsFilterMode === "and") {
filters.tagsFilterMode = "exact";
} else {
filters.tagsFilterMode = "or";
}
save();
updateTagsFilterModeIcon();
selectChangeCallbackFn();
});

function verifyResultFiltersStructure(filterIn: ResultFilters): ResultFilters {
const filter = Misc.deepClone(filterIn);
Object.entries(defaultResultFilters).forEach((entry) => {
Expand All @@ -937,3 +966,7 @@ function verifyResultFiltersStructure(filterIn: ResultFilters): ResultFilters {
});
return filter;
}

export function getTagsFilterMode(): "and" | "or" | "exact" {
return filters.tagsFilterMode;
}
72 changes: 62 additions & 10 deletions frontend/src/ts/pages/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,18 +450,70 @@ async function fillContent(): Promise<void> {

if (validTags === undefined) return;

result.tags.forEach((tag) => {
//check if i even need to check tags anymore
if (!tagHide) return;
//check if tag is valid
if (validTags?.includes(tag)) {
//tag valid, check if filter is on
if (ResultFilters.getFilter("tags", tag)) tagHide = false;
if (ResultFilters.getTagsFilterMode() === "or") {
result.tags.forEach((tag) => {
//check if i even need to check tags anymore
if (!tagHide) return;
//check if tag is valid
if (validTags?.includes(tag)) {
//tag valid, check if filter is on
if (ResultFilters.getFilter("tags", tag)) tagHide = false;
} else {
//tag not found in valid tags, meaning probably deleted
if (ResultFilters.getFilter("tags", "none")) tagHide = false;
}
});
} else if (ResultFilters.getTagsFilterMode() === "and") {
// AND mode - show results that match ALL selected tags
// First, identify all the enabled tags using validTags
const enabledTagIds: string[] = [];

// Loop through all valid tags to find which ones are enabled in the filter
validTags.forEach((tagId) => {
if (tagId !== "none" && ResultFilters.getFilter("tags", tagId)) {
enabledTagIds.push(tagId);
}
});

if (enabledTagIds.length === 0) {
// No tag filters enabled, show everything
tagHide = false;
Comment on lines +478 to +480
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since "none" does not get pushed to enabledTagIds, does this mean when only "none" is enabled, all results (with & without tags) will be shown?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, TBH I did not know how to handle this edge case, do you think we should display only the tests with no tags at all ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, behavior-wise, only results with no tags should be shown imo.
Implementation-wise, I don't think these 3 lines are needed here (I did not test this however)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually this is already done in the existing code and this block won't be reachable if the result has no tag so I will remove this part and ensure we have the correct behavior anyway

} else {
//tag not found in valid tags, meaning probably deleted
if (ResultFilters.getFilter("tags", "none")) tagHide = false;
// Check if result has ALL the enabled tag filters
const resultHasAllTags = enabledTagIds.every((tagId) =>
result.tags?.includes(tagId)
);
Comment on lines +483 to +485

This comment was marked as resolved.


if (resultHasAllTags) {
tagHide = false;
}
}
});
} else if (ResultFilters.getTagsFilterMode() === "exact") {
// EXACT mode - show results where tags exactly match the selected filters
// First, identify all the enabled tags
const enabledTagIds: string[] = [];

// Loop through all valid tags to find which ones are enabled in the filter
validTags.forEach((tagId) => {
if (tagId !== "none" && ResultFilters.getFilter("tags", tagId)) {
enabledTagIds.push(tagId);
}
});

if (enabledTagIds.length === 0) {
// No tag filters enabled, show everything
tagHide = false;
} else {
// Check if result tags exactly match the enabled filters (same number and same tags)
const resultHasExactTags =
result.tags.length === enabledTagIds.length &&
enabledTagIds.every((tagId) => result.tags?.includes(tagId));

if (resultHasExactTags) {
tagHide = false;
}
}
}
}

if (tagHide) {
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/schemas/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const ResultFiltersSchema = z.object({
})
.strict(),
tags: z.record(z.string(), z.boolean()),
tagsFilterMode: z.enum(["and", "or", "exact"]),
language: z.record(LanguageSchema, z.boolean()),
funbox: z.record(z.string(), z.boolean()),
});
Expand Down