diff --git a/client/.eslintrc.json b/client/.eslintrc.json
index 3dccc2141e..74c8b26452 100644
--- a/client/.eslintrc.json
+++ b/client/.eslintrc.json
@@ -126,6 +126,7 @@
"craco",
"crosshair",
"dagre",
+ "dataconnector",
"dataset",
"datasets",
"dataverse",
diff --git a/client/src/components/PageNav.tsx b/client/src/components/PageNav.tsx
index fb80614e1e..e334bacd9f 100644
--- a/client/src/components/PageNav.tsx
+++ b/client/src/components/PageNav.tsx
@@ -16,12 +16,13 @@
* limitations under the License.
*/
import cx from "classnames";
-import { Eye, Sliders } from "react-bootstrap-icons";
+import { Eye, Search, Sliders } from "react-bootstrap-icons";
import { Nav, NavItem } from "reactstrap";
import RenkuNavLinkV2 from "./RenkuNavLinkV2";
export interface PageNavOptions {
overviewUrl: string;
+ searchUrl: string;
settingsUrl: string;
}
export default function PageNav({ options }: { options: PageNavOptions }) {
@@ -33,18 +34,29 @@ export default function PageNav({ options }: { options: PageNavOptions }) {
end
to={options.overviewUrl}
title="Overview"
- data-cy="nav-link-overview"
+ data-cy="group-overview-link"
>
Overview
+
+
+
+ Search
+
+
Settings
diff --git a/client/src/components/keywords/KeywordBadge.tsx b/client/src/components/keywords/KeywordBadge.tsx
new file mode 100644
index 0000000000..a4227172ac
--- /dev/null
+++ b/client/src/components/keywords/KeywordBadge.tsx
@@ -0,0 +1,65 @@
+/*!
+ * Copyright 2025 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import cx from "classnames";
+import RenkuBadge from "../renkuBadge/RenkuBadge";
+import { XCircle } from "react-bootstrap-icons";
+
+interface KeywordBadgeProps {
+ children?: React.ReactNode;
+ className?: string;
+ "data-cy"?: string;
+ highlighted?: boolean;
+ removable?: boolean;
+ removeHandler?: () => void;
+}
+
+export default function KeywordBadge({
+ children,
+ className,
+ "data-cy": dataCy = "keyword",
+ highlighted,
+ removable = true,
+ removeHandler,
+}: KeywordBadgeProps) {
+ const remove =
+ removable && removeHandler ? (
+
+ ) : null;
+
+ return (
+
+ {children}
+ {remove}
+
+ );
+}
diff --git a/client/src/components/keywords/KeywordContainer.tsx b/client/src/components/keywords/KeywordContainer.tsx
new file mode 100644
index 0000000000..50ca1dc51f
--- /dev/null
+++ b/client/src/components/keywords/KeywordContainer.tsx
@@ -0,0 +1,47 @@
+/*!
+ * Copyright 2025 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import cx from "classnames";
+
+interface KeywordContainerProps {
+ children?: React.ReactNode;
+ className?: string;
+ "data-cy"?: string;
+}
+
+export default function KeywordContainer({
+ children,
+ className,
+ "data-cy": dataCy,
+}: KeywordContainerProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/client/src/components/renkuBadge/RenkuBadge.tsx b/client/src/components/renkuBadge/RenkuBadge.tsx
new file mode 100644
index 0000000000..aa232e9a62
--- /dev/null
+++ b/client/src/components/renkuBadge/RenkuBadge.tsx
@@ -0,0 +1,59 @@
+/*!
+ * Copyright 2025 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import cx from "classnames";
+
+interface RenkuBadgeProps {
+ children?: React.ReactNode;
+ className?: string;
+ color?: "success" | "danger" | "warning" | "light";
+ "data-cy"?: string;
+ pills?: boolean;
+}
+
+export default function RenkuBadge({
+ children,
+ className,
+ color = "light",
+ "data-cy": dataCy,
+ pills = false,
+}: RenkuBadgeProps) {
+ const colorClasses =
+ color === "success"
+ ? ["border-success", "bg-success-subtle", "text-success-emphasis"]
+ : color === "danger"
+ ? ["border-danger", "bg-danger-subtle", "text-danger-emphasis"]
+ : color === "warning"
+ ? ["border-warning", "bg-warning-subtle", "text-warning-emphasis"]
+ : ["border-dark-subtle", "bg-light", "text-dark-emphasis"];
+
+ const baseClasses = [
+ "border",
+ "badge",
+ pills ? "rounded-pill" : "",
+ ...colorClasses,
+ ];
+
+ const finalClasses = className ? cx(className, baseClasses) : cx(baseClasses);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformation.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformation.tsx
index d2591d9da5..263edecf63 100644
--- a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformation.tsx
+++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformation.tsx
@@ -30,28 +30,28 @@ import {
} from "react-bootstrap-icons";
import { Link, generatePath } from "react-router";
import { Badge, Card, CardBody, CardHeader } from "reactstrap";
-
+import KeywordBadge from "~/components/keywords/KeywordBadge";
+import KeywordContainer from "~/components/keywords/KeywordContainer";
import { Loader } from "../../../../components/Loader";
import { TimeCaption } from "../../../../components/TimeCaption";
import { UnderlineArrowLink } from "../../../../components/buttons/Button";
import { ABSOLUTE_ROUTES } from "../../../../routing/routes.constants";
import projectPreviewImg from "../../../../styles/assets/projectImagePreview.svg";
import type {
+ Project,
ProjectMemberListResponse,
ProjectMemberResponse,
} from "../../../projectsV2/api/projectV2.api";
import {
useGetNamespacesByNamespaceSlugQuery,
- useGetProjectsByProjectIdQuery,
useGetProjectsByProjectIdMembersQuery,
+ useGetProjectsByProjectIdQuery,
} from "../../../projectsV2/api/projectV2.enhanced-api";
-import type { Project } from "../../../projectsV2/api/projectV2.api";
import { useProject } from "../../ProjectPageContainer/ProjectPageContainer";
import { getMemberNameToDisplay, toSortedMembers } from "../../utils/roleUtils";
import useProjectPermissions from "../../utils/useProjectPermissions.hook";
-
-import ProjectInformationButton from "./ProjectInformationButton";
import styles from "./ProjectInformation.module.scss";
+import ProjectInformationButton from "./ProjectInformationButton";
const MAX_MEMBERS_DISPLAYED = 5;
@@ -164,6 +164,12 @@ export default function ProjectInformation({
}),
[namespace?.namespace_kind, project.namespace]
);
+ const keywordsSorted = useMemo(() => {
+ if (!project.keywords) return [];
+ return project.keywords
+ .map((keyword) => keyword.trim())
+ .sort((a, b) => a.localeCompare(b));
+ }, [project.keywords]);
const information = (
+ Keywords help orginizing your work and are available to search. You
+ can use them to group elements that belong together or to create
+ specific topics. You can add multiple keywords.
+
+
+
{flatDataConnector.dataConnectorId == null &&
hasPasswordFieldWithInput &&
validationResult?.isSuccess && (
diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorModal/index.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorModal/index.tsx
index 2e8c15eaf5..c653354fe1 100644
--- a/client/src/features/dataConnectorsV2/components/DataConnectorModal/index.tsx
+++ b/client/src/features/dataConnectorsV2/components/DataConnectorModal/index.tsx
@@ -21,7 +21,6 @@ import cx from "classnames";
import { useCallback, useEffect } from "react";
import { Database, XLg } from "react-bootstrap-icons";
import { Button, ModalBody, ModalFooter, ModalHeader } from "reactstrap";
-
import { ErrorAlert } from "../../../../components/Alert";
import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert";
import { Loader } from "../../../../components/Loader";
@@ -43,11 +42,10 @@ import {
dataConnectorToFlattened,
getDataConnectorScope,
} from "../dataConnector.utils";
+import styles from "./DataConnectorModal.module.scss";
import DataConnectorModalBody from "./DataConnectorModalBody";
import DataConnectorModalFooter from "./DataConnectorModalFooter";
-import styles from "./DataConnectorModal.module.scss";
-
export function DataConnectorModalBodyAndFooter({
dataConnector = null,
isOpen,
@@ -108,8 +106,9 @@ export function DataConnectorModalBodyAndFooter({
) : (
)}
diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorView.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorView.tsx
index 55dc1933b0..8332eb736c 100644
--- a/client/src/features/dataConnectorsV2/components/DataConnectorView.tsx
+++ b/client/src/features/dataConnectorsV2/components/DataConnectorView.tsx
@@ -15,6 +15,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
import { skipToken } from "@reduxjs/toolkit/query";
import cx from "classnames";
import { useMemo, useRef } from "react";
@@ -29,12 +30,9 @@ import {
PersonBadge,
} from "react-bootstrap-icons";
import { Link, generatePath } from "react-router";
-import {
- Badge,
- Offcanvas,
- OffcanvasBody,
- UncontrolledTooltip,
-} from "reactstrap";
+import { Offcanvas, OffcanvasBody, UncontrolledTooltip } from "reactstrap";
+import KeywordBadge from "~/components/keywords/KeywordBadge";
+import KeywordContainer from "~/components/keywords/KeywordContainer";
import { WarnAlert } from "../../../components/Alert";
import { Clipboard } from "../../../components/clipboard/Clipboard";
import { Loader } from "../../../components/Loader";
@@ -432,6 +430,13 @@ function DataConnectorViewMetadata({
[dataConnector.storage.configuration, scope]
);
+ const sortedKeywords = useMemo(() => {
+ if (!dataConnector.keywords) return [];
+ return dataConnector.keywords
+ .map((keyword) => keyword.trim())
+ .sort((a, b) => a.localeCompare(b));
+ }, [dataConnector.keywords]);
+
const dataConnectorSource = useGetDataConnectorSource(dataConnector);
return (
@@ -544,13 +549,11 @@ function DataConnectorViewMetadata({
{dataConnector.keywords && dataConnector.keywords.length > 0 && (
-
+ );
+}
diff --git a/client/src/features/groupsV2/search/GroupSearchQueryInput.tsx b/client/src/features/groupsV2/search/GroupSearchQueryInput.tsx
new file mode 100644
index 0000000000..69eede7fa5
--- /dev/null
+++ b/client/src/features/groupsV2/search/GroupSearchQueryInput.tsx
@@ -0,0 +1,115 @@
+/*!
+ * Copyright 2025 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import cx from "classnames";
+import { useCallback, useEffect } from "react";
+import { Search, XCircleFill } from "react-bootstrap-icons";
+import { Controller, useForm } from "react-hook-form";
+import { useSearchParams } from "react-router";
+import { Button, Form, InputGroup } from "reactstrap";
+import { FILTER_PAGE, FILTER_QUERY } from "./groupsSearch.constants";
+
+interface SearchBarForm {
+ query: string;
+}
+export default function GroupSearchQueryInput() {
+ // Set the input properly
+ const [searchParams, setSearchParams] = useSearchParams();
+ const query = searchParams.get(FILTER_QUERY.name) ?? "";
+
+ const { control, register, handleSubmit, setFocus } = useForm({
+ defaultValues: { query },
+ });
+
+ // focus search input when loading the component
+ useEffect(() => {
+ setFocus("query");
+ }, [setFocus]);
+
+ const onClick = useCallback(() => {
+ const newParams = new URLSearchParams(searchParams);
+ newParams.set(FILTER_QUERY.name, "");
+ setSearchParams(newParams);
+ }, [searchParams, setSearchParams]);
+
+ const onSubmit = useCallback(
+ (data: SearchBarForm) => {
+ const newParams = new URLSearchParams(searchParams);
+ newParams.set(FILTER_QUERY.name, data.query);
+ setSearchParams(newParams, { replace: true });
+
+ const page_default_value = (
+ FILTER_PAGE.defaultValue as number
+ ).toString();
+ if (newParams.get(FILTER_PAGE.name) !== page_default_value) {
+ newParams.set(FILTER_PAGE.name, page_default_value);
+ }
+ setSearchParams(newParams);
+ },
+ [searchParams, setSearchParams]
+ );
+
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/client/src/features/groupsV2/search/GroupSearchResultRecap.tsx b/client/src/features/groupsV2/search/GroupSearchResultRecap.tsx
new file mode 100644
index 0000000000..22e701d1e1
--- /dev/null
+++ b/client/src/features/groupsV2/search/GroupSearchResultRecap.tsx
@@ -0,0 +1,56 @@
+/*!
+ * Copyright 2025 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import cx from "classnames";
+import { useSearchParams } from "react-router";
+import { useGroupSearch } from "./groupSearch.hook";
+import { getQueryHumanReadable } from "./groupSearch.utils";
+import { FILTER_QUERY } from "./groupsSearch.constants";
+
+export default function GroupSearchResultRecap() {
+ // Get the query and results data
+ const [searchParams] = useSearchParams();
+ const { data, isFetching } = useGroupSearch();
+ const total = data?.pagingInfo.totalResult;
+ const filters = getQueryHumanReadable(searchParams);
+ const query = searchParams.get(FILTER_QUERY.name) ?? "";
+
+ return (
+
+ );
+}
diff --git a/client/src/features/groupsV2/search/GroupSearchResults.tsx b/client/src/features/groupsV2/search/GroupSearchResults.tsx
new file mode 100644
index 0000000000..fd0e5805bb
--- /dev/null
+++ b/client/src/features/groupsV2/search/GroupSearchResults.tsx
@@ -0,0 +1,216 @@
+/*!
+ * Copyright 2025 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import cx from "classnames";
+import { useMemo } from "react";
+import {
+ Database,
+ Eye,
+ Folder2Open,
+ Globe2,
+ Lock,
+ Pencil,
+ Question,
+} from "react-bootstrap-icons";
+import { generatePath, Link, useSearchParams } from "react-router";
+import { Col, ListGroup, Row } from "reactstrap";
+import Pagination from "~/components/Pagination";
+import { TimeCaption } from "~/components/TimeCaption";
+import KeywordBadge from "~/components/keywords/KeywordBadge";
+import KeywordContainer from "~/components/keywords/KeywordContainer";
+import { ShowGlobalDataConnector } from "~/features/searchV2/components/SearchV2Results";
+import UserAvatar from "~/features/usersV2/show/UserAvatar";
+import { ABSOLUTE_ROUTES } from "~/routing/routes.constants";
+import { useGroupSearch } from "./groupSearch.hook";
+import { GroupSearchEntity } from "./groupSearch.types";
+import { FILTER_PAGE, FILTER_PER_PAGE } from "./groupsSearch.constants";
+
+export default function GroupSearchResults() {
+ // Load and visualize the search results
+ const [searchParams] = useSearchParams();
+ const { data } = useGroupSearch();
+
+ return (
+
+
+
+
+ );
+}
+
+function SearchResultListItemIcon({ item }: { item: GroupSearchEntity }) {
+ return item.type === "Project" ? (
+
+ ) : item.type === "DataConnector" ? (
+
+ ) : (
+
+ );
+}
diff --git a/client/src/features/groupsV2/search/GroupV2Search.tsx b/client/src/features/groupsV2/search/GroupV2Search.tsx
new file mode 100644
index 0000000000..1fb7b312bb
--- /dev/null
+++ b/client/src/features/groupsV2/search/GroupV2Search.tsx
@@ -0,0 +1,71 @@
+/*!
+ * Copyright 2025 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import cx from "classnames";
+import { useEffect } from "react";
+import { useSearchParams } from "react-router";
+import { Col, Row } from "reactstrap";
+import { Loader } from "~/components/Loader";
+import { getSearchQueryMissingFilters } from "./groupSearch.utils";
+import GroupSearchFilters from "./GroupSearchFilters";
+import GroupSearchQueryInput from "./GroupSearchQueryInput";
+import GroupSearchResultRecap from "./GroupSearchResultRecap";
+import GroupSearchResults from "./GroupSearchResults";
+
+export default function GroupV2Search() {
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ // Add any missing default parameter. There shouldn't be anything content-dependant.
+ useEffect(() => {
+ const missingParams = getSearchQueryMissingFilters(searchParams);
+ if (Object.keys(missingParams).length > 0) {
+ const newSearchParams = new URLSearchParams(searchParams);
+ Object.entries(missingParams).forEach(([key, value]) => {
+ newSearchParams.set(key, String(value));
+ });
+ setSearchParams(newSearchParams, { replace: true });
+ }
+ }, [searchParams, setSearchParams]);
+
+ // This prevents loading the page on semi-ready content and sending unnecessary requests.
+ const missingParams = getSearchQueryMissingFilters(searchParams);
+ if (Object.keys(missingParams).length > 0) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/features/groupsV2/search/groupSearch.hook.ts b/client/src/features/groupsV2/search/groupSearch.hook.ts
new file mode 100644
index 0000000000..da4fd34e01
--- /dev/null
+++ b/client/src/features/groupsV2/search/groupSearch.hook.ts
@@ -0,0 +1,35 @@
+/*!
+ * Copyright 2025 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useMemo } from "react";
+import { useSearchParams } from "react-router";
+import { useGetSearchQueryQuery } from "~/features/searchV2/api/searchV2Api.api";
+import { useGroup } from "../show/GroupPageContainer";
+import { generateQueryParams } from "./groupSearch.utils";
+
+export function useGroupSearch(ignoredParams?: string[]) {
+ const [searchParams] = useSearchParams();
+ const { group } = useGroup();
+
+ const params = useMemo(
+ () => generateQueryParams(searchParams, group.slug, ignoredParams),
+ [group.slug, ignoredParams, searchParams]
+ );
+
+ return useGetSearchQueryQuery({ params });
+}
diff --git a/client/src/features/groupsV2/search/groupSearch.types.ts b/client/src/features/groupsV2/search/groupSearch.types.ts
new file mode 100644
index 0000000000..0d079a1f6e
--- /dev/null
+++ b/client/src/features/groupsV2/search/groupSearch.types.ts
@@ -0,0 +1,62 @@
+/*!
+ * Copyright 2025 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { SearchEntity } from "~/features/searchV2/api/searchV2Api.generated-api";
+
+export type GroupSearchEntity = Exclude<
+ SearchEntity,
+ { type: "Group" | "User" }
+>;
+
+interface FilterValue {
+ label: React.ReactNode;
+ quantity?: number;
+ value: string;
+}
+
+type FilterType = "enum" | "number" | "string";
+
+interface BaseFilter {
+ doNotPassEmpty?: boolean;
+ label: React.ReactNode;
+ mustQuote?: boolean;
+ name: string;
+ type: FilterType;
+ validFor?: GroupSearchEntity["type"][];
+}
+
+export interface StringFilter extends BaseFilter {
+ defaultValue?: string;
+ type: "string";
+}
+
+export interface EnumFilter extends BaseFilter {
+ allowedValues: FilterValue[];
+ allowSelectMany?: boolean;
+ defaultValue?: string;
+ type: "enum";
+}
+
+export interface NumberFilter extends BaseFilter {
+ defaultValue?: number;
+ maxValues?: number;
+ minValues?: number;
+ type: "number";
+}
+
+export type Filter = StringFilter | EnumFilter | NumberFilter;
diff --git a/client/src/features/groupsV2/search/groupSearch.utils.tsx b/client/src/features/groupsV2/search/groupSearch.utils.tsx
new file mode 100644
index 0000000000..33e5daabac
--- /dev/null
+++ b/client/src/features/groupsV2/search/groupSearch.utils.tsx
@@ -0,0 +1,142 @@
+/*!
+ * Copyright 2025 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { SearchQuery } from "~/features/searchV2/api/searchV2Api.generated-api";
+import {
+ KEY_VALUE_SEPARATOR,
+ TERM_SEPARATOR,
+} from "../../searchV2/searchV2.constants";
+import { SearchQueryFilters } from "~/features/searchV2/searchV2.types";
+import { Filter } from "./groupSearch.types";
+import {
+ ALL_FILTERS,
+ COMMON_FILTERS,
+ DATACONNECTORS_FILTERS,
+ FILTER_CONTENT,
+ FILTER_PAGE,
+ FILTER_PER_PAGE,
+ FILTER_QUERY,
+ PROJECT_FILTERS,
+ SELECTABLE_FILTERS,
+ VALUE_SEPARATOR_AND,
+} from "./groupsSearch.constants";
+
+export function getSearchQueryFilters(
+ searchParams: URLSearchParams,
+ filters: Filter[] = COMMON_FILTERS
+): SearchQueryFilters {
+ return filters.reduce((acc, filter) => {
+ const raw = searchParams.get(filter.name);
+ if (raw !== null) {
+ acc[filter.name] = filter.type === "number" ? Number(raw) : raw;
+ }
+ return acc;
+ }, {});
+}
+
+export function getSearchQueryMissingFilters(
+ searchParams: URLSearchParams,
+ filters: Filter[] = COMMON_FILTERS
+): SearchQueryFilters {
+ const existing = getSearchQueryFilters(searchParams, filters);
+ return filters.reduce((acc, filter) => {
+ if (
+ existing[filter.name] === undefined &&
+ filter.defaultValue !== undefined
+ ) {
+ acc[filter.name] = filter.defaultValue;
+ }
+ return acc;
+ }, {});
+}
+
+export function generateQueryParams(
+ searchParams: URLSearchParams,
+ groupSlug?: string,
+ ignoredParams: string[] = []
+): SearchQuery {
+ const commonFilters = getSearchQueryFilters(searchParams, COMMON_FILTERS);
+ const queryFilters = getSearchQueryFilters(searchParams, [
+ FILTER_CONTENT,
+ ...(searchParams.get(FILTER_CONTENT.name) === "DataConnector"
+ ? DATACONNECTORS_FILTERS
+ : PROJECT_FILTERS),
+ ]);
+ const queryFiltersForGroup = groupSlug
+ ? {
+ ...queryFilters,
+ namespace: groupSlug,
+ }
+ : queryFilters;
+ const mustQuoteFilters = ALL_FILTERS.filter((filter) => filter.mustQuote).map(
+ (filter) => filter.name
+ );
+
+ const queryFiltersProcessed = Object.entries(queryFiltersForGroup).reduce<
+ string[]
+ >((acc, [key, value]) => {
+ if (!ignoredParams.includes(key) && value !== undefined) {
+ const quote = mustQuoteFilters.includes(key) ? '"' : "";
+ const values =
+ typeof value === "string" && value.includes(VALUE_SEPARATOR_AND)
+ ? value.split(VALUE_SEPARATOR_AND)
+ : [value];
+ for (const value of values) {
+ acc = [...acc, `${key}${KEY_VALUE_SEPARATOR}${quote}${value}${quote}`];
+ }
+ }
+ return acc;
+ }, []);
+
+ const query = [
+ ...queryFiltersProcessed,
+ commonFilters[FILTER_QUERY.name],
+ ].join(TERM_SEPARATOR);
+ return {
+ q: query.trim(),
+ page: (commonFilters[FILTER_PAGE.name] ??
+ (FILTER_PAGE.defaultValue as number)) as number,
+ per_page: (commonFilters[FILTER_PER_PAGE.name] ??
+ FILTER_PER_PAGE.defaultValue) as number,
+ };
+}
+
+export function getQueryHumanReadable(
+ searchParams: URLSearchParams,
+ filters: Filter[] = SELECTABLE_FILTERS
+): React.ReactNode {
+ const filterNamesToLabel = filters.reduce>(
+ (acc, filter) => {
+ acc[filter.name] = filter.label;
+ return acc;
+ },
+ {}
+ );
+ const queryFilters = getSearchQueryFilters(searchParams, filters);
+ const validFilters = Object.entries(queryFilters).filter(
+ ([, value]) => value !== undefined
+ );
+ const queryParts = validFilters.map(([key, value], index) => (
+
+ {filterNamesToLabel[key] ?? key}: {value}
+ {index < validFilters.length - 1 && <> + >}
+
+ ));
+
+ return <>{queryParts}>;
+}
diff --git a/client/src/features/groupsV2/search/groupsSearch.constants.tsx b/client/src/features/groupsV2/search/groupsSearch.constants.tsx
new file mode 100644
index 0000000000..f787673b11
--- /dev/null
+++ b/client/src/features/groupsV2/search/groupsSearch.constants.tsx
@@ -0,0 +1,196 @@
+/*!
+ * Copyright 2025 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import cx from "classnames";
+import {
+ Binoculars,
+ Briefcase,
+ Database,
+ Folder2Open,
+ Globe,
+ Lock,
+ People,
+ Tag,
+} from "react-bootstrap-icons";
+import {
+ EnumFilter,
+ Filter,
+ NumberFilter,
+ StringFilter,
+} from "./groupSearch.types";
+
+export const VALUE_SEPARATOR_AND = "+";
+
+export const DEFAULT_ELEMENTS_LIMIT_IN_FILTERS = 5;
+
+export const FILTER_PAGE: NumberFilter = {
+ name: "page",
+ label: "Page",
+ type: "number",
+ defaultValue: 1,
+ minValues: 1,
+};
+
+export const FILTER_PER_PAGE: NumberFilter = {
+ name: "perPage",
+ label: "Per page",
+ type: "number",
+ defaultValue: 10,
+ minValues: 1,
+ maxValues: 100,
+};
+
+export const FILTER_QUERY: StringFilter = {
+ name: "q",
+ label: "Query",
+ type: "string",
+ defaultValue: "",
+};
+
+export const FILTER_CONTENT: EnumFilter = {
+ name: "type",
+ label: (
+ <>
+
+ Content
+ >
+ ),
+ type: "enum",
+ allowedValues: [
+ {
+ value: "Project",
+ label: (
+ <>
+
+ Project
+ >
+ ),
+ },
+ {
+ value: "DataConnector",
+ label: (
+ <>
+
+ Data
+ >
+ ),
+ },
+ ],
+ allowSelectMany: false,
+ defaultValue: "Project",
+};
+
+export const FILTER_MEMBER: EnumFilter = {
+ name: "direct_member",
+ label: (
+ <>
+
+ Group member
+ >
+ ),
+ type: "enum",
+ allowedValues: [],
+ allowSelectMany: false,
+ doNotPassEmpty: true,
+ mustQuote: false,
+ validFor: ["Project"],
+};
+
+export const FILTER_KEYWORD: EnumFilter = {
+ name: "keyword",
+ label: (
+ <>
+
+ Keyword
+ >
+ ),
+ type: "enum",
+ allowedValues: [],
+ allowSelectMany: true,
+ doNotPassEmpty: true,
+ mustQuote: true,
+};
+
+export const FILTER_VISIBILITY: EnumFilter = {
+ name: "visibility",
+ label: (
+ <>
+
+ Visibility
+ >
+ ),
+ type: "enum",
+ allowedValues: [
+ { value: "", label: "Any" },
+ {
+ value: "public",
+ label: (
+ <>
+
+ Public
+ >
+ ),
+ },
+ {
+ value: "private",
+ label: (
+ <>
+
+ Private
+ >
+ ),
+ },
+ ],
+ allowSelectMany: false,
+ doNotPassEmpty: true,
+};
+
+export const COMMON_FILTERS: Filter[] = [
+ FILTER_CONTENT,
+ FILTER_PAGE,
+ FILTER_PER_PAGE,
+ FILTER_QUERY,
+];
+
+export const PROJECT_FILTERS: Filter[] = [
+ FILTER_MEMBER,
+ FILTER_KEYWORD,
+ FILTER_VISIBILITY,
+];
+
+export const DATACONNECTORS_FILTERS: Filter[] = [
+ FILTER_KEYWORD,
+ FILTER_VISIBILITY,
+];
+
+export const SELECTABLE_FILTERS: Filter[] = [
+ FILTER_CONTENT,
+ FILTER_MEMBER,
+ FILTER_KEYWORD,
+ FILTER_VISIBILITY,
+];
+
+export const ALL_FILTERS: Filter[] = [
+ FILTER_PAGE,
+ FILTER_PER_PAGE,
+ FILTER_QUERY,
+ FILTER_CONTENT,
+ FILTER_MEMBER,
+ FILTER_KEYWORD,
+ FILTER_VISIBILITY,
+];
diff --git a/client/src/features/groupsV2/show/GroupPageContainer.tsx b/client/src/features/groupsV2/show/GroupPageContainer.tsx
index 84356bd4a2..a2c2e16826 100644
--- a/client/src/features/groupsV2/show/GroupPageContainer.tsx
+++ b/client/src/features/groupsV2/show/GroupPageContainer.tsx
@@ -101,6 +101,9 @@ export default function GroupPageContainer() {
overviewUrl: generatePath(ABSOLUTE_ROUTES.v2.groups.show.root, {
slug: group.slug,
}),
+ searchUrl: generatePath(ABSOLUTE_ROUTES.v2.groups.show.search, {
+ slug: group.slug,
+ }),
settingsUrl: generatePath(ABSOLUTE_ROUTES.v2.groups.show.settings, {
slug: group.slug,
}),
diff --git a/client/src/features/groupsV2/show/GroupV2Show.tsx b/client/src/features/groupsV2/show/GroupV2Show.tsx
index f1c8b0a6a4..dd2ed63024 100644
--- a/client/src/features/groupsV2/show/GroupV2Show.tsx
+++ b/client/src/features/groupsV2/show/GroupV2Show.tsx
@@ -16,7 +16,10 @@
* limitations under the License.
*/
+import cx from "classnames";
+import { Link } from "react-router";
import { Col, Row } from "reactstrap";
+import { RELATIVE_ROUTES } from "~/routing/routes.constants";
import DataConnectorsBox from "../../dataConnectorsV2/components/DataConnectorsBox";
import ProjectV2ListDisplay from "../../projectsV2/list/ProjectV2ListDisplay";
import { useGroup } from "./GroupPageContainer";
@@ -31,17 +34,45 @@ export default function GroupV2Show() {