Przeglądaj źródła

Merge pull request #113 from porter-dev/launch-template

Launch template
jusrhee 5 lat temu
rodzic
commit
e0caea6e92
25 zmienionych plików z 921 dodań i 140 usunięć
  1. 4 0
      dashboard/src/assets/edit.svg
  2. BIN
      dashboard/src/assets/tag.png
  3. 90 13
      dashboard/src/components/image-selector/ImageSelector.tsx
  4. 142 0
      dashboard/src/components/image-selector/TagList.tsx
  5. 0 3
      dashboard/src/components/repo-selector/BranchList.tsx
  6. 14 0
      dashboard/src/components/values-form/Heading.tsx
  7. 14 0
      dashboard/src/components/values-form/Helper.tsx
  8. 7 1
      dashboard/src/components/values-form/InputRow.tsx
  9. 64 0
      dashboard/src/components/values-form/TextArea.tsx
  10. 2 16
      dashboard/src/components/values-form/ValuesForm.tsx
  11. 1 0
      dashboard/src/main/home/Home.tsx
  12. 8 5
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  13. 7 14
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  14. 24 36
      dashboard/src/main/home/integrations/IntegrationList.tsx
  15. 109 12
      dashboard/src/main/home/integrations/Integrations.tsx
  16. 4 4
      dashboard/src/main/home/integrations/integration-form/DockerHubForm.tsx
  17. 81 0
      dashboard/src/main/home/integrations/integration-form/ECRForm.tsx
  18. 101 0
      dashboard/src/main/home/integrations/integration-form/EKSForm.tsx
  19. 70 0
      dashboard/src/main/home/integrations/integration-form/GCRForm.tsx
  20. 90 0
      dashboard/src/main/home/integrations/integration-form/GKEForm.tsx
  21. 37 0
      dashboard/src/main/home/integrations/integration-form/IntegrationForm.tsx
  22. 21 32
      dashboard/src/main/home/modals/IntegrationsModal.tsx
  23. 13 3
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  24. 13 1
      dashboard/src/shared/common.tsx
  25. 5 0
      dashboard/src/shared/types.tsx

+ 4 - 0
dashboard/src/assets/edit.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.6643 21.9897H7.33488C5.88835 22.0796 4.46781 21.5781 3.3989 20.6011C2.4219 19.5312 1.92041 18.1107 2.01032 16.6652V7.33482C1.92041 5.88932 2.4209 4.46878 3.3979 3.39889C4.46781 2.42189 5.88835 1.92041 7.33488 2.01032H16.6643C18.1089 1.92041 19.5284 2.4209 20.5973 3.39789C21.5733 4.46878 22.0758 5.88832 21.9899 7.33482V16.6652C22.0788 18.1107 21.5783 19.5312 20.6013 20.6011C19.5314 21.5781 18.1109 22.0796 16.6643 21.9897Z" fill="white"/>
+<path d="M17.0545 10.3976L10.5018 16.9829C10.161 17.3146 9.7131 17.5 9.24574 17.5H6.95762C6.83105 17.5 6.71421 17.4512 6.62658 17.3634C6.53895 17.2756 6.5 17.1585 6.5 17.0317L6.55842 14.7195C6.56816 14.261 6.75315 13.8317 7.07446 13.5098L11.7189 8.8561C11.7967 8.77805 11.9331 8.77805 12.011 8.8561L13.6399 10.4785C13.747 10.5849 13.9028 10.6541 14.0683 10.6541C14.4286 10.6541 14.7109 10.3615 14.7109 10.0102C14.7109 9.83463 14.6428 9.67854 14.5357 9.56146C14.5065 9.52244 12.9554 7.97805 12.9554 7.97805C12.858 7.88049 12.858 7.71463 12.9554 7.61707L13.6078 6.95366C14.2114 6.34878 15.1851 6.34878 15.7888 6.95366L17.0545 8.22195C17.6485 8.81707 17.6485 9.79268 17.0545 10.3976Z" fill="white"/>
+</svg>

BIN
dashboard/src/assets/tag.png


+ 90 - 13
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -1,12 +1,15 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import info from '../../assets/info.svg';
+import edit from '../../assets/edit.svg';
 
 import api from '../../shared/api';
-import { getRegistryIcon } from '../../shared/common';
+import { getIntegrationIcon } from '../../shared/common';
 import { Context } from '../../shared/Context';
+import { ImageType } from '../../shared/types';
 
 import Loading from '../Loading';
