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/dataConnector.utils.ts b/client/src/features/dataConnectorsV2/components/dataConnector.utils.ts index 60f14dd16f..239201209c 100644 --- a/client/src/features/dataConnectorsV2/components/dataConnector.utils.ts +++ b/client/src/features/dataConnectorsV2/components/dataConnector.utils.ts @@ -43,6 +43,7 @@ import type { DataConnectorConfiguration } from "./useDataConnectorConfiguration export type DataConnectorFlat = { // DataConnectorRead metadata fields dataConnectorId?: string; + keywords?: string[]; name?: string; namespace?: string; slug?: string; @@ -76,6 +77,7 @@ export const EMPTY_DATA_CONNECTOR_FLAT: DataConnectorFlat = { visibility: "private", mountPoint: undefined, readOnly: true, + keywords: undefined, }; export function dataConnectorPostFromFlattened( @@ -91,6 +93,7 @@ export function dataConnectorPostFromFlattened( flatDataConnector.visibility === "public" ? ("public" as const) : ("private" as const), + keywords: flatDataConnector.keywords, }; const storage: CloudStorageCorePost = { configuration: { type: flatDataConnector.schema ?? null }, @@ -146,6 +149,7 @@ export function dataConnectorToFlattened( const { type, provider, ...options } = configurationOptions; // eslint-disable-line @typescript-eslint/no-unused-vars const flattened: DataConnectorFlat = { dataConnectorId: dataConnector.id, + keywords: dataConnector.keywords, name: dataConnector.name, namespace: dataConnector.namespace, slug: dataConnector.slug, diff --git a/client/src/features/groupsV2/LazyGroupV2Search.tsx b/client/src/features/groupsV2/LazyGroupV2Search.tsx new file mode 100644 index 0000000000..8745b3c5a9 --- /dev/null +++ b/client/src/features/groupsV2/LazyGroupV2Search.tsx @@ -0,0 +1,30 @@ +/*! + * 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 { Suspense, lazy } from "react"; +import PageLoader from "../../components/PageLoader"; + +const GroupV2Search = lazy(() => import("./search/GroupV2Search")); + +export default function LazyGroupV2Search() { + return ( + }> + + + ); +} diff --git a/client/src/features/groupsV2/search/GroupV2Search.tsx b/client/src/features/groupsV2/search/GroupV2Search.tsx new file mode 100644 index 0000000000..cebcfa19c2 --- /dev/null +++ b/client/src/features/groupsV2/search/GroupV2Search.tsx @@ -0,0 +1,341 @@ +/*! + * 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 { Controller, useForm } from "react-hook-form"; +import { + AccordionBody, + AccordionHeader, + AccordionItem, + Button, + Col, + Form, + InputGroup, + ListGroup, + ListGroupItem, + Row, + UncontrolledAccordion, +} from "reactstrap"; +// import { useGroup } from "../show/GroupPageContainer"; +import { useSearchParams } from "react-router"; +import { useGetSearchQueryQuery } from "~/features/searchV2/api/searchV2Api.api.ts"; + +export default function GroupV2Search() { + return ( +
+ + + + + + + + + + + + + +
+ ); +} + +interface SearchBarForm { + query: string; +} + +function GroupSearchQueryInput() { + // const { group } = useGroup(); + const [searchParams, setSearchParams] = useSearchParams(); + const query = searchParams.get("q") ?? ""; + + const { control, register, handleSubmit, setFocus } = useForm({ + defaultValues: { query }, + }); + + // focus search input when loading the component + useEffect(() => { + setFocus("query"); + }, [setFocus]); + + const onSubmit = useCallback( + (data: SearchBarForm) => { + setSearchParams({ q: data.query }); + }, + [setSearchParams] + ); + + return ( + <> +
+ + ( + + )} + /> + + +
+ + ); +} + +// ! TODO: add this back +{ + /* */ +} + +// interface GroupSearchResultRecapProps { +// query?: string; +// total?: number; +// isFetching: boolean; +// } +// function GroupSearchResultRecap({ +// query, +// total, +// isFetching, +// }: GroupSearchResultRecapProps) { +// return ( +//

+// {isFetching ? ( +// "Loading results" +// ) : ( +// <> +// {total ? total : "No"} {total && total > 1 ? "results" : "result"} +// +// )} +// {query && ( +// <> +// {" "} +// for {`"${query}"`} +// +// )} +//

+// ); +// } + +function GroupSearchFilters() { + return ( +
+

Filters

+ + +
+ ); +} + +function GroupSearchFilterContent() { + return ( + <> + {}} + > + + +
Content
+
+ + + + + +
+
+ + +
Content
+ +
+
+ + ); +} + +interface GroupSearchFilterPropsMain { + visualization: "accordion" | "list"; +} +function GroupSearchFilterContentMain({ + visualization, +}: GroupSearchFilterPropsMain) { + const [searchParams, setSearchParams] = useSearchParams(); + const current = searchParams.get("content") ?? ""; + + const onChange = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams); + params.set("content", value); + setSearchParams(params); + }, + [searchParams, setSearchParams] + ); + + return ( +
+ onChange("project")} + > + Project + + onChange("data")} + > + Data + +
+ ); +} + +interface GroupSearchFilterRadioElementProps { + children: React.ReactNode; + identifier: string; + isChecked: boolean; + onChange?: () => void; + visualization: "accordion" | "list"; +} +function GroupSearchFilterRadioElement({ + children, + identifier, + isChecked, + onChange, + visualization, +}: GroupSearchFilterRadioElementProps) { + return ( + //
+ //
Content
+ //
+ // + // + //
+ //
+ +
+ + +
+ ); +} + +function GroupSearchResults() { + const [searchParams] = useSearchParams(); + + const query = searchParams.get("q") ?? ""; + + // const [searchParams] = useSearchParams(); + // const [query, setQuery] = useState(searchParams.get("q") ?? ""); + // useEffect(() => { + // setQuery(searchParams.get("q") ?? ""); + // }, [searchParams]); + + const { data } = useGetSearchQueryQuery({ + params: { + page: 1, + per_page: 20, + q: query, + }, + }); + + // ! TODO: Add some visualization + + return ( +
+

Results

+ {data?.items?.length && ( +
    + {data.items.map((item) => ( +
  • +

    {item.slug}

    +
  • + ))} +
+ )} +
+ ); +} + +// ! TODO: implement the useGetQuery hook to get the search query from the URL +// ! Check what we do in the search page and, hopefully, re-use that +// function useGetQuery(): string { +// const [searchParams] = useSearchParams(); +// return searchParams.get("q") ?? ""; +// } 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/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/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"] }