diff --git a/app/components/assets/assets-index/advanced-asset-index-filters-and-sorting.tsx b/app/components/assets/assets-index/advanced-asset-index-filters-and-sorting.tsx index 864c5d43b..35af06565 100644 --- a/app/components/assets/assets-index/advanced-asset-index-filters-and-sorting.tsx +++ b/app/components/assets/assets-index/advanced-asset-index-filters-and-sorting.tsx @@ -128,6 +128,7 @@ function AdvancedFilter() { useFilterFormValidation(filters, initialFilters); const validation = getValidationState(); + return ( diff --git a/app/components/assets/assets-index/advanced-filters/value-field.tsx b/app/components/assets/assets-index/advanced-filters/value-field.tsx index 5f22c0149..8d5013093 100644 --- a/app/components/assets/assets-index/advanced-filters/value-field.tsx +++ b/app/components/assets/assets-index/advanced-filters/value-field.tsx @@ -330,6 +330,22 @@ export function ValueField({ ); case "array": + if (filter.name === "tags") { + return ( + <> + + + + ); + } + return ( ) { + const data = useLoaderData(); + + // 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 ( + +
+ + {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"} + + +
+ + } + triggerWrapperClassName="w-full" + className="z-[999999]" + selectionMode="none" + defaultValues={selectedIds} + placeholder="Select tags" + onSelectionChange={(selectedTagIds) => { + handleChange(selectedTagIds.join(",")); + }} + /> + ); + } + + return ( + { + 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 */ @@ -1171,6 +1291,21 @@ function ValueEnumField({ ); } + if (fieldName === "tags") { + return ( + <> + + {error &&
{error}
} + + ); + } + return null; } // Define the props for the DateField component diff --git a/app/components/assets/assets-index/advanced-filters/value.client.validator.tsx b/app/components/assets/assets-index/advanced-filters/value.client.validator.tsx index d3b3ad23b..099118533 100644 --- a/app/components/assets/assets-index/advanced-filters/value.client.validator.tsx +++ b/app/components/assets/assets-index/advanced-filters/value.client.validator.tsx @@ -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"), }; /** diff --git a/app/modules/asset/data.server.ts b/app/modules/asset/data.server.ts index 46602aef0..09180060c 100644 --- a/app/modules/asset/data.server.ts +++ b/app/modules/asset/data.server.ts @@ -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, @@ -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"; @@ -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; @@ -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([ @@ -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 }, @@ -374,6 +375,8 @@ export async function advancedModeLoader({ totalLocations, kits, totalKits, + tags, + totalTags, }), { headers, diff --git a/app/modules/asset/query.server.ts b/app/modules/asset/query.server.ts index 3a5064633..18c9975d2 100644 --- a/app/modules/asset/query.server.ts +++ b/app/modules/asset/query.server.ts @@ -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()); @@ -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: diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index c2c6fb7ce..027f84e6c 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -1336,52 +1336,28 @@ export async function getPaginatedAndFilterableAssets({ const { perPage } = cookie; try { - const [ - categoryExcludedSelected, - selectedCategories, - totalCategories, - tagsExcludedSelected, - selectedTags, + const { + tags, totalTags, - locationExcludedSelected, - selectedLocations, + categories, + totalCategories, + locations, totalLocations, - teamMembersData, - ] = await Promise.all([ - db.category.findMany({ - where: { organizationId, id: { notIn: categoriesIds } }, - take: getAllEntries.includes("category") ? undefined : 12, - }), - db.category.findMany({ - where: { organizationId, id: { in: categoriesIds } }, - }), - db.category.count({ where: { organizationId } }), - db.tag.findMany({ - where: { organizationId, id: { notIn: tagsIds } }, - take: getAllEntries.includes("tag") ? undefined : 12, - }), - db.tag.findMany({ - where: { organizationId, id: { in: tagsIds } }, - }), - db.tag.count({ where: { organizationId } }), - // locations - db.location.findMany({ - where: { organizationId, id: { notIn: locationIds } }, - take: getAllEntries.includes("location") ? undefined : 12, - }), - db.location.findMany({ - where: { organizationId, id: { in: locationIds } }, - }), - db.location.count({ where: { organizationId } }), - // team members/custodian - getTeamMemberForCustodianFilter({ - organizationId, - selectedTeamMembers: teamMemberIds, - getAll: getAllEntries.includes("teamMember"), - isSelfService, - userId, - }), - ]); + } = await getEntitiesWithSelectedValues({ + organizationId, + allSelectedEntries: getAllEntries, + selectedCategoryIds: categoriesIds, + selectedTagIds: tagsIds, + selectedLocationIds: locationIds, + }); + + const teamMembersData = await getTeamMemberForCustodianFilter({ + organizationId, + selectedTeamMembers: teamMemberIds, + getAll: getAllEntries.includes("teamMember"), + isSelfService, + userId, + }); const { assets, totalAssets } = await getAssets({ organizationId, @@ -1411,16 +1387,12 @@ export async function getPaginatedAndFilterableAssets({ totalAssets, totalCategories, totalTags, - categories: excludeCategoriesQuery - ? [] - : [...selectedCategories, ...categoryExcludedSelected], - tags: excludeTagsQuery ? [] : [...selectedTags, ...tagsExcludedSelected], + categories: excludeCategoriesQuery ? [] : categories, + tags: excludeTagsQuery ? [] : tags, assets, totalPages, cookie, - locations: excludeLocationQuery - ? [] - : [...selectedLocations, ...locationExcludedSelected], + locations: excludeLocationQuery ? [] : locations, totalLocations, ...teamMembersData, }; @@ -2849,3 +2821,82 @@ export async function getAssetsTabLoaderData({ }); } } + +/** + * This function returns the categories, tags and locations + * including already selected items + * + * e.g if `id1` is selected for tag then it will return `[id1, ...other tags]` for tags + */ +export async function getEntitiesWithSelectedValues({ + organizationId, + allSelectedEntries, + selectedTagIds = [], + selectedCategoryIds = [], + selectedLocationIds = [], +}: { + organizationId: Organization["id"]; + allSelectedEntries: AllowedModelNames[]; + selectedTagIds: Array; + selectedCategoryIds: Array; + selectedLocationIds: Array; +}) { + const [ + // Categories + categoryExcludedSelected, + selectedCategories, + totalCategories, + + // Tags + tagsExcludedSelected, + selectedTags, + totalTags, + + // Locations + locationExcludedSelected, + selectedLocations, + totalLocations, + ] = await Promise.all([ + /** Categories start */ + db.category.findMany({ + where: { organizationId, id: { notIn: selectedCategoryIds } }, + take: allSelectedEntries.includes("category") ? undefined : 12, + }), + db.category.findMany({ + where: { organizationId, id: { in: selectedCategoryIds } }, + }), + db.category.count({ where: { organizationId } }), + /** Categories end */ + + /** Tags start */ + db.tag.findMany({ + where: { organizationId, id: { notIn: selectedTagIds } }, + take: allSelectedEntries.includes("tag") ? undefined : 12, + }), + db.tag.findMany({ + where: { organizationId, id: { in: selectedTagIds } }, + }), + db.tag.count({ where: { organizationId } }), + /** Tags end */ + + /** Location start */ + db.location.findMany({ + where: { organizationId, id: { notIn: selectedLocationIds } }, + take: allSelectedEntries.includes("location") ? undefined : 12, + }), + db.location.findMany({ + where: { organizationId, id: { in: selectedLocationIds } }, + }), + db.location.count({ where: { organizationId } }), + /** Location end */ + ]); + + return { + categories: [...selectedCategories, ...categoryExcludedSelected], + totalCategories, + tags: [...selectedTags, ...tagsExcludedSelected], + totalTags, + locations: [...selectedLocations, ...locationExcludedSelected], + totalLocations, + }; +} diff --git a/app/modules/asset/utils.server.ts b/app/modules/asset/utils.server.ts index 531ec6d72..162dd5dff 100644 --- a/app/modules/asset/utils.server.ts +++ b/app/modules/asset/utils.server.ts @@ -1,8 +1,10 @@ import type { Asset, AssetStatus, Location, Prisma } from "@prisma/client"; +import _ from "lodash"; import { z } from "zod"; import { filterOperatorSchema } from "~/components/assets/assets-index/advanced-filters/schema"; import { getDateTimeFormat } from "~/utils/client-hints"; import { getParamsValues } from "~/utils/list"; +import { parseFilters } from "./query.server"; import type { AdvancedIndexAsset } from "./types"; import type { Column } from "../asset-index-settings/helpers"; @@ -250,3 +252,35 @@ export const ASSET_CSV_HEADERS = [ "valuation", "qrId", ]; + +type AllSelectedValues = { + selectedTags: string[]; + selectedCategory: string[]; + selectedLocation: string[]; +}; + +/** + * This function returns all the selected values from filters + * + * @returns {AllSelectedValues} + */ +export function getAllSelectedValuesFromFilters( + filters: string = "", + columns: Column[] +) { + const parsedFilters = parseFilters(filters, columns); + return parsedFilters.reduce((acc, curr) => { + /* + * We only have to take care of string values because most dropdown has string values only. + * If in future we need any other type of selected values, then we can add them here. + */ + if (typeof curr.value !== "string") { + return acc; + } + + return { + ...acc, + [`selected${_.capitalize(curr.name)}`]: curr.value.split(",") ?? [], + }; + }, {} as AllSelectedValues); +}