+import TagList from './TagList';
 
 type PropsType = {
   forceExpanded?: boolean,
@@ -18,13 +21,14 @@ type StateType = {
   isExpanded: boolean,
   loading: boolean,
   error: boolean,
-  images: any[]
+  images: ImageType[],
+  clickedImage: ImageType | null,
 };
 
 const dummyImages = [
   {
     kind: 'docker-hub',
-    source: 'https://index.docker.io/jusrhee/image1',
+    source: 'index.docker.io/jusrhee/image1',
   },
   {
     kind: 'docker-hub',
@@ -57,7 +61,8 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     isExpanded: this.props.forceExpanded,
     loading: false,
     error: false,
-    images: [] as any[]
+    images: [] as ImageType[],
+    clickedImage: null as ImageType | null,
   }
 
   componentDidMount() {
@@ -72,14 +77,17 @@ export default class ImageSelector extends Component<PropsType, StateType> {
       return <LoadingWrapper>Error loading repos</LoadingWrapper>
     }
 
-    return images.map((image: any, i: number) => {
-      let icon = getRegistryIcon(image.kind);
+    return images.map((image: ImageType, i: number) => {
+      let icon = getIntegrationIcon(image.kind);
       return (
         <ImageItem
           key={i}
           isSelected={image.source === this.props.selectedImageUrl}
           lastItem={i === images.length - 1}
-          onClick={() => this.props.setSelectedImageUrl(image.source)}
+          onClick={() => { 
+            this.props.setSelectedImageUrl(image.source);
+            this.setState({ clickedImage: image });
+          }}
         >
           <img src={icon && icon} />{image.source}
         </ImageItem>
@@ -87,24 +95,68 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     });
   }
 
+  renderBackButton = () => {
+    let { setSelectedImageUrl } = this.props;
+    if (this.state.clickedImage) {
+      return (
+        <BackButton
+          width='175px'
+          onClick={() => {
+            setSelectedImageUrl('');
+            this.setState({ clickedImage: null });
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Image Repo
+        </BackButton>
+      );
+    }
+  }
+
   renderExpanded = () => {
-    return (
-      <ExpandedWrapper>
-        {this.renderImageList()}
-      </ExpandedWrapper>
-    );
+    let { selectedImageUrl, setSelectedImageUrl } = this.props;
+    if (!this.state.clickedImage) {
+      return (
+        <div>
+          <ExpandedWrapper>
+            {this.renderImageList()}
+          </ExpandedWrapper>
+          {this.renderBackButton()}
+        </div>
+      );
+    } else {
+      return (
+        <div>
+          <ExpandedWrapper>
+            <TagList
+              selectedImageUrl={selectedImageUrl}
+              setSelectedImageUrl={setSelectedImageUrl}
+            />
+          </ExpandedWrapper>
+          {this.renderBackButton()}
+        </div>
+      );
+    }
   }
 
   renderSelected = () => {
     let { selectedImageUrl, setSelectedImageUrl } = this.props;
     let icon = info;
+    if (this.state.clickedImage) {
+      icon = getIntegrationIcon(this.state.clickedImage.kind);
+    } else if (selectedImageUrl && selectedImageUrl !== '') {
+      icon = edit;
+    }
     return (
       <Label>
         <img src={icon} />
         <Input
           onClick={(e: any) => e.stopPropagation()}
           value={selectedImageUrl}
-          onChange={(e: any) => setSelectedImageUrl(e.value)}
+          onChange={(e: any) => { 
+            setSelectedImageUrl(e.target.value); 
+            this.setState({ clickedImage: null });
+          }}
           placeholder='Enter or select your container image URL'
         />
       </Label>
@@ -137,6 +189,31 @@ export default class ImageSelector extends Component<PropsType, StateType> {
 
 ImageSelector.contextType = Context;
 
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 10px;
+  cursor: pointer;
+  font-size: 13px;
+  padding: 5px 13px;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
 const Input = styled.input`
   outline: 0;
   background: none;

+ 142 - 0
dashboard/src/components/image-selector/TagList.tsx

@@ -0,0 +1,142 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import tag_icon from '../../assets/tag.png';
+import info from '../../assets/info.svg';
+
+import api from '../../shared/api';
+
+import Loading from '../Loading';
+
+type PropsType = {
+  setSelectedImageUrl: (x: string) => void,
+  selectedImageUrl: string
+};
+
+type StateType = {
+  loading: boolean,
+  error: boolean,
+  tags: string[],
+  currentTag: string | null,
+};
+
+export default class TagList extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    error: false,
+    tags: [] as string[],
+    currentTag: null as string | null,
+  }
+
+  componentDidMount() {
+    this.setState({ tags: ['123', '456', '889', '5521', '5212'], loading: false });
+
+    /* Get branches
+    api.getTags('<token>', {}, {
+
+    }, (err: any, res: any) => {
+      if (err) {
+        this.setState({ loading: false, error: true });
+      } else {
+        this.setState({ tags: res.data, loading: false, error: false });
+      }
+    });
+    */
+  }
+
+  setTag = (tag: string) => {
+    let { selectedImageUrl, setSelectedImageUrl} = this.props;
+    let splits = selectedImageUrl.split(':');
+    if (splits[splits.length - 1] === this.state.currentTag) {
+      selectedImageUrl = splits.reduce((acc: string, curr: string) => {
+        if (curr !== this.state.currentTag) {
+          return acc + ':' + curr;
+        } else {
+          return acc;
+        }
+      });
+    }
+    setSelectedImageUrl(selectedImageUrl + ':' + tag);
+    this.setState({ currentTag: tag });
+  }
+
+  renderTagList = () => {
+    let { tags, loading, error } = this.state;
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error || !tags) {
+      return <LoadingWrapper>Error loading tags</LoadingWrapper>
+    }
+
+    return tags.map((tag: string, i: number) => {
+      return (
+        <TagName
+          key={i}
+          isSelected={tag === this.state.currentTag}
+          lastItem={i === tags.length - 1}
+          onClick={() => this.setTag(tag)}
+        >
+          <img src={tag_icon} />{tag}
+        </TagName>
+      );
+    });
+  }
+
+  render() {
+    return (
+      <div>
+        <TagNameAlt>
+          <img src={info} /> Select Image Tag
+        </TagNameAlt>
+        {this.renderTagList()}
+      </div>
+    );
+  }
+}
+
+const TagName = 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 TagNameAlt = styled(TagName)`
+  color: #ffffff55;
+  cursor: default;
+  :hover {
+    background: #ffffff11;
+    > i {
+      background: none;
+    }
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: #ffffff44;
+`;

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

@@ -1,10 +1,8 @@
-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';
 
@@ -22,7 +20,6 @@ type StateType = {
 
 export default class BranchList extends Component<PropsType, StateType> {
   state = {
-    selectedBranch: '',
     loading: true,
     error: false,
     branches: [] as string[]

+ 14 - 0
dashboard/src/components/values-form/Heading.tsx

@@ -0,0 +1,14 @@
+import React from 'react';  
+import styled from 'styled-components';
+
+export default function Heading(props: { children: string }) {
+  return <StyledHeading>{props.children}</StyledHeading>;
+}
+
+const StyledHeading = styled.div`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-top: 30px;
+  margin-bottom: 5px;
+`;

+ 14 - 0
dashboard/src/components/values-form/Helper.tsx

@@ -0,0 +1,14 @@
+import React from 'react';  
+import styled from 'styled-components';
+
+export default function Helper(props: { children: string }) {
+  return <StyledHelper>{props.children}</StyledHelper>;
+}
+
+const StyledHelper = styled.div`
+  color: #aaaabb;
+  line-height: 1.6em;
+  font-size: 13px;
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 7 - 1
dashboard/src/components/values-form/InputRow.tsx

@@ -13,9 +13,14 @@ type PropsType = {
 };
 
 type StateType = {
+  readOnly: boolean
 };
 
 export default class InputRow extends Component<PropsType, StateType> {
+  state = {
+    readOnly: true
+  }
+
   handleChange = (e: ChangeEvent<HTMLInputElement>) => {
     if (this.props.type === 'number') {
       this.props.setValue(parseInt(e.target.value));
@@ -23,7 +28,7 @@ export default class InputRow extends Component<PropsType, StateType> {
       this.props.setValue(e.target.value);
     }
   }
-
+  
   render() {
     let { label, value, type, unit, placeholder, width } = this.props;
     return (
@@ -31,6 +36,7 @@ export default class InputRow extends Component<PropsType, StateType> {
         <Label>{label}</Label>
         <InputWrapper>
           <Input
+            readOnly={this.state.readOnly} onFocus={() => this.setState({ readOnly: false })}
             disabled={this.props.disabled}
             placeholder={placeholder}
             width={width}

+ 64 - 0
dashboard/src/components/values-form/TextArea.tsx

@@ -0,0 +1,64 @@
+import React, { ChangeEvent, Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  label?: string,
+  value: string,
+  setValue: (x: string) => void,
+  placeholder?: string
+  width?: string
+  disabled?: boolean
+};
+
+type StateType = {
+};
+
+export default class TextArea extends Component<PropsType, StateType> {
+  handleChange = (e: any) => {
+    this.props.setValue(e.target.value);
+  }
+
+  render() {
+    let { label, value, placeholder, width } = this.props;
+    return (
+      <StyledTextArea>
+        <Label>{label}</Label>
+        <InputArea
+          disabled={this.props.disabled}
+          placeholder={placeholder}
+          width={width}
+          value={value || ''}
+          onChange={this.handleChange}
+        />
+      </StyledTextArea>
+    );
+  }
+}
+
+const InputArea = styled.textarea`
+  outline: none;
+  border: none;
+  resize: none;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled: boolean, width: string }) => props.width ? props.width : '270px'};
+  color: ${(props: { disabled: boolean, width: string }) => props.disabled ? '#ffffff44' : 'white'};
+  padding: 5px 10px;
+  margin-right: 8px;
+  height: 8em;
+  line-height: 1.5em;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+`;
+
+const StyledTextArea = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 2 - 16
dashboard/src/components/values-form/ValuesForm.tsx

@@ -9,6 +9,8 @@ import SaveButton from '../SaveButton';
 import CheckboxRow from './CheckboxRow';
 import InputRow from './InputRow';
 import SelectRow from './SelectRow';
+import Helper from './Helper';
+import Heading from './Heading';
 
 type PropsType = {
   onSubmit: (formValues: any) => void,
@@ -166,22 +168,6 @@ const Wrapper = styled.div`
   height: 100%;
 `;
 
-const Helper = styled.div`
-  color: #aaaabb;
-  line-height: 1.6em;
-  font-size: 13px;
-  margin-bottom: 15px;
-  margin-top: 20px;
-`;
-
-const Heading = styled.div`
-  color: white;
-  font-weight: 500;
-  font-size: 16px;
-  margin-top: 30px;
-  margin-bottom: 5px;
-`;
-
 const StyledValuesForm = styled.div`
   width: 100%;
   height: 100%;

+ 1 - 0
dashboard/src/main/home/Home.tsx

@@ -77,6 +77,7 @@ export default class Home extends Component<PropsType, StateType> {
         <ClusterDashboard
           currentCluster={currentCluster}
           setSidebar={(x: boolean) => this.setState({ forceSidebar: x })}
+          setCurrentView={(x: string) => this.setState({ currentView: x })}
         />
       </DashboardWrapper>
     );

+ 8 - 5
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -13,6 +13,7 @@ import ExpandedChart from './expanded-chart/ExpandedChart';
 type PropsType = {
   currentCluster: Cluster,
   setSidebar: (x: boolean) => void
+  setCurrentView: (x: string) => void,
 };
 
 type StateType = {
@@ -108,8 +109,10 @@ export default class ClusterDashboard extends Component<PropsType, StateType> {
         <LineBreak />
         
         <ControlRow>
-          <Button disabled={true}>
-            <i className="material-icons">add</i> Deploy a Chart
+          <Button
+            onClick={() => this.props.setCurrentView('templates')}
+          >
+            <i className="material-icons">add</i> Deploy Template
           </Button>
           <NamespaceSelector
             setNamespace={(namespace) => this.setState({ namespace })}
@@ -198,11 +201,11 @@ const Button = styled.div`
   white-space: nowrap;
   text-overflow: ellipsis;
   box-shadow: 0 5px 8px 0px #00000010;
-  cursor: not-allowed;
+  cursor: ${(props: { disabled?: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
 
-  background: ${(props: { disabled: boolean }) => props.disabled ? '#aaaabbee' :'#616FEEcc'};
+  background: ${(props: { disabled?: boolean }) => props.disabled ? '#aaaabbee' : '#616FEEcc'};
   :hover {
-    background: ${(props: { disabled: boolean }) => props.disabled ? '' : '#505edddd'};
+    background: ${(props: { disabled?: boolean }) => props.disabled ? '' : '#505edddd'};
   }
 
   > i {

+ 7 - 14
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -3,23 +3,19 @@ import styled from 'styled-components';
 
 import { RepoType } from '../../../../shared/types';
 
-import RepoSelector from '../../../../components/repo-selector/RepoSelector';
+import ImageSelector from '../../../../components/image-selector/ImageSelector';
 import SaveButton from '../../../../components/SaveButton';
 
 type PropsType = {
 };
 
 type StateType = {
-  selectedRepo: RepoType | null,
-  selectedBranch: string,
-  subdirectory: string,
+  selectedImageUrl: string | null,
 };
 
 export default class SettingsSection extends Component<PropsType, StateType> {
   state = {
-    selectedRepo: null as RepoType | null,
-    selectedBranch: '',
-    subdirectory: '',
+    selectedImageUrl: '',
   }
 
   render() {
@@ -27,13 +23,10 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       <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 })}
+          <ImageSelector
+            selectedImageUrl={this.state.selectedImageUrl}
+            setSelectedImageUrl={(x: string) => this.setState({ selectedImageUrl: x })}
+            forceExpanded={true}
           />
         </StyledSettingsSection>
         <SaveButton

+ 24 - 36
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -2,55 +2,37 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 
 import { Context } from '../../../shared/Context';
-import { getRegistryIcon } from '../../../shared/common';
+import { getIntegrationIcon } from '../../../shared/common';
 import api from '../../../shared/api';
 
 type PropsType = {
-  setCurrentIntegration: (x: any) => void
+  setCurrent: (x: any) => void,
+  integrations: any,
+  isCategory?: boolean
 };
 
 type StateType = {
-  integrations: any[]
 };
 
-const dummyIntegrations = [
-  {
-    name: 'docker-hub',
-    label: 'Docker Hub',
-  },
-  {
-    name: 'gcr',
-    label: 'Google Container Registry (GCR)',
-  },
-  {
-    name: 'ecr',
-    label: 'Amazon Elastic Container Registry (ECR)',
-  },
-];
-
 export default class IntegrationList extends Component<PropsType, StateType> {
-  state = {
-    integrations: [] as any[]
-  }
-
-  componentDidMount() {
-    this.setState({ integrations: dummyIntegrations });
-  }
-
   renderContents = () => {
-    if (this.state.integrations) {
-      return this.state.integrations.map((integration: any, i: number) => {
-        let icon = getRegistryIcon(integration.name);
+    let { integrations, setCurrent, isCategory } = this.props;
+    if (integrations) {
+      return integrations.map((integration: any, i: number) => {
+        let icon = getIntegrationIcon(integration.value);
+        let disabled = integration.value === 'repo';
         return (
           <Integration
             key={i}
-            onClick={() => this.props.setCurrentIntegration(integration)}
+            onClick={() => disabled ? null : setCurrent(integration)}
+            isCategory={isCategory}
+            disabled={disabled}
           >
             <Flex>
               <Icon src={icon && icon} />
               <Label>{integration.label}</Label>
             </Flex>
-            <i className="material-icons">launch</i>
+            <i className="material-icons">{isCategory ? 'launch' : 'more_vert'}</i>
           </Integration>
         );
       });
@@ -85,19 +67,25 @@ const Integration = styled.div`
   align-items: center;
   justify-content: space-between;
   padding: 25px;
-  cursor: pointer;
   background: #26282f;
-  cursor: pointer;
+  cursor: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
   margin-bottom: 15px;
   border-radius: 5px;
   box-shadow: 0 5px 8px 0px #00000033;
   :hover {
-    background: #ffffff11;
+    background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
+
+    > i {
+      background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
+    }
   }
 
   > i {
+    border-radius: 20px;
     font-size: 18px;
-    color: #616feecc;
+    padding: 5px;
+    color: ${(props: { isCategory: boolean, disabled: boolean }) => props.isCategory ? '#616feecc' : '#ffffff44'};
+    margin-right: -7px;
   }
 `;
 
@@ -109,7 +97,7 @@ const Label = styled.div`
 
 const Icon = styled.img`
   width: 30px;
-  margin-right: 15px;
+  margin-right: 18px;
 `;
 
 const Placeholder = styled.div`

+ 109 - 12
dashboard/src/main/home/integrations/Integrations.tsx

@@ -3,27 +3,90 @@ import styled from 'styled-components';
 
 import { Context } from '../../../shared/Context';
 import api from '../../../shared/api';
-import { getRegistryIcon } from '../../../shared/common';
+import { getIntegrationIcon } from '../../../shared/common';
+import { ChoiceType } from '../../../shared/types';
 
 import IntegrationList from './IntegrationList';
-import DockerHubForm from './integration-forms/DockerHubForm';
+import IntegrationForm from './integration-form/IntegrationForm';
 
 type PropsType = {
 };
 
 type StateType = {
-  currentIntegration: null | any
+  currentCategory: ChoiceType | null,
+  currentIntegration: any | null,
+  currentOptions: any[],
 };
 
+const categories = [
+  {
+    value: 'kubernetes',
+    label: 'Kubernetes',
+    buttonText: 'Add a Cluster',
+  },
+  {
+    value: 'registry',
+    label: 'Docker Registry',
+    buttonText: 'Add a Registry',
+  },
+  {
+    value: 'repo',
+    label: 'Git Repository',
+    buttonText: 'Add a Repository',
+  },
+];
+
 export default class Integrations extends Component<PropsType, StateType> {
   state = {
-    currentIntegration: null as null | any,
+    currentCategory: null as any | null,
+    currentIntegration: null as any | null,
+    currentOptions: [] as any[],
+  }
+
+  getIntegrations = (categoryType: string): any[] => {
+    switch (categoryType) {
+      case 'kubernetes':
+        return [
+          {
+            value: 'gke',
+            label: 'Google Kubernetes Engine (GKE)',
+          },
+          {
+            value: 'eks',
+            label: 'Amazon Elastic Kubernetes Service (EKS)',
+          },
+        ];
+      case 'registry':
+        return [
+          {
+            value: 'gcr',
+            label: 'Google Container Registry (GCR)',
+          },
+          {
+            value: 'ecr',
+            label: 'Elastic Container Registry (ECR)',
+          },
+          {
+            value: 'docker-hub',
+            label: 'Docker Hub',
+          },
+        ];
+      default:
+        return [];
+    }
+  }
+
+  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
+    if (this.state.currentCategory && this.state.currentCategory !== prevState.currentCategory) {
+      this.setState({ currentOptions: this.getIntegrations(this.state.currentCategory.value) });
+    }
   }
 
   renderContents = () => {
-    let { currentIntegration } = this.state;
+    let { currentCategory, currentIntegration } = this.state;
+
     if (currentIntegration) {
-      let icon = getRegistryIcon(currentIntegration.name);
+      let icon = getIntegrationIcon(currentIntegration.value);
       return (
         <div>
           <TitleSectionAlt>
@@ -36,7 +99,38 @@ export default class Integrations extends Component<PropsType, StateType> {
             </Flex>
           </TitleSectionAlt>
 
-          <DockerHubForm />
+          <IntegrationForm integrationName={currentIntegration.value} />
+          <Br />
+        </div>
+      );
+    } else if (currentCategory) {
+      let icon = getIntegrationIcon(currentCategory.value);
+      return (
+        <div>
+          <TitleSectionAlt>
+            <Flex>
+              <i className="material-icons" onClick={() => this.setState({ currentCategory: null })}>
+                keyboard_backspace
+              </i>
+              <Icon src={icon && icon} />
+              <Title>{currentCategory.label}</Title>
+            </Flex>
+
+            <Button 
+              onClick={() => this.context.setCurrentModal('IntegrationsModal', { 
+                integrations: this.state.currentOptions,
+                setCurrentIntegration: (x: any) => this.setState({ currentIntegration: x })
+              })}
+            >
+              <i className="material-icons">add</i>
+              {currentCategory.buttonText}
+            </Button>
+          </TitleSectionAlt>
+
+          <IntegrationList
+            integrations={this.state.currentOptions}
+            setCurrent={(x: any) => this.setState({ currentIntegration: x })}
+          />
         </div>
       );
     }
@@ -44,14 +138,12 @@ export default class Integrations extends Component<PropsType, StateType> {
       <div>
         <TitleSection>
           <Title>Integrations</Title>
-          <Button onClick={() => this.context.setCurrentModal('IntegrationsModal', {})}>
-            <i className="material-icons">add</i>
-            Add Integration
-          </Button>
         </TitleSection>
 
         <IntegrationList
-          setCurrentIntegration={(x: any) => this.setState({ currentIntegration: x })}
+          integrations={categories}
+          setCurrent={(x: any) => this.setState({ currentCategory: x })}
+          isCategory={true}
         />
       </div>
     );
@@ -68,6 +160,11 @@ export default class Integrations extends Component<PropsType, StateType> {
 
 Integrations.contextType = Context;
 
+const Br = styled.div`
+  width: 100%;
+  height: 150px;
+`;
+
 const Icon = styled.img`
   width: 27px;
   margin-right: 12px;

+ 4 - 4
dashboard/src/main/home/integrations/integration-forms/DockerHubForm.tsx → dashboard/src/main/home/integrations/integration-form/DockerHubForm.tsx

@@ -27,7 +27,7 @@ export default class DockerHubForm extends Component<PropsType, StateType> {
 
   render() {
     return ( 
-      <StyledDockerHubForm>
+      <StyledForm>
         <CredentialWrapper>
           <InputRow
             type='text'
@@ -63,11 +63,11 @@ export default class DockerHubForm extends Component<PropsType, StateType> {
           />
         </CredentialWrapper>
         <SaveButton
-          text='Save Changes'
+          text='Save Settings'
           makeFlush={true}
           onClick={() => console.log('unimplemented')}
         />
-      </StyledDockerHubForm>
+      </StyledForm>
     );
   }
 }
@@ -78,7 +78,7 @@ const CredentialWrapper = styled.div`
   border-radius: 5px;
 `;
 
-const StyledDockerHubForm = styled.div`
+const StyledForm = styled.div`
   position: relative;
   padding-bottom: 75px;
 `;

+ 81 - 0
dashboard/src/main/home/integrations/integration-form/ECRForm.tsx

@@ -0,0 +1,81 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import TextArea from '../../../../components/values-form/TextArea';
+import SaveButton from '../../../../components/SaveButton';
+import Heading from '../../../../components/values-form/Heading';
+import Helper from '../../../../components/values-form/Helper';
+
+type PropsType = {
+};
+
+type StateType = {
+  credentialsName: string,
+  awsAccessId: string,
+  awsSecretKey: string,
+};
+
+export default class ECRForm extends Component<PropsType, StateType> {
+  state = {
+    credentialsName: '',
+    awsAccessId: '',
+    awsSecretKey: '',
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Porter Settings</Heading>
+          <Helper>Give a name to this set of registry credentials (just for Porter).</Helper>
+          <InputRow
+            type='text'
+            value={this.state.credentialsName}
+            setValue={(x: string) => this.setState({ credentialsName: x })}
+            label='🏷️ Registry Name'
+            placeholder='ex: paper-straw'
+            width='100%'
+          />
+          <Heading>AWS Settings</Heading>
+          <Helper>AWS access credentials.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.awsAccessId}
+            setValue={(x: string) => this.setState({ awsAccessId: x })}
+            label='👤 AWS Access ID'
+            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
+            width='100%'
+          />
+          <InputRow
+            type='password'
+            value={this.state.awsSecretKey}
+            setValue={(x: string) => this.setState({ awsSecretKey: x })}
+            label='🔒 AWS Secret Key'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          onClick={() => console.log('unimplemented')}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 10px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 101 - 0
dashboard/src/main/home/integrations/integration-form/EKSForm.tsx

@@ -0,0 +1,101 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import TextArea from '../../../../components/values-form/TextArea';
+import SaveButton from '../../../../components/SaveButton';
+import Heading from '../../../../components/values-form/Heading';
+import Helper from '../../../../components/values-form/Helper';
+
+type PropsType = {
+};
+
+type StateType = {
+  clusterName: string,
+  clusterEndpoint: string,
+  clusterCA: string,
+  awsAccessId: string,
+  awsSecretKey: string,
+};
+
+export default class EKSForm extends Component<PropsType, StateType> {
+  state = {
+    clusterName: '',
+    clusterEndpoint: '',
+    clusterCA: '',
+    awsAccessId: '',
+    awsSecretKey: '',
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Cluster Settings</Heading>
+          <Helper>Credentials for accessing your GKE cluster.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.clusterName}
+            setValue={(x: string) => this.setState({ clusterName: x })}
+            label='🏷️ Cluster Name'
+            placeholder='ex: briny-pagelet'
+            width='100%'
+          />
+          <InputRow
+            type='text'
+            value={this.state.clusterEndpoint}
+            setValue={(x: string) => this.setState({ clusterEndpoint: x })}
+            label='🌐 Cluster Endpoint'
+            placeholder='ex: 00.00.000.00'
+            width='100%'
+          />
+          <TextArea
+            value={this.state.clusterCA}
+            setValue={(x: string) => this.setState({ clusterCA: x })}
+            label='🔏 Cluster Certificate'
+            placeholder='(Paste your certificate here)'
+            width='100%'
+          />
+
+          <Heading>AWS Settings</Heading>
+          <Helper>AWS access credentials.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.awsAccessId}
+            setValue={(x: string) => this.setState({ awsAccessId: x })}
+            label='👤 AWS Access ID'
+            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
+            width='100%'
+          />
+          <InputRow
+            type='password'
+            value={this.state.awsSecretKey}
+            setValue={(x: string) => this.setState({ awsSecretKey: x })}
+            label='🔒 AWS Secret Key'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          onClick={() => console.log('unimplemented')}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 10px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 70 - 0
dashboard/src/main/home/integrations/integration-form/GCRForm.tsx

@@ -0,0 +1,70 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import TextArea from '../../../../components/values-form/TextArea';
+import SaveButton from '../../../../components/SaveButton';
+import Heading from '../../../../components/values-form/Heading';
+import Helper from '../../../../components/values-form/Helper';
+
+type PropsType = {
+};
+
+type StateType = {
+  credentialsName: string,
+  serviceAccountKey: string,
+};
+
+export default class GCRForm extends Component<PropsType, StateType> {
+  state = {
+    credentialsName: '',
+    serviceAccountKey: '',
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Porter Settings</Heading>
+          <Helper>Give a name to this set of registry credentials (just for Porter).</Helper>
+          <InputRow
+            type='text'
+            value={this.state.credentialsName}
+            setValue={(x: string) => this.setState({ credentialsName: x })}
+            label='🏷️ Registry Name'
+            placeholder='ex: paper-straw'
+            width='100%'
+          />
+          <Heading>GCP Settings</Heading>
+          <Helper>Service account credentials for GCP permissions.</Helper>
+          <TextArea
+            value={this.state.serviceAccountKey}
+            setValue={(x: string) => this.setState({ serviceAccountKey: x })}
+            label='🔑 Service Account Key (JSON)'
+            placeholder='(Paste your JSON service account key here)'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          onClick={() => console.log('unimplemented')}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 10px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 90 - 0
dashboard/src/main/home/integrations/integration-form/GKEForm.tsx

@@ -0,0 +1,90 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import TextArea from '../../../../components/values-form/TextArea';
+import SaveButton from '../../../../components/SaveButton';
+import Heading from '../../../../components/values-form/Heading';
+import Helper from '../../../../components/values-form/Helper';
+
+type PropsType = {
+};
+
+type StateType = {
+  clusterName: string,
+  clusterEndpoint: string,
+  clusterCA: string,
+  serviceAccountKey: string
+};
+
+export default class GKEForm extends Component<PropsType, StateType> {
+  state = {
+    clusterName: '',
+    clusterEndpoint: '',
+    clusterCA: '',
+    serviceAccountKey: ''
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Cluster Settings</Heading>
+          <Helper>Credentials for accessing your GKE cluster.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.clusterName}
+            setValue={(x: string) => this.setState({ clusterName: x })}
+            label='🏷️ Cluster Name'
+            placeholder='ex: briny-pagelet'
+            width='100%'
+          />
+          <InputRow
+            type='text'
+            value={this.state.clusterEndpoint}
+            setValue={(x: string) => this.setState({ clusterEndpoint: x })}
+            label='🌐 Cluster Endpoint'
+            placeholder='ex: 00.00.000.00'
+            width='100%'
+          />
+          <TextArea
+            value={this.state.clusterCA}
+            setValue={(x: string) => this.setState({ clusterCA: x })}
+            label='🔏 Cluster Certificate'
+            placeholder='(Paste your certificate here)'
+            width='100%'
+          />
+
+          <Heading>GCP Settings</Heading>
+          <Helper>Service account credentials for GCP permissions.</Helper>
+          <TextArea
+            value={this.state.serviceAccountKey}
+            setValue={(x: string) => this.setState({ serviceAccountKey: x })}
+            label='🔑 Service Account Key (JSON)'
+            placeholder='(Paste your JSON service account key here)'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          onClick={() => console.log('unimplemented')}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 10px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 37 - 0
dashboard/src/main/home/integrations/integration-form/IntegrationForm.tsx

@@ -0,0 +1,37 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import DockerHubForm from './DockerHubForm';
+import GKEForm from './GKEForm';
+import EKSForm from './EKSForm';
+import GCRForm from './GCRForm';
+import ECRForm from './ECRForm';
+
+type PropsType = {
+  integrationName: string
+};
+
+type StateType = {
+};
+
+export default class IntegrationForm extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  render() {
+    switch (this.props.integrationName) {
+      case 'docker-hub':
+        return <DockerHubForm />;
+      case 'gke':
+        return <GKEForm />;
+      case 'eks':
+        return <EKSForm />;
+      case 'ecr':
+        return <ECRForm />;
+      case 'gcr':
+        return <GCRForm />;
+      default:
+        return null;
+    }
+  }
+}

+ 21 - 32
dashboard/src/main/home/modals/IntegrationsModal.tsx

@@ -3,50 +3,38 @@ import styled from 'styled-components';
 import close from '../../../assets/close.png';
 
 import { Context } from '../../../shared/Context';
-import { getRegistryIcon } from '../../../shared/common';
+import { getIntegrationIcon } from '../../../shared/common';
 
 type PropsType = {
 };
 
 type StateType = {
-  integrations: any[]
 };
 
-const dummyIntegrations = [
-  {
-    name: 'docker-hub',
-    label: 'Docker Hub',
-  },
-  {
-    name: 'gcr',
-    label: 'Google Container Registry (GCR)',
-  },
-  {
-    name: 'ecr',
-    label: 'Amazon Elastic Container Registry (ECR)',
-  },
-];
-
 export default class IntegrationsModal extends Component<PropsType, StateType> {
   state = {
-    currentTab: 'mac',
-    integrations: [] as any[]
-  }
-
-  componentDidMount() {
-    this.setState({ integrations: dummyIntegrations });
   }
 
   renderIntegrationsCatalog = () => {
-    return this.state.integrations.map((integration: any, i: number) => {
-      let icon = getRegistryIcon(integration.name);
-      return (
-        <IntegrationOption key={i}>
-          <Icon src={icon && icon} />
-          <Label>{integration.label}</Label>
-        </IntegrationOption>
-      );
-    });
+    if (this.context.currentModalData) {
+      let { integrations, setCurrentIntegration } = this.context.currentModalData;
+      
+      return integrations.map((integration: any, i: number) => {
+        let icon = getIntegrationIcon(integration.value);
+        return (
+          <IntegrationOption 
+            key={i}
+            onClick={() => {
+              setCurrentIntegration(integration);
+              this.context.setCurrentModal(null, null);
+            }}
+          >
+            <Icon src={icon && icon} />
+            <Label>{integration.label}</Label>
+          </IntegrationOption>
+        );
+      });
+    }
   }
  
   render() {
@@ -84,6 +72,7 @@ const Icon = styled.img`
 
 const IntegrationOption = styled.div`
   height: 60px;
+  user-select: none;
   width: 100%;
   border-bottom: 1px solid #ffffff44;
   display: flex;

+ 13 - 3
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -29,7 +29,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     currentView: 'repo',
     clusterOptions: [] as { label: string, value: string }[],
     selectedCluster: this.context.currentCluster.name,
-    selectedImageUrl: '',
+    selectedImageUrl: '' as string | null,
     tabOptions: [] as ChoiceType[],
     tabContents: [] as any,
   };
@@ -54,7 +54,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     });
   }
 
-  componentDidMount() {
+  refreshTabs = () => {
     // Generate settings tabs from the provided form
     let tabOptions = [] as ChoiceType[];
     let tabContents = [] as any;
@@ -66,13 +66,17 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
             <ValuesForm 
               sections={tab.sections} 
               onSubmit={this.onSubmit}
-              disabled={this.state.selectedImageUrl === ''}
+              disabled={!this.state.selectedImageUrl || this.state.selectedImageUrl === ''}
             />
           </ValuesFormWrapper>
         ),
       });
     });
     this.setState({ tabOptions, tabContents });
+  }
+
+  componentDidMount() {
+    this.refreshTabs();
 
     // TODO: query with selected filter once implemented
     let { currentProject } = this.context;
@@ -88,6 +92,12 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     });
   }
 
+  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
+    if (this.state.selectedImageUrl != prevState.selectedImageUrl) {
+      this.refreshTabs();
+    }
+  }
+
   renderIcon = (icon: string) => {
     if (icon) {
       return <Icon src={icon} />

+ 13 - 1
dashboard/src/shared/common.tsx

@@ -1,11 +1,23 @@
-export const getRegistryIcon = (kind: string) => {
+export const getIntegrationIcon = (kind: string) => {
   switch (kind) {
+    case 'gke':
+      return 'https://sysdig.com/wp-content/uploads/2016/08/GKE_color.png';
+    case 'eks':
+      return 'https://img.stackshare.io/service/7991/amazon-eks.png';
+    case 'kubeconfig':
+      return 'https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png';
     case 'docker-hub':
       return 'https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png';
     case 'gcr':
       return 'https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640';
     case 'ecr':
       return 'https://avatars2.githubusercontent.com/u/52505464?s=400&u=da920f994c67665c7ad6c606a5286557d4f8555f&v=4';
+    case 'kubernetes':
+      return 'https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png';
+    case 'repo':
+      return 'https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png';
+    case 'registry':
+      return 'https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png';
     default:
       return null
   }

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

@@ -131,4 +131,9 @@ export interface ProjectType {
 export interface ChoiceType {
   value: string,
   label: string
+}
+
+export interface ImageType {
+  kind: string,
+  source: string,
 }