Ver código fonte

repo, branch, subfolder selection integrated on fe (hardcoded credentials)

jusrhee 5 anos atrás
pai
commit
5651203fb5
34 arquivos alterados com 1304 adições e 67 exclusões
  1. BIN
      dashboard/src/assets/branch.png
  2. 4 0
      dashboard/src/assets/file.svg
  3. 4 0
      dashboard/src/assets/folder.svg
  4. BIN
      dashboard/src/assets/github.png
  5. 4 0
      dashboard/src/assets/info.svg
  6. 2 2
      dashboard/src/components/SaveButton.tsx
  7. 111 0
      dashboard/src/components/repo-selector/BranchList.tsx
  8. 197 0
      dashboard/src/components/repo-selector/ContentsList.tsx
  9. 295 0
      dashboard/src/components/repo-selector/RepoSelector.tsx
  10. 0 0
      dashboard/src/components/values-form/CheckboxRow.tsx
  11. 0 0
      dashboard/src/components/values-form/InputRow.tsx
  12. 1 1
      dashboard/src/components/values-form/SelectRow.tsx
  13. 6 4
      dashboard/src/components/values-form/ValuesForm.tsx
  14. 7 0
      dashboard/src/main/Main.tsx
  15. 13 4
      dashboard/src/main/home/Home.tsx
  16. 1 1
      dashboard/src/main/home/dashboard/Dashboard.tsx
  17. 6 1
      dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx
  18. 77 0
      dashboard/src/main/home/dashboard/expanded-chart/SettingsSection.tsx
  19. 3 3
      dashboard/src/main/home/modals/ClusterConfigModal.tsx
  20. 263 0
      dashboard/src/main/home/modals/LaunchTemplateModal.tsx
  21. 7 6
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  22. 5 6
      dashboard/src/main/home/sidebar/Drawer.tsx
  23. 11 6
      dashboard/src/main/home/sidebar/Sidebar.tsx
  24. 7 9
      dashboard/src/main/home/templates/Templates.tsx
  25. 21 16
      dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx
  26. 2 5
      dashboard/src/shared/Context.tsx
  27. 18 1
      dashboard/src/shared/api.tsx
  28. 10 0
      dashboard/src/shared/types.tsx
  29. 1 0
      go.mod
  30. 5 0
      go.sum
  31. 102 0
      server/api/repo_handler.go
  32. 114 0
      server/api/repo_handler_test.go
  33. 2 2
      server/api/template_handler.go
  34. 5 0
      server/router/router.go

BIN
dashboard/src/assets/branch.png


+ 4 - 0
dashboard/src/assets/file.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M16.191 2H7.81C4.77 2 3 3.78 3 6.83V17.16C3 20.26 4.77 22 7.81 22H16.191C19.28 22 21 20.26 21 17.16V6.83C21 3.78 19.28 2 16.191 2Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8.07999 6.65V6.66C7.64899 6.66 7.29999 7.01 7.29999 7.44C7.29999 7.87 7.64899 8.22 8.07999 8.22H11.069C11.5 8.22 11.85 7.87 11.85 7.429C11.85 7 11.5 6.65 11.069 6.65H8.07999ZM15.92 12.74H8.07999C7.64899 12.74 7.29999 12.39 7.29999 11.96C7.29999 11.53 7.64899 11.179 8.07999 11.179H15.92C16.35 11.179 16.7 11.53 16.7 11.96C16.7 12.39 16.35 12.74 15.92 12.74ZM15.92 17.31H8.07999C7.77999 17.35 7.48999 17.2 7.32999 16.95C7.16999 16.69 7.16999 16.36 7.32999 16.11C7.48999 15.85 7.77999 15.71 8.07999 15.74H15.92C16.319 15.78 16.62 16.12 16.62 16.53C16.62 16.929 16.319 17.27 15.92 17.31Z" fill="white"/>
+</svg>

+ 4 - 0
dashboard/src/assets/folder.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M16.8843 5.11485H13.9413C13.2081 5.11969 12.512 4.79355 12.0474 4.22751L11.0782 2.88762C10.6214 2.31661 9.9253 1.98894 9.19321 2.00028H7.11261C3.37819 2.00028 2.00001 4.19201 2.00001 7.91884V11.9474C1.99536 12.3904 21.9956 12.3898 21.9969 11.9474V10.7761C22.0147 7.04924 20.6721 5.11485 16.8843 5.11485Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M20.8321 6.54346C21.1521 6.91754 21.3993 7.34785 21.5612 7.81235C21.8798 8.76704 22.0273 9.77029 21.9969 10.7761V16.0291C21.9956 16.4716 21.963 16.9134 21.8991 17.3512C21.7775 18.124 21.5057 18.8655 21.0989 19.5341C20.9119 19.8571 20.6849 20.1552 20.4231 20.4214C19.2383 21.5089 17.665 22.0749 16.0574 21.992H7.93061C6.32049 22.0743 4.74462 21.5085 3.55601 20.4214C3.2974 20.1547 3.07337 19.8566 2.88915 19.5341C2.48475 18.866 2.21869 18.1237 2.1067 17.3512C2.03549 16.9141 1.99981 16.472 2 16.0291V10.7761C1.99983 10.3373 2.02357 9.89895 2.07113 9.4628C2.08113 9.38628 2.09614 9.31101 2.11098 9.23652C2.13573 9.11233 2.16005 8.99031 2.16005 8.86829C2.25031 8.34196 2.41496 7.83108 2.64908 7.35094C3.34261 5.86908 4.76525 5.11484 7.09481 5.11484H16.8754C18.1802 5.01393 19.4753 5.40673 20.5032 6.21514C20.6215 6.31552 20.7316 6.42532 20.8321 6.54346ZM6.97033 15.5411H17.0355H17.0533C17.2741 15.5507 17.4896 15.4716 17.6517 15.3216C17.8137 15.1715 17.9088 14.963 17.9157 14.7425C17.9282 14.5487 17.8644 14.3576 17.7379 14.2101C17.5924 14.0118 17.3618 13.8934 17.1155 13.8906H6.97033C6.51365 13.8906 6.14343 14.2601 6.14343 14.7159C6.14343 15.1716 6.51365 15.5411 6.97033 15.5411Z" fill="white"/>
+</svg>

BIN
dashboard/src/assets/github.png


