diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 3ad5daf2..a113d419 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -124,6 +124,13 @@ jobs: - name: Run Integration Test run: make it-test-api + - uses: codecov/codecov-action@v4 + with: + flags: api-test + name: api-test + token: ${{ secrets.CODECOV_TOKEN }} + working-directory: ./api + e2e-test: runs-on: ubuntu-latest needs: @@ -359,4 +366,4 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | yarn set-version-from-git - yarn lib publish + yarn lib publish \ No newline at end of file diff --git a/api/config/config.go b/api/config/config.go index f78994ef..9bb84c38 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -116,8 +116,9 @@ type UIConfig struct { StaticPath string `validated:"required"` IndexPath string `validated:"required"` - ClockworkUIHomepage string `json:"REACT_APP_CLOCKWORK_UI_HOMEPAGE"` - KubeflowUIHomepage string `json:"REACT_APP_KUBEFLOW_UI_HOMEPAGE"` + ClockworkUIHomepage string `json:"REACT_APP_CLOCKWORK_UI_HOMEPAGE"` + KubeflowUIHomepage string `json:"REACT_APP_KUBEFLOW_UI_HOMEPAGE"` + ProjectInfoUpdateEnabled bool `json:"REACT_APP_PROJECT_INFO_UPDATE_ENABLED"` } // Transform env variables to the format consumed by koanf. diff --git a/api/config/config_test.go b/api/config/config_test.go index 7a23cb20..0af09856 100644 --- a/api/config/config_test.go +++ b/api/config/config_test.go @@ -238,8 +238,9 @@ func TestLoad(t *testing.T) { StaticPath: "ui/build", IndexPath: "index.html", - ClockworkUIHomepage: "http://clockwork.dev", - KubeflowUIHomepage: "http://kubeflow.org", + ClockworkUIHomepage: "http://clockwork.dev", + KubeflowUIHomepage: "http://kubeflow.org", + ProjectInfoUpdateEnabled: true, }, DefaultSecretStorage: &config.SecretStorage{ Name: "default-secret-storage", @@ -322,6 +323,9 @@ func TestValidate(t *testing.T) { }, }, }, + UI: &config.UIConfig{ + ProjectInfoUpdateEnabled: true, + }, }, }, "extended | success": { @@ -366,6 +370,9 @@ func TestValidate(t *testing.T) { }, }, }, + UI: &config.UIConfig{ + ProjectInfoUpdateEnabled: true, + }, }, }, "default config | failure": { @@ -414,6 +421,9 @@ func TestValidate(t *testing.T) { }, }, }, + UI: &config.UIConfig{ + ProjectInfoUpdateEnabled: true, + }, }, error: errors.New( "failed to validate configuration: " + @@ -459,6 +469,9 @@ func TestValidate(t *testing.T) { }, }, }, + UI: &config.UIConfig{ + ProjectInfoUpdateEnabled: true, + }, }, error: errors.New( "failed to validate configuration: " + diff --git a/api/config/testdata/config-2.yaml b/api/config/testdata/config-2.yaml index 635b6f02..bce88425 100644 --- a/api/config/testdata/config-2.yaml +++ b/api/config/testdata/config-2.yaml @@ -28,6 +28,7 @@ authorization: ui: clockworkUIHomepage: http://clockwork.dev kubeflowUIHomepage: http://kubeflow.org + projectinfoUpdateEnabled: true defaultSecretStorage: name: default-secret-storage diff --git a/ui/packages/app/src/config.js b/ui/packages/app/src/config.js index 3ce499f5..a7151626 100644 --- a/ui/packages/app/src/config.js +++ b/ui/packages/app/src/config.js @@ -33,7 +33,9 @@ const config = { ), CLOCKWORK_UI_HOMEPAGE: getEnv("REACT_APP_CLOCKWORK_UI_HOMEPAGE"), - KUBEFLOW_UI_HOMEPAGE: getEnv("REACT_APP_KUBEFLOW_UI_HOMEPAGE") + KUBEFLOW_UI_HOMEPAGE: getEnv("REACT_APP_KUBEFLOW_UI_HOMEPAGE"), + PROJECT_INFO_UPDATE_ENABLED: + getEnv("REACT_APP_PROJECT_INFO_UPDATE_ENABLED") || false }; export default config; diff --git a/ui/packages/app/src/project_setting/ProjectInfoSetting.js b/ui/packages/app/src/project_setting/ProjectInfoSetting.js new file mode 100644 index 00000000..2e58cad5 --- /dev/null +++ b/ui/packages/app/src/project_setting/ProjectInfoSetting.js @@ -0,0 +1,29 @@ +import React, { useContext } from "react"; +import ProjectInfoForm from "./project_info/ProjectInfoForm"; +import { ProjectFormContextProvider } from "./form/context"; +import { ProjectsContext } from "@caraml-dev/ui-lib"; +import { Project } from "./form/project"; +import { EuiLoadingChart, EuiTextAlign } from "@elastic/eui"; + +const ProjectInfoSetting = () => { + const { currentProject, refresh } = useContext(ProjectsContext); + + return ( + <> + {!currentProject ? ( + + + + ) : ( + + + + )} + + ); +}; + +export default ProjectInfoSetting; diff --git a/ui/packages/app/src/project_setting/ProjectSetting.js b/ui/packages/app/src/project_setting/ProjectSetting.js index 7441652c..69b0367c 100644 --- a/ui/packages/app/src/project_setting/ProjectSetting.js +++ b/ui/packages/app/src/project_setting/ProjectSetting.js @@ -3,6 +3,7 @@ import { EuiSideNav, EuiIcon, EuiPageTemplate } from "@elastic/eui"; import { slugify } from "@caraml-dev/ui-lib/src/utils"; import UserRoleSetting from "./UserRoleSetting"; import SecretSetting from "./SecretSetting"; +import ProjectInfoSetting from "./ProjectInfoSetting"; import { Navigate, Route, @@ -16,6 +17,10 @@ const sections = { iconType: "user", name: "User Roles" }, + "project-info": { + iconType: "iInCircle", + name: "Project Info" + }, "secrets-management": { iconType: "lock", name: "Secrets" @@ -64,6 +69,7 @@ const ProjectSetting = () => { } /> } /> + } /> } /> { - const [items, setItems] = useState([]); +export const Labels = ({ + labels, + setLabels, + setIsValidLabels, + isValidLabels, + isDisabled = false +}) => { + const [items, setItems] = useState( + (e => { + if (e) { + return e.map((label, idx) => ({ + ...label, + idx, + isKeyValid: true, + isValueValid: true + })); + } else { + return []; + } + })(labels) + ); const addItem = () => { const newItems = [ @@ -62,57 +82,91 @@ export const Labels = ({ onChange }) => { }; }; - return ( - - {items.map((element, idx) => { - return ( - - - - - - - - - - - - - - ); - })} + const [labelError, setLabelError] = useState(""); + const onChange = labels => { + const labelsValid = + labels.length === 0 + ? true + : labels.reduce((labelsValid, label) => { + return labelsValid && label.isKeyValid && label.isValueValid; + }, true); + setIsValidLabels(labelsValid); + if (!labelsValid) { + setLabelError( + "Invalid labels. Both key and value of a label must contain only lowercase alphanumeric and dash (-), and must start and end with an alphanumeric character" + ); + } + + //deep copy + let newLabels = JSON.parse(JSON.stringify(labels)); + newLabels = newLabels.map(element => { + delete element.isKeyValid; + delete element.isValueValid; + delete element.idx; + return element; + }); - - { - return ( - addButtonDisabled || - !currentValue.isKeyValid || - !currentValue.isValueValid - ); - }, false) - } - /> - - + setLabels(newLabels); + + console.log(isDisabled || items.length === 0); + }; + + return ( + + + {items.map((element, idx) => { + return ( + + + + + + + + + + + + + + ); + })} + + { + return ( + addButtonDisabled || + !currentValue.isKeyValid || + !currentValue.isValueValid + ); + }, false)) + } + /> + + + ); }; diff --git a/ui/packages/app/src/project_setting/form/ProjectForm.js b/ui/packages/app/src/project_setting/form/ProjectForm.js index 2a61f88a..82cfa37d 100644 --- a/ui/packages/app/src/project_setting/form/ProjectForm.js +++ b/ui/packages/app/src/project_setting/form/ProjectForm.js @@ -1,4 +1,4 @@ -import React, { useContext, useState, useEffect, useMemo } from "react"; +import React, { useContext, useState, useEffect } from "react"; import { EuiPanel, EuiFormRow, @@ -9,12 +9,13 @@ import { EuiButton, EuiForm } from "@elastic/eui"; -import { addToast, EuiComboBoxSelect, useMlpApi } from "@caraml-dev/ui-lib"; +import { addToast, useMlpApi } from "@caraml-dev/ui-lib"; import { ProjectFormContext } from "./context"; import { EmailTextArea } from "./EmailTextArea"; import { Labels } from "./Labels"; +import { Stream } from "./Stream"; +import { Team } from "./Team"; import { isDNS1123Label } from "../../validation/validation"; -import config from "../../config"; import { useNavigate } from "react-router-dom"; const ProjectForm = () => { @@ -30,19 +31,6 @@ const ProjectForm = () => { setLabels } = useContext(ProjectFormContext); - const streamOptions = useMemo(() => { - return Object.entries(config.STREAMS) - .map(([stream]) => stream.trim()) - .sort((a, b) => a.localeCompare(b)) - .map(stream => ({ label: stream })); - }, []); - - const teamOptions = useMemo(() => { - return (config.STREAMS[project.stream] || []) - .sort((a, b) => a.localeCompare(b)) - .map(team => ({ label: team.trim() })); - }, [project.stream]); - const [projectError, setProjectError] = useState(""); const [isValidProject, setIsValidProject] = useState(false); const onProjectChange = e => { @@ -57,41 +45,8 @@ const ProjectForm = () => { setName(newValue); }; - const [streamError, setStreamError] = useState(""); const [isValidStream, setIsValidStream] = useState(false); - const onStreamChange = selectedStream => { - if (selectedStream !== project.stream) { - let isValid = isDNS1123Label(selectedStream); - if (!isValid) { - setStreamError( - "Stream name is invalid. It should contain only lowercase alphanumeric and dash (-)" - ); - } - setIsValidStream(isValid); - setStream(selectedStream); - } - }; - - const [teamError, setTeamError] = useState(""); const [isValidTeam, setIsValidTeam] = useState(false); - const onTeamChange = selectedTeam => { - if (selectedTeam !== project.team) { - let isValid = isDNS1123Label(selectedTeam); - if (!isValid) { - setTeamError( - "Team name is invalid. It should contain only lowercase alphanumeric and dash (-)" - ); - } - setIsValidTeam(isValid); - setTeam(selectedTeam); - } - }; - - useEffect(() => { - if (!project.team) { - setIsValidTeam(false); - } - }, [project.team]); const onAdminValueChange = emails => { setAdmin(emails); @@ -118,32 +73,6 @@ const ProjectForm = () => { }; const [isValidLabels, setIsValidLabels] = useState(true); - const [labelError, setLabelError] = useState(""); - const onLabelChange = labels => { - const labelsValid = - labels.length === 0 - ? true - : labels.reduce((labelsValid, label) => { - return labelsValid && label.isKeyValid && label.isValueValid; - }, true); - setIsValidLabels(labelsValid); - if (!labelsValid) { - setLabelError( - "Invalid labels. Both key and value of a label must contain only alphanumeric and dash (-)" - ); - } - - //deep copy - let newLabels = JSON.parse(JSON.stringify(labels)); - newLabels = newLabels.map(element => { - delete element.isKeyValid; - delete element.isValueValid; - delete element.idx; - return element; - }); - - setLabels(newLabels); - }; const onSubmit = () => { submitForm({ body: JSON.stringify(project) }); @@ -192,26 +121,23 @@ const ProjectForm = () => { Stream} description="Product stream the project belongs to"> - - - + Team} description="Owner of the project"> - - - + Project Members} @@ -238,9 +164,12 @@ const ProjectForm = () => { Labels} description="Additional Labels"> - - - + diff --git a/ui/packages/app/src/project_setting/form/Stream.js b/ui/packages/app/src/project_setting/form/Stream.js new file mode 100644 index 00000000..e11dd707 --- /dev/null +++ b/ui/packages/app/src/project_setting/form/Stream.js @@ -0,0 +1,47 @@ +import React, { useState, useMemo } from "react"; +import { EuiFormRow } from "@elastic/eui"; +import { EuiComboBoxSelect } from "@caraml-dev/ui-lib"; +import { isDNS1123Label } from "../../validation/validation"; +import config from "../../config"; + +export const Stream = ({ + stream, + setStream, + isValidStream, + setIsValidStream, + isDisabled = false +}) => { + const streamOptions = useMemo(() => { + return Object.entries(config.STREAMS) + .map(([stream]) => stream.trim()) + .sort((a, b) => a.localeCompare(b)) + .map(stream => ({ label: stream })); + }, []); + + const [streamError, setStreamError] = useState(""); + + const onStreamChange = stream => { + let isValid = isDNS1123Label(stream); + if (!isValid) { + setStreamError( + "Stream name is invalid. It should contain only lowercase alphanumeric and dash (-), and must start and end with an alphanumeric character" + ); + } + setIsValidStream(isValid); + setStream(stream); + }; + + return ( + + + + ); +}; + +export default Stream; diff --git a/ui/packages/app/src/project_setting/form/Team.js b/ui/packages/app/src/project_setting/form/Team.js new file mode 100644 index 00000000..307c75a6 --- /dev/null +++ b/ui/packages/app/src/project_setting/form/Team.js @@ -0,0 +1,53 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { EuiFormRow } from "@elastic/eui"; +import { EuiComboBoxSelect } from "@caraml-dev/ui-lib"; +import { isDNS1123Label } from "../../validation/validation"; +import config from "../../config"; + +export const Team = ({ + team, + setTeam, + stream, + isValidTeam, + setIsValidTeam, + isDisabled = false +}) => { + const teamOptions = useMemo(() => { + return (config.STREAMS[stream] || []) + .sort((a, b) => a.localeCompare(b)) + .map(team => ({ label: team.trim() })); + }, [stream]); + + const [teamError, setTeamError] = useState(""); + + const onTeamChange = team => { + let isValid = isDNS1123Label(team); + if (!isValid) { + setTeamError( + "Team name is invalid. It should contain only lowercase alphanumeric and dash (-), and must start and end with an alphanumeric character" + ); + } + setIsValidTeam(isValid); + setTeam(team); + }; + + useEffect(() => { + if (!team) { + setIsValidTeam(false); + } + }, [team, setIsValidTeam]); + + return ( + + + + ); +}; + +export default Team; diff --git a/ui/packages/app/src/project_setting/project_info/ProjectInfoForm.js b/ui/packages/app/src/project_setting/project_info/ProjectInfoForm.js new file mode 100644 index 00000000..9c77433b --- /dev/null +++ b/ui/packages/app/src/project_setting/project_info/ProjectInfoForm.js @@ -0,0 +1,114 @@ +import React, { useContext, useState } from "react"; +import { + EuiFlexItem, + EuiText, + EuiFlexGroup, + EuiForm, + EuiSpacer, + EuiTitle +} from "@elastic/eui"; +import SubmitProjectInfoForm from "./SubmitProjectInfoForm"; +import config from "../../config"; +import { isDNS1123Label } from "../../validation/validation"; +import { ProjectFormContext } from "../form/context"; +import { Labels } from "../form/Labels"; +import { Stream } from "../form/Stream"; +import { Team } from "../form/Team"; + +const ProjectInfoForm = ({ originalProject, fetchUpdates }) => { + const { project, setStream, setTeam, setLabels } = useContext( + ProjectFormContext + ); + + const [isValidStream, setIsValidStream] = useState( + isDNS1123Label(project.stream) + ); + const [isValidTeam, setIsValidTeam] = useState(isDNS1123Label(project.team)); + + const [isValidLabels, setIsValidLabels] = useState( + project.labels.length === 0 + ? true + : project.labels.reduce((labelsValid, label) => { + return ( + labelsValid && + isDNS1123Label(label.key) && + isDNS1123Label(label.value) + ); + }, true) + ); + + const isDisabled = !config.PROJECT_INFO_UPDATE_ENABLED; + + return ( + <> + + + + +

