Repository: IgnisDa/ryot (language: rust)

Issue:
There's any way to filter any type of media by more than a single collection?
For instance if I wanted to filter all my games that are both "In Progress" and with the "Steam" collection, is there a way to do it currently?


Discussion / hints:
Not yet, but should be fairly easy to add.

Fix patch:
diff --git a/apps/backend/src/miscellaneous.rs b/apps/backend/src/miscellaneous.rs
index a54ec45808..62730be19e 100644
--- a/apps/backend/src/miscellaneous.rs
+++ b/apps/backend/src/miscellaneous.rs
@@ -529,7 +529,7 @@ enum MediaGeneralFilter {
 #[derive(Debug, Serialize, Deserialize, InputObject, Clone)]
 struct MediaFilter {
     general: Option<MediaGeneralFilter>,
-    collection: Option<String>,
+    collections: Option<Vec<String>>,
 }
 
 #[derive(Debug, Serialize, Deserialize, InputObject, Clone)]
@@ -2297,7 +2297,7 @@ impl MiscellaneousService {
                 )
             })
             .apply_if(
-                input.filter.clone().and_then(|f| f.collection),
+                input.filter.clone().and_then(|f| f.collections),
                 |query, v| {
                     apply_collection_filter(
                         query,
@@ -6344,7 +6344,7 @@ impl MiscellaneousService {
                 )
             })
             .apply_if(
-                input.filter.clone().and_then(|f| f.collection),
+                input.filter.clone().and_then(|f| f.collections),
                 |query, v| {
                     apply_collection_filter(
                         query,
@@ -6411,7 +6411,7 @@ impl MiscellaneousService {
                 )
             })
             .apply_if(
-                input.filter.clone().and_then(|f| f.collection),
+                input.filter.clone().and_then(|f| f.collections),
                 |query, v| {
                     apply_collection_filter(
                         query,
diff --git a/apps/backend/src/utils.rs b/apps/backend/src/utils.rs
index e988b7de80..f1603dd3c2 100644
--- a/apps/backend/src/utils.rs
+++ b/apps/backend/src/utils.rs
@@ -9,6 +9,7 @@ use axum::{
     Extension, RequestPartsExt,
 };
 use chrono::{NaiveDate, Utc};
+use database::AliasedCollectionToEntity;
 use itertools::Itertools;
 use openidconnect::{
     core::{CoreClient, CoreProviderMetadata},
@@ -24,6 +25,7 @@ use sea_orm::{
     ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait,
     QueryFilter, QuerySelect, QueryTrait, Select,
 };
+use sea_query::{Expr, PgFunc};
 
 use crate::{
     background::{ApplicationJob, CoreApplicationJob},
@@ -371,7 +373,7 @@ pub async fn add_entity_to_collection(
 
 pub fn apply_collection_filter<E, C, D>(
     query: Select<E>,
-    collection_id: Option<String>,
+    collection_id: Option<Vec<String>>,
     invert_collection: Option<bool>,
     entity_column: C,
     id_column: D,
@@ -382,11 +384,25 @@ where
     D: ColumnTrait,
 {
     query.apply_if(collection_id, |query, v| {
+        let unique_collections = v.into_iter().unique().collect_vec();
+        let count = unique_collections.len() as i32;
         let subquery = CollectionToEntity::find()
             .select_only()
             .column(id_column)
-            .filter(collection_to_entity::Column::CollectionId.eq(v))
+            .filter(
+                Expr::col((
+                    AliasedCollectionToEntity::Table,
+                    collection_to_entity::Column::CollectionId,
+                ))
+                .eq(PgFunc::any(unique_collections)),
+            )
             .filter(id_column.is_not_null())
+            .group_by(id_column)
+            .having(
+                collection_to_entity::Column::CollectionId
+                    .count()
+                    .eq(Expr::val(count)),
+            )
             .into_query();
         if invert_collection.unwrap_or_default() {
             query.filter(entity_column.not_in_subquery(subquery))
diff --git a/apps/frontend/app/components/common.tsx b/apps/frontend/app/components/common.tsx
index 3a1b1c0ccf..207f339c83 100644
--- a/apps/frontend/app/components/common.tsx
+++ b/apps/frontend/app/components/common.tsx
@@ -10,6 +10,7 @@ import {
 	Group,
 	Image,
 	Modal,
+	MultiSelect,
 	SimpleGrid,
 	Stack,
 	Text,
@@ -38,6 +39,7 @@ import {
 	useAppSearchParam,
 	useCoreDetails,
 	useFallbackImageUrl,
+	useUserCollections,
 } from "~/lib/hooks";
 import classes from "~/styles/common.module.css";
 
@@ -215,3 +217,30 @@ export const FiltersModal = (props: {
 		</Modal>
 	);
 };
+
+export const CollectionsFilter = (props: {
+	cookieName: string;
+	collections?: string[];
+}) => {
+	const collections = useUserCollections();
+	const [_, { setP }] = useAppSearchParam(props.cookieName);
+
+	return (
+		<MultiSelect
+			placeholder="Select a collection"
+			defaultValue={props.collections}
+			data={[
+				{
+					group: "My collections",
+					items: collections.map((c) => ({
+						value: c.id.toString(),
+						label: c.name,
+					})),
+				},
+			]}
+			onChange={(v) => setP("collections", v.join(","))}
+			clearable
+			searchable
+		/>
+	);
+};
diff --git a/apps/frontend/app/lib/generals.ts b/apps/frontend/app/lib/generals.ts
index 40384d2f41..5fdc1de146 100644
--- a/apps/frontend/app/lib/generals.ts
+++ b/apps/frontend/app/lib/generals.ts
@@ -12,6 +12,7 @@ import {
 	SetLot,
 	UserMetadataDetailsDocument,
 } from "@ryot/generated/graphql/backend/graphql";
+import { isString } from "@ryot/ts-utils";
 import {
 	IconBook,
 	IconBook2,
@@ -31,6 +32,15 @@ import relativeTime from "dayjs/plugin/relativeTime";
 import { GraphQLClient } from "graphql-request";
 import Cookies from "js-cookie";
 import { match } from "ts-pattern";
+import { z } from "zod";
+
+declare global {
+	interface Window {
+		umami?: {
+			track: typeof Umami.trackEvent;
+		};
+	}
+}
 
 dayjs.extend(relativeTime);
 dayjs.extend(duration);
@@ -38,6 +48,11 @@ dayjs.extend(localizedFormat);
 
 export { dayjs as dayjsLib };
 
+export const commaDelimitedString = z
+	.string()
+	.optional()
+	.transform((v) => (isString(v) ? v.split(",") : undefined));
+
 export const CurrentWorkoutKey = "CurrentWorkout";
 export const LOGO_IMAGE_URL =
 	"https://raw.githubusercontent.com/IgnisDa/ryot/main/libs/assets/icon-512x512.png";
@@ -49,14 +64,6 @@ export const queryClient = new QueryClient({
 	defaultOptions: { queries: { staleTime: Number.POSITIVE_INFINITY } },
 });
 
-declare global {
-	interface Window {
-		umami?: {
-			track: typeof Umami.trackEvent;
-		};
-	}
-}
-
 export const getSetColor = (l: SetLot) =>
 	match(l)
 		.with(SetLot.WarmUp, () => "yellow")
diff --git a/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx b/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx
index eb41a04fac..533a380102 100644
--- a/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx
+++ b/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx
@@ -55,16 +55,16 @@ import { z } from "zod";
 import { zx } from "zodix";
 import {
 	ApplicationGrid,
+	CollectionsFilter,
 	DebouncedSearchInput,
 	FiltersModal,
 } from "~/components/common";
 import { BaseMediaDisplayItem, MetadataDisplayItem } from "~/components/media";
-import { Verb, getLot, getVerb } from "~/lib/generals";
+import { Verb, commaDelimitedString, getLot, getVerb } from "~/lib/generals";
 import {
 	useAppSearchParam,
 	useApplicationEvents,
 	useCoreDetails,
-	useUserCollections,
 	useUserDetails,
 	useUserPreferences,
 } from "~/lib/hooks";
@@ -135,7 +135,7 @@ export const loader = unstable_defineLoader(async ({ request, params }) => {
 				generalFilter: z
 					.nativeEnum(MediaGeneralFilter)
 					.default(defaultFilters.mineGeneralFilter),
-				collection: z.string().optional(),
+				collections: commaDelimitedString,
 				invertCollection: zx.BoolAsString.optional(),
 			});
 			const { metadataList } = await serverGqlService.authenticatedRequest(
@@ -148,7 +148,7 @@ export const loader = unstable_defineLoader(async ({ request, params }) => {
 						sort: { order: urlParse.sortOrder, by: urlParse.sortBy },
 						filter: {
 							general: urlParse.generalFilter,
-							collection: urlParse.collection,
+							collections: urlParse.collections,
 						},
 						invertCollection: urlParse.invertCollection,
 					},
@@ -221,7 +221,7 @@ export default function Page() {
 			defaultFilters.mineGeneralFilter ||
 		loaderData.mediaList?.url.sortOrder !== defaultFilters.mineSortOrder ||
 		loaderData.mediaList?.url.sortBy !== defaultFilters.mineSortBy ||
-		loaderData.mediaList?.url.collection !== defaultFilters.mineCollection;
+		loaderData.mediaList?.url.collections !== defaultFilters.mineCollection;
 
 	return (
 		<Container>
@@ -524,7 +524,6 @@ const MediaSearchItem = (props: {
 
 const FiltersModalForm = () => {
 	const loaderData = useLoaderData<typeof loader>();
-	const collections = useUserCollections();
 	const [_, { setP }] = useAppSearchParam(loaderData.cookieName);
 
 	if (!loaderData.mediaList) return null;
@@ -578,22 +577,9 @@ const FiltersModalForm = () => {
 				</ActionIcon>
 			</Flex>
 			<Flex gap="xs" align="center">
-				<Select
-					flex={1}
-					placeholder="Select a collection"
-					defaultValue={loaderData.mediaList.url.collection?.toString()}
-					data={[
-						{
-							group: "My collections",
-							items: collections.map((c) => ({
-								value: c.id.toString(),
-								label: c.name,
-							})),
-						},
-					]}
-					onChange={(v) => setP("collection", v)}
-					clearable
-					searchable
+				<CollectionsFilter
+					cookieName={loaderData.cookieName}
+					collections={loaderData.mediaList.url.collections}
 				/>
 				<Checkbox
 					label="Invert"
diff --git a/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx b/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx
index 2025dbfa20..3a3da4d16b 100644
--- a/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx
+++ b/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx
@@ -46,6 +46,7 @@ import { z } from "zod";
 import { zx } from "zodix";
 import {
 	ApplicationGrid,
+	CollectionsFilter,
 	DebouncedSearchInput,
 	FiltersModal,
 } from "~/components/common";
@@ -53,11 +54,8 @@ import {
 	BaseMediaDisplayItem,
 	MetadataGroupDisplayItem,
 } from "~/components/media";
-import {
-	useAppSearchParam,
-	useCoreDetails,
-	useUserCollections,
-} from "~/lib/hooks";
+import { commaDelimitedString } from "~/lib/generals";
+import { useAppSearchParam, useCoreDetails } from "~/lib/hooks";
 import {
 	getEnhancedCookieName,
 	serverGqlService,
@@ -94,7 +92,7 @@ export const loader = unstable_defineLoader(async ({ request, params }) => {
 			const urlParse = zx.parseQuery(request, {
 				sortBy: z.nativeEnum(PersonSortBy).default(defaultFilters.sortBy),
 				orderBy: z.nativeEnum(GraphqlSortOrder).default(defaultFilters.orderBy),
-				collection: z.string().optional(),
+				collections: commaDelimitedString,
 				invertCollection: zx.BoolAsString.optional(),
 			});
 			const { metadataGroupsList } =
@@ -105,7 +103,7 @@ export const loader = unstable_defineLoader(async ({ request, params }) => {
 						input: {
 							search: { page, query },
 							sort: { by: urlParse.sortBy, order: urlParse.orderBy },
-							filter: { collection: urlParse.collection },
+							filter: { collections: urlParse.collections },
 							invertCollection: urlParse.invertCollection,
 						},
 					},
@@ -188,7 +186,7 @@ export default function Page() {
 								color={
 									loaderData.list?.url.orderBy !== defaultFilters.orderBy ||
 									loaderData.list?.url.sortBy !== defaultFilters.sortBy ||
-									isString(loaderData.list?.url.collection)
+									isString(loaderData.list?.url.collections)
 										? "blue"
 										: "gray"
 								}
@@ -343,7 +341,6 @@ const commitGroup = async (
 
 const FiltersModalForm = () => {
 	const loaderData = useLoaderData<typeof loader>();
-	const collections = useUserCollections();
 	const [_, { setP }] = useAppSearchParam(loaderData.cookieName);
 
 	if (!loaderData.list) return null;
@@ -375,22 +372,9 @@ const FiltersModalForm = () => {
 				</ActionIcon>
 			</Flex>
 			<Flex gap="xs" align="center">
-				<Select
-					flex={1}
-					placeholder="Select a collection"
-					defaultValue={loaderData.list.url.collection?.toString()}
-					data={[
-						{
-							group: "My collections",
-							items: collections.map((c) => ({
-								value: c.id.toString(),
-								label: c.name,
-							})),
-						},
-					]}
-					onChange={(v) => setP("collection", v)}
-					clearable
-					searchable
+				<CollectionsFilter
+					cookieName={loaderData.cookieName}
+					collections={loaderData.list.url.collections}
 				/>
 				<Checkbox
 					label="Invert"
diff --git a/apps/frontend/app/routes/_dashboard.media.people.$action.tsx b/apps/frontend/app/routes/_dashboard.media.people.$action.tsx
index 24b260a95d..3677470d51 100644
--- a/apps/frontend/app/routes/_dashboard.media.people.$action.tsx
+++ b/apps/frontend/app/routes/_dashboard.media.people.$action.tsx
@@ -44,15 +44,13 @@ import { z } from "zod";
 import { zx } from "zodix";
 import {
 	ApplicationGrid,
+	CollectionsFilter,
 	DebouncedSearchInput,
 	FiltersModal,
 } from "~/components/common";
 import { BaseMediaDisplayItem, PersonDisplayItem } from "~/components/media";
-import {
-	useAppSearchParam,
-	useCoreDetails,
-	useUserCollections,
-} from "~/lib/hooks";
+import { commaDelimitedString } from "~/lib/generals";
+import { useAppSearchParam, useCoreDetails } from "~/lib/hooks";
 import {
 	getEnhancedCookieName,
 	redirectUsingEnhancedCookieSearchParams,
@@ -96,7 +94,7 @@ export const loader = unstable_defineLoader(async ({ request, params }) => {
 			const urlParse = zx.parseQuery(request, {
 				sortBy: z.nativeEnum(PersonSortBy).default(defaultFilters.sortBy),
 				orderBy: z.nativeEnum(GraphqlSortOrder).default(defaultFilters.orderBy),
-				collection: z.string().optional(),
+				collections: commaDelimitedString,
 				invertCollection: zx.BoolAsString.optional(),
 			});
 			const { peopleList } = await serverGqlService.authenticatedRequest(
@@ -106,9 +104,7 @@ export const loader = unstable_defineLoader(async ({ request, params }) => {
 					input: {
 						search: { page, query },
 						sort: { by: urlParse.sortBy, order: urlParse.orderBy },
-						filter: {
-							collection: urlParse.collection,
-						},
+						filter: { collections: urlParse.collections },
 						invertCollection: urlParse.invertCollection,
 					},
 				},
@@ -370,7 +366,6 @@ const commitPerson = async (
 
 const FiltersModalForm = () => {
 	const loaderData = useLoaderData<typeof loader>();
-	const collections = useUserCollections();
 	const [_, { setP }] = useAppSearchParam(loaderData.cookieName);
 
 	if (!loaderData.peopleList) return null;
@@ -402,22 +397,9 @@ const FiltersModalForm = () => {
 				</ActionIcon>
 			</Flex>
 			<Flex gap="xs" align="center">
-				<Select
-					flex={1}
-					placeholder="Select a collection"
-					defaultValue={loaderData.peopleList.url.collection?.toString()}
-					data={[
-						{
-							group: "My collections",
-							items: collections.map((c) => ({
-								value: c.id.toString(),
-								label: c.name,
-							})),
-						},
-					]}
-					onChange={(v) => setP("collection", v)}
-					clearable
-					searchable
+				<CollectionsFilter
+					cookieName={loaderData.cookieName}
+					collections={loaderData.peopleList.url.collections}
 				/>
 				<Checkbox
 					label="Invert"
diff --git a/apps/frontend/app/routes/_dashboard.settings.integrations.tsx b/apps/frontend/app/routes/_dashboard.settings.integrations.tsx
index 81dd33c04c..3bf052f110 100644
--- a/apps/frontend/app/routes/_dashboard.settings.integrations.tsx
+++ b/apps/frontend/app/routes/_dashboard.settings.integrations.tsx
@@ -33,7 +33,7 @@ import {
 	UserIntegrationsDocument,
 	type UserIntegrationsQuery,
 } from "@ryot/generated/graphql/backend/graphql";
-import { changeCase, isString, processSubmission } from "@ryot/ts-utils";
+import { changeCase, processSubmission } from "@ryot/ts-utils";
 import {
 	IconCheck,
 	IconCopy,
@@ -49,7 +49,7 @@ import { withQuery } from "ufo";
 import { z } from "zod";
 import { zx } from "zodix";
 import { confirmWrapper } from "~/components/confirmation";
-import { dayjsLib } from "~/lib/generals";
+import { commaDelimitedString, dayjsLib } from "~/lib/generals";
 import { useConfirmSubmit, useUserCollections } from "~/lib/hooks";
 import { createToastHeaders, serverGqlService } from "~/lib/utilities.server";
 
@@ -145,11 +145,6 @@ export const action = unstable_defineAction(async ({ request }) => {
 const MINIMUM_PROGRESS = "2";
 const MAXIMUM_PROGRESS = "95";
 
-const commaDelimitedString = z
-	.string()
-	.optional()
-	.transform((v) => (isString(v) ? v.split(",") : undefined));
-
 const createSchema = z.object({
 	provider: z.nativeEnum(IntegrationProvider),
 	minimumProgress: z.string().optional(),
diff --git a/libs/generated/src/graphql/backend/graphql.ts b/libs/generated/src/graphql/backend/graphql.ts
index 8f8fe404a5..dd50d61992 100644
--- a/libs/generated/src/graphql/backend/graphql.ts
+++ b/libs/generated/src/graphql/backend/graphql.ts
@@ -791,7 +791,7 @@ export type MediaCollectionContentsResults = {
 };
 
 export type MediaFilter = {
-  collection?: InputMaybe<Scalars['String']['input']>;
+  collections?: InputMaybe<Array<Scalars['String']['input']>>;
   general?: InputMaybe<MediaGeneralFilter>;
 };
 


Test patch: