Procházet zdrojové kódy

Merge pull request #2197 from porter-dev/master

Frontend improvements -> staging
abelanger5 před 3 roky
rodič
revize
cf018a8f79

+ 39 - 2
dashboard/src/components/repo-selector/RepoList.tsx

@@ -19,6 +19,42 @@ type Props = {
   filteredRepos?: string[];
   filteredRepos?: string[];
 };
 };
 
 
+type Provider =
+  | {
+      provider: "github";
+      name: string;
+      installation_id: number;
+    }
+  | {
+      provider: "gitlab";
+      instance_url: string;
+      integration_id: number;
+    };
+
+// Sort provider by name if it's github or instance url if it's gitlab
+const sortProviders = (providers: Provider[]) => {
+  const githubProviders = providers.filter(
+    (provider) => provider.provider === "github"
+  );
+
+  const gitlabProviders = providers.filter(
+    (provider) => provider.provider === "gitlab"
+  );
+
+  const githubSortedProviders = githubProviders.sort((a, b) => {
+    if (a.provider === "github" && b.provider === "github") {
+      return a.name.localeCompare(b.name);
+    }
+  });
+
+  const gitlabSortedProviders = gitlabProviders.sort((a, b) => {
+    if (a.provider === "gitlab" && b.provider === "gitlab") {
+      return a.instance_url.localeCompare(b.instance_url);
+    }
+  });
+  return [...gitlabSortedProviders, ...githubSortedProviders];
+};
+
 const RepoList: React.FC<Props> = ({
 const RepoList: React.FC<Props> = ({
   actionConfig,
   actionConfig,
   setActionConfig,
   setActionConfig,
@@ -51,8 +87,9 @@ const RepoList: React.FC<Props> = ({
           return;
           return;
         }
         }
 
 
-        setProviders(data);
-        setCurrentProvider(data[0]);
+        const sortedProviders = sortProviders(data);
+        setProviders(sortedProviders);
+        setCurrentProvider(sortedProviders[0]);
       })
       })
       .catch((err) => {
       .catch((err) => {
         setHasProviders(false);
         setHasProviders(false);

+ 6 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -174,7 +174,12 @@ const ExpandedStack = () => {
             value: "source_config",
             value: "source_config",
             component: (
             component: (
               <>
               <>
-                <SourceConfig revision={currentRevision}></SourceConfig>
+                <SourceConfig
+                  namespace={namespace}
+                  revision={currentRevision}
+                  readOnly={stack.latest_revision.id !== currentRevision.id}
+                  onSourceConfigUpdate={() => getStack()}
+                ></SourceConfig>
               </>
               </>
             ),
             ),
           },
           },

+ 82 - 9
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx

@@ -1,17 +1,72 @@
 import { Tooltip } from "@material-ui/core";
 import { Tooltip } from "@material-ui/core";
 import ImageSelector from "components/image-selector/ImageSelector";
 import ImageSelector from "components/image-selector/ImageSelector";
-import React from "react";
+import SaveButton from "components/SaveButton";
+import React, { useContext, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
 import styled from "styled-components";
 import styled from "styled-components";
-import { AppResource, FullStackRevision, SourceConfig } from "../types";
+import { AppResource, FullStackRevision, SourceConfig, Stack } from "../types";
+import SourceEditorDocker from "./components/SourceEditorDocker";
+
+const _SourceConfig = ({
+  namespace,
+  revision,
+  readOnly,
+  onSourceConfigUpdate,
+}: {
+  namespace: string;
+  revision: FullStackRevision;
+  readOnly: boolean;
+  onSourceConfigUpdate: () => void;
+}) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [sourceConfigArrayCopy, setSourceConfigArrayCopy] = useState<
+    SourceConfig[]
+  >(() => revision.source_configs);
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const handleChange = (sourceConfig: SourceConfig) => {
+    const newSourceConfigArray = [...sourceConfigArrayCopy];
+    const index = newSourceConfigArray.findIndex(
+      (sc) => sc.id === sourceConfig.id
+    );
+    newSourceConfigArray[index] = sourceConfig;
+    setSourceConfigArrayCopy(newSourceConfigArray);
+  };
+
+  const handleSave = () => {
+    setButtonStatus("loading");
+    api
+      .updateStackSourceConfig(
+        "<token>",
+        {
+          source_configs: sourceConfigArrayCopy,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: namespace,
+          stack_id: revision.stack_id,
+        }
+      )
+      .then(() => {
+        setButtonStatus("successful");
+        onSourceConfigUpdate();
+      })
+      .catch((err) => {
+        setButtonStatus("Something went wrong");
+        setCurrentError(err);
+      });
+  };
 
 
-const _SourceConfig = ({ revision }: { revision: FullStackRevision }) => {
   return (
   return (
     <SourceConfigStyles.Wrapper>
     <SourceConfigStyles.Wrapper>
       {revision.source_configs.map((sourceConfig) => {
       {revision.source_configs.map((sourceConfig) => {
         const apps = getAppsFromSourceConfig(revision.resources, sourceConfig);
         const apps = getAppsFromSourceConfig(revision.resources, sourceConfig);
 
 
         const appList = formatAppList(apps, 2);
         const appList = formatAppList(apps, 2);
-        console.log({ appList });
         return (
         return (
           <SourceConfigStyles.ItemContainer>
           <SourceConfigStyles.ItemContainer>
             {appList.hiddenApps?.length ? (
             {appList.hiddenApps?.length ? (
@@ -36,15 +91,24 @@ const _SourceConfig = ({ revision }: { revision: FullStackRevision }) => {
                 Used by {appList.value}
                 Used by {appList.value}
               </SourceConfigStyles.ItemTitle>
               </SourceConfigStyles.ItemTitle>
             )}
             )}
-            <ImageSelector
-              selectedImageUrl={sourceConfig.image_repo_uri}
-              selectedTag={sourceConfig.image_tag}
-              forceExpanded
-              readOnly
+            <SourceEditorDocker
+              sourceConfig={sourceConfig}
+              onChange={handleChange}
+              readOnly={readOnly || buttonStatus === "loading"}
             />
             />
           </SourceConfigStyles.ItemContainer>
           </SourceConfigStyles.ItemContainer>
         );
         );
       })}
       })}
+      <SourceConfigStyles.SaveButtonRow>
+        <SourceConfigStyles.SaveButton
+          onClick={handleSave}
+          text="Save"
+          clearPosition={true}
+          makeFlush={true}
+          status={buttonStatus}
+          statusPosition="left"
+        />
+      </SourceConfigStyles.SaveButtonRow>
     </SourceConfigStyles.Wrapper>
     </SourceConfigStyles.Wrapper>
   );
   );
 };
 };
@@ -89,6 +153,7 @@ const formatAppList = (apps: AppResource[], limit: number = 3) => {
 const SourceConfigStyles = {
 const SourceConfigStyles = {
   Wrapper: styled.div`
   Wrapper: styled.div`
     margin-top: 30px;
     margin-top: 30px;
+    position: relative;
   `,
   `,
   ItemContainer: styled.div`
   ItemContainer: styled.div`
     background: #ffffff11;
     background: #ffffff11;
@@ -102,4 +167,12 @@ const SourceConfigStyles = {
   TooltipItem: styled.div`
   TooltipItem: styled.div`
     font-size: 14px;
     font-size: 14px;
   `,
   `,
+  SaveButtonRow: styled.div`
+    margin-top: 15px;
+    display: flex;
+    justify-content: flex-end;
+  `,
+  SaveButton: styled(SaveButton)`
+    z-index: unset;
+  `,
 };
 };

+ 258 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Select.tsx

@@ -0,0 +1,258 @@
+import Loading from "components/Loading";
+import React, { useRef, useState } from "react";
+import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
+import styled from "styled-components";
+
+export type SelectProps<T> = {
+  value: T;
+  options: T[];
+  accessor: (option: T) => string | React.ReactNode;
+  onChange: (value: T) => void;
+  isOptionEqualToValue?: (option: T, value: T) => boolean;
+  label: string;
+  isLoading?: boolean;
+  dropdown?: {
+    maxH?: string;
+    width?: string;
+    label?: string;
+    option?: {
+      height?: string;
+    };
+  };
+  placeholder: string;
+  className?: string;
+  readOnly?: boolean;
+};
+
+const Select = <T extends unknown>({
+  value,
+  options,
+  accessor,
+  onChange,
+  isOptionEqualToValue,
+  label,
+  isLoading,
+  placeholder,
+  dropdown,
+  className,
+  readOnly,
+}: SelectProps<T>) => {
+  const wrapperRef = useRef();
+  const [expanded, setExpanded] = useState(false);
+
+  useOutsideAlerter(wrapperRef, () => {
+    setExpanded(false);
+  });
+
+  const handleOptionClick = (value: T) => {
+    setExpanded(false);
+    onChange(value);
+  };
+
+  const getLabel = () => {
+    if (label) {
+      return <SelectStyles.Label> {label} </SelectStyles.Label>;
+    }
+    return null;
+  };
+
+  if (isLoading) {
+    return (
+      <div>
+        {getLabel()}
+        <SelectStyles.Wrapper>
+          <SelectStyles.Selector
+            className={className}
+            expanded={false}
+            readOnly={readOnly}
+          >
+            <SelectStyles.Loading>
+              <Loading />
+            </SelectStyles.Loading>
+          </SelectStyles.Selector>
+        </SelectStyles.Wrapper>
+      </div>
+    );
+  }
+
+  return (
+    <div>
+      {getLabel()}
+      <SelectStyles.Wrapper ref={wrapperRef}>
+        <SelectStyles.Selector
+          className={className}
+          onClick={() => setExpanded(!expanded)}
+          expanded={expanded}
+          readOnly={readOnly}
+        >
+          <SelectStyles.CurrentValue>
+            <span>{value ? accessor(value) : placeholder}</span>
+          </SelectStyles.CurrentValue>
+          {readOnly ? null : <i className="material-icons">arrow_drop_down</i>}
+        </SelectStyles.Selector>
+        {expanded && !readOnly ? (
+          <SelectStyles.Dropdown.Wrapper
+            width={dropdown?.width}
+            maxH={dropdown?.maxH}
+          >
+            {dropdown?.label && (
+              <SelectStyles.Dropdown.Label>
+                {dropdown?.label}
+              </SelectStyles.Dropdown.Label>
+            )}
+            {options.length > 0 ? (
+              <>
+                {options.map((option, i) => (
+                  <SelectStyles.Dropdown.Option
+                    key={i}
+                    onClick={() => !readOnly && handleOptionClick(option)}
+                    lastItem={i === options.length - 1}
+                    selected={
+                      isOptionEqualToValue
+                        ? isOptionEqualToValue(option, value)
+                        : option === value
+                    }
+                    height={dropdown?.option?.height}
+                  >
+                    {accessor(option)}
+                  </SelectStyles.Dropdown.Option>
+                ))}
+              </>
+            ) : (
+              <SelectStyles.Dropdown.NoOptions>
+                No options available
+              </SelectStyles.Dropdown.NoOptions>
+            )}
+          </SelectStyles.Dropdown.Wrapper>
+        ) : null}
+      </SelectStyles.Wrapper>
+    </div>
+  );
+};
+
+export default Select;
+
+export const SelectStyles = {
+  Wrapper: styled.div`
+    position: relative;
+  `,
+  Label: styled.div`
+    color: #ffffff;
+    margin-bottom: 10px;
+    margin-top: 20px;
+    font-size: 13px;
+  `,
+
+  Selector: styled.div<{ expanded: boolean; readOnly: boolean }>`
+    height: 35px;
+    border: 1px solid #ffffff55;
+    font-size: 13px;
+    padding: 5px 10px;
+    padding-left: 15px;
+    border-radius: 3px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    cursor: ${(props) => (props.readOnly ? "normal" : "pointer")};
+    background: ${(props) => {
+      if (props.readOnly) {
+        return "#ffffff55";
+      }
+
+      if (props.expanded) {
+        return "#ffffff33";
+      }
+      return "#ffffff11";
+    }};
+
+    :hover {
+      background: ${(props) => {
+        if (props.readOnly) {
+          return "#ffffff55";
+        }
+
+        if (props.expanded) {
+          return "#ffffff33";
+        }
+        return "#ffffff22";
+      }};
+    }
+
+    > i {
+      font-size: 20px;
+      transform: ${(props) => (props.expanded ? "rotate(180deg)" : "")};
+    }
+  `,
+
+  Loading: styled.div`
+    width: 100%;
+  `,
+
+  CurrentValue: styled.div`
+    display: flex;
+    align-items: center;
+    width: 85%;
+
+    > span {
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      z-index: 0;
+    }
+  `,
+
+  Dropdown: {
+    Wrapper: styled.div<{ width: string; maxH?: string }>`
+      background: #26282f;
+      width: ${(props) => props.width || "100%"};
+      max-height: ${(props) => props.maxH || "300px"};
+      border-radius: 3px;
+      z-index: 999;
+      overflow-y: auto;
+      margin-bottom: 20px;
+      box-shadow: 0 8px 20px 0px #00000088;
+      position: absolute;
+    `,
+    Option: styled.div<{
+      selected: boolean;
+      lastItem: boolean;
+      height?: string;
+    }>`
+      width: 100%;
+      border-top: 1px solid #00000000;
+      border-bottom: 1px solid
+        ${(props) => (props.lastItem ? "#ffffff00" : "#ffffff15")};
+      height: ${(props) => props.height || "37px"};
+      font-size: 13px;
+      align-items: center;
+      display: flex;
+      align-items: center;
+      padding-left: 15px;
+      cursor: pointer;
+      padding-right: 10px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      background: ${(props) => (props.selected ? "#ffffff11" : "")};
+
+      :hover {
+        background: #ffffff22;
+      }
+    `,
+    Label: styled.div`
+      font-size: 13px;
+      color: #ffffff44;
+      font-weight: 500;
+      margin: 10px 13px;
+    `,
+    NoOptions: styled.div`
+      font-size: 13px;
+      color: #ffffff44;
+      font-weight: 500;
+      margin: 10px 13px;
+      :not(:first-child) {
+        border-top: 1px solid #ffffff15;
+      }
+    `,
+  },
+};

+ 321 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/SourceEditorDocker.tsx

@@ -0,0 +1,321 @@
+import SelectRow from "components/form-components/SelectRow";
+import SearchSelector from "components/SearchSelector";
+import Selector from "components/Selector";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
+import styled from "styled-components";
+import { proxy, useSnapshot } from "valtio";
+import { SourceConfig } from "../../types";
+import Select from "./Select";
+
+const SourceEditorDocker = ({
+  sourceConfig,
+  onChange,
+  readOnly = false,
+}: {
+  readOnly: boolean;
+  sourceConfig: SourceConfig;
+  onChange: (sourceConfig: SourceConfig) => void;
+}) => {
+  const [registry, setRegistry] = useState<DockerRegistry | null>(null);
+  const [image, setImage] = useState<string | null>(
+    () => sourceConfig.image_repo_uri
+  );
+  const [tag, setTag] = useState<string | null>(() => sourceConfig.image_tag);
+
+  const imageName = useMemo(() => {
+    if (!registry) {
+      return "";
+    }
+
+    if (!image) {
+      return "";
+    }
+
+    return image.replace(registry.url + "/", "");
+  }, [image, registry]);
+
+  useEffect(() => {
+    const newSourceConfig: SourceConfig = {
+      ...sourceConfig,
+      image_repo_uri: image,
+      image_tag: tag,
+    };
+
+    onChange(newSourceConfig);
+  }, [image, tag]);
+
+  return (
+    <>
+      <SourceEditorDockerStlyes.RegistryWrapper>
+        <_DockerRepositorySelector
+          currentImageUrl={sourceConfig.image_repo_uri}
+          value={registry}
+          onChange={setRegistry}
+          readOnly={readOnly}
+        />
+      </SourceEditorDockerStlyes.RegistryWrapper>
+      {registry && (
+        <SourceEditorDockerStlyes.ImageAndTagWrapper>
+          <_ImageSelector
+            registry={registry}
+            value={image}
+            onChange={setImage}
+            readOnly={readOnly}
+          />
+
+          {registry && imageName && (
+            <_TagSelector
+              registry={registry}
+              imageName={imageName}
+              value={tag}
+              onChange={setTag}
+              readOnly={readOnly}
+            />
+          )}
+        </SourceEditorDockerStlyes.ImageAndTagWrapper>
+      )}
+    </>
+  );
+};
+
+type DockerRegistry = {
+  id: number;
+  project_id: number;
+  name: string;
+  url: string;
+  service: string;
+  infra_id: number;
+  aws_integration_id: number;
+};
+
+const _DockerRepositorySelector = ({
+  currentImageUrl,
+  value,
+  onChange,
+  readOnly,
+}: {
+  currentImageUrl: string;
+  value: DockerRegistry;
+  onChange: (newRegistry: DockerRegistry) => void;
+  readOnly: boolean;
+}) => {
+  const { currentProject } = useContext(Context);
+
+  const [registries, setRegistries] = useState<DockerRegistry[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    api
+      .getProjectRegistries<DockerRegistry[]>(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        setRegistries(data);
+        if (!value) {
+          const currentRegistry = data.find((r) =>
+            currentImageUrl.includes(r.url)
+          );
+          onChange(currentRegistry);
+        }
+        setIsLoading(false);
+      });
+  }, []);
+
+  const handleChange = (newRegistry: DockerRegistry) => {
+    onChange(newRegistry);
+  };
+
+  return (
+    <>
+      <Select
+        value={value}
+        options={registries}
+        onChange={handleChange}
+        accessor={(val) => val.name}
+        label="Docker Registry"
+        placeholder="Select a registry"
+        isOptionEqualToValue={(a, b) => a.url === b.url}
+        readOnly={readOnly}
+        isLoading={isLoading}
+        dropdown={{
+          maxH: "200px",
+        }}
+      />
+    </>
+  );
+};
+
+type ImageRepo = {
+  name: string;
+  created_at: string;
+  uri: string;
+};
+
+const _ImageSelector = ({
+  registry,
+  value,
+  onChange,
+  readOnly,
+}: {
+  registry: DockerRegistry;
+  value: string;
+  onChange: (newValue: string) => void;
+  readOnly: boolean;
+}) => {
+  const { currentProject } = useContext(Context);
+
+  const [images, setImages] = useState<ImageRepo[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    setIsLoading(true);
+    api
+      .getImageRepos<ImageRepo[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          registry_id: registry.id,
+        }
+      )
+      .then(({ data }) => {
+        setImages(data);
+
+        if (!value) {
+          onChange(data[0].uri);
+        }
+        setIsLoading(false);
+      });
+  }, []);
+
+  const handleChange = (image: string) => {
+    onChange(image);
+  };
+
+  const displayName = (imageUrl: string) => {
+    const image = images.find((i) => i.uri === imageUrl);
+    if (!image) {
+      return imageUrl;
+    }
+    return image.name;
+  };
+
+  return (
+    <Select
+      value={value}
+      options={images.map((image) => image.uri)}
+      accessor={displayName}
+      label="Image"
+      placeholder="Select an image"
+      onChange={handleChange}
+      isOptionEqualToValue={(a, b) => a === b}
+      readOnly={readOnly}
+      isLoading={isLoading}
+      dropdown={{
+        maxH: "200px",
+      }}
+    />
+  );
+};
+
+type DockerImageTag = {
+  digest: string;
+  tag: string;
+  manifest: string;
+  repository_name: string;
+  pushed_at: string;
+};
+
+const _TagSelector = ({
+  registry,
+  imageName,
+  value,
+  onChange,
+  readOnly,
+}: {
+  registry: DockerRegistry;
+  imageName: string;
+  value: string;
+  onChange: (newTag: string) => void;
+  readOnly: boolean;
+}) => {
+  const { currentProject } = useContext(Context);
+  const [imageTags, setImageTags] = useState<string[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    setIsLoading(true);
+    api
+      .getImageTags<DockerImageTag[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          registry_id: registry?.id,
+          repo_name: imageName,
+        }
+      )
+      .then(({ data }) => {
+        if (!data?.length) {
+          setImageTags([]);
+          onChange("");
+          setIsLoading(false);
+          return;
+        }
+
+        const sortedTags = data.sort((a, b) => {
+          const aDate = new Date(a.pushed_at);
+          const bDate = new Date(b.pushed_at);
+          return bDate.getTime() - aDate.getTime();
+        });
+        setImageTags(sortedTags.map((tag) => tag.tag));
+
+        if (sortedTags.map((tag) => tag.tag).includes(value)) {
+          onChange(value);
+        } else {
+          onChange(sortedTags[0].tag);
+        }
+
+        setIsLoading(false);
+      });
+  }, [registry, imageName]);
+
+  const handleChange = (tag: string) => {
+    onChange(tag);
+  };
+
+  return (
+    <Select
+      value={value}
+      options={imageTags}
+      accessor={(tag) => tag}
+      label="Tag"
+      placeholder="Select a tag"
+      onChange={handleChange}
+      readOnly={readOnly}
+      isLoading={isLoading}
+      dropdown={{
+        maxH: "200px",
+      }}
+    />
+  );
+};
+
+export default SourceEditorDocker;
+
+const SourceEditorDockerStlyes = {
+  RegistryWrapper: styled.div``,
+  ImageAndTagWrapper: styled.div`
+    display: grid;
+    grid-template-columns: 3fr 1fr;
+    grid-gap: 10px;
+    align-items: center;
+  `,
+};

