Skip to content

feat(tag-filter): create dropdown for tag filter in advanced mode #1685

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

Merged
merged 6 commits into from
Mar 18, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ function AdvancedFilter() {
useFilterFormValidation(filters, initialFilters);

const validation = getValidationState();

return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
Expand Down
137 changes: 136 additions & 1 deletion app/components/assets/assets-index/advanced-filters/value-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,22 @@ export function ValueField({
);

case "array":
if (filter.name === "tags") {
return (
<>
<ValueEnumField
fieldName={filter.name}
value={filter.value as string}
handleChange={setFilter}
multiSelect={filter.operator !== "contains"}
name={fieldName}
disabled={disabled}
/>
<ErrorDisplay error={error} />
</>
);
}

return (
<Input
{...commonInputProps}
Expand Down Expand Up @@ -949,7 +965,7 @@ function LocationEnumField({
);
}

/** Component that handles location selection for both single and multi-select scenarios */
/** Component that handles kit selection for both single and multi-select scenarios */
function KitEnumField({
value,
handleChange,
Expand Down Expand Up @@ -1059,6 +1075,110 @@ function KitEnumField({
);
}

/** Component that handles tag selection for multi-select scenario */
function TagsField({
handleChange,
value,
disabled,
multiSelect,
name,
}: Omit<EnumFieldProps, "options">) {
const data = useLoaderData<AssetIndexLoaderData>();

// Parsing the existing value to get selected Tag Ids
const selectedIds = useMemo(() => {
if (!value) {
return [];
}

if (multiSelect && typeof value === "string") {
return value.split(",").map((v) => v.trim());
}

return [value];
}, [multiSelect, value]);

const commonProps = {
model: {
name: "tag" as const,
queryKey: "name",
},
initialDataKey: "tags",
countKey: "totalTags",
label: "Filter by tag",
hideLabel: true,
hideCounter: true,
withoutValueItem: {
id: "untagged",
name: "Untagged",
},
disabled,
};

if (multiSelect) {
return (
<DynamicDropdown
{...commonProps}
name={name}
trigger={
<Button
variant="secondary"
className="w-full justify-start font-normal [&_span]:w-full [&_span]:max-w-full [&_span]:truncate"
disabled={disabled}
>
<div className="flex items-center justify-between">
<span
className={tw(
"text-left",
selectedIds.length <= 0 && "text-gray-500"
)}
>
{disabled
? "Select a column first"
: selectedIds.length > 0
? selectedIds
.map((id) => {
const tag = data.tags?.find((t) => t.id === id);
return id === "untagged" ? "Untagged" : tag?.name || "";
})
.join(", ")
: "Select Tag"}
</span>
<ChevronRight className="mr-1 inline-block rotate-90" />
</div>
</Button>
}
triggerWrapperClassName="w-full"
className="z-[999999]"
selectionMode="none"
defaultValues={selectedIds}
placeholder="Select tags"
onSelectionChange={(selectedTagIds) => {
handleChange(selectedTagIds.join(","));
}}
/>
);
}

return (
<DynamicSelect
{...commonProps}
fieldName={name}
placeholder={disabled ? "Select a column first" : "Select tag"}
defaultValue={value}
onChange={(selectedId) => {
if (selectedId) {
handleChange(selectedId);
}
}}
closeOnSelect
triggerWrapperClassName="w-full text-gray-700"
className="z-[999999]"
contentLabel="tags"
/>
);
}

/**
* Component that determines which enum field to render based on field name
*/
Expand Down Expand Up @@ -1171,6 +1291,21 @@ function ValueEnumField({
);
}

if (fieldName === "tags") {
return (
<>
<TagsField
value={value}
handleChange={handleChange}
name={name}
multiSelect={multiSelect}
disabled={disabled}
/>
{error && <div className="mt-1 text-xs text-red-500">{error}</div>}
</>
);
}

return null;
}
// Define the props for the DateField component
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const filterValueSchema = {
),
]),
boolean: z.boolean(),
array: z.array(z.string()).min(1, "Please select at least one value"),
array: z.string().min(1, "Please select at least one value"),
};

/**
Expand Down
53 changes: 28 additions & 25 deletions app/modules/asset/data.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { json, redirect } from "@remix-run/node";
import type { HeaderData } from "~/components/layout/header/types";
import { db } from "~/database/db.server";
import { hasGetAllValue } from "~/hooks/use-model-filters";
import type { AllowedModelNames } from "~/routes/api+/model-filters";
import { getClientHint } from "~/utils/client-hints";
import {
getAdvancedFiltersFromRequest,
Expand All @@ -24,9 +25,12 @@ import { hasPermission } from "~/utils/permissions/permission.validator.server";
import { canImportAssets } from "~/utils/subscription.server";
import {
getAdvancedPaginatedAndFilterableAssets,
getEntitiesWithSelectedValues,
getPaginatedAndFilterableAssets,
updateAssetsWithBookingCustodians,
} from "./service.server";
import { getAllSelectedValuesFromFilters } from "./utils.server";
import type { Column } from "../asset-index-settings/helpers";
import { getActiveCustomFields } from "../custom-field/service.server";
import { getTeamMemberForCustodianFilter } from "../team-member/service.server";
import { getOrganizationTierLimit } from "../tier/service.server";
Expand Down Expand Up @@ -225,6 +229,10 @@ export async function advancedModeLoader({
const searchParams = filters
? currentFilterParams
: getCurrentSearchParams(request);
const allSelectedEntries = searchParams.getAll(
"getAll"
) as AllowedModelNames[];

const paramsValues = getParamsValues(searchParams);
const { teamMemberIds } = paramsValues;

Expand All @@ -235,17 +243,30 @@ export async function advancedModeLoader({
});
}

const { selectedTags, selectedCategory, selectedLocation } =
getAllSelectedValuesFromFilters(filters, settings.columns as Column[]);

const {
tags,
totalTags,
categories,
totalCategories,
locations,
totalLocations,
} = await getEntitiesWithSelectedValues({
organizationId,
allSelectedEntries,
selectedTagIds: selectedTags,
selectedCategoryIds: selectedCategory,
selectedLocationIds: selectedLocation,
});

/** Query tierLimit, assets & Asset index settings */
let [
tierLimit,
{ search, totalAssets, perPage, page, assets, totalPages, cookie },
customFields,
teamMembersData,

categories,
totalCategories,
locations,
totalLocations,
kits,
totalKits,
] = await Promise.all([
Expand Down Expand Up @@ -276,26 +297,6 @@ export async function advancedModeLoader({
userId,
}),

// Categories
db.category.findMany({
where: { organizationId },
take:
searchParams.has("getAll") && hasGetAllValue(searchParams, "category")
? undefined
: 12,
}),
db.category.count({ where: { organizationId } }),

// Locations
db.location.findMany({
where: { organizationId },
take:
searchParams.has("getAll") && hasGetAllValue(searchParams, "location")
? undefined
: 12,
}),
db.location.count({ where: { organizationId } }),

// Kits
db.kit.findMany({
where: { organizationId },
Expand Down Expand Up @@ -374,6 +375,8 @@ export async function advancedModeLoader({
totalLocations,
kits,
totalKits,
tags,
totalTags,
}),
{
headers,
Expand Down
17 changes: 9 additions & 8 deletions app/modules/asset/query.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,27 +780,28 @@ function addArrayFilter(whereClause: Prisma.Sql, filter: Filter): Prisma.Sql {
*/
switch (filter.operator) {
case "contains": {
// Single tag filtering using the existing join with case-insensitive comparison
return Prisma.sql`${whereClause} AND LOWER(t.name) = LOWER(${filter.value})`;
// Single tag filtering using the existing join
return Prisma.sql`${whereClause} AND t.id = ${filter.value}`;
}
case "containsAll": {
// ALL tags must be present, case-insensitive
// ALL tags must be present
const values = (filter.value as string).split(",").map((v) => v.trim());
return Prisma.sql`${whereClause} AND NOT EXISTS (
SELECT LOWER(unnest(${values}::text[])) AS required_tag
SELECT unnest(${values}::text[]) AS required_tag
EXCEPT
SELECT LOWER(t.name)
SELECT t.id
FROM "_AssetToTag" att
JOIN "Tag" t ON t.id = att."B"
WHERE att."A" = a.id
)`;
}
case "containsAny": {
// ANY of the tags must be present, case-insensitive
// ANY of the tags must be present
const values = (filter.value as string).split(",").map((v) => v.trim());
const valuesArray = `{${values.map((v) => `"${v}"`).join(",")}}`;
return Prisma.sql`${whereClause} AND LOWER(t.name) = ANY(ARRAY(SELECT LOWER(unnest(${valuesArray}::text[]))))`;
return Prisma.sql`${whereClause} AND t.id = ANY(ARRAY(SELECT unnest(${valuesArray}::text[])))`;
}

case "excludeAny": {
// Exclude assets that have ANY of the specified tags
const values = (filter.value as string).split(",").map((v) => v.trim());
Expand All @@ -819,7 +820,7 @@ function addArrayFilter(whereClause: Prisma.Sql, filter: Filter): Prisma.Sql {
FROM "_AssetToTag" att2
JOIN "Tag" t2 ON t2.id = att2."B"
WHERE att2."A" = a.id
AND t2.name = ANY(${valuesArray}::text[])
AND t2.id = ANY(${valuesArray}::text[])
)`;
}
default:
Expand Down
Loading