Stream

+
+ + +

Product stream the project belongs to

+
+ + + + +

Team

+
+ + +

The owner of the project

+
+ + + + +

Labels

+
+ + +

Additional Labels

+
+ + +
+ + + + +
+
+ + ); +}; + +export default ProjectInfoForm; diff --git a/ui/packages/app/src/project_setting/project_info/SubmitProjectInfoForm.js b/ui/packages/app/src/project_setting/project_info/SubmitProjectInfoForm.js new file mode 100644 index 00000000..6b69dcb0 --- /dev/null +++ b/ui/packages/app/src/project_setting/project_info/SubmitProjectInfoForm.js @@ -0,0 +1,42 @@ +import React, { useState } from "react"; +import { EuiButton, EuiText, EuiFlexGroup, EuiFlexItem } from "@elastic/eui"; +import UpdateProjectInfoModal from "./UpdateProjectInfoModal"; + +const SubmitProjectInfoForm = ({ + project, + isValidTeam, + isValidStream, + isValidLabels, + fetchUpdates, + isDisabled, + originalProject +}) => { + const [showUpdateModal, setShowUpdateModal] = useState(false); + + return ( + + + setShowUpdateModal(true)} + disabled={ + isDisabled || !(isValidTeam && isValidStream && isValidLabels) + } + fill> + Submit + + {showUpdateModal && ( + setShowUpdateModal(false)} + fetchUpdates={fetchUpdates} + originalProject={originalProject} + /> + )} + + + ); +}; + +export default SubmitProjectInfoForm; diff --git a/ui/packages/app/src/project_setting/project_info/UpdateProjectInfoModal.js b/ui/packages/app/src/project_setting/project_info/UpdateProjectInfoModal.js new file mode 100644 index 00000000..d0b2b15c --- /dev/null +++ b/ui/packages/app/src/project_setting/project_info/UpdateProjectInfoModal.js @@ -0,0 +1,125 @@ +import React, { useEffect } from "react"; +import { addToast, useMlpApi } from "@caraml-dev/ui-lib"; +import { + EuiConfirmModal, + EuiOverlayMask, + EuiCode, + EuiBasicTable, + EuiFlexItem, + EuiFlexGroup +} from "@elastic/eui"; +import { useNavigate } from "react-router-dom"; + +const UpdateProjectInfoModal = ({ + project, + closeModal, + fetchUpdates, + originalProject +}) => { + const [submissionResponse, submitForm] = useMlpApi( + `/v1/projects/${project.id}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" } + }, + {}, + false + ); + + const navigate = useNavigate(); + + useEffect(() => { + if (submissionResponse.isLoaded && !submissionResponse.error) { + closeModal(); + addToast({ + id: "submit-success-create", + title: "Project Info Updated!", + color: "success", + iconType: "check" + }); + fetchUpdates(); + navigate(`/projects/${submissionResponse.data.id}/settings/project-info`); + } + }, [navigate, submissionResponse, project, fetchUpdates, closeModal]); + + const handleUpdate = () => { + const updatedProjectInfo = { + ...project, + stream: project.stream, + team: project.team, + labels: project.labels + }; + + submitForm({ body: JSON.stringify(updatedProjectInfo) }); + }; + + const columns = [ + { + field: "key", + name: "Key" + }, + { + field: "value", + name: "Value" + } + ]; + + const rows = project.labels.map(label => ({ + ...label + })); + + const originalRows = originalProject.labels.map(label => ({ + ...label + })); + + return ( + + closeModal()} + onConfirm={handleUpdate} + cancelButtonText="Cancel" + confirmButtonText="Update" + buttonColor="primary" + defaultFocusedButton="confirm"> +

+ You are about to update the project info for the project{" "} + {project.name} + , are you sure? +
+ Note: Project info changes will take approximately 10 minutes. +

+ + +

Current Project Info

+

+ Stream: {originalProject.stream} +
+ Team: {originalProject.team} +
+ Labels: + +

+
+ +

New Project Info

+

+ Stream: {project.stream} +
+ Team: {project.team} +
+ Labels: + +

+
+
+
+
+ ); +}; + +export default UpdateProjectInfoModal; diff --git a/ui/packages/lib/src/components/form/combo_box/EuiComboBoxSelect.js b/ui/packages/lib/src/components/form/combo_box/EuiComboBoxSelect.js index 8b1a5ccd..90dd95b7 100644 --- a/ui/packages/lib/src/components/form/combo_box/EuiComboBoxSelect.js +++ b/ui/packages/lib/src/components/form/combo_box/EuiComboBoxSelect.js @@ -32,6 +32,7 @@ export const EuiComboBoxSelect = ({ value, onChange, options, ...props }) => { onChange(selected.length ? selected[0].label : undefined); }} isClearable={props.isClearable || true} + isDisabled={props.isDisabled} selectedOptions={selected} rowHeight={EuiComboboxSuggestItemRowHeight} onCreateOption={props.onCreateOption}