+ 34 - 62
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -103,6 +103,34 @@ class Sidebar extends Component<PropsType, StateType> {
     }
     }
   };
   };
 
 
+  /**
+   * Helper function that will keep the query params before redirect the user to a new page
+   *
+   * @param location
+   * @param path Path to redirect to
+   * @returns React router `to` object
+   */
+  withQueryParams = (location: any, path: string) => {
+    let { currentCluster, currentProject } = this.context;
+    let params = this.props.match.params as any;
+    let pathNamespace = params.namespace;
+    let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
+
+    if (!pathNamespace) {
+      pathNamespace = getQueryParam(this.props, "namespace");
+    }
+
+    if (pathNamespace) {
+      search = search.concat(`&namespace=${pathNamespace}`);
+    }
+
+    return {
+      ...location,
+      pathname: path,
+      search,
+    };
+  };
+
   renderClusterContent = () => {
   renderClusterContent = () => {
     let { currentCluster, currentProject } = this.context;
     let { currentCluster, currentProject } = this.context;
 
 
@@ -110,74 +138,16 @@ class Sidebar extends Component<PropsType, StateType> {
       return (
       return (
         <>
         <>
           <NavButton
           <NavButton
-            to={(location) => {
-              let params = this.props.match.params as any;
-              let pathNamespace = params.namespace;
-              let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
-
-              if (!pathNamespace) {
-                pathNamespace = getQueryParam(this.props, "namespace");
-              }
-
-              if (pathNamespace) {
-                search = search.concat(`&namespace=${pathNamespace}`);
-              }
-
-              return {
-                ...location,
-                pathname: "/applications",
-                search,
-              };
-            }}
+            to={(location) => this.withQueryParams(location, "/applications")}
           >
           >
             <Img src={monoweb} />
             <Img src={monoweb} />
             Applications
             Applications
           </NavButton>
           </NavButton>
-          <NavButton
-            to={() => {
-              let params = this.props.match.params as any;
-              let pathNamespace = params.namespace;
-              let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
-
-              if (!pathNamespace) {
-                pathNamespace = getQueryParam(this.props, "namespace");
-              }
-
-              if (pathNamespace) {
-                search = search.concat(`&namespace=${pathNamespace}`);
-              }
-
-              return {
-                ...location,
-                pathname: "/jobs",
-                search,
-              };
-            }}
-          >
+          <NavButton to={() => this.withQueryParams(location, "/jobs")}>
             <Img src={monojob} />
             <Img src={monojob} />
             Jobs
             Jobs
           </NavButton>
           </NavButton>
-          <NavButton
-            to={() => {
-              let params = this.props.match.params as any;
-              let pathNamespace = params.namespace;
-              let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
-
-              if (!pathNamespace) {
-                pathNamespace = getQueryParam(this.props, "namespace");
-              }
-
-              if (pathNamespace) {
-                search = search.concat(`&namespace=${pathNamespace}`);
-              }
-
-              return {
-                ...location,
-                pathname: "/env-groups",
-                search,
-              };
-            }}
-          >
+          <NavButton to={() => this.withQueryParams(location, "/env-groups")}>
             <Img src={sliders} />
             <Img src={sliders} />
             Env Groups
             Env Groups
           </NavButton>
           </NavButton>
@@ -224,7 +194,9 @@ class Sidebar extends Component<PropsType, StateType> {
             </NavButton>
             </NavButton>
           )}
           )}
           {currentProject?.stacks_enabled ? (
           {currentProject?.stacks_enabled ? (
-            <NavButton to="/stacks">
+            <NavButton
+              to={(location) => this.withQueryParams(location, "/stacks")}
+            >
               <Icon className="material-icons-outlined">lan</Icon>
               <Icon className="material-icons-outlined">lan</Icon>
               Stacks
               Stacks
             </NavButton>
             </NavButton>

+ 21 - 1
dashboard/src/shared/api.tsx

@@ -3,7 +3,10 @@ import { PullRequest } from "main/home/cluster-dashboard/preview-environments/ty
 import { baseApi } from "./baseApi";
 import { baseApi } from "./baseApi";
 
 
 import { BuildConfig, FullActionConfigType } from "./types";
 import { BuildConfig, FullActionConfigType } from "./types";
-import { CreateStackBody } from "main/home/cluster-dashboard/stacks/types";
+import {
+  CreateStackBody,
+  SourceConfig,
+} from "main/home/cluster-dashboard/stacks/types";
 
 
 /**
 /**
  * Generic api call format
  * Generic api call format
@@ -2029,6 +2032,22 @@ const deleteStack = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
 );
 );
 
 
+const updateStackSourceConfig = baseApi<
+  {
+    source_configs: SourceConfig[];
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "PUT",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/source`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
 export default {
   checkAuth,
   checkAuth,
@@ -2220,4 +2239,5 @@ export default {
   createStack,
   createStack,
   rollbackStack,
   rollbackStack,
   deleteStack,
   deleteStack,
+  updateStackSourceConfig,
 };
 };