Skip to content

Commit 1059e20

Browse files
authored
Merge pull request #1685 from rockingrohit9639/feature/tags-filter
feat(tag-filter): create dropdown for tag filter in advanced mode
2 parents 365a178 + 2b98ee5 commit 1059e20

File tree

7 files changed

+311
-86
lines changed

7 files changed

+311
-86
lines changed

app/components/assets/assets-index/advanced-asset-index-filters-and-sorting.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ function AdvancedFilter() {
128128
useFilterFormValidation(filters, initialFilters);
129129

130130
const validation = getValidationState();
131+
131132
return (
132133
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
133134
<PopoverTrigger asChild>

app/components/assets/assets-index/advanced-filters/value-field.tsx

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,22 @@ export function ValueField({
330330
);
331331

332332
case "array":
333+
if (filter.name === "tags") {
334+
return (
335+
<>
336+
<ValueEnumField
337+
fieldName={filter.name}
338+
value={filter.value as string}
339+
handleChange={setFilter}
340+
multiSelect={filter.operator !== "contains"}
341+
name={fieldName}
342+
disabled={disabled}
343+
/>
344+
<ErrorDisplay error={error} />
345+
</>
346+
);
347+
}
348+
333349
return (
334350
<Input
335351
{...commonInputProps}
@@ -949,7 +965,7 @@ function LocationEnumField({
949965
);
950966
}
951967

952-
/** Component that handles location selection for both single and multi-select scenarios */
968+
/** Component that handles kit selection for both single and multi-select scenarios */
953969
function KitEnumField({
954970
value,
955971
handleChange,
@@ -1059,6 +1075,110 @@ function KitEnumField({
10591075
);
10601076
}
10611077

1078+
/** Component that handles tag selection for multi-select scenario */
1079+
function TagsField({
1080+
handleChange,
1081+
value,
1082+
disabled,
1083+
multiSelect,
1084+
name,
1085+
}: Omit<EnumFieldProps, "options">) {
1086+
const data = useLoaderData<AssetIndexLoaderData>();
1087+
1088+
// Parsing the existing value to get selected Tag Ids
1089+
const selectedIds = useMemo(() => {
1090+
if (!value) {
1091+
return [];
1092+
}
1093+
1094+
if (multiSelect && typeof value === "string") {
1095+
return value.split(",").map((v) => v.trim());
1096+
}
1097+
1098+
return [value];
1099+
}, [multiSelect, value]);
1100+
1101+
const commonProps = {
1102+
model: {
1103+
name: "tag" as const,
1104+
queryKey: "name",
1105+
},
1106+
initialDataKey: "tags",
1107+
countKey: "totalTags",
1108+
label: "Filter by tag",
1109+
hideLabel: true,
1110+
hideCounter: true,
1111+
withoutValueItem: {
1112+
id: "untagged",
1113+
name: "Untagged",
1114+
},
1115+
disabled,
1116+
};
1117+
1118+
if (multiSelect) {
1119+
return (
1120+
<DynamicDropdown
1121+
{...commonProps}
1122+
name={name}
1123+
trigger={
1124+
<Button
1125+
variant="secondary"
1126+
className="w-full justify-start font-normal [&_span]:w-full [&_span]:max-w-full [&_span]:truncate"
1127+
disabled={disabled}
1128+
>
1129+
<div className="flex items-center justify-between">
1130+
<span
1131+
className={tw(
1132+
"text-left",
1133+
selectedIds.length <= 0 && "text-gray-500"
1134+
)}
1135+
>
1136+
{disabled
1137+
? "Select a column first"
1138+
: selectedIds.length > 0
1139+
? selectedIds
1140+
.map((id) => {
1141+
const tag = data.tags?.find((t) => t.id === id);
1142+
return id === "untagged" ? "Untagged" : tag?.name || "";
1143+
})
1144+
.join(", ")
1145+
: "Select Tag"}
1146+
</span>
1147+
<ChevronRight className="mr-1 inline-block rotate-90" />
1148+
</div>
1149+
</Button>
1150+
}
1151+
triggerWrapperClassName="w-full"
1152+
className="z-[999999]"
1153+
selectionMode="none"
1154+
defaultValues={selectedIds}
1155+
placeholder="Select tags"
1156+
onSelectionChange={(selectedTagIds) => {
1157+
handleChange(selectedTagIds.join(","));
1158+
}}
1159+
/>
1160+
);
1161+
}
1162+
1163+
return (
1164+
<DynamicSelect
1165+
{...commonProps}
1166+
fieldName={name}
1167+
placeholder={disabled ? "Select a column first" : "Select tag"}
1168+
defaultValue={value}
1169+
onChange={(selectedId) => {
1170+
if (selectedId) {
1171+
handleChange(selectedId);
1172+
}
1173+
}}
1174+
closeOnSelect
1175+
triggerWrapperClassName="w-full text-gray-700"
1176+
className="z-[999999]"
1177+
contentLabel="tags"
1178+
/>
1179+
);
1180+
}
1181+
10621182
/**
10631183
* Component that determines which enum field to render based on field name
10641184
*/
@@ -1171,6 +1291,21 @@ function ValueEnumField({
11711291
);
11721292
}
11731293

1294+
if (fieldName === "tags") {
1295+
return (
1296+
<>
1297+
<TagsField
1298+
value={value}
1299+
handleChange={handleChange}
1300+
name={name}
1301+
multiSelect={multiSelect}
1302+
disabled={disabled}
1303+
/>
1304+
{error && <div className="mt-1 text-xs text-red-500">{error}</div>}
1305+
</>
1306+
);
1307+
}
1308+
11741309
return null;
11751310
}
11761311
// Define the props for the DateField component

app/components/assets/assets-index/advanced-filters/value.client.validator.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const filterValueSchema = {
4646
),
4747
]),
4848
boolean: z.boolean(),
49-
array: z.array(z.string()).min(1, "Please select at least one value"),
49+
array: z.string().min(1, "Please select at least one value"),
5050
};
5151

5252
/**

app/modules/asset/data.server.ts

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { json, redirect } from "@remix-run/node";
66
import type { HeaderData } from "~/components/layout/header/types";
77
import { db } from "~/database/db.server";
88
import { hasGetAllValue } from "~/hooks/use-model-filters";
9+
import type { AllowedModelNames } from "~/routes/api+/model-filters";
910
import { getClientHint } from "~/utils/client-hints";
1011
import {
1112
getAdvancedFiltersFromRequest,
@@ -24,9 +25,12 @@ import { hasPermission } from "~/utils/permissions/permission.validator.server";
2425
import { canImportAssets } from "~/utils/subscription.server";
2526
import {
2627
getAdvancedPaginatedAndFilterableAssets,
28+
getEntitiesWithSelectedValues,
2729
getPaginatedAndFilterableAssets,
2830
updateAssetsWithBookingCustodians,
2931
} from "./service.server";
32+
import { getAllSelectedValuesFromFilters } from "./utils.server";
33+
import type { Column } from "../asset-index-settings/helpers";
3034
import { getActiveCustomFields } from "../custom-field/service.server";
3135
import { getTeamMemberForCustodianFilter } from "../team-member/service.server";
3236
import { getOrganizationTierLimit } from "../tier/service.server";
@@ -225,6 +229,10 @@ export async function advancedModeLoader({
225229
const searchParams = filters
226230
? currentFilterParams
227231
: getCurrentSearchParams(request);
232+
const allSelectedEntries = searchParams.getAll(
233+
"getAll"
234+
) as AllowedModelNames[];
235+
228236
const paramsValues = getParamsValues(searchParams);
229237
const { teamMemberIds } = paramsValues;
230238

@@ -235,17 +243,30 @@ export async function advancedModeLoader({
235243
});
236244
}
237245

246+
const { selectedTags, selectedCategory, selectedLocation } =
247+
getAllSelectedValuesFromFilters(filters, settings.columns as Column[]);
248+
249+
const {
250+
tags,
251+
totalTags,
252+
categories,
253+
totalCategories,
254+
locations,
255+
totalLocations,
256+
} = await getEntitiesWithSelectedValues({
257+
organizationId,
258+
allSelectedEntries,
259+
selectedTagIds: selectedTags,
260+
selectedCategoryIds: selectedCategory,
261+
selectedLocationIds: selectedLocation,
262+
});
263+
238264
/** Query tierLimit, assets & Asset index settings */
239265
let [
240266
tierLimit,
241267
{ search, totalAssets, perPage, page, assets, totalPages, cookie },
242268
customFields,
243269
teamMembersData,
244-
245-
categories,
246-
totalCategories,
247-
locations,
248-
totalLocations,
249270
kits,
250271
totalKits,
251272
] = await Promise.all([
@@ -276,26 +297,6 @@ export async function advancedModeLoader({
276297
userId,
277298
}),
278299

279-
// Categories
280-
db.category.findMany({
281-
where: { organizationId },
282-
take:
283-
searchParams.has("getAll") && hasGetAllValue(searchParams, "category")
284-
? undefined
285-
: 12,
286-
}),
287-
db.category.count({ where: { organizationId } }),
288-
289-
// Locations
290-
db.location.findMany({
291-
where: { organizationId },
292-
take:
293-
searchParams.has("getAll") && hasGetAllValue(searchParams, "location")
294-
? undefined
295-
: 12,
296-
}),
297-
db.location.count({ where: { organizationId } }),
298-
299300
// Kits
300301
db.kit.findMany({
301302
where: { organizationId },
@@ -374,6 +375,8 @@ export async function advancedModeLoader({
374375
totalLocations,
375376
kits,
376377
totalKits,
378+
tags,
379+
totalTags,
377380
}),
378381
{
379382
headers,

app/modules/asset/query.server.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -780,27 +780,28 @@ function addArrayFilter(whereClause: Prisma.Sql, filter: Filter): Prisma.Sql {
780780
*/
781781
switch (filter.operator) {
782782
case "contains": {
783-
// Single tag filtering using the existing join with case-insensitive comparison
784-
return Prisma.sql`${whereClause} AND LOWER(t.name) = LOWER(${filter.value})`;
783+
// Single tag filtering using the existing join
784+
return Prisma.sql`${whereClause} AND t.id = ${filter.value}`;
785785
}
786786
case "containsAll": {
787-
// ALL tags must be present, case-insensitive
787+
// ALL tags must be present
788788
const values = (filter.value as string).split(",").map((v) => v.trim());
789789
return Prisma.sql`${whereClause} AND NOT EXISTS (
790-
SELECT LOWER(unnest(${values}::text[])) AS required_tag
790+
SELECT unnest(${values}::text[]) AS required_tag
791791
EXCEPT
792-
SELECT LOWER(t.name)
792+
SELECT t.id
793793
FROM "_AssetToTag" att
794794
JOIN "Tag" t ON t.id = att."B"
795795
WHERE att."A" = a.id
796796
)`;
797797
}
798798
case "containsAny": {
799-
// ANY of the tags must be present, case-insensitive
799+
// ANY of the tags must be present
800800
const values = (filter.value as string).split(",").map((v) => v.trim());
801801
const valuesArray = `{${values.map((v) => `"${v}"`).join(",")}}`;
802-
return Prisma.sql`${whereClause} AND LOWER(t.name) = ANY(ARRAY(SELECT LOWER(unnest(${valuesArray}::text[]))))`;
802+
return Prisma.sql`${whereClause} AND t.id = ANY(ARRAY(SELECT unnest(${valuesArray}::text[])))`;
803803
}
804+
804805
case "excludeAny": {
805806
// Exclude assets that have ANY of the specified tags
806807
const values = (filter.value as string).split(",").map((v) => v.trim());
@@ -819,7 +820,7 @@ function addArrayFilter(whereClause: Prisma.Sql, filter: Filter): Prisma.Sql {
819820
FROM "_AssetToTag" att2
820821
JOIN "Tag" t2 ON t2.id = att2."B"
821822
WHERE att2."A" = a.id
822-
AND t2.name = ANY(${valuesArray}::text[])
823+
AND t2.id = ANY(${valuesArray}::text[])
823824
)`;
824825
}
825826
default:

0 commit comments

Comments
 (0)