+ 4 - 0
dashboard/src/assets/info.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M16.34 1.9998H7.67C4.28 1.9998 2 4.3798 2 7.9198V16.0898C2 19.6198 4.28 21.9998 7.67 21.9998H16.34C19.73 21.9998 22 19.6198 22 16.0898V7.9198C22 4.3798 19.73 1.9998 16.34 1.9998Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1247 8.1893C11.1247 8.6713 11.5157 9.0643 11.9947 9.0643C12.4877 9.0643 12.8797 8.6713 12.8797 8.1893C12.8797 7.7073 12.4877 7.3143 12.0047 7.3143C11.5197 7.3143 11.1247 7.7073 11.1247 8.1893ZM12.8697 11.3621C12.8697 10.8801 12.4767 10.4871 11.9947 10.4871C11.5127 10.4871 11.1197 10.8801 11.1197 11.3621V15.7821C11.1197 16.2641 11.5127 16.6571 11.9947 16.6571C12.4767 16.6571 12.8697 16.2641 12.8697 15.7821V11.3621Z" fill="white"/>
+</svg>

+ 2 - 2
dashboard/src/components/SaveButton.tsx

@@ -105,12 +105,12 @@ const Button = styled.button`
   text-align: left;
   border: 0;
   border-radius: 5px;
-  background: ${(props) => (!props.disabled ? '#616FEEcc' : '#bbd')};
+  background: ${(props) => (!props.disabled ? '#616FEEcc' : '#aaaabb')};
   box-shadow: ${(props) => (!props.disabled ? '0 2px 5px 0 #00000030' : 'none')};
   cursor: ${(props) => (!props.disabled ? 'pointer' : 'default')};
   user-select: none;
   :focus { outline: 0 }
   :hover {
-    background: ${(props) => (!props.disabled ? '#616FEEff' : '#bbd')};
+    background: ${(props) => (!props.disabled ? '#616FEEff' : '#aaaabb')};
   }
 `;

+ 111 - 0
dashboard/src/components/repo-selector/BranchList.tsx

@@ -0,0 +1,111 @@
+import { stringify } from 'querystring';
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import branch_icon from '../../assets/branch.png';
+
+import api from '../../shared/api';
+import { RepoType } from '../../shared/types';
+
+import Loading from '../Loading';
+
+type PropsType = {
+  repoName: string,
+  setSelectedBranch: (x: string) => void,
+  selectedBranch: string
+};
+
+type StateType = {
+  loading: boolean,
+  error: boolean,
+  branches: string[]
+};
+
+export default class BranchList extends Component<PropsType, StateType> {
+  state = {
+    selectedBranch: '',
+    loading: true,
+    error: false,
+    branches: [] as string[]
+  }
+
+  componentDidMount() {
+
+    // Get branches
+    api.getBranches('<token>', {}, {
+      kind: 'github',
+      repo: this.props.repoName
+    }, (err: any, res: any) => {
+      if (err) {
+        this.setState({ loading: false, error: true });
+      } else {
+        this.setState({ branches: res.data, loading: false, error: false });
+      }
+    });
+  }
+
+  renderBranchList = () => {
+    let { branches, loading, error } = this.state;
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error || !branches) {
+      return <LoadingWrapper>Error loading branches</LoadingWrapper>
+    }
+
+    return branches.map((branch: string, i: number) => {
+      return (
+        <BranchName
+          key={i}
+          isSelected={branch === this.props.selectedBranch}
+          lastItem={i === branches.length - 1}
+          onClick={() => this.props.setSelectedBranch(branch)}
+        >
+          <img src={branch_icon} />{branch}
+        </BranchName>
+      );
+    });
+  }
+
+  render() {
+    return (
+      <div>
+        {this.renderBranchList()}
+      </div>
+    );
+  }
+}
+
+const BranchName = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid ${(props: { lastItem: boolean, isSelected: boolean }) => props.lastItem ? '#00000000' : '#606166'};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${(props: { isSelected: boolean, lastItem: boolean }) => props.isSelected ? '#ffffff22' : '#ffffff11'};
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+`;

+ 197 - 0
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -0,0 +1,197 @@
+import { stringify } from 'querystring';
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import file from '../../assets/file.svg';
+import folder from '../../assets/folder.svg';
+import info from '../../assets/info.svg';
+
+import api from '../../shared/api';
+import { FileType } from '../../shared/types';
+
+import Loading from '../Loading';
+
+type PropsType = {
+  repoName: string,
+  selectedBranch: string,
+  subdirectory: string,
+  setSubdirectory: (x: string) => void,
+};
+
+type StateType = {
+  loading: boolean,
+  error: boolean,
+  contents: FileType[]
+};
+
+export default class ContentsList extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    error: false,
+    contents: [] as FileType[]
+  }
+
+  updateContents = () => {
+    // Get branch contents
+    api.getBranchContents('<token>', { dir: this.props.subdirectory }, {
+      kind: 'github',
+      repo: this.props.repoName,
+      branch: this.props.selectedBranch
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        this.setState({ loading: false, error: true });
+      } else {
+        let files = [] as FileType[];
+        let folders = [] as FileType[];
+        res.data.map((x: FileType, i: number) => {
+          x.Type === 'dir' ? folders.push(x) : files.push(x);
+        });
+
+        folders.sort((a: FileType, b: FileType) => { return a.Path < b.Path ? 1 : 0 });
+        files.sort((a: FileType, b: FileType) => { return a.Path < b.Path ? 1 : 0 });
+        let contents = folders.concat(files);
+        
+        this.setState({ contents, loading: false, error: false });
+      }
+    });
+  }
+
+  componentDidMount() {
+    this.updateContents();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (this.props.subdirectory !== prevProps.subdirectory) {
+      console.log('New subdirectory:', this.props.subdirectory);
+      this.updateContents();  
+    }
+  }
+
+  renderContentList = () => {
+    let { contents, loading, error } = this.state;
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error || !contents) {
+      return <LoadingWrapper>Error loading repo contents</LoadingWrapper>
+    }
+
+    return contents.map((item: FileType, i: number) => {
+      let splits = item.Path.split('/');
+      let fileName = splits[splits.length - 1];
+      if (item.Type === 'dir') {
+        return (
+          <Item
+            key={i}
+            isSelected={item.Path === this.props.subdirectory}
+            lastItem={i === contents.length - 1}
+            onClick={() => this.props.setSubdirectory(item.Path)}
+          >
+            <img src={folder} />
+            {fileName}
+          </Item>
+        );
+      }
+
+      return (
+        <FileItem
+          key={i}
+          lastItem={i === contents.length - 1}
+        >
+          <img src={file} />
+          {fileName}
+        </FileItem>
+      );
+    });
+  }
+
+  renderJumpToParent = () => {
+    let { subdirectory, setSubdirectory } = this.props;
+    if (subdirectory !== '') {
+      let splits = subdirectory.split('/');
+      let subdir = '';
+      if (splits.length !== 1) {
+        subdir = subdirectory.replace(splits[splits.length - 1], '');
+        if (subdir.charAt(subdir.length - 1) === '/') {
+          subdir = subdir.slice(0, subdir.length - 1);
+        }
+      }
+
+      return (
+        <Item
+          lastItem={false}
+          onClick={() => setSubdirectory(subdir)}
+        >
+          <BackLabel>..</BackLabel>
+        </Item>
+      );
+    }
+
+    return (
+      <FileItem
+        lastItem={false}
+      >
+        <img src={info} />
+        Select subfolder (optional)
+      </FileItem>
+    );
+  }
+
+  render() {
+    return (
+      <div>
+        {this.renderJumpToParent()}
+        {this.renderContentList()}
+      </div>
+    );
+  }
+}
+
+const BackLabel = styled.div`
+  font-size: 16px;
+  padding-left: 16px;
+  margin-top: -4px;
+`;
+
+const Item = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid ${(props: { lastItem: boolean, isSelected?: boolean }) => props.lastItem ? '#00000000' : '#606166'};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${(props: { isSelected?: boolean, lastItem: boolean }) => props.isSelected ? '#ffffff22' : '#ffffff11'};
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const FileItem = styled(Item)`
+  cursor: default;
+  color: #ffffff55;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+`;

