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 = (
@@ -208,11 +214,11 @@ export default function ProjectInformation({ } > - {project.keywords?.map((keyword, index) => ( -

- #{keyword} -

- ))} + + {keywordsSorted.map((keyword, index) => ( + {keyword} + ))} +
diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectKeywordsFormField.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectKeywordsFormField.tsx new file mode 100644 index 0000000000..a99ff5ec78 --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectKeywordsFormField.tsx @@ -0,0 +1,122 @@ +import cx from "classnames"; +import { Controller } from "react-hook-form"; +import { Button, FormText, Label } from "reactstrap"; +import KeywordContainer from "~/components/keywords/KeywordContainer"; +import KeywordBadge from "~/components/keywords/KeywordBadge"; +import { PlusLg } from "react-bootstrap-icons"; +import type { + FieldErrors, + UseFormGetValues, + UseFormSetValue, + Control, +} from "react-hook-form"; +import type { ProjectV2MetadataWithKeyword } from "../../settings/projectSettings.types"; + +interface ProjectKeywordsFormFieldProps { + control: Control>; + errors: FieldErrors>; + getValues: UseFormGetValues>; + oldKeywords?: string[]; + setValue: UseFormSetValue>; +} + +export default function ProjectKeywordsFormField({ + control, + errors, + getValues, + oldKeywords, + setValue, +}: ProjectKeywordsFormFieldProps) { + return ( +
+ +
+ ( + <> + { + field.onChange(e); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && field.value) { + e.preventDefault(); + const newValue = field.value.trim(); + const currentKeywords = getValues("keywords"); + if (!currentKeywords.includes(newValue)) { + const newKeywords = [...currentKeywords, newValue]; + setValue("keywords", newKeywords); + } + setValue("keyword", ""); + } + }} + /> + + + )} + /> +
+ ( + <> + {field.value && field.value.length > 0 && ( + + {getValues("keywords") + .sort((a, b) => a.localeCompare(b)) + .map((keyword, index) => ( + { + const newKeywords = getValues("keywords").filter( + (k) => k !== keyword + ); + setValue("keywords", newKeywords); + }} + > + {keyword} + + ))} + + )} + + )} + /> + + Add keywords to help categorize and search for this project. + +
+ ); +} diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx index cd6f8d29ec..214c9fd7be 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx @@ -15,6 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import cx from "classnames"; import { useCallback, useContext, useEffect, useState } from "react"; import { Diagram3Fill, Pencil, Sliders } from "react-bootstrap-icons"; @@ -31,11 +32,9 @@ import { Input, Label, } from "reactstrap"; - import { RenkuAlert, SuccessAlert } from "../../../../components/Alert"; import { Loader } from "../../../../components/Loader"; import { RtkErrorAlert } from "../../../../components/errors/RtkErrorAlert"; -import KeywordsInput from "../../../../components/form-field/KeywordsInput"; import { NOTIFICATION_TOPICS } from "../../../../notifications/Notifications.constants"; import { NotificationsManager } from "../../../../notifications/notifications.types"; import { ABSOLUTE_ROUTES } from "../../../../routing/routes.constants"; @@ -47,13 +46,15 @@ import ProjectDescriptionFormField from "../../../projectsV2/fields/ProjectDescr import ProjectNameFormField from "../../../projectsV2/fields/ProjectNameFormField"; import ProjectNamespaceFormField from "../../../projectsV2/fields/ProjectNamespaceFormField"; import ProjectVisibilityFormField from "../../../projectsV2/fields/ProjectVisibilityFormField"; - import { useProject } from "../../ProjectPageContainer/ProjectPageContainer"; -import type { ProjectV2Metadata } from "../../settings/projectSettings.types"; +import type { + ProjectV2Metadata, + ProjectV2MetadataWithKeyword, +} from "../../settings/projectSettings.types"; import useProjectPermissions from "../../utils/useProjectPermissions.hook"; - import ProjectSessionSecrets from "../SessionSecrets/ProjectSessionSecrets"; import ProjectPageDelete from "./ProjectDelete"; +import ProjectKeywordsFormField from "./ProjectKeywordsFormField"; import ProjectPageSettingsMembers from "./ProjectSettingsMembers"; import ProjectUnlinkTemplate from "./ProjectUnlinkTemplate"; @@ -180,17 +181,19 @@ function ProjectSettingsForm({ project }: ProjectPageSettingsProps) { const { control, formState: { errors, isDirty }, + getValues, handleSubmit, watch, - register, reset, - } = useForm>({ + setValue, + } = useForm>({ defaultValues: { description: project.description ?? "", name: project.name, namespace: project.namespace, visibility: project.visibility, keywords: project.keywords ?? [], + keyword: "", is_template: project.is_template ?? false, }, }); @@ -199,7 +202,6 @@ function ProjectSettingsForm({ project }: ProjectPageSettingsProps) { const navigate = useNavigate(); const [redirectAfterUpdate, setRedirectAfterUpdate] = useState(false); const { notifications } = useContext(AppContext); - const [areKeywordsDirty, setKeywordsDirty] = useState(false); const [ updateProject, @@ -209,13 +211,17 @@ function ProjectSettingsForm({ project }: ProjectPageSettingsProps) { const isUpdating = isLoading; const onSubmit = useCallback( - (data: ProjectV2Metadata) => { + (data: ProjectV2MetadataWithKeyword) => { const namespaceChanged = data.namespace !== project.namespace; setRedirectAfterUpdate(namespaceChanged); + const editedData = { + ...data, + }; + delete editedData.keyword; updateProject({ "If-Match": project.etag ? project.etag : "", projectId: project.id, - projectPatch: data, + projectPatch: editedData as ProjectV2Metadata, }); }, [project, updateProject] @@ -394,27 +400,26 @@ function ProjectSettingsForm({ project }: ProjectPageSettingsProps) { !areKeywordsDirty, - })} - setDirty={setKeywordsDirty} - value={project.keywords as string[]} + } requestedPermission="write" userPermissions={permissions} /> + {error && } + {isSuccess && ( - +