+ 295 - 0
dashboard/src/components/repo-selector/RepoSelector.tsx

@@ -0,0 +1,295 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import github from '../../assets/github.png';
+import info from '../../assets/info.svg';
+
+import api from '../../shared/api';
+import { RepoType } from '../../shared/types';
+
+import Loading from '../../components/Loading';
+import BranchList from './BranchList';
+import ContentsList from './ContentsList';
+
+type PropsType = {
+  forceExpanded?: boolean,
+  selectedRepo: RepoType | null,
+  selectedBranch: string,
+  subdirectory: string,
+  setSelectedRepo: (x: RepoType) => void,
+  setSelectedBranch: (x: string) => void,
+  setSubdirectory: (x: string) => void
+};
+
+type StateType = {
+  isExpanded: boolean,
+  loading: boolean,
+  error: boolean,
+  repos: RepoType[]
+};
+
+export default class RepoSelector extends Component<PropsType, StateType> {
+  state = {
+    isExpanded: this.props.forceExpanded,
+    loading: true,
+    error: false,
+    repos: [] as RepoType[]
+  }
+
+  componentDidMount() {
+
+    // Get repos
+    api.getRepos('<token>', {}, {}, (err: any, res: any) => {
+      if (err) {
+        this.setState({ loading: false, error: true });
+      } else {
+        this.setState({ repos: res.data, loading: false, error: false });
+      }
+    });
+  }
+
+  renderRepoList = () => {
+    let { repos, loading, error } = this.state;
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error || !repos) {
+      return <LoadingWrapper>Error loading repos</LoadingWrapper>
+    }
+
+    return repos.map((repo: RepoType, i: number) => {
+      return (
+        <RepoName
+          key={i}
+          isSelected={repo === this.props.selectedRepo}
+          lastItem={i === repos.length - 1}
+          onClick={() => this.props.setSelectedRepo(repo)}
+        >
+          <img src={github} />{repo.FullName}
+        </RepoName>
+      );
+    });
+  }
+
+  renderExpanded = () => {
+    let {
+      selectedRepo,
+      selectedBranch,
+      subdirectory,
+      setSelectedRepo,
+      setSelectedBranch,
+      setSubdirectory
+    } = this.props;
+
+    if (!selectedRepo) {
+      return (
+        <ExpandedWrapper>
+          {this.renderRepoList()}
+        </ExpandedWrapper>
+      );
+    } else if (selectedBranch === '') {
+      return (
+        <div>
+          <ExpandedWrapperAlt>
+            <BranchList
+              setSelectedBranch={(branch: string) => setSelectedBranch(branch)}
+              repoName={selectedRepo.FullName.split('/')[1]}
+              selectedBranch={selectedBranch}
+            />
+          </ExpandedWrapperAlt>
+          <BackButton
+            width='130px'
+            onClick={() => setSelectedRepo(null)}
+          >
+            <i className="material-icons">keyboard_backspace</i>
+            Select Repo
+          </BackButton>
+        </div>
+      );
+    }
+    return (
+      <div>
+        <ExpandedWrapperAlt>
+          <ContentsList
+            setSubdirectory={(subdirectory: string) => setSubdirectory(subdirectory)}
+            repoName={selectedRepo.FullName.split('/')[1]}
+            selectedBranch={selectedBranch}
+            subdirectory={subdirectory}
+          />
+        </ExpandedWrapperAlt>
+        <BackButton
+          onClick={() => setSelectedBranch('')}
+          width='140px'
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Branch
+        </BackButton>
+      </div>
+    );
+  }
+
+  renderSelected = () => {
+    let { selectedRepo, subdirectory, selectedBranch } = this.props;
+    if (selectedRepo) {
+      let subdir = subdirectory === '' ? '' : '/' + subdirectory;
+      return (
+        <RepoLabel>
+          <img src={github} />
+          {selectedRepo.FullName + subdir}
+          <SelectedBranch>
+            {!selectedBranch ? '(Select Branch)' : selectedBranch}
+          </SelectedBranch>
+        </RepoLabel>
+      );
+    }
+    return (
+      <RepoLabel>
+        <img src={info} />
+        No source selected
+      </RepoLabel>
+    );
+  }
+
+  handleClick = () => {
+    if (!this.props.forceExpanded) {
+      this.setState({ isExpanded: !this.state.isExpanded });
+    }
+  }
+
+  render() {
+    return (
+      <div>
+        <StyledRepoSelector
+          onClick={this.handleClick}
+          isExpanded={this.state.isExpanded}
+          forceExpanded={this.props.forceExpanded}
+        >
+          {this.renderSelected()}
+          {this.props.forceExpanded ? null : <i className="material-icons">{this.state.isExpanded ? 'close' : 'build'}</i>}
+        </StyledRepoSelector>
+
+        {this.state.isExpanded ? this.renderExpanded() : null}
+      </div>
+    );
+  }
+}
+
+const SelectedBranch = styled.div`
+  color: #ffffff55;
+  margin-left: 10px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 10px;
+  cursor: pointer;
+  font-size: 13px;
+  padding: 5px 10px;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+
+  :hover {
+    background: #ffffff11;
+  }
+
+  > i {
+    color: white;
+    font-size: 18px;
+    margin-right: 10px;
+  }
+`;
+
+const RepoName = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid ${(props: { lastItem: boolean, isSelected: boolean }) => props.lastItem ? '#00000000' : '#606166'};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${(props: { isSelected: boolean, lastItem: boolean }) => props.isSelected ? '#ffffff22' : '#ffffff11'};
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+`;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+  overflow-y: auto;
+`;
+
+const ExpandedWrapperAlt = styled(ExpandedWrapper)`
+`;
+
+const RepoLabel = styled.div`
+  display: flex;
+  align-items: center;
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const StyledRepoSelector = styled.div`
+  width: 100%;
+  border: 1px solid #ffffff55;
+  background: ${(props: { isExpanded: boolean, forceExpanded: boolean }) => props.isExpanded ? '#ffffff11' : ''};
+  border-radius: 3px;
+  user-select: none;
+  height: 40px;
+  font-size: 13px;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: ${(props: { isExpanded: boolean, forceExpanded: boolean }) => props.forceExpanded ? '' : 'pointer'};;
+  :hover {
+    background: #ffffff11;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    font-size: 16px;
+    color: #ffffff66;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 20px;
+    padding: 4px;
+  }
+`;

+ 0 - 0
dashboard/src/main/home/dashboard/expanded-chart/values-form/CheckboxRow.tsx → dashboard/src/components/values-form/CheckboxRow.tsx


+ 0 - 0
dashboard/src/main/home/dashboard/expanded-chart/values-form/InputRow.tsx → dashboard/src/components/values-form/InputRow.tsx


+ 1 - 1
dashboard/src/main/home/dashboard/expanded-chart/values-form/SelectRow.tsx → dashboard/src/components/values-form/SelectRow.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 
-import Selector from '../../../../../components/Selector';
+import Selector from '../Selector';
 
 type PropsType = {
   label: string,

+ 6 - 4
dashboard/src/main/home/dashboard/expanded-chart/values-form/ValuesForm.tsx → dashboard/src/components/values-form/ValuesForm.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 
-import SaveButton from '../../../../../components/SaveButton';
+import SaveButton from '../SaveButton';
 import CheckboxRow from './CheckboxRow';
 import InputRow from './InputRow';
 import SelectRow from './SelectRow';
@@ -101,7 +101,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           {this.renderFormContents()}
         </StyledValuesForm>
         <SaveButton
-          text='Save Values'
+          text='Deploy'
           onClick={() => console.log(this.state)}
           status={null}
         />
@@ -136,10 +136,12 @@ const Heading = styled.div`
 
 const StyledValuesForm = styled.div`
   width: 100%;
-  height: calc(100% - 60px);
+  height: 100%;
   background: #ffffff11;
-  padding: 0px 35px 50px;
+  color: #ffffff;
+  padding: 0px 35px;
   position: relative;
   border-radius: 5px;
+  font-size: 13px;
   overflow: auto;
 `;

+ 7 - 0
dashboard/src/main/Main.tsx

@@ -118,12 +118,19 @@ const GlobalStyle = createGlobalStyle`
     box-sizing: border-box;
     font-family: 'Work Sans', sans-serif;
   }
+  
   body {
     background: #202227;
     overscroll-behavior-x: none;
   }
+
   a {
     color: #949eff;
+    text-decoration: none;
+  }
+
+  img {
+    max-width: 100%;
   }
 `;
 

+ 13 - 4
dashboard/src/main/home/Home.tsx

@@ -7,6 +7,7 @@ import { Context } from '../../shared/Context';
 import Sidebar from './sidebar/Sidebar';
 import Dashboard from './dashboard/Dashboard';
 import ClusterConfigModal from './modals/ClusterConfigModal';
+import LaunchTemplateModal from './modals/LaunchTemplateModal';
 import Loading from '../../components/Loading';
 import Templates from './templates/Templates';
 
@@ -28,7 +29,7 @@ export default class Home extends Component<PropsType, StateType> {
   }
 
   renderDashboard = () => {
-    let { currentCluster, setCurrentModal, setCurrentModalData } = this.context;
+    let { currentCluster, setCurrentModal } = this.context;
 
     if (currentCluster === '' || this.state.showWelcome) {
       return (
@@ -37,8 +38,7 @@ export default class Home extends Component<PropsType, StateType> {
             <Bold>Porter - Getting Started</Bold><br /><br />
             1. Navigate to <A onClick={() => setCurrentModal('ClusterConfigModal')}>+ Add a Cluster</A> and provide a kubeconfig. *<br /><br />
             2. Choose which contexts you would like to use from the <A onClick={() => {
-              setCurrentModal('ClusterConfigModal');
-              setCurrentModalData({ currentTab: 'select' });
+              setCurrentModal('ClusterConfigModal', { currentTab: 'select' });
             }}>Select Clusters</A> tab.<br /><br />
             3. For additional information, please refer to our <A>docs</A>.<br /><br /><br />
             
@@ -77,18 +77,27 @@ export default class Home extends Component<PropsType, StateType> {
       <StyledHome>
         <ReactModal
           isOpen={this.context.currentModal === 'ClusterConfigModal'}
-          onRequestClose={() => this.context.setCurrentModal(null)}
+          onRequestClose={() => this.context.setCurrentModal(null, null)}
           style={MediumModalStyles}
           ariaHideApp={false}
         >
           <ClusterConfigModal />
         </ReactModal>
+        <ReactModal
+          isOpen={this.context.currentModal === 'LaunchTemplateModal'}
+          onRequestClose={() => this.context.setCurrentModal(null, null)}
+          style={MediumModalStyles}
+          ariaHideApp={false}
+        >
+          <LaunchTemplateModal />
+        </ReactModal>
 
         <Sidebar
           logOut={this.props.logOut}
           forceSidebar={this.state.forceSidebar}
           setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
           setCurrentView={(x: string) => this.setState({ currentView: x })}
+          currentView={this.state.currentView}
         />
         
         {this.renderContents()}

+ 1 - 1
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -285,7 +285,7 @@ const TitleSection = styled.div`
 
   > i {
     margin-left: 10px;
-    cursor: pointer;
+    cursor: not-allowed;
     font-size 18px;
     color: #858FAAaa;
     padding: 5px;

+ 6 - 1
dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx

@@ -12,7 +12,8 @@ import ValuesYaml from './ValuesYaml';
 import GraphSection from './GraphSection';
 import ListSection from './ListSection';
 import LogSection from './LogSection';
-import ValuesForm from './values-form/ValuesForm';
+import ValuesForm from '../../../../components/values-form/ValuesForm';
+import SettingsSection from './SettingsSection';
 
 type PropsType = {
   currentChart: ChartType,
@@ -191,6 +192,10 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         <ValuesForm
         />
       );
+    } else if (this.state.currentTab === 'settings') {
+      return (
+        <SettingsSection />
+      );
     }
 
     return (

+ 77 - 0
dashboard/src/main/home/dashboard/expanded-chart/SettingsSection.tsx

@@ -0,0 +1,77 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { RepoType } from '../../../../shared/types';
+
+import RepoSelector from '../../../../components/repo-selector/RepoSelector';
+import SaveButton from '../../../../components/SaveButton';
+
+type PropsType = {
+};
+
+type StateType = {
+  selectedRepo: RepoType | null,
+  selectedBranch: string,
+  subdirectory: string,
+};
+
+export default class SettingsSection extends Component<PropsType, StateType> {
+  state = {
+    selectedRepo: null as RepoType | null,
+    selectedBranch: '',
+    subdirectory: '',
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <StyledSettingsSection>
+          <Subtitle>Connected source</Subtitle>
+          <RepoSelector
+            selectedRepo={this.state.selectedRepo}
+            selectedBranch={this.state.selectedBranch}
+            subdirectory={this.state.subdirectory}
+            setSelectedRepo={(selectedRepo: RepoType) => this.setState({ selectedRepo })}
+            setSelectedBranch={(selectedBranch: string) => this.setState({ selectedBranch })}
+            setSubdirectory={(subdirectory: string) => this.setState({ subdirectory })}
+          />
+        </StyledSettingsSection>
+        <SaveButton
+          text='Save Settings'
+          onClick={() => console.log(this.state)}
+          status={null}
+        />
+      </Wrapper>
+    );
+  }
+}
+
+const Subtitle = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;
+
+const Heading = styled.div`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-top: 35px;
+  margin-bottom: 22px;
+`;
+
+const Wrapper = styled.div`
+  width: 100%;
+  height: 100%;
+`;
+
+const StyledSettingsSection = styled.div`
+  width: 100%;
+  height: calc(100% - 60px);
+  background: #ffffff11;
+  padding: 15px 35px 50px;
+  position: relative;
+  border-radius: 5px;
+  overflow: auto;
+`;

+ 3 - 3
dashboard/src/main/home/modals/ClusterConfigModal.tsx

@@ -24,7 +24,7 @@ type StateType = {
 const tabOptions = [
   { label: 'Raw Kubeconfig', value: 'kubeconfig' },
   { label: 'Select Clusters', value: 'select' }
-]
+];
 
 export default class ClusterConfigModal extends Component<PropsType, StateType> {
   state = {
@@ -195,8 +195,7 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
     return (
       <StyledClusterConfigModal>
         <CloseButton onClick={() => {
-          this.context.setCurrentModal(null);
-          this.context.setCurrentModalData(null);
+          this.context.setCurrentModal(null, null);
         }}>
           <CloseButtonImg src={close} />
         </CloseButton>
@@ -347,6 +346,7 @@ const Plus = styled.span`
 
 const CloseButton = styled.div`
   position: absolute;
+  z-index: 1;
   display: block;
   width: 40px;
   height: 40px;

+ 263 - 0
dashboard/src/main/home/modals/LaunchTemplateModal.tsx

@@ -0,0 +1,263 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import close from '../../../assets/close.png';
+
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+import { KubeContextConfig, RepoType } from '../../../shared/types';
+
+import SaveButton from '../../../components/SaveButton';
+import Selector from '../../../components/Selector';
+import RepoSelector from '../../../components/repo-selector/RepoSelector';
+import ValuesForm from '../../../components/values-form/ValuesForm';
+
+type PropsType = {
+};
+
+type StateType = {
+  currentView: string,
+  contextOptions: { label: string, value: string }[],
+  selectedCluster: string,
+  selectedRepo: RepoType | null,
+  selectedBranch: string,
+  subdirectory: string,
+};
+
+export default class LaunchTemplateModal extends Component<PropsType, StateType> {
+  state = {
+    currentView: 'repo',
+    contextOptions: [] as { label: string, value: string }[],
+    selectedCluster: this.context.currentCluster,
+    selectedRepo: null as RepoType | null,
+    selectedBranch: '',
+    subdirectory: '',
+  };
+  
+  componentDidMount() {
+    let { setCurrentError, user } = this.context;
+
+    // TODO: query with selected filter once implemented
+    api.getContexts('<token>', {}, { id: user.userId }, (err: any, res: any) => {
+      if (err) {
+        // console.log(err)
+      } else if (res.data) {
+
+        // Filter selected (temporary)
+        let kubeContexts = res.data.filter((x: KubeContextConfig) => x.selected);
+        let contextOptions = kubeContexts.map((x: KubeContextConfig) => { return { label: x.name, value: x.name } });
+        if (kubeContexts.length > 0) {
+          this.setState({ contextOptions });
+        }
+      }
+    });
+  }
+
+  renderIcon = (icon: string) => {
+    if (icon) {
+      return <Icon src={icon} />
+    }
+
+    return (
+      <Polymer><i className="material-icons">layers</i></Polymer>
+    );
+  }
+
+  renderContents = () => {
+    if (this.state.currentView === 'repo') {
+      return (
+        <div>
+          <Subtitle>Select the source and branch you would like to use</Subtitle>
+          <RepoSelector
+            forceExpanded={true}
+            selectedRepo={this.state.selectedRepo}
+            selectedBranch={this.state.selectedBranch}
+            subdirectory={this.state.subdirectory}
+            setSelectedRepo={(selectedRepo: RepoType) => this.setState({ selectedRepo })}
+            setSelectedBranch={(selectedBranch: string) => this.setState({ selectedBranch })}
+            setSubdirectory={(subdirectory: string) => this.setState({ subdirectory })}
+          />
+          <SaveButton
+            disabled={this.state.selectedBranch === ''}
+            text='Continue'
+            onClick={() => this.setState({ currentView: 'values'})}
+          />
+        </div>
+      );
+    }
+
+    console.log(this.context.currentModalData.template.Form)
+    return (
+      <Div>
+        <Subtitle>Optionally edit default settings for this template</Subtitle>
+        <ValuesFormWrapper>
+          <ValuesForm />
+        </ValuesFormWrapper>
+      </Div>
+    );
+  }
+
+  render() {
+    let { currentModalData } = this.context;
+    if (currentModalData) {
+      let { Name, Icon, Description } = currentModalData.template.Form;
+      let name = Name ? Name : currentModalData.template.Name;
+
+      return (
+        <StyledClusterConfigModal>
+          <CloseButton onClick={() => {
+            this.context.setCurrentModal(null, null);
+          }}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+
+          <ModalTitle>Launch Template</ModalTitle>
+          <ClusterSection>
+            <Template>
+              {Icon ? this.renderIcon(Icon) : this.renderIcon(currentModalData.template.Icon)}
+              {name}
+            </Template>
+            <i className="material-icons">arrow_right_alt</i>
+            <ClusterLabel>
+              <i className="material-icons">device_hub</i>Cluster
+            </ClusterLabel>
+            <Selector
+              activeValue={this.state.selectedCluster}
+              setActiveValue={(cluster: string) => this.setState({ selectedCluster: cluster })}
+              options={this.state.contextOptions}
+              width='250px'
+              dropdownWidth='335px'
+              closeOverlay={true}
+            />
+          </ClusterSection>
+          {this.renderContents()}
+        </StyledClusterConfigModal>
+      );
+    }
+    return null;
+  }
+}
+
+LaunchTemplateModal.contextType = Context;
+
+const Div = styled.div`
+  width: calc(100% + 64px);
+  margin-left: -32px;
+  height: calc(100% - 50px);
+  position: relative;
+  padding: 0 32px;
+`;
+
+const ValuesFormWrapper = styled.div`
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: 100%;
+  height: calc(100% - 149px);
+`;
+
+const ClusterLabel = styled.div`
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+  > i {
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const Icon = styled.img`
+  width: 21px;
+  margin-right: 10px;
+`;
+
+
+const Polymer = styled.div`
+  margin-bottom: -3px;
+
+  > i {
+    color: ${props => props.theme.containerIcon};
+    font-size: 18px;
+    margin-right: 10px;
+  }
+`;
+
+const Template = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 13px;
+`;
+
+const ClusterSection = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 14px;
+  font-weight: 500;
+  margin-top: 20px;
+
+  > i {
+    font-size: 25px;
+    color: #ffffff44;
+    margin-right: 13px;
+  }
+`;
+
+const Subtitle = styled.div`
+  padding: 17px 0px 25px;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  margin-top: 3px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: 'Assistant';
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledClusterConfigModal= styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 32px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 7 - 6
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -12,7 +12,8 @@ type PropsType = {
   forceCloseDrawer: boolean,
   releaseDrawer: () => void,
   setWelcome: (x: boolean) => void,
-  setCurrentView: (x: string) => void
+  setCurrentView: (x: string) => void,
+  isSelected: boolean
 };
 
 type StateType = {
@@ -94,8 +95,7 @@ export default class ClusterSection extends Component<PropsType, StateType> {
   };
 
   showClusterConfigModal = () => {
-    this.context.setCurrentModal('ClusterConfigModal');
-    this.context.setCurrentModalData({ updateClusters: this.updateClusters });
+    this.context.setCurrentModal('ClusterConfigModal', { updateClusters: this.updateClusters });
   }
 
   renderContents = (): JSX.Element => {
@@ -104,7 +104,7 @@ export default class ClusterSection extends Component<PropsType, StateType> {
 
     if (kubeContexts.length > 0) {
       return (
-        <ClusterSelector showDrawer={showDrawer}>
+        <ClusterSelector isSelected={this.props.isSelected}>
           <LinkWrapper onClick={() => this.props.setCurrentView('dashboard')}>
             <ClusterIcon><i className="material-icons">device_hub</i></ClusterIcon>
             <ClusterName>{currentCluster}</ClusterName>
@@ -169,6 +169,7 @@ const BgAccent = styled.img`
   background: #819BFD;
   width: 30px;
   border-top-left-radius: 100px;
+  max-width: 30px;
   border-bottom-left-radius: 100px;
   position: absolute;
   top: 0;
@@ -260,10 +261,10 @@ const ClusterSelector = styled.div`
   font-weight: 500;
   color: white;
   cursor: pointer;
-  background: ${(props: { showDrawer: boolean }) => props.showDrawer ? '#ffffff0f' : ''};
+  background: ${(props: { isSelected: boolean }) => props.isSelected ? '#ffffff11' : ''};
   z-index: 1;
 
   :hover {
-    background: #ffffff0f;
+    background: ${(props: { isSelected: boolean }) => props.isSelected ? '' : '#ffffff08'};
   }
 `;

+ 5 - 6
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -64,8 +64,7 @@ export default class Drawer extends Component<PropsType, StateType> {
           {this.renderClusterList()}
 
           <InitializeButton onClick={() => {
-            this.context.setCurrentModal('ClusterConfigModal');
-            this.context.setCurrentModalData({ updateClusters: this.props.updateClusters });
+            this.context.setCurrentModal('ClusterConfigModal', { updateClusters: this.props.updateClusters });
           }}>
             <Plus>+</Plus> Manage Clusters
           </InitializeButton>
@@ -197,12 +196,12 @@ const StyledDrawer = styled.div`
   animation: ${(props: { showDrawer: boolean }) => (props.showDrawer ? 'slideDrawerRight 0.4s' : 'slideDrawerLeft 0.4s')};
   animation-fill-mode: forwards;
   @keyframes slideDrawerRight {
-    from { left: -30px }
-    to { left: 200px }
+    from { left: -30px; opacity: 0; }
+    to { left: 200px; opacity: 1; }
   }
   @keyframes slideDrawerLeft {
-    from { left: 200px }
-    to { left: -30px }
+    from { left: 200px; opacity: 1; }
+    to { left: -30px; opacity: 0; }
   }
 `;
 

+ 11 - 6
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -14,7 +14,8 @@ type PropsType = {
   logOut: () => void,
   forceSidebar: boolean,
   setWelcome: (x: boolean) => void,
-  setCurrentView: (x: string) => void
+  setCurrentView: (x: string) => void,
+  currentView: string,
 };
 
 type StateType = {
@@ -125,7 +126,10 @@ export default class Sidebar extends Component<PropsType, StateType> {
           </UserSection>
 
           <SidebarLabel>Home</SidebarLabel>
-          <NavButton onClick={() => this.props.setCurrentView('templates')}>
+          <NavButton
+            onClick={() => this.props.setCurrentView('templates')}
+            selected={this.props.currentView === 'templates'}
+          >
             <img src={category} />
             Templates
           </NavButton>
@@ -146,6 +150,7 @@ export default class Sidebar extends Component<PropsType, StateType> {
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             setWelcome={this.props.setWelcome}
             setCurrentView={this.props.setCurrentView}
+            isSelected={this.props.currentView === 'dashboard'}
           />
 
           <BottomSection>
@@ -166,7 +171,6 @@ const NavButton = styled.div`
   position: relative;
   text-decoration: none;
   height: 42px;
-  margin: 3px 0px;
   padding: 10px 35px 12px 53px;
   font-size: 14px;
   font-family: 'Hind Siliguri', sans-serif;
@@ -174,10 +178,11 @@ const NavButton = styled.div`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  cursor: ${(props: { disabled?: boolean }) => props.disabled ? 'not-allowed': 'pointer'};
+  background: ${(props: { disabled?: boolean, selected?: boolean }) => props.selected ? '#ffffff11' : ''};
+  cursor: ${(props: { disabled?: boolean, selected?: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
 
   :hover {
-    background: #ffffff0f;
+    background: ${(props: { disabled?: boolean, selected?: boolean }) => props.selected ? '' : '#ffffff08'};
   }
 
   > i {
@@ -244,6 +249,7 @@ const SidebarLabel = styled.div`
   padding: 5px 16px;
   margin-bottom: 5px;
   font-size: 14px;
+  z-index: 1;
   font-weight: 500;
 `;
 
@@ -369,7 +375,6 @@ const StyledSidebar = styled.section`
   padding-top: 20px;
   height: 100vh;
   z-index: 2;
-  background-color: #333748;
   animation: ${(props: { showSidebar: boolean }) => (props.showSidebar ? 'showSidebar 0.4s' : 'hideSidebar 0.4s')};
   animation-fill-mode: forwards;
   @keyframes showSidebar {

+ 7 - 9
dashboard/src/main/home/templates/Templates.tsx

@@ -17,7 +17,7 @@ type PropsType = {
 };
 
 type StateType = {
-  currentChart: PorterChart | null,
+  currentTemplate: PorterChart | null,
   currentTab: string,
   porterCharts: PorterChart[],
   loading: boolean,
@@ -26,23 +26,21 @@ type StateType = {
 
 export default class Templates extends Component<PropsType, StateType> {
   state = {
-    currentChart: null as (PorterChart | null),
+    currentTemplate: null as (PorterChart | null),
     currentTab: 'community',
     porterCharts: [] as PorterChart[],
-    loading: false,
+    loading: true,
     error: false,
   }
 
   componentDidMount() {
 
     // Get templates
-    this.setState({ loading: true });
     api.getTemplates('<token>', {}, {}, (err: any, res: any) => {
       if (err) {
         this.setState({ loading: false, error: true });
       } else {
         this.setState({ porterCharts: res.data, loading: false, error: false });
-        console.log(res.data)
       }
     });
   }
@@ -79,7 +77,7 @@ export default class Templates extends Component<PropsType, StateType> {
     return this.state.porterCharts.map((template: PorterChart, i: number) => {
       let { Name, Icon, Description } = template.Form;
       return (
-        <TemplateBlock key={i} onClick={() => this.setState({ currentChart: template })}>
+        <TemplateBlock key={i} onClick={() => this.setState({ currentTemplate: template })}>
           {Icon ? this.renderIcon(Icon) : this.renderIcon(template.Icon)}
           <TemplateTitle>
             {Name ? Name : template.Name}
@@ -93,11 +91,11 @@ export default class Templates extends Component<PropsType, StateType> {
   }
 
   renderContents = () => {
-    if (this.state.currentChart) {
+    if (this.state.currentTemplate) {
       return (
         <ExpandedTemplate
-          currentChart={this.state.currentChart}
-          setCurrentChart={(currentChart: PorterChart) => this.setState({ currentChart })}
+          currentTemplate={this.state.currentTemplate}
+          setCurrentTemplate={(currentTemplate: PorterChart) => this.setState({ currentTemplate })}
         />
       );
     }

+ 21 - 16
dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx

@@ -3,11 +3,13 @@ import styled from 'styled-components';
 import launch from '../../../../assets/launch.svg';
 import Markdown from 'markdown-to-jsx';
 
+import { Context } from '../../../../shared/Context';
+
 import { PorterChart } from '../../../../shared/types';
 
 type PropsType = {
-  currentChart: PorterChart,
-  setCurrentChart: (x: PorterChart) => void
+  currentTemplate: PorterChart,
+  setCurrentTemplate: (x: PorterChart) => void
 };
 
 type StateType = {
@@ -28,7 +30,7 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
   }
 
   renderTagList = () => {
-    return this.props.currentChart.Form.Tags.map((tag: string, i: number) => {
+    return this.props.currentTemplate.Form.Tags.map((tag: string, i: number) => {
       return (
         <Tag key={i}>{tag}</Tag>
       )
@@ -36,33 +38,34 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
   }
 
   renderMarkdown = () => {
-    let { currentChart } = this.props;
-    if (currentChart.Markdown) {
+    let { currentTemplate } = this.props;
+    if (currentTemplate.Markdown) {
       return (
-        <Markdown>{currentChart.Markdown}</Markdown>
+        <Markdown>{currentTemplate.Markdown}</Markdown>
       );
-    } else if (currentChart.Form.Description) {
-      return currentChart.Form.Description;
+    } else if (currentTemplate.Form.Description) {
+      return currentTemplate.Form.Description;
     }
 
-    return currentChart.Description;
+    return currentTemplate.Description;
   }
 
   render() {
-    let { Name, Icon, Description } = this.props.currentChart.Form;
-    let { currentChart } = this.props;
+    let { Name, Icon, Description } = this.props.currentTemplate.Form;
+    let { currentTemplate } = this.props;
+    let name = Name ? Name : currentTemplate.Name;
 
     return (
       <StyledExpandedTemplate>
         <TitleSection>
           <Flex>
-            <i className="material-icons" onClick={() => this.props.setCurrentChart(null)}>
+            <i className="material-icons" onClick={() => this.props.setCurrentTemplate(null)}>
               keyboard_backspace
             </i>
-            {Icon ? this.renderIcon(Icon) : this.renderIcon(currentChart.Icon)}
-            <Title>{Name ? Name : currentChart.Name}</Title>
+            {Icon ? this.renderIcon(Icon) : this.renderIcon(currentTemplate.Icon)}
+            <Title>{name}</Title>
           </Flex>
-          <Button>
+          <Button onClick={() => this.context.setCurrentModal('LaunchTemplateModal', { template: currentTemplate })}>
             <img src={launch} />
             Launch Template
           </Button>
@@ -79,8 +82,10 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
   }
 }
 
+ExpandedTemplate.contextType = Context;
+
 const ContentSection = styled.div`
-  margin-top: 40px;
+  margin-top: 50px;
   font-size: 14px;
   line-height: 1.8em;
   padding-bottom: 100px;

+ 2 - 5
dashboard/src/shared/Context.tsx

@@ -25,13 +25,10 @@ const ContextConsumer = Context.Consumer;
 class ContextProvider extends Component {
   state = {
     currentModal: null as string | null,
-    setCurrentModal: (currentModal: string): void => {
-      this.setState({ currentModal });
+    setCurrentModal: (currentModal: string, currentModalData?: any): void => {
+      this.setState({ currentModal, currentModalData });
     },
     currentModalData: null as any,
-    setCurrentModalData: (currentModalData: any): void => {
-      this.setState({ currentModalData });
-    },
     currentError: null as string | null,
     setCurrentError: (currentError: string): void => {
       this.setState({ currentError });

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

@@ -98,12 +98,27 @@ const upgradeChartValues = baseApi<{
 
 const getTemplates = baseApi('GET', '/api/templates');
 
+const getRepos = baseApi('GET', '/api/repos');
+
+const getBranches = baseApi<{}, { kind: string, repo: string }>('GET', pathParams => {
+  return `/api/repos/${pathParams.kind}/${pathParams.repo}/branches`;
+});
+
+const getBranchContents = baseApi<{ dir: string }, {
+  kind: string,
+  repo: string,
+  branch: string
+}>('GET', pathParams => {
+  return `/api/repos/github/${pathParams.repo}/${pathParams.branch}/contents`;
+});
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
   registerUser,
   logInUser,
   logOutUser,
+  getRepos,
   getUser,
   updateUser,
   getContexts,
@@ -114,5 +129,7 @@ export default {
   getRevisions,
   rollbackChart,
   upgradeChartValues,
-  getTemplates
+  getTemplates,
+  getBranches,
+  getBranchContents
 }

+ 10 - 0
dashboard/src/shared/types.tsx

@@ -93,4 +93,14 @@ export interface FormElement {
   Settings: {
     Default: number
   }
+}
+
+export interface RepoType {
+  FullName: string,
+  kind: string
+}
+
+export interface FileType {
+  Path: string,
+  Type: string
 }

+ 1 - 0
go.mod

@@ -23,6 +23,7 @@ require (
 	github.com/go-playground/validator/v10 v10.3.0
 	github.com/go-test/deep v1.0.7
 	github.com/google/go-cmp v0.5.1
+	github.com/google/go-github/v32 v32.1.0
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1
 	github.com/imdario/mergo v0.3.11 // indirect

+ 5 - 0
go.sum

@@ -399,6 +399,11 @@ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k=
 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
+github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II=
+github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI=
+github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
+github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g=
 github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=

+ 102 - 0
server/api/repo_handler.go

@@ -0,0 +1,102 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"github.com/go-chi/chi"
+	"github.com/google/go-github/v32/github"
+)
+
+// Repo represents a GitHub or Gitab repository
+type Repo struct {
+	FullName string
+	Kind     string
+}
+
+// DirectoryItem represents a file or subfolder in a repository
+type DirectoryItem struct {
+	Path string
+	Type string
+}
+
+// HandleListRepos retrieves a list of repo names
+func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
+	client := github.NewClient(nil)
+
+	// list all organizations for specified user
+	// TODO: fix hardcoded user/org
+	repos, _, err := client.Repositories.List(context.Background(), "porter-dev", nil)
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	res := []Repo{}
+	for i := range repos {
+		r := Repo{}
+		r.FullName = *repos[i].FullName
+		r.Kind = "github"
+		res = append(res, r)
+	}
+	json.NewEncoder(w).Encode(res)
+}
+
+// HandleGetBranches retrieves a list of branch names for a specified repo
+func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+	client := github.NewClient(nil)
+
+	// List all branches for a specified repo
+	// TODO: fix hardcoded user/org
+	branches, _, err := client.Repositories.ListBranches(context.Background(), "porter-dev", name, nil)
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	res := []string{}
+	for i := range branches {
+		b := *branches[i].Name
+		res = append(res, b)
+	}
+	json.NewEncoder(w).Encode(res)
+}
+
+// HandleGetBranchContents retrieves the contents of a specific branch and subdirectory
+func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request) {
+	queryParams, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	name := chi.URLParam(r, "name")
+	branch := chi.URLParam(r, "branch")
+	client := github.NewClient(nil)
+
+	// TODO: fix hardcoded user/org
+	repoContentOptions := github.RepositoryContentGetOptions{}
+	repoContentOptions.Ref = branch
+	_, directoryContents, _, err := client.Repositories.GetContents(context.Background(), "porter-dev", name, queryParams["dir"][0], &repoContentOptions)
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	res := []DirectoryItem{}
+	for i := range directoryContents {
+		d := DirectoryItem{}
+		d.Path = *directoryContents[i].Path
+		d.Type = *directoryContents[i].Type
+		res = append(res, d)
+	}
+
+	// Ret2: recursively traverse all dirs to create config bundle (case on type == dir)
+	// https://api.github.com/repos/porter-dev/porter/contents?ref=frontend-graph
+	fmt.Println(res)
+	json.NewEncoder(w).Encode(res)
+}

+ 114 - 0
server/api/repo_handler_test.go

@@ -0,0 +1,114 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+)
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type reposTest struct {
+	initializers []func(tester *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *reposTest, tester *tester, t *testing.T)
+}
+
+func testReposRequests(t *testing.T, tests []*reposTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		tester := newTester(canQuery)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		tester.req = req
+
+		if c.useCookie {
+			req.AddCookie(tester.cookie)
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		tester.execute()
+		rr := tester.rr
+
+		// first, check that the status matches
+		if status := rr.Code; status != c.expStatus {
+			t.Errorf("%s, handler returned wrong status code: got %v want %v",
+				c.msg, status, c.expStatus)
+		}
+
+		// if there's a validator, call it
+		for _, validate := range c.validators {
+			validate(c, tester, t)
+		}
+	}
+}
+
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
+var listReposTests = []*reposTest{
+	&reposTest{
+		initializers: []func(tester *tester){
+			initDefaultRepos,
+		},
+		msg:       "List repos",
+		method:    "GET",
+		endpoint:  "/api/repos/github/porter/master/contents?dir=" + url.QueryEscape("./"),
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   "unimplemented",
+		useCookie: true,
+		validators: []func(c *reposTest, tester *tester, t *testing.T){
+			reposListValidator,
+		},
+	},
+}
+
+func TestHandleListRepos(t *testing.T) {
+	testReposRequests(t, listReposTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initDefaultRepos(tester *tester) {
+	initUserDefault(tester)
+
+	agent := kubernetes.GetAgentTesting(defaultObjects...)
+
+	// overwrite the test agent with new resources
+	tester.app.TestAgents.K8sAgent = agent
+}
+
+func reposListValidator(c *reposTest, tester *tester, t *testing.T) {
+	var gotBody map[string]interface{}
+	var expBody map[string]interface{}
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if string(tester.rr.Body.Bytes()) != c.expBody {
+		t.Errorf("Mismatch")
+	}
+}

+ 2 - 2
server/api/template_handler.go

@@ -15,8 +15,6 @@ import (
 	"gopkg.in/yaml.v2"
 )
 
-var baseURL string = "https://porter-dev.github.io/chart-repo/"
-
 // IndexYAML represents a chart repo's index.yaml
 type IndexYAML struct {
 	APIVersion string                    `yaml:"apiVersion"`
@@ -71,6 +69,8 @@ type FormYAML struct {
 // TODO: test and reduce fragility (handle untar/parse error for individual charts)
 // TODO: separate markdown retrieval into its own query if necessary
 func (app *App) HandleListTemplates(w http.ResponseWriter, r *http.Request) {
+	baseURL := "https://porter-dev.github.io/chart-repo/"
+
 	resp, err := http.Get(baseURL + "index.yaml")
 	if err != nil {
 		fmt.Println(err)

+ 5 - 0
server/router/router.go

@@ -46,6 +46,11 @@ func New(
 		// /api/templates routes
 		r.Method("GET", "/templates", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListTemplates, l)))
 
+		// /api/repos routes
+		r.Method("GET", "/repos", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListRepos, l)))
+		r.Method("GET", "/repos/{kind}/{name}/branches", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetBranches, l)))
+		r.Method("GET", "/repos/{kind}/{name}/{branch}/contents", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetBranchContents, l)))
+
 		// /api/k8s routes
 		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))
 	})