The project has been successfully updated.

)} + ; + +export type ProjectV2MetadataWithKeyword = ProjectV2Metadata & { + keyword?: string; +}; diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalBody.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalBody.tsx index 5b0c74a858..b5cd4963de 100644 --- a/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalBody.tsx +++ b/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalBody.tsx @@ -18,22 +18,24 @@ import cx from "classnames"; import { useCallback } from "react"; -import { Globe, Lock } from "react-bootstrap-icons"; +import { Globe, Lock, PlusLg } from "react-bootstrap-icons"; import { Controller, useForm } from "react-hook-form"; -import { ButtonGroup, FormText, Input, Label } from "reactstrap"; - -import { Loader } from "../../../../components/Loader"; +import { + Button, + ButtonGroup, + Col, + FormText, + Input, + Label, + Row, +} from "reactstrap"; +import KeywordBadge from "~/components/keywords/KeywordBadge"; +import KeywordContainer from "~/components/keywords/KeywordContainer"; import { WarnAlert } from "../../../../components/Alert"; +import { Loader } from "../../../../components/Loader"; import useAppDispatch from "../../../../utils/customHooks/useAppDispatch.hook"; import useAppSelector from "../../../../utils/customHooks/useAppSelector.hook"; import { slugFromTitle } from "../../../../utils/helpers/HelperFunctions"; - -import { CLOUD_STORAGE_TOTAL_STEPS } from "../../../project/components/cloudStorage/projectCloudStorage.constants"; -import type { - AddCloudStorageState, - CloudStorageDetails, -} from "../../../project/components/cloudStorage/projectCloudStorage.types"; -import { getSchemaOptions } from "../../../project/utils/projectCloudStorage.utils"; import { AddStorageAdvanced, AddStorageAdvancedToggle, @@ -41,24 +43,34 @@ import { AddStorageType, type AddStorageStepProps, } from "../../../project/components/cloudStorage/AddOrEditCloudStorage"; +import { CLOUD_STORAGE_TOTAL_STEPS } from "../../../project/components/cloudStorage/projectCloudStorage.constants"; +import type { + AddCloudStorageState, + CloudStorageDetails, +} from "../../../project/components/cloudStorage/projectCloudStorage.types"; +import { getSchemaOptions } from "../../../project/utils/projectCloudStorage.utils"; +import type { Project } from "../../../projectsV2/api/projectV2.api"; import { ProjectNamespaceControl } from "../../../projectsV2/fields/ProjectNamespaceFormField"; -import type { DataConnectorSecret } from "../../api/data-connectors.api"; +import type { + DataConnectorRead, + DataConnectorSecret, +} from "../../api/data-connectors.api"; import dataConnectorFormSlice from "../../state/dataConnectors.slice"; - import DataConnectorModalResult from "./DataConnectorModalResult"; import DataConnectorSaveCredentialsInfo from "./DataConnectorSaveCredentialsInfo"; -import type { Project } from "../../../projectsV2/api/projectV2.api"; interface AddOrEditDataConnectorProps { - storageSecrets: DataConnectorSecret[]; + dataConnector?: DataConnectorRead | null; project?: Project; + storageSecrets: DataConnectorSecret[]; } type DataConnectorModalBodyProps = AddOrEditDataConnectorProps; export default function DataConnectorModalBody({ - storageSecrets, + dataConnector = null, project, + storageSecrets, }: DataConnectorModalBodyProps) { const { flatDataConnector, schemata, success } = useAppSelector( (state) => state.dataConnectorFormSlice @@ -80,16 +92,18 @@ export default function DataConnectorModalBody({

)} ); } function AddOrEditDataConnector({ - storageSecrets, + dataConnector, project, + storageSecrets, }: AddOrEditDataConnectorProps) { const { cloudStorageState, flatDataConnector, schemata, validationResult } = useAppSelector((state) => state.dataConnectorFormSlice); @@ -158,8 +172,9 @@ function AddOrEditDataConnector({ /> ); @@ -167,23 +182,30 @@ function AddOrEditDataConnector({ } export interface DataConnectorMountForm { + keyword: string; + keywords: string[]; + mountPoint: string; name: string; namespace: string; - slug: string; - visibility: string; - mountPoint: string; readOnly: boolean; saveCredentials: boolean; + slug: string; + visibility: string; } type DataConnectorMountFormFields = + | "keyword" + | "keywords" + | "mountPoint" | "name" | "namespace" - | "slug" - | "visibility" - | "mountPoint" | "readOnly" - | "saveCredentials"; -export function DataConnectorMount() { + | "saveCredentials" + | "slug" + | "visibility"; + +export function DataConnectorMount({ + dataConnector, +}: AddOrEditDataConnectorProps) { const dispatch = useAppDispatch(); const { cloudStorageState, flatDataConnector, schemata } = useAppSelector( (state) => state.dataConnectorFormSlice @@ -191,22 +213,28 @@ export function DataConnectorMount() { const { control, formState: { errors, touchedFields }, - setValue, getValues, + setValue, + watch, } = useForm({ mode: "onChange", defaultValues: { - name: flatDataConnector.name || "", - namespace: flatDataConnector.namespace || "", - visibility: flatDataConnector.visibility || "private", - slug: flatDataConnector.slug || "", + keyword: "", + keywords: flatDataConnector.keywords || [], mountPoint: flatDataConnector.mountPoint || `${flatDataConnector.schema?.toLowerCase()}`, + name: flatDataConnector.name || "", + namespace: flatDataConnector.namespace || "", readOnly: flatDataConnector.readOnly ?? false, saveCredentials: cloudStorageState.saveCredentials, + slug: flatDataConnector.slug || "", + visibility: flatDataConnector.visibility || "private", }, }); + const currentKeywords = watch("keywords"); + const oldKeywords = dataConnector?.keywords ?? []; + const onFieldValueChange = useCallback( (field: DataConnectorMountFormFields, value: string | boolean) => { setValue(field, value); @@ -497,7 +525,7 @@ export function DataConnectorMount() { -
+
@@ -544,6 +572,112 @@ export function DataConnectorMount() {
+
+ + + + +
+ ( + <> + { + field.onChange(e); + onFieldValueChange("keyword", e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && field.value) { + const newValue = field.value.trim(); + if (!currentKeywords.includes(newValue)) { + const newKeywords = [...currentKeywords, newValue]; + setValue("keywords", newKeywords); + } + setValue("keyword", ""); + onFieldValueChange("keyword", ""); + } + }} + /> + + + )} + /> +
+ + + + ( + <> + {field.value && field.value.length > 0 && ( + + {[...currentKeywords] + .sort((a, b) => a.localeCompare(b)) + .map((keyword, index) => ( + { + const newKeywords = currentKeywords.filter( + (k) => k !== keyword + ); + setValue("keywords", newKeywords); + onFieldValueChange("keyword", ""); + }} + > + {keyword} + + ))} + + )} + + )} + /> + +
+ +
+ 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 && ( -
- {dataConnector.keywords.map((keyword, index) => ( - - {keyword} - + + {sortedKeywords.map((keyword, index) => ( + {keyword} ))} -
+
)} diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorsBox.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorsBox.tsx index c5161d3bc5..a5c57eaa11 100644 --- a/client/src/features/dataConnectorsV2/components/DataConnectorsBox.tsx +++ b/client/src/features/dataConnectorsV2/components/DataConnectorsBox.tsx @@ -27,6 +27,7 @@ import { CardBody, CardHeader, ListGroup, + ListGroupItem, } from "reactstrap"; import { Loader } from "../../../components/Loader"; import Pagination from "../../../components/Pagination"; @@ -94,6 +95,8 @@ function AddButtonForUserNamespace({ } interface DataConnectorListDisplayProps { + children?: React.ReactNode; + limit?: number; namespace: string; namespaceKind: NamespaceKind; pageParam?: string; @@ -101,6 +104,8 @@ interface DataConnectorListDisplayProps { } export default function DataConnectorsBox({ + children, + limit, namespace: ns, namespaceKind, pageParam: pageParam_, @@ -111,13 +116,14 @@ export default function DataConnectorsBox({ [pageParam_] ); const perPage = useMemo( - () => (perPage_ ? perPage_ : DEFAULT_PER_PAGE), - [perPage_] + () => (limit ? limit : perPage_ ? perPage_ : DEFAULT_PER_PAGE), + [limit, perPage_] ); const [searchParams, setSearchParams] = useSearchParams(); const page = useMemo(() => { + if (limit) return 1; const pageRaw = searchParams.get(pageParam); if (!pageRaw) { return 1; @@ -128,7 +134,7 @@ export default function DataConnectorsBox({ } catch { return 1; } - }, [pageParam, searchParams]); + }, [limit, pageParam, searchParams]); const { data, error, isLoading } = useGetDataConnectorsQuery({ params: { @@ -164,25 +170,32 @@ export default function DataConnectorsBox({ + > + {children} + ); } interface DataConnectorBoxContentProps { + children?: React.ReactNode; data: GetDataConnectorsApiResponse; isLoading: boolean; + limit?: number; namespace: string; namespaceKind: NamespaceKind; pageParam: string; perPage: number; } function DataConnectorBoxContent({ + children, data, isLoading, + limit, namespace, namespaceKind, pageParam, @@ -221,15 +234,23 @@ function DataConnectorBoxContent({ /> ) )} + {limit && data.dataConnectors.length >= limit && ( + + And {data.total - data.dataConnectors.length} more... + + )} )} - + {!limit && ( + + )} + {children} {scopeIcon} -

+

{dataConnectorSource}

- {extendedPreview &&
{type}
} + {extendedPreview &&
{type}
}
import("./search/GroupV2Search")); + +export default function LazyGroupV2Search() { + return ( + }> + + + ); +} diff --git a/client/src/features/groupsV2/search/GroupSearchFilters.tsx b/client/src/features/groupsV2/search/GroupSearchFilters.tsx new file mode 100644 index 0000000000..0fcb795561 --- /dev/null +++ b/client/src/features/groupsV2/search/GroupSearchFilters.tsx @@ -0,0 +1,431 @@ +/*! + * 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, useMemo, useState } from "react"; +import { XCircleFill } from "react-bootstrap-icons"; +import { useSearchParams } from "react-router"; +import { + AccordionBody, + AccordionHeader, + AccordionItem, + Badge, + Button, + Col, + ListGroup, + ListGroupItem, + Row, + UncontrolledAccordion, +} from "reactstrap"; +import KeywordBadge from "~/components/keywords/KeywordBadge"; +import { useGetGroupsByGroupSlugMembersQuery } from "~/features/projectsV2/api/namespace.api"; +import UserAvatar from "~/features/usersV2/show/UserAvatar"; +import { useGroup } from "../show/GroupPageContainer"; +import { useGroupSearch } from "./groupSearch.hook"; +import { Filter, GroupSearchEntity } from "./groupSearch.types"; +import { + DEFAULT_ELEMENTS_LIMIT_IN_FILTERS, + FILTER_CONTENT, + FILTER_KEYWORD, + FILTER_MEMBER, + FILTER_PAGE, + FILTER_VISIBILITY, + VALUE_SEPARATOR_AND, +} from "./groupsSearch.constants"; + +export default function GroupSearchFilters() { + const [searchParams] = useSearchParams(); + const { data: search } = useGroupSearch(); + const { data: searchAnyType } = useGroupSearch([FILTER_CONTENT.name]); + const { group } = useGroup(); + const { data: groupMembers } = useGetGroupsByGroupSlugMembersQuery({ + groupSlug: group.slug, + }); + + // Add numbers to the content types. Mind that this requires an additional request. + const hydratedFilterContentAllowedValues = useMemo(() => { + return FILTER_CONTENT.allowedValues.map((option) => ({ + ...option, + quantity: searchAnyType?.facets?.entityType?.[option.value] ?? 0, + })); + }, [searchAnyType?.facets?.entityType]); + const filterContentWithQuantities = useMemo(() => { + return { + ...FILTER_CONTENT, + allowedValues: hydratedFilterContentAllowedValues, + }; + }, [hydratedFilterContentAllowedValues]); + + // Create the enum filter for keywords with quantities. + const hydratedFilterKeywordAllowedValues = useMemo(() => { + return Object.entries(search?.facets?.keywords ?? {}) + .map(([value, quantity]) => ({ + value, + label: ( + + ), + _label: value, + _quantity: quantity, + })) + .sort((a, b) => { + // sort by quantity first, then by value + const qtyDiff = b._quantity - a._quantity; + if (qtyDiff !== 0) return qtyDiff; + return a.value.localeCompare(b.value); + }); + }, [search?.facets?.keywords]); + // Add the current keywords if missing so users can always de-select. + if (searchParams.get(FILTER_KEYWORD.name)) { + const existingKeywords = + searchParams.get(FILTER_KEYWORD.name)?.split(VALUE_SEPARATOR_AND) ?? []; + existingKeywords.forEach((keyword) => { + if ( + !hydratedFilterKeywordAllowedValues.some( + (v) => v._label === keyword.trim() + ) + ) { + hydratedFilterKeywordAllowedValues.unshift({ + value: keyword.trim(), + label: ( + + ), + _label: keyword.trim(), + _quantity: 0, + }); + } + }); + } + const filterKeywordWithQuantities = useMemo(() => { + return { + ...FILTER_KEYWORD, + allowedValues: hydratedFilterKeywordAllowedValues, + }; + }, [hydratedFilterKeywordAllowedValues]); + + // Create the enum filter for members + const hydratedFilterMembersAllowedValues = useMemo(() => { + return ( + groupMembers?.map((member) => ({ + value: `@${member.namespace}`, + label: ( +
+ {" "} + {member.first_name} {member.last_name} +
+ ), + })) ?? [] + ); + }, [groupMembers]); + const filterMembersWithValues = useMemo(() => { + return { + ...FILTER_MEMBER, + allowedValues: [ + { value: "", label: "Any" }, + ...hydratedFilterMembersAllowedValues, + ], + }; + }, [hydratedFilterMembersAllowedValues]); + + return ( +
+

Filters

+ + + + + +
+ ); +} + +interface GroupFilterKeywordRenderingProps { + label: string; + quantity: number; +} +function GroupFilterKeywordRendering({ + label, + quantity, +}: GroupFilterKeywordRenderingProps) { + return ( +
+
+ {label} +
+ {quantity} +
+ ); +} + +interface GroupSearchFilterProps { + defaultElementsToShow?: number; + filter: Filter; +} +function GroupSearchFilter({ + defaultElementsToShow = DEFAULT_ELEMENTS_LIMIT_IN_FILTERS, + filter, +}: GroupSearchFilterProps) { + // Do not show invalid filter, but give the opportunity to reset it. + const [searchParams, setSearchParams] = useSearchParams(); + const searchedType = searchParams.get(FILTER_CONTENT.name); + + const resetFilter = useCallback(() => { + const params = new URLSearchParams(searchParams); + params.delete(filter.name); + setSearchParams(params); + }, [filter.name, searchParams, setSearchParams]); + + const isInvalid = useMemo(() => { + return ( + filter.validFor && + !filter.validFor.includes(searchedType as GroupSearchEntity["type"]) + ); + }, [filter.validFor, searchedType]); + if (isInvalid && searchParams.get(filter.name) === null) return null; + + return ( + <> + {}} + > + + +
+ {filter.label} +
+
+ + + {isInvalid ? ( + +

+ This filter is set, not valid for the current Content. +

+ + + ) : ( + + )} +
+
+
+
+ + +
{filter.label}
+ {isInvalid ? ( + <> +

+ This filter is set, not valid for the current Content. +

+ + + ) : ( + + )} +
+
+ + ); +} + +interface GroupSearchFilterContentProps { + defaultElementsToShow?: number; + filter: Filter; + visualization?: "accordion" | "list"; +} +function GroupSearchFilterContent({ + defaultElementsToShow, + filter, + visualization = "list", +}: GroupSearchFilterContentProps) { + const [searchParams, setSearchParams] = useSearchParams(); + const [showAll, setShowAll] = useState(false); + const current = searchParams.get(filter.name) ?? ""; + const allowSelectMany = filter.type === "enum" && filter.allowSelectMany; + + const onChange = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams); + if (filter.doNotPassEmpty && !value) { + params.delete(filter.name); + } else if (allowSelectMany) { + // Move logic to handle multiple values to a utility function? + const currentValues = + params.get(filter.name)?.split(VALUE_SEPARATOR_AND) ?? []; + if (currentValues.includes(value)) { + const newValues = currentValues.filter((v) => v !== value); + if (newValues.length > 0) { + params.set(filter.name, newValues.join(VALUE_SEPARATOR_AND)); + } else { + params.delete(filter.name); + } + } else { + currentValues.push(value); + params.set(filter.name, currentValues.join(VALUE_SEPARATOR_AND)); + } + } else { + params.set(filter.name, value); + } + const page_default_value = ( + FILTER_PAGE.defaultValue as number + ).toString(); + if (params.get(FILTER_PAGE.name) !== page_default_value) { + params.set(FILTER_PAGE.name, page_default_value); + } + setSearchParams(params); + }, + [allowSelectMany, filter, searchParams, setSearchParams] + ); + + if (filter.type === "enum") { + const elementsToShow = + !defaultElementsToShow || showAll + ? filter.allowedValues + : filter.allowedValues.slice(0, defaultElementsToShow); + return ( + <> + {elementsToShow.length > 0 ? ( + <> + {elementsToShow.map((element) => { + return ( + onChange(element.value)} + visualization={visualization} + type={filter.allowSelectMany ? "checkbox" : "radio"} + > + {element.label} + {element.quantity !== undefined ? ( + {element.quantity} + ) : null} + + ); + })} + {defaultElementsToShow && + defaultElementsToShow < filter.allowedValues.length && ( + + )} + + ) : ( +

None

+ )} + + ); + } + + return null; +} + +interface GroupSearchFilterRadioOrCheckboxElementProps { + children: React.ReactNode; + identifier: string; + isChecked: boolean; + onChange?: () => void; + type: "radio" | "checkbox"; + visualization: "accordion" | "list"; +} +function GroupSearchFilterRadioOrCheckboxElement({ + children, + identifier, + isChecked, + onChange, + type, + visualization, +}: GroupSearchFilterRadioOrCheckboxElementProps) { + return ( +
+ + +
+ ); +} 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 ( +

+ {isFetching ? ( + "Fetching results" + ) : ( + + {total ? total : "No"} {total && total > 1 ? "results" : "result"} + + )} + {query && ( + <> + {" "} + for {`"${query}"`} + + )} + {filters && ( + <> + {" "} + (filtered by <>{filters}) + + )} +

+ ); +} 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 ( +
+

Results

+ + {data?.items?.length ? ( + <> + + {data.items.map((item) => { + return ( + + ); + })} + + + + ) : ( +

Nothing here. Try another search.

+ )} + +
+ ); +} + +interface SearchResultListItemProps { + item: GroupSearchEntity; +} +function SearchResultListItem({ item }: SearchResultListItemProps) { + const sortedKeywords = useMemo(() => { + if (!item.keywords) return []; + return item.keywords + .map((keyword) => keyword.trim()) + .sort((a, b) => a.localeCompare(b)); + }, [item.keywords]); + + const url = + item.type === "Project" + ? generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { + namespace: item.namespace?.path ?? "", + slug: item.slug, + }) + : item.type === "DataConnector" + ? `${location.search}#data-connector-${item.id}` + : ""; + + return ( + + + +
+ +
+ + +
{item.name}
+ {item.createdBy && ( +

+ Created by{" "} + + {" "} + {item.createdBy.firstName} {item.createdBy.lastName} + +

+ )} + {item.description &&

{item.description}

} + {sortedKeywords.length > 0 && ( + + {sortedKeywords.map((keyword, index) => ( + {keyword} + ))} + + )} + {item.type === "DataConnector" && ( +

+ + + {item.storageType} + {" "} + + {item.readonly ? ( + <> + + Read only + + ) : ( + <> + + Read/Write + + )} + +

+ )} +
+ {item.visibility.toLowerCase() === "private" ? ( +
+ + Private +
+ ) : ( +
+ + Public +
+ )} + +
+ +
+ + ); +} + +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() { + > +
+ + View all the group projects + +
+
+ > +
+ + View all the group data connectors + +
+
diff --git a/client/src/features/projectsV2/list/ProjectV2ListDisplay.tsx b/client/src/features/projectsV2/list/ProjectV2ListDisplay.tsx index cc9965939b..72bacc12fb 100644 --- a/client/src/features/projectsV2/list/ProjectV2ListDisplay.tsx +++ b/client/src/features/projectsV2/list/ProjectV2ListDisplay.tsx @@ -20,8 +20,14 @@ import cx from "classnames"; import { useEffect, useMemo } from "react"; import { Folder, PlusLg } from "react-bootstrap-icons"; import { Link, useLocation, useSearchParams } from "react-router"; -import { Badge, Card, CardBody, CardHeader, ListGroup } from "reactstrap"; - +import { + Badge, + Card, + CardBody, + CardHeader, + ListGroup, + ListGroupItem, +} from "reactstrap"; import { Loader } from "../../../components/Loader"; import Pagination from "../../../components/Pagination"; import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert"; @@ -37,6 +43,8 @@ const DEFAULT_PER_PAGE = 5; const DEFAULT_PAGE_PARAM = "page"; interface ProjectListDisplayProps { + children?: React.ReactNode; + limit?: number; namespace?: string; pageParam?: string; perPage?: number; @@ -44,6 +52,8 @@ interface ProjectListDisplayProps { } export default function ProjectListDisplay({ + children, + limit, namespace: ns, pageParam: pageParam_, perPage: perPage_, @@ -54,13 +64,14 @@ export default function ProjectListDisplay({ [pageParam_] ); const perPage = useMemo( - () => (perPage_ ? perPage_ : DEFAULT_PER_PAGE), - [perPage_] + () => (limit ? limit : perPage_ ? perPage_ : DEFAULT_PER_PAGE), + [limit, perPage_] ); const [searchParams, setSearchParams] = useSearchParams(); const page = useMemo(() => { + if (limit) return 1; const pageRaw = searchParams.get(pageParam); if (!pageRaw) { return 1; @@ -71,7 +82,7 @@ export default function ProjectListDisplay({ } catch { return 1; } - }, [pageParam, searchParams]); + }, [limit, pageParam, searchParams]); const { data, error, isLoading } = useGetProjectsQuery({ params: { @@ -136,17 +147,25 @@ export default function ProjectListDisplay({ project={project} /> ))} + {limit && data.projects.length >= limit && ( + + And {data.total - data.projects.length} more... + + )}
- + {!limit && ( + + )} )} + {children} diff --git a/client/src/features/projectsV2/show/ProjectShortHandDisplay.tsx b/client/src/features/projectsV2/show/ProjectShortHandDisplay.tsx index 32ee7e9c7a..35827a43db 100644 --- a/client/src/features/projectsV2/show/ProjectShortHandDisplay.tsx +++ b/client/src/features/projectsV2/show/ProjectShortHandDisplay.tsx @@ -54,16 +54,12 @@ export default function ProjectShortHandDisplay({ )} > -

- {project.namespace} -

+

{project.namespace}

{project.description && (
-

- {project.description} -

+

{project.description}

)} diff --git a/client/src/features/rootV2/RootV2.tsx b/client/src/features/rootV2/RootV2.tsx index 5ed000c810..655659d9b2 100644 --- a/client/src/features/rootV2/RootV2.tsx +++ b/client/src/features/rootV2/RootV2.tsx @@ -54,6 +54,7 @@ import LazySessionStartPage from "../sessionsV2/LazySessionStartPage"; import LazyShowSessionPage from "../sessionsV2/LazyShowSessionPage"; import LazyUserRedirect from "../usersV2/LazyUserRedirect"; import LazyUserShow from "../usersV2/LazyUserShow"; +import LazyGroupV2Search from "../groupsV2/LazyGroupV2Search"; function BetaV2Redirect() { const navigate = useNavigate(); @@ -198,6 +199,10 @@ function GroupsV2Routes() { }> } /> + } + /> } diff --git a/client/src/features/searchV2/api/search.openapi.json b/client/src/features/searchV2/api/search.openapi.json index 7fbdf23f8a..82e12646df 100644 --- a/client/src/features/searchV2/api/search.openapi.json +++ b/client/src/features/searchV2/api/search.openapi.json @@ -195,10 +195,13 @@ } ], "type": "object", - "required": ["entityType"], + "required": ["entityType", "keywords"], "properties": { "entityType": { "$ref": "#/components/schemas/Map_EntityType_Int" + }, + "keywords": { + "$ref": "#/components/schemas/Map_EntityType_Int" } } }, diff --git a/client/src/features/searchV2/api/searchV2Api.api.ts b/client/src/features/searchV2/api/searchV2Api.api.ts index 21acce24d9..6101dfadcf 100644 --- a/client/src/features/searchV2/api/searchV2Api.api.ts +++ b/client/src/features/searchV2/api/searchV2Api.api.ts @@ -34,6 +34,7 @@ export const searchV2Api = searchV2GeneratedApi.injectEndpoints({ url: "/search/query", params, }), + keepUnusedDataFor: 10, }), }), }); diff --git a/client/src/features/searchV2/api/searchV2Api.generated-api.ts b/client/src/features/searchV2/api/searchV2Api.generated-api.ts index f154534ef2..7fecc92003 100644 --- a/client/src/features/searchV2/api/searchV2Api.generated-api.ts +++ b/client/src/features/searchV2/api/searchV2Api.generated-api.ts @@ -132,6 +132,7 @@ export type MapEntityTypeInt = { }; export type FacetData = { entityType: MapEntityTypeInt; + keywords: MapEntityTypeInt; }; export type PageDef = { limit: number; diff --git a/client/src/features/searchV2/components/SearchV2Results.tsx b/client/src/features/searchV2/components/SearchV2Results.tsx index e5509dbd22..fe1f81d4ee 100644 --- a/client/src/features/searchV2/components/SearchV2Results.tsx +++ b/client/src/features/searchV2/components/SearchV2Results.tsx @@ -453,7 +453,6 @@ function SearchV2ResultDataConnector({ namespace={namespace?.path ?? ""} namespaceUrl={namespaceUrl} /> - {/* */} {description && {description}}
>; diff --git a/client/src/routing/routes.constants.ts b/client/src/routing/routes.constants.ts index cd443bb918..3f946208f8 100644 --- a/client/src/routing/routes.constants.ts +++ b/client/src/routing/routes.constants.ts @@ -57,6 +57,7 @@ export const ABSOLUTE_ROUTES = { show: { root: "/g/:slug", settings: "/g/:slug/settings", + search: "/g/:slug/search", splat: "/g/:slug/*", }, beta: { splat: "/v2/groups/:slug/*" }, @@ -120,6 +121,7 @@ export const RELATIVE_ROUTES = { new: "new", show: { root: ":slug/*", + search: "search", settings: "settings", }, }, diff --git a/client/src/storybook/bootstrap/BadgeInfo.stories.tsx b/client/src/storybook/bootstrap/BadgeInfo.stories.tsx index 288b6779f9..7e1fd7a950 100644 --- a/client/src/storybook/bootstrap/BadgeInfo.stories.tsx +++ b/client/src/storybook/bootstrap/BadgeInfo.stories.tsx @@ -1,18 +1,18 @@ import cx from "classnames"; import { Meta, StoryObj } from "@storybook/react"; -import { Badge } from "reactstrap"; import { CircleFill } from "react-bootstrap-icons"; import { Loader } from "../../components/Loader"; +import RenkuBadge from "~/components/renkuBadge/RenkuBadge"; export default { args: { - children: "Info", + color: "light", + content: "Some text", loader: false, - status: "bg-light border-dark-subtle text-dark-emphasis", }, argTypes: { - children: { + content: { description: "Content to display inside the badge.", }, loader: { @@ -21,31 +21,14 @@ export default { name: "boolean", }, }, - status: { + color: { description: "Color scheme to apply.", type: { name: "enum", - value: [ - "bg-light border-dark-subtle text-dark-emphasis", - "bg-success-subtle border-success text-success-emphasis", - "bg-danger-subtle border-danger text-danger-emphasis", - "bg-warning-subtle border-warning text-warning-emphasis", - ], + value: ["light", "success", "warning", "danger"], }, control: { type: "select", - labels: { - "bg-light border-dark-subtle text-dark-emphasis": "Neutral", - "bg-success-subtle border-success text-success-emphasis": "Success", - "bg-danger-subtle border-danger text-danger-emphasis": "Error", - "bg-warning-subtle border-warning text-warning-emphasis": "Warning", - }, - }, - mapping: { - Neutral: "bg-light border-dark-subtle text-dark-emphasis", - Success: "bg-success-subtle border-success text-success-emphasis", - Error: "bg-danger-subtle border-danger text-danger-emphasis", - Warning: "bg-warning-subtle border-warning text-warning-emphasis", }, }, }, @@ -53,32 +36,33 @@ export default { docs: { description: { component: - "Info Badges are a variation of the standard Badges, used in many places in the UI to convey readable information about the current status of a resource.", + "Renku Badges are a variation of the standard Badges, used in many places in the UI to convey readable information about the current status of a resource.", }, }, }, - title: "Bootstrap/Badge/Info Badge", + title: "Bootstrap/Badge/Renku Badge", } as Meta; -interface BadgeInfoProps extends React.HTMLAttributes { +interface RenkuBadgeProps extends React.HTMLAttributes { + color: "light" | "success" | "warning" | "danger"; + content?: string; loader: boolean; - status: string; } -type Story = StoryObj; +type Story = StoryObj; CircleFill.displayName = "CircleFill"; -export const BadgeInfo_: Story = { +export const RenkuBadge_: Story = { render: (_args) => { return ( - + {_args.loader ? ( ) : ( )} - {_args.children} - + {_args.content} + ); }, }; diff --git a/tests/cypress/e2e/groupV2.spec.ts b/tests/cypress/e2e/groupV2.spec.ts index 3104a8c996..03f56bce47 100644 --- a/tests/cypress/e2e/groupV2.spec.ts +++ b/tests/cypress/e2e/groupV2.spec.ts @@ -162,7 +162,7 @@ describe("Edit v2 group", () => { cy.contains("test 2 group-v2").should("be.visible").click(); cy.wait("@readGroupV2"); cy.contains("test 2 group-v2").should("be.visible"); - cy.getDataCy("nav-link-settings").should("be.visible").click(); + cy.getDataCy("group-settings-link").should("be.visible").click(); cy.getDataCy("group-name-input").clear().type("new name"); cy.getDataCy("group-slug-input").clear().type("new-slug"); cy.getDataCy("group-description-input").clear().type("new description"); @@ -198,7 +198,7 @@ describe("Edit v2 group", () => { cy.contains("test 2 group-v2").should("be.visible").click(); cy.wait("@readGroupV2"); cy.contains("test 2 group-v2").should("be.visible"); - cy.getDataCy("nav-link-settings").should("be.visible").click(); + cy.getDataCy("group-settings-link").should("be.visible").click(); cy.contains("@user1").should("be.visible"); cy.contains("user3-uuid").should("be.visible"); fixtures @@ -244,7 +244,7 @@ describe("Edit v2 group", () => { cy.contains("test 2 group-v2").should("be.visible").click(); cy.wait("@readGroupV2"); cy.contains("test 2 group-v2").should("be.visible"); - cy.getDataCy("nav-link-settings").should("be.visible").click(); + cy.getDataCy("group-settings-link").should("be.visible").click(); cy.getDataCy("group-description-input").clear().type("new description"); cy.get("button").contains("Delete").should("be.visible").click(); cy.get("button") @@ -541,7 +541,7 @@ describe("Create projects in a group", () => { cy.contains("test 2 group-v2").should("be.visible").click(); cy.wait("@readGroupV2"); cy.contains("test 2 group-v2").should("be.visible"); - cy.getDataCy("nav-link-settings").should("be.visible").click(); + cy.getDataCy("group-settings-link").should("be.visible").click(); cy.getDataCy("navbar-new-entity").click(); cy.getDataCy("navbar-project-new").click(); cy.contains("Create a new project").should("be.visible"); diff --git a/tests/cypress/e2e/projectV2.spec.ts b/tests/cypress/e2e/projectV2.spec.ts index 5544de0e9f..8ca433467e 100644 --- a/tests/cypress/e2e/projectV2.spec.ts +++ b/tests/cypress/e2e/projectV2.spec.ts @@ -517,6 +517,54 @@ describe("Edit v2 project", () => { cy.contains("My projects"); cy.contains("Project deleted").should("be.visible"); }); + + it("changes project keywords", () => { + const keywords = ["keyword1", "keyword2"]; + fixtures.readProjectV2().listNamespaceV2().updateProjectV2(); + cy.contains("My projects").should("be.visible"); + cy.getDataCy("dashboard-project-list") + .contains("a", "test 2 v2-project") + .should("be.visible") + .click(); + cy.wait("@readProjectV2"); + cy.contains("test 2 v2-project").should("be.visible"); + cy.getDataCy("project-settings-link").click(); + + // No keywords and button disabled + cy.getDataCy("project-settings-keywords").should("not.exist"); + cy.getDataCy("project-settings-keyword-button").should("be.disabled"); + + // Add keywords + cy.getDataCy("project-settings-keyword-input") + .should("be.visible") + .clear() + .type(keywords[0]); + cy.getDataCy("project-settings-keyword-button").click(); + cy.getDataCy("project-settings-keywords") + .should("be.visible") + .contains(keywords[0]); + cy.getDataCy("project-settings-keyword-input") + .should("be.visible") + .should("be.empty") + .type(keywords[1]) + .type("{enter}"); + cy.getDataCy("project-settings-keywords") + .should("be.visible") + .should("contain", keywords[0]) + .should("contain", keywords[1]); + + // Check they stick after the update + fixtures.readProjectV2({ + fixture: "projectV2/update-projectV2-metadata.json", + name: "readPostUpdate", + }); + cy.getDataCy("project-update-button").should("be.visible").click(); + cy.wait("@updateProjectV2"); + cy.wait("@readPostUpdate"); + cy.getDataCy("project-settings-keywords") + .should("contain", keywords[0]) + .should("contain", keywords[1]); + }); }); describe("Editor cannot maintain members", () => { diff --git a/tests/cypress/fixtures/projectV2/update-projectV2-metadata.json b/tests/cypress/fixtures/projectV2/update-projectV2-metadata.json index af4c91ef1b..a28b19964b 100644 --- a/tests/cypress/fixtures/projectV2/update-projectV2-metadata.json +++ b/tests/cypress/fixtures/projectV2/update-projectV2-metadata.json @@ -12,5 +12,6 @@ "visibility": "public", "description": "new description", "is_template": true, - "secrets_mount_directory": "/secrets" + "secrets_mount_directory": "/secrets", + "keywords": ["keyword1", "keyword2"] }