Explorar o código

fix merge conflicts and router endpoints

Alexander Belanger %!s(int64=5) %!d(string=hai) anos
pai
achega
ce94356351
Modificáronse 56 ficheiros con 3054 adicións e 412 borrados
  1. 1 0
      cmd/migrate/main.go
  2. 15 5
      dashboard/package-lock.json
  3. 2 0
      dashboard/package.json
  4. BIN=BIN
      dashboard/src/assets/branch.png
  5. 4 0
      dashboard/src/assets/category.svg
  6. 4 0
      dashboard/src/assets/file.svg
  7. 4 0
      dashboard/src/assets/folder.svg
  8. BIN=BIN
      dashboard/src/assets/github.png
  9. 4 0
      dashboard/src/assets/info.svg
  10. 4 0
      dashboard/src/assets/integrations.svg
  11. 4 0
      dashboard/src/assets/launch.svg
  12. 6 0
      dashboard/src/assets/pipelines.svg
  13. 2 2
      dashboard/src/components/SaveButton.tsx
  14. 8 21
      dashboard/src/components/TabSelector.tsx
  15. 111 0
      dashboard/src/components/repo-selector/BranchList.tsx
  16. 196 0
      dashboard/src/components/repo-selector/ContentsList.tsx
  17. 295 0
      dashboard/src/components/repo-selector/RepoSelector.tsx
  18. 0 0
      dashboard/src/components/values-form/CheckboxRow.tsx
  19. 12 4
      dashboard/src/components/values-form/InputRow.tsx
  20. 1 1
      dashboard/src/components/values-form/SelectRow.tsx
  21. 177 0
      dashboard/src/components/values-form/ValuesForm.tsx
  22. 10 0
      dashboard/src/main/Main.tsx
  23. 33 9
      dashboard/src/main/home/Home.tsx
  24. 2 2
      dashboard/src/main/home/dashboard/Dashboard.tsx
  25. 150 17
      dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx
  26. 16 5
      dashboard/src/main/home/dashboard/expanded-chart/RevisionSection.tsx
  27. 77 0
      dashboard/src/main/home/dashboard/expanded-chart/SettingsSection.tsx
  28. 1 0
      dashboard/src/main/home/dashboard/expanded-chart/ValuesYaml.tsx
  29. 0 168
      dashboard/src/main/home/dashboard/expanded-chart/values-form/ValuesForm.tsx
  30. 3 3
      dashboard/src/main/home/modals/ClusterConfigModal.tsx
  31. 297 0
      dashboard/src/main/home/modals/LaunchTemplateModal.tsx
  32. 9 7
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  33. 5 6
      dashboard/src/main/home/sidebar/Drawer.tsx
  34. 46 8
      dashboard/src/main/home/sidebar/Sidebar.tsx
  35. 260 0
      dashboard/src/main/home/templates/Templates.tsx
  36. 211 0
      dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx
  37. 2 5
      dashboard/src/shared/Context.tsx
  38. 21 1
      dashboard/src/shared/api.tsx
  39. 5 0
      dashboard/src/shared/images.d.ts
  40. 47 1
      dashboard/src/shared/types.tsx
  41. 1 1
      dashboard/webpack.config.js
  42. 1 1
      go.mod
  43. 2 0
      go.sum
  44. 14 13
      internal/models/repoclient.go
  45. 1 1
      internal/oauth/config.go
  46. 178 0
      internal/repository/gorm/helpers_test.go
  47. 0 77
      internal/repository/gorm/project_test.go
  48. 134 0
      internal/repository/gorm/repoclient.go
  49. 112 0
      internal/repository/gorm/repoclient_test.go
  50. 1 0
      internal/repository/gorm/repository.go
  51. 0 54
      internal/repository/gorm/serviceaccount_test.go
  52. 102 0
      server/api/repo_handler.go
  53. 114 0
      server/api/repo_handler_test.go
  54. 196 0
      server/api/template_handler.go
  55. 113 0
      server/api/template_handler_test.go
  56. 40 0
      server/router/router.go

+ 1 - 0
cmd/migrate/main.go

@@ -31,6 +31,7 @@ func main() {
 		&models.Cluster{},
 		&models.User{},
 		&models.Session{},
+		&models.RepoClient{},
 	)
 
 	if err != nil {

+ 15 - 5
dashboard/package-lock.json

@@ -407,6 +407,14 @@
       "integrity": "sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==",
       "dev": true
     },
+    "@types/markdown-to-jsx": {
+      "version": "6.11.3",
+      "resolved": "https://registry.npmjs.org/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.3.tgz",
+      "integrity": "sha512-30nFYpceM/ZEvhGiqWjm5quLUxNeld0HCzJEXMZZDpq53FPkS85mTwkWtCXzCqq8s5JYLgM5W392a02xn8Bdaw==",
+      "requires": {
+        "@types/react": "*"
+      }
+    },
     "@types/minimatch": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@@ -422,8 +430,7 @@
     "@types/prop-types": {
       "version": "15.7.3",
       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
-      "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
-      "dev": true
+      "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
     },
     "@types/qs": {
       "version": "6.9.5",
@@ -434,7 +441,6 @@
       "version": "16.9.49",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.49.tgz",
       "integrity": "sha512-DtLFjSj0OYAdVLBbyjhuV9CdGVHCkHn2R+xr3XkBvK2rS1Y1tkc14XSGjYgm5Fjjr90AxH9tiSzc1pCFMGO06g==",
-      "dev": true,
       "requires": {
         "@types/prop-types": "*",
         "csstype": "^3.0.2"
@@ -2034,8 +2040,7 @@
     "csstype": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.3.tgz",
-      "integrity": "sha512-jPl+wbWPOWJ7SXsWyqGRk3lGecbar0Cb0OvZF/r/ZU011R4YqiRehgkQ9p4eQfo9DSDLqLL3wHwfxeJiuIsNag==",
-      "dev": true
+      "integrity": "sha512-jPl+wbWPOWJ7SXsWyqGRk3lGecbar0Cb0OvZF/r/ZU011R4YqiRehgkQ9p4eQfo9DSDLqLL3wHwfxeJiuIsNag=="
     },
     "cyclist": {
       "version": "1.0.1",
@@ -4306,6 +4311,11 @@
         "object-visit": "^1.0.0"
       }
     },
+    "markdown-to-jsx": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.0.1.tgz",
+      "integrity": "sha512-0agsmQtnoAzGt2fO8jgoFL5wvDUbpjoIfQffErQZmP66BxaWMBGifsAo0RRuye+2Ydlp7csX6X6HpF2xaL6J1w=="
+    },
     "md5.js": {
       "version": "1.3.5",
       "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",

+ 2 - 0
dashboard/package.json

@@ -4,11 +4,13 @@
   "private": true,
   "dependencies": {
     "@types/js-yaml": "^3.12.5",
+    "@types/markdown-to-jsx": "^6.11.3",
     "@types/qs": "^6.9.5",
     "ace-builds": "^1.4.12",
     "axios": "^0.20.0",
     "dotenv": "^8.2.0",
     "js-yaml": "^3.14.0",
+    "markdown-to-jsx": "^7.0.1",
     "qs": "^6.9.4",
     "react": "^16.13.1",
     "react-ace": "^9.1.3",

BIN=BIN
dashboard/src/assets/branch.png


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

@@ -0,0 +1,4 @@
+<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M20.0943 2.5H24.3268C26.0796 2.5 27.4999 3.93231 27.4999 5.69995V9.96816C27.4999 11.7358 26.0796 13.1681 24.3268 13.1681H20.0943C18.3415 13.1681 16.9211 11.7358 16.9211 9.96816V5.69995C16.9211 3.93231 18.3415 2.5 20.0943 2.5Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M5.67316 2.5H9.90561C11.6584 2.5 13.0788 3.93231 13.0788 5.69995V9.96816C13.0788 11.7358 11.6584 13.1681 9.90561 13.1681H5.67316C3.92032 13.1681 2.5 11.7358 2.5 9.96816V5.69995C2.5 3.93231 3.92032 2.5 5.67316 2.5ZM5.67316 16.8319H9.90561C11.6584 16.8319 13.0788 18.2642 13.0788 20.0318V24.3C13.0788 26.0665 11.6584 27.5 9.90561 27.5H5.67316C3.92032 27.5 2.5 26.0665 2.5 24.3V20.0318C2.5 18.2642 3.92032 16.8319 5.67316 16.8319ZM24.3268 16.8319H20.0944C18.3415 16.8319 16.9212 18.2642 16.9212 20.0318V24.3C16.9212 26.0665 18.3415 27.5 20.0944 27.5H24.3268C26.0797 27.5 27.5 26.0665 27.5 24.3V20.0318C27.5 18.2642 26.0797 16.8319 24.3268 16.8319Z" fill="white"/>
+</svg>

+ 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=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>

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

@@ -0,0 +1,4 @@
+<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.6909 6.94442C12.7546 7.07401 12.7967 7.21278 12.8155 7.35544L13.1635 12.5303L13.3363 15.1314C13.3381 15.3988 13.38 15.6646 13.4608 15.9201C13.6695 16.4157 14.1715 16.7307 14.7176 16.7088L23.0392 16.1644C23.3995 16.1585 23.7475 16.2933 24.0065 16.5391C24.2224 16.744 24.3618 17.012 24.4057 17.3002L24.4204 17.4752C24.0761 22.2436 20.5739 26.2208 15.8154 27.2475C11.0569 28.2742 6.17733 26.1054 3.82589 21.9186C3.14798 20.7023 2.72456 19.3653 2.58048 17.9862C2.52029 17.578 2.49378 17.1656 2.50122 16.7532C2.49379 11.6409 6.13434 7.22121 11.2304 6.15572C11.8438 6.06022 12.445 6.38492 12.6909 6.94442Z" fill="white"/>
+<path opacity="0.4" d="M16.0875 2.50102C21.7873 2.64603 26.5779 6.74474 27.5 12.2654L27.4912 12.3061L27.466 12.3653L27.4695 12.528C27.4564 12.7434 27.3733 12.9507 27.2299 13.1181C27.0806 13.2925 26.8766 13.4113 26.652 13.4574L26.515 13.4762L16.914 14.0983C16.5946 14.1298 16.2766 14.0268 16.0391 13.8149C15.8412 13.6384 15.7147 13.4001 15.679 13.1433L15.0345 3.55633C15.0233 3.52391 15.0233 3.48877 15.0345 3.45635C15.0433 3.19209 15.1597 2.9423 15.3575 2.76279C15.5554 2.58327 15.8183 2.489 16.0875 2.50102Z" fill="white"/>
+</svg>

+ 4 - 0
dashboard/src/assets/launch.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="M22 12.0048C22 17.5137 17.5116 22 12 22C6.48842 22 2 17.5137 2 12.0048C2 6.48625 6.48842 2 12 2C17.5116 2 22 6.48625 22 12.0048Z" fill="white"/>
+<path d="M16 12.0049C16 12.2576 15.9205 12.5113 15.7614 12.7145C15.7315 12.7543 15.5923 12.9186 15.483 13.0255L15.4233 13.0838C14.5881 13.9694 12.5099 15.3011 11.456 15.7278C11.456 15.7375 10.8295 15.9913 10.5312 16H10.4915C10.0341 16 9.60653 15.7482 9.38778 15.34C9.26847 15.1154 9.15909 14.4642 9.14915 14.4554C9.05966 13.8712 9 12.9769 9 11.9951C9 10.9657 9.05966 10.0316 9.16903 9.45808C9.16903 9.44836 9.27841 8.92345 9.34801 8.74848C9.45739 8.49672 9.65625 8.2819 9.90483 8.14581C10.1037 8.04957 10.3125 8 10.5312 8C10.7599 8.01069 11.1875 8.15553 11.3565 8.22357C12.4702 8.65128 14.598 10.051 15.4134 10.9064C15.5526 11.0425 15.7017 11.2087 15.7415 11.2467C15.9105 11.4605 16 11.723 16 12.0049Z" fill="white"/>
+</svg>

+ 6 - 0
dashboard/src/assets/pipelines.svg

@@ -0,0 +1,6 @@
+<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M8.38194 16.1132C7.7368 16.1132 7.21454 16.6432 7.21454 17.2979L6.8927 23.0213C6.8927 23.8557 7.55979 24.5312 8.38194 24.5312C9.20409 24.5312 9.86972 23.8557 9.86972 23.0213L9.54934 17.2979C9.54934 16.6432 9.02708 16.1132 8.38194 16.1132Z" fill="white"/>
+<path d="M9.97546 4.59181C9.97546 4.59181 9.64045 4.24737 9.43272 4.09741C9.13136 3.8658 8.75978 3.75 8.38967 3.75C7.9742 3.75 7.58799 3.88065 7.27347 4.12711C7.21642 4.18501 6.97357 4.40326 6.77315 4.60666C5.51505 5.79588 3.45674 8.90033 2.82769 10.526C2.72821 10.7725 2.51317 11.3961 2.5 11.7301C2.5 12.0478 2.57022 12.3537 2.71358 12.6432C2.914 13.0054 3.22853 13.2964 3.60011 13.4553C3.85758 13.5577 4.62853 13.7166 4.64316 13.7166C5.48726 13.8769 6.85947 13.9631 8.37504 13.9631C9.81893 13.9631 11.1341 13.8769 11.9913 13.7463C12.006 13.7314 12.9627 13.5726 13.2919 13.3974C13.8917 13.0782 14.2647 12.4546 14.2647 11.788V11.7301C14.2501 11.2951 13.877 10.3805 13.8639 10.3805C13.2348 8.84242 11.276 5.81072 9.97546 4.59181Z" fill="white"/>
+<path opacity="0.4" d="M21.6184 13.887C22.2635 13.887 22.7858 13.357 22.7858 12.7022L23.1062 6.97869C23.1062 6.14429 22.4405 5.46875 21.6184 5.46875C20.7962 5.46875 20.1292 6.14429 20.1292 6.97869L20.451 12.7022C20.451 13.357 20.9732 13.887 21.6184 13.887Z" fill="white"/>
+<path d="M27.2865 17.3566C27.0861 16.9944 26.7715 16.7048 26.3999 16.5445C26.1425 16.4421 25.3701 16.2832 25.3569 16.2832C24.5128 16.1228 23.1406 16.0367 21.625 16.0367C20.1811 16.0367 18.866 16.1228 18.0087 16.2535C17.9941 16.2683 17.0373 16.4287 16.7082 16.6024C16.1069 16.9216 15.7354 17.5452 15.7354 18.2133V18.2712C15.75 18.7062 16.1216 19.6193 16.1362 19.6193C16.7652 21.1575 18.7226 24.1907 20.0246 25.4082C20.0246 25.4082 20.3596 25.7526 20.5673 25.9011C20.8672 26.1342 21.2388 26.25 21.6119 26.25C22.0259 26.25 22.4106 26.1194 22.7266 25.8729C22.7836 25.815 23.0265 25.5967 23.2269 25.3948C24.4835 24.2041 26.5433 21.0996 27.1709 19.4753C27.2718 19.2288 27.4869 18.6038 27.5001 18.2712C27.5001 17.952 27.4298 17.6461 27.2865 17.3566Z" 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')};
   }
 `;

+ 8 - 21
dashboard/src/components/TabSelector.tsx

@@ -10,7 +10,8 @@ type PropsType = {
   currentTab: string,
   options: selectOption[],
   setCurrentTab: (value: string) => void,
-  addendum?: any
+  addendum?: any,
+  color?: string
 };
 
 type StateType = {
@@ -22,6 +23,7 @@ export default class TabSelector extends Component<PropsType, StateType> {
   }
 
   renderTabList = () => {
+    let color = this.props.color || '#949effcc';
     return (
       this.props.options.map((option: selectOption, i: number) => {
         return (
@@ -29,7 +31,7 @@ export default class TabSelector extends Component<PropsType, StateType> {
             key={i}
             onClick={() => this.handleTabClick(option.value)}
             lastItem={i === this.props.options.length - 1}
-            highlight={option.value === this.props.currentTab}
+            highlight={option.value === this.props.currentTab ? color : null}
           >
             {option.label}
           </Tab>
@@ -48,29 +50,14 @@ export default class TabSelector extends Component<PropsType, StateType> {
   }
 }
 
-const Highlight = styled.div`
-  width: 80%;
-  height: 1px;
-  margin-top: 5px;
-  background: #949EFFcc00;
-
-  opacity: 0;
-  animation: lineEnter 0.5s 0s;
-  animation-fill-mode: forwards;
-  @keyframes lineEnter {
-    from { width: 0%; opacity: 0; }
-    to   { width: 80%; opacity: 1; }
-  }
-`; 
-
 const Tab = styled.div`
   height: 30px;
-  margin-right: ${(props: { lastItem: boolean, highlight: boolean }) => props.lastItem ? '' : '30px'};
+  margin-right: ${(props: { lastItem: boolean, highlight: string }) => props.lastItem ? '' : '30px'};
   display: flex;
   font-family: 'Work Sans', sans-serif;
   font-size: 13px;
   user-select: none;
-  color: ${(props: { lastItem: boolean, highlight: boolean }) => props.highlight ? '#949effcc' : '#aaaabb55'};
+  color: ${(props: { lastItem: boolean, highlight: string }) => props.highlight ? props.highlight : '#aaaabb55'};
   flex-direction: column;
   padding-top: 7px;
   padding-bottom: 2px;
@@ -78,9 +65,9 @@ const Tab = styled.div`
   align-items: center;
   cursor: pointer;
   white-space: nowrap;
-  border-bottom: 1px solid ${(props: { lastItem: boolean, highlight: boolean }) => props.highlight ? '#949effcc' : 'none'};
+  border-bottom: 1px solid ${(props: { lastItem: boolean, highlight: string }) => props.highlight ? props.highlight : 'none'};
   :hover {
-    color: ${(props: { lastItem: boolean, highlight: boolean }) => props.highlight ? '' : '#aaaabb'};
+    color: ${(props: { lastItem: boolean, highlight: string }) => props.highlight ? '' : '#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;
+`;

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

@@ -0,0 +1,196 @@
+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) {
+      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


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

@@ -1,10 +1,11 @@
-import React, { Component } from 'react';
+import React, { ChangeEvent, Component } from 'react';
 import styled from 'styled-components';
 
 type PropsType = {
   label: string,
   type: string,
   value: string | number,
+  setValue: (x: string) => void,
   unit?: string
 };
 
@@ -13,12 +14,19 @@ type StateType = {
 
 export default class InputRow extends Component<PropsType, StateType> {
   render() {
+    let { label, value, type, unit } = this.props;
     return (
       <StyledInputRow>
-        <Label>{this.props.label}</Label>
+        <Label>{label}</Label>
         <InputWrapper>
-          <Input type={this.props.type} />
-          <Unit>{this.props.unit}</Unit>
+          <Input
+            type={type}
+            value={value}
+            onChange={(e: ChangeEvent<HTMLInputElement>) =>
+              this.props.setValue(e.target.value)
+            }
+          />
+          <Unit>{unit}</Unit>
         </InputWrapper>
       </StyledInputRow>
     );

+ 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,

+ 177 - 0
dashboard/src/components/values-form/ValuesForm.tsx

@@ -0,0 +1,177 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { FormYAML, Section, FormElement } from '../../shared/types';
+
+import SaveButton from '../SaveButton';
+import CheckboxRow from './CheckboxRow';
+import InputRow from './InputRow';
+import SelectRow from './SelectRow';
+
+type PropsType = {
+  formData?: FormYAML
+};
+
+type StateType = any;
+
+export default class ValuesForm extends Component<PropsType, StateType> {
+
+  // Initialize corresponding state fields for form blocks
+  componentDidMount() {
+    let formState: any = {};
+    this.props.formData.Sections.forEach((section: Section, i: number) => {
+      section.Contents.forEach((item: FormElement, i: number) => {
+
+        // If no name is assigned use values.yaml variable as identifier
+        let key = item.Name || item.Variable;
+        
+        let def = item.Settings.Default;
+        switch (item.Type) {
+          case 'checkbox':
+            formState[key] = def ? def : false;
+            break;
+          case 'string-input':
+            formState[key] = def ? def : '';
+            break;
+          case 'number-input':
+            formState[key] = def ? def : '';
+            break;
+          case 'select':
+            formState[key] = def ? def : item.Settings.Options[0].Value;
+          default:
+        }
+      });
+    });
+    this.setState(formState);
+  }
+
+  renderSection = (section: Section) => {
+    return section.Contents.map((item: FormElement, i: number) => {
+
+      // If no name is assigned use values.yaml variable as identifier
+      let key = item.Name || item.Variable;
+      switch (item.Type) {
+        case 'heading':
+          return <Heading key={i}>{item.Label}</Heading>
+        case 'subtitle':
+          return <Helper key={i}>{item.Label}</Helper>
+        case 'checkbox':
+          return (
+            <CheckboxRow
+              key={i}
+              checked={this.state[key]}
+              toggle={() => this.setState({ [key]: !this.state[key] })}
+              label={item.Label}
+            />
+          );
+        case 'string-input':
+          return (
+            <InputRow
+              key={i}
+              type={'text'}
+              value={this.state[key]}
+              setValue={(x: string) => this.setState({ [key]: x })}
+              label={item.Label}
+              unit={item.Settings ? item.Settings.Unit : null}
+            />
+          );
+        case 'number-input':
+          return (
+            <InputRow
+              key={i}
+              type={'number'}
+              value={this.state[key]}
+              setValue={(x: string) => this.setState({ [key]: parseInt(x) })}
+              label={item.Label}
+              unit={item.Settings ? item.Settings.Unit : null}
+            />
+          );
+        case 'select':
+          return (
+            <SelectRow
+              key={i}
+              value={this.state[key]}
+              setActiveValue={(val) => this.setState({ [key]: val })}
+              options={item.Settings.Options}
+              dropdownLabel=''
+              label={item.Label}
+            />
+          );
+        default:
+      }
+    });
+  }
+
+  renderFormContents = () => {
+    if (this.state) {
+      return this.props.formData.Sections.map((section: Section, i: number) => {
+
+        // Hide collapsible section if deciding field is false
+        if (section.ShowIf) {
+          if (!this.state[section.ShowIf]) {
+            return null;
+          }
+        }
+
+        return (
+          <div key={i}>
+            {this.renderSection(section)}
+          </div>
+        );
+      });
+    }
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <StyledValuesForm>
+          <DarkMatter />
+          {this.renderFormContents()}
+        </StyledValuesForm>
+        <SaveButton
+          text='Deploy'
+          onClick={() => console.log(this.state)}
+          status={null}
+        />
+      </Wrapper>
+    );
+  }
+}
+
+const DarkMatter = styled.div`
+  margin-top: 0px;
+`;
+
+const Wrapper = styled.div`
+  width: 100%;
+  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%;
+  background: #ffffff11;
+  color: #ffffff;
+  padding: 0px 35px 30px;
+  position: relative;
+  border-radius: 5px;
+  font-size: 13px;
+  overflow: auto;
+`;

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

@@ -118,10 +118,20 @@ 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%;
+  }
 `;
 
 const StyledMain = styled.div`

+ 33 - 9
dashboard/src/main/home/Home.tsx

@@ -7,7 +7,9 @@ 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';
 
 type PropsType = {
   logOut: () => void
@@ -15,17 +17,19 @@ type PropsType = {
 
 type StateType = {
   forceSidebar: boolean,
-  showWelcome: boolean
+  showWelcome: boolean,
+  currentView: string,
 };
 
 export default class Home extends Component<PropsType, StateType> {
   state = {
     forceSidebar: true,
-    showWelcome: false
+    showWelcome: false,
+    currentView: 'dashboard'
   }
 
   renderDashboard = () => {
-    let { currentCluster, setCurrentModal, setCurrentModalData } = this.context;
+    let { currentCluster, setCurrentModal } = this.context;
 
     if (currentCluster === '' || this.state.showWelcome) {
       return (
@@ -34,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 />
             
@@ -57,26 +60,47 @@ export default class Home extends Component<PropsType, StateType> {
     );
   }
 
+  renderContents = () => {
+    if (this.state.currentView === 'dashboard') {
+      return (
+        <StyledDashboard>
+          {this.renderDashboard()}
+        </StyledDashboard>
+      );
+    }
+
+    return <Templates />
+  }
+
   render() {
     return (
       <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}
         />
-        <StyledDashboard>
-          {this.renderDashboard()}
-        </StyledDashboard>
+        
+        {this.renderContents()}
       </StyledHome>
     );
   }

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

@@ -43,7 +43,7 @@ export default class Dashboard extends Component<PropsType, StateType> {
       storage: StorageType.Secret
     }, { name: this.state.currentChart.name, revision: 0 }, (err: any, res: any) => {
       if (err) {
-        console.log(err)
+        console.log(err);
       } else {
         this.setState({ currentChart: res.data });
       }
@@ -285,7 +285,7 @@ const TitleSection = styled.div`
 
   > i {
     margin-left: 10px;
-    cursor: pointer;
+    cursor: not-allowed;
     font-size 18px;
     color: #858FAAaa;
     padding: 5px;

+ 150 - 17
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,
@@ -33,15 +34,111 @@ const tabOptions = [
   { label: 'Chart Overview', value: 'graph' },
   { label: 'Search Chart', value: 'list' },
   { label: 'Raw Values', value: 'values' },
-  { label: 'Logs', value: 'logs' },
+  { label: 'Detailed Logs', value: 'detailed-logs' },
+  { label: 'Deploy', value: 'deploy' },
+  { label: 'Settings', value: 'settings' },
 ];
 
 const basicOptions = [
-  { label: 'Update Values', value: 'values-form' },
+  { label: 'Values', value: 'values-form' },
   { label: 'Environment', value: 'environment' },
   { label: 'Logs', value: 'logs' },
+  { label: 'Deploy', value: 'deploy' },
+  { label: 'Settings', value: 'settings' },
 ];
 
+// FormYAML represents a chart's values.yaml form abstraction
+export interface FormYAML {
+	Name?: string,  
+	Icon?: string,   
+	Description?: string,   
+	Tags?: string[],
+  Sections?: Section[]
+}
+
+export interface Section {
+  Name?: string,
+  ShowIf?: string,
+  Contents: FormElement[]
+}
+
+// FormElement represents a form element
+export interface FormElement {
+  Type: string,
+  Label: string,
+  Name?: string,
+  Variable?: string,
+  Settings?: {
+    Default?: number | string | boolean,
+    Options?: any[],
+    Unit?: string
+  }
+}
+
+const dummyForm = {
+  Sections: [
+    {
+      Name: 'main',
+      Contents: [
+        {
+          Type: 'heading',
+          Label: '⚡ Electric feel settings',
+          Settings: {}
+        },
+        {
+          Type: 'subtitle',
+          Label: 'Shock me like an electric eel',
+          Settings: {}
+        },
+        {
+          Type: 'number-input',
+          Name: 'voltage',
+          Variable: 'volts',
+          Label: 'Voltage',
+          Settings: {
+            Default: 200,
+            Unit: 'Volts'
+          }
+        },
+        {
+          Type: 'number-input',
+          Name: 'batteries',
+          Variable: 'batteries',
+          Label: 'Batteries',
+          Settings: {
+            Default: 4,
+            Unit: 'AA'
+          }
+        },
+        {
+          Type: 'checkbox',
+          Name: 'trivia-checkbox',
+          Label: 'Show a fun fact?',
+          Settings: {
+            Default: true
+          }
+        },
+      ]
+    },
+    {
+      Name: 'trivia',
+      ShowIf: 'trivia-checkbox',
+      Contents: [
+        {
+          Type: 'heading',
+          Label: '🌊 Ocean fact No. 11232',
+          Settings: {}
+        },
+        {
+          Type: 'subtitle',
+          Label: 'Electric eels can reach huge proportions, exceeding 8 feet in length and 44 pounds in weight.',
+          Settings: {}
+        }
+      ]
+    }
+  ]
+}
+
 // TODO: consolidate revisionPreview and currentChart (currentChart can just be the initial state)
 export default class ExpandedChart extends Component<PropsType, StateType> {
   state = {
@@ -83,17 +180,26 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     let { currentCluster } = this.context;
     this.setState({ revisionPreview: oldChart });
 
-    api.getChartComponents('<token>', {
-      namespace: oldChart.namespace,
-      context: currentCluster,
-      storage: StorageType.Secret
-    }, { name: oldChart.name, revision: oldChart.version }, (err: any, res: any) => {
-      if (err) {
-        console.log(err)
-      } else {
-        this.setState({ components: res.data });
+    if (oldChart) {
+      api.getChartComponents('<token>', {
+        namespace: oldChart.namespace,
+        context: currentCluster,
+        storage: StorageType.Secret
+      }, { name: oldChart.name, revision: oldChart.version }, (err: any, res: any) => {
+        if (err) {
+          console.log(err)
+        } else {
+          this.setState({ components: res.data });
+        }
+      });
+
+      // Handle preview old chart while logs tab is open
+      if (this.state.currentTab === 'logs') {
+        this.setState({ currentTab: 'values-form' });
+      } else if (this.state.currentTab === 'detailed-logs') {
+        this.setState({ currentTab: 'graph' });
       }
-    });
+    }
   }
 
   toggleDevOpsMode = () => {
@@ -121,6 +227,21 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     return `${time} on ${date}`;
   }
 
+  // Hide certain tabs when previewing old charts
+  getTabOptions = () => {
+    let options = basicOptions.slice();
+    if (this.state.devOpsMode) {
+      options = tabOptions.slice();
+    }
+
+    if (this.state.revisionPreview) {
+      options.pop();
+      options.pop();
+      options.pop();
+    }
+    return options;
+  }
+
   renderTabContents = () => {
     let { currentChart, refreshChart, setSidebar } = this.props;
     let chart = currentChart;
@@ -160,8 +281,13 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       );
     } else if (this.state.currentTab === 'values-form') {
       return (
-        <ValuesForm
-        />
+        <ValuesFormWrapper>
+          <ValuesForm formData={dummyForm} />
+        </ValuesFormWrapper>
+      );
+    } else if (this.state.currentTab === 'settings') {
+      return (
+        <SettingsSection />
       );
     }
 
@@ -199,7 +325,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
               <TagWrapper>
                 Namespace
-              <NamespaceTag>
+                <NamespaceTag>
                   {chart.namespace}
                 </NamespaceTag>
               </TagWrapper>
@@ -219,7 +345,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
             <TabSelectorWrapper>
               <TabSelector
-                options={this.state.devOpsMode ? tabOptions : basicOptions}
+                options={this.getTabOptions()}
+                color={this.state.revisionPreview ? '#f5cb42' : null}
                 currentTab={this.state.currentTab}
                 setCurrentTab={(value: string) => this.setState({ currentTab: value })}
                 addendum={
@@ -242,6 +369,12 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 ExpandedChart.contextType = Context;
 
+const ValuesFormWrapper = styled.div`
+  width: 100%;
+  height: calc(100% - 60px);
+  margin-bottom: 60px;
+`;
+
 const Unimplemented = styled.div`
   width: 100%;
   height: 100%;

+ 16 - 5
dashboard/src/main/home/dashboard/expanded-chart/RevisionSection.tsx

@@ -21,6 +21,7 @@ type StateType = {
   maxVersion: number
 };
 
+// TODO: handle refresh when new revision is generated from an old revision
 export default class RevisionSection extends Component<PropsType, StateType> {
   state = {
     revisions: [] as ChartType[],
@@ -89,13 +90,22 @@ export default class RevisionSection extends Component<PropsType, StateType> {
     });
   }
 
+  handleClickRevision = (revision: ChartType) => {
+    let isCurrent = revision.version === this.state.maxVersion;
+    if (isCurrent) {
+      this.props.setRevisionPreview(null);
+    } else {
+      this.props.setRevisionPreview(revision);
+    }
+  }
+
   renderRevisionList = () => {
     return this.state.revisions.map((revision: ChartType, i: number) => {
       let isCurrent = revision.version === this.state.maxVersion;
       return (
         <Tr
           key={i}
-          onClick={() => this.props.setRevisionPreview(revision)}
+          onClick={() => this.handleClickRevision(revision)}
           selected={this.props.chart.version === revision.version}
         >
           <Td>{revision.version}</Td>
@@ -172,6 +182,7 @@ export default class RevisionSection extends Component<PropsType, StateType> {
       <div>
         <RevisionHeader
           showRevisions={this.props.showRevisions}
+          isCurrent={isCurrent}
           onClick={this.props.toggleShowRevisions}
         >
           {isCurrent ? `Current Revision` : `Previewing Revision (Not Deployed)`} - <Revision>No. {this.props.chart.version}</Revision>
@@ -343,7 +354,7 @@ const Revision = styled.div`
 `;
 
 const RevisionHeader = styled.div`
-  color: #ffffff66;
+  color: ${(props: { showRevisions: boolean, isCurrent: boolean }) => props.isCurrent ? '#ffffff66' : '#f5cb42'};
   display: flex;
   align-items: center;
   height: 40px;
@@ -351,7 +362,7 @@ const RevisionHeader = styled.div`
   width: 100%;
   padding-left: 15px;
   cursor: pointer;
-  background: ${(props: { showRevisions: boolean }) => props.showRevisions ? '#ffffff11' : ''};
+  background: ${(props: { showRevisions: boolean, isCurrent: boolean }) => props.showRevisions ? '#ffffff11' : ''};
   :hover {
     background: #ffffff18;
     > i {
@@ -364,8 +375,8 @@ const RevisionHeader = styled.div`
     font-size: 20px;
     cursor: pointer;
     border-radius: 20px;
-    background: ${(props: { showRevisions: boolean }) => props.showRevisions ? '#ffffff18' : ''};
-    transform: ${(props: { showRevisions: boolean }) => props.showRevisions ? 'rotate(180deg)' : ''};
+    background: ${(props: { showRevisions: boolean, isCurrent: boolean }) => props.showRevisions ? '#ffffff18' : ''};
+    transform: ${(props: { showRevisions: boolean, isCurrent: boolean }) => props.showRevisions ? 'rotate(180deg)' : ''};
   }
 `;
 

+ 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;
+`;

+ 1 - 0
dashboard/src/main/home/dashboard/expanded-chart/ValuesYaml.tsx

@@ -19,6 +19,7 @@ type StateType = {
   saveValuesStatus: string | null
 };
 
+// TODO: handle zoom out
 export default class ValuesYaml extends Component<PropsType, StateType> {
   state = {
     values: '',

+ 0 - 168
dashboard/src/main/home/dashboard/expanded-chart/values-form/ValuesForm.tsx

@@ -1,168 +0,0 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-
-import SaveButton from '../../../../../components/SaveButton';
-import CheckboxRow from './CheckboxRow';
-import InputRow from './InputRow';
-import SelectRow from './SelectRow';
-
-type PropsType = {
-};
-
-type StateType = any;
-
-const naiveFormArray = [
-  { type: 'heading', data: '🍦 Dessert' },
-  { type: 'helper', data: 'Select your favorite dessert' },
-  {
-    field: 'dessert', type: 'select', data: {
-      label: 'Base flavor',
-      options: [
-        { label: 'vanilla', value: 'A' },
-        { label: 'chocolate', value: 'B' },
-        { label: 'wasabi', value: 'C' }
-      ]
-    }
-  },
-  {
-    field: 'topping', type: 'select', data: {
-      label: 'Topping',
-      options: [
-        { label: 'sprinkles', value: 'A' },
-        { label: 'gummy-worms', value: 'B' },
-        { label: 'salt', value: 'C' }
-      ]
-    }
-  },
-  { type: 'heading', data: '⚡ Resources' },
-  { type: 'helper', data: 'Update computing resources and memory for certain resources.' },
-  { field: 'arguable', type: 'checkbox', data: { label: 'Use a persistent volume' } },
-  { field: 'horizon', type: 'checkbox', data: { label: 'Use a refurbished Telecaster' } },
-  { type: 'helper', data: 'Update computing resources and memory for certain resources.' },
-  { field: 'name', type: 'input', data: { type: 'string', label: 'Resource name' } },
-  { field: 'oof', type: 'checkbox', data: { label: 'Use a perspective vortex' } },
-  { field: 'memory', type: 'input', data: { type: 'number', label: 'Memory', unit: 'Mi' } },
-  { type: 'helper', data: 'Update computing resources and memory for certain resources.' },
-  {
-    field: 'ocean', type: 'select', data: {
-      label: 'Some stuff',
-      options: [
-        { label: 'volcano', value: 'A' },
-        { label: 'typhon', value: 'B' },
-        { label: 'intergalactic', value: 'C' }
-      ]
-    }
-  },
-];
-
-export default class ValuesForm extends Component<PropsType, StateType> {
-
-  // Initialize corresponding state fields for form blocks
-  componentDidMount() {
-    let formState: any = {};
-    naiveFormArray.forEach((item: any, i: number) => {
-      switch (item.type) {
-        case 'checkbox':
-          formState[item.field] = false;
-          break;
-        case 'input':
-          formState[item.field] = '';
-          break;
-        case 'select':
-          formState[item.field] = item.data.options[0].value;
-        default:
-      }
-    });
-
-    this.setState(formState);
-  }
-
-  renderFormContents = () => {
-    if (this.state) {
-      return naiveFormArray.map((item: any, i: number) => {
-        switch (item.type) {
-          case 'heading':
-            return <Heading key={i}>{item.data}</Heading>
-          case 'helper':
-            return <Helper key={i}>{item.data}</Helper>
-          case 'checkbox':
-            return (
-              <CheckboxRow
-                key={i}
-                checked={this.state[item.field]}
-                toggle={() => this.setState({ [item.field]: !this.state[item.field] })}
-                label={item.data.label}
-              />
-            );
-          case 'input':
-            return (
-              <InputRow
-                key={i}
-                type={item.data.type}
-                value={this.state[item.field]}
-                label={item.data.label}
-                unit={item.data.unit}
-              />
-            );
-          case 'select':
-            return (
-              <SelectRow
-                key={i}
-                value={this.state[item.field]}
-                setActiveValue={(val) => this.setState({ [item.field]: val })}
-                options={item.data.options}
-                dropdownLabel=''
-                label={item.data.label}
-              />
-            );
-          default:
-        }
-      });
-    }
-  }
-
-  render() {
-    return (
-      <Wrapper>
-        <StyledValuesForm>
-          {this.renderFormContents()}
-        </StyledValuesForm>
-        <SaveButton
-          text='Save Values'
-          onClick={() => console.log(this.state)}
-          status={null}
-        />
-      </Wrapper>
-    );
-  }
-}
-
-const Wrapper = styled.div`
-  width: 100%;
-  height: 100%;
-`;
-
-const Helper = 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: 30px;
-  margin-bottom: 5px;
-`;
-
-const StyledValuesForm = styled.div`
-  width: 100%;
-  height: calc(100% - 60px);
-  background: #ffffff11;
-  padding: 0px 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;

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

@@ -0,0 +1,297 @@
+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>
+      );
+    }
+
+    let subdir = this.state.subdirectory === '' ? '' : '/' + this.state.subdirectory;
+    return (
+      <Div>
+        <Subtitle>Optionally edit default settings for this template</Subtitle>
+        <ValuesFormWrapper>
+          <ValuesForm
+            formData={this.context.currentModalData.template.Form}
+          />
+        </ValuesFormWrapper>
+        <RepoButton onClick={() => this.setState({ currentView: 'repo' })}>
+          <i className="material-icons">keyboard_backspace</i>
+          {this.state.selectedRepo.FullName + subdir}
+        </RepoButton>
+      </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 RepoButton = styled.div`
+  height: 40px;
+  font-size: 13px;
+  padding: 6px 20px 7px 13px;
+  border-radius: 5px;
+  background: #ffffff11;
+  color: #ffffff;
+  border: 1px solid #ffffff55;
+  cursor: pointer;
+  user-select: none;
+  display: flex;
+  align-items: center;
+  position: absolute;
+  bottom: 25px;
+  left: 30px;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    font-size: 16px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+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;
+`;

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

@@ -11,7 +11,9 @@ import Drawer from './Drawer';
 type PropsType = {
   forceCloseDrawer: boolean,
   releaseDrawer: () => void,
-  setWelcome: (x: boolean) => void
+  setWelcome: (x: boolean) => void,
+  setCurrentView: (x: string) => void,
+  isSelected: boolean
 };
 
 type StateType = {
@@ -93,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 => {
@@ -103,8 +104,8 @@ export default class ClusterSection extends Component<PropsType, StateType> {
 
     if (kubeContexts.length > 0) {
       return (
-        <ClusterSelector showDrawer={showDrawer}>
-          <LinkWrapper>
+        <ClusterSelector isSelected={this.props.isSelected}>
+          <LinkWrapper onClick={() => this.props.setCurrentView('dashboard')}>
             <ClusterIcon><i className="material-icons">device_hub</i></ClusterIcon>
             <ClusterName>{currentCluster}</ClusterName>
           </LinkWrapper>
@@ -168,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;
@@ -259,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; }
   }
 `;
 

+ 46 - 8
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -1,6 +1,9 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import gradient from '../../../assets/gradient.jpg';
+import category from '../../../assets/category.svg';
+import pipelines from '../../../assets/pipelines.svg';
+import integrations from '../../../assets/integrations.svg';
 
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
@@ -10,7 +13,9 @@ import ClusterSection from './ClusterSection';
 type PropsType = {
   logOut: () => void,
   forceSidebar: boolean,
-  setWelcome: (x: boolean) => void
+  setWelcome: (x: boolean) => void,
+  setCurrentView: (x: string) => void,
+  currentView: string,
 };
 
 type StateType = {
@@ -120,11 +125,32 @@ export default class Sidebar extends Component<PropsType, StateType> {
             <UserName>{this.context.user.email}</UserName>
           </UserSection>
 
+          <SidebarLabel>Home</SidebarLabel>
+          <NavButton
+            onClick={() => this.props.setCurrentView('templates')}
+            selected={this.props.currentView === 'templates'}
+          >
+            <img src={category} />
+            Templates
+          </NavButton>
+          <NavButton disabled={true}>
+            <img src={pipelines} />
+            Pipelines
+          </NavButton>
+          <NavButton disabled={true}>
+            <img src={integrations} />
+            Integrations
+          </NavButton>
+
+          <br />
+
           <SidebarLabel>Current Cluster</SidebarLabel>
           <ClusterSection 
             forceCloseDrawer={this.state.forceCloseDrawer} 
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             setWelcome={this.props.setWelcome}
+            setCurrentView={this.props.setCurrentView}
+            isSelected={this.props.currentView === 'dashboard'}
           />
 
           <BottomSection>
@@ -145,18 +171,18 @@ 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;
   color: #ffffff;
-  cursor: pointer;
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
+  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 {
@@ -165,10 +191,20 @@ const NavButton = styled.div`
     height: 20px;
     width: 20px;
     border-radius: 3px;
-    font-size: 12px;
+    font-size: 18px;
     position: absolute;
-    left: 21px;
-    top: 11px;
+    left: 19px;
+    top: 8px;
+  }
+
+  > img {
+    padding: 4px 4px;
+    height: 23px;
+    width: 23px;
+    border-radius: 3px;
+    position: absolute;
+    left: 20px;
+    top: 9px;
   }
 `;
 
@@ -188,6 +224,8 @@ const LogOutButton = styled(NavButton)`
   > i {
     background: none;
     display: flex;
+    font-size: 12px;
+    top: 11px;
     align-items: center;
     justify-content: center;
     color: #ffffffaa;
@@ -211,6 +249,7 @@ const SidebarLabel = styled.div`
   padding: 5px 16px;
   margin-bottom: 5px;
   font-size: 14px;
+  z-index: 1;
   font-weight: 500;
 `;
 
@@ -336,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 {

+ 260 - 0
dashboard/src/main/home/templates/Templates.tsx

@@ -0,0 +1,260 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import api from '../../../shared/api';
+import { PorterChart } from '../../../shared/types';
+
+import TabSelector from '../../../components/TabSelector';
+import ExpandedTemplate from './expanded-template/ExpandedTemplate';
+import Loading from '../../../components/Loading';
+
+const tabOptions = [
+  { label: 'Community Templates', value: 'community' }
+];
+
+type PropsType = {
+};
+
+type StateType = {
+  currentTemplate: PorterChart | null,
+  currentTab: string,
+  porterCharts: PorterChart[],
+  loading: boolean,
+  error: boolean
+};
+
+export default class Templates extends Component<PropsType, StateType> {
+  state = {
+    currentTemplate: null as (PorterChart | null),
+    currentTab: 'community',
+    porterCharts: [] as PorterChart[],
+    loading: true,
+    error: false,
+  }
+
+  componentDidMount() {
+
+    // Get templates
+    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 });
+      }
+    });
+  }
+
+  renderIcon = (icon: string) => {
+    if (icon) {
+      return <Icon src={icon} />;
+    }
+
+    return (
+      <Polymer><i className="material-icons">layers</i></Polymer>
+    );
+  }
+
+  renderTemplateList = () => {
+    let { loading, error, porterCharts } = this.state;
+
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error retrieving templates.
+        </Placeholder>
+      );
+    } else if (porterCharts.length === 0) {
+      return (
+        <Placeholder>
+          <i className="material-icons">category</i> No templates found.
+        </Placeholder>
+      );
+    }
+
+    return this.state.porterCharts.map((template: PorterChart, i: number) => {
+      let { Name, Icon, Description } = template.Form;
+      return (
+        <TemplateBlock key={i} onClick={() => this.setState({ currentTemplate: template })}>
+          {Icon ? this.renderIcon(Icon) : this.renderIcon(template.Icon)}
+          <TemplateTitle>
+            {Name ? Name : template.Name}
+          </TemplateTitle>
+          <TemplateDescription>
+            {Description ? Description : template.Description}
+          </TemplateDescription>
+        </TemplateBlock>
+      )
+    });
+  }
+
+  renderContents = () => {
+    if (this.state.currentTemplate) {
+      return (
+        <ExpandedTemplate
+          currentTemplate={this.state.currentTemplate}
+          setCurrentTemplate={(currentTemplate: PorterChart) => this.setState({ currentTemplate })}
+        />
+      );
+    }
+
+    return (
+      <TemplatesWrapper>
+        <TitleSection>
+          <Title>Template Explorer</Title>
+        </TitleSection>
+        <TabSelector
+          options={tabOptions}
+          currentTab={this.state.currentTab}
+          setCurrentTab={(value: string) => this.setState({ currentTab: value })}
+        />
+        <TemplateList>
+          {this.renderTemplateList()}
+        </TemplateList>
+      </TemplatesWrapper>
+    );
+  }
+  
+  render() {
+    return ( 
+      <StyledTemplates>
+        {this.renderContents()}
+      </StyledTemplates>
+    );
+  }
+}
+
+Templates.contextType = Context;
+
+const Placeholder = styled.div`
+  padding-top: 100px;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding-top: 300px;
+`;
+
+const Icon = styled.img`
+  height: 42px;
+  margin-top: 35px;
+  margin-bottom: 13px;
+`;
+
+const Polymer = styled.div`
+  > i {
+    font-size: 34px;
+    margin-top: 38px;
+    margin-bottom: 20px;
+  }
+`;
+
+const TemplateDescription = styled.div`
+  margin-bottom: 26px;
+  color: #ffffff55;
+  text-align: center;
+  font-weight: default;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;  
+`;
+
+const TemplateTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TemplateBlock = styled.div`
+  background: none;
+  border: 1px solid #ffffff44;
+  align-items: center;
+  user-select: none;
+  border-radius: 5px;
+  display: flex;
+  color: #ffffff;
+  ma: 'Work Sans', sans-serif;
+  font-size: 13px;
+  font-weight: 500;
+  padding: 3px 0px 5px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 200px;
+  cursor: pointer;
+  color: #ffffff;
+  position: relative;
+  :hover {
+    background: #ffffff11;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const TemplateList = styled.div`
+  overflow-y: auto;
+  margin-top: 35px;
+  padding-bottom: 150px;
+  display: grid;
+  grid-column-gap: 15px;
+  grid-row-gap: 15px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  margin-bottom: 20px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+`;
+
+const StyledTemplates = styled.div`
+  height: 100%;
+  width: 100vw;
+  padding-top: 45px;
+  overflow-y: auto;
+  display: flex;
+  flex: 1;
+  justify-content: center;
+  position: relative;
+`;
+
+const TemplatesWrapper = styled.div`
+  width: calc(90% - 30px);
+  min-width: 300px;
+  padding-top: 20px;
+`;

+ 211 - 0
dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx

@@ -0,0 +1,211 @@
+import React, { Component } from 'react';
+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 = {
+  currentTemplate: PorterChart,
+  setCurrentTemplate: (x: PorterChart) => void
+};
+
+type StateType = {
+};
+
+export default class ExpandedTemplate extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  renderIcon = (icon: string) => {
+    if (icon) {
+      return <Icon src={icon} />
+    }
+
+    return (
+      <Polymer><i className="material-icons">layers</i></Polymer>
+    );
+  }
+
+  renderTagList = () => {
+    return this.props.currentTemplate.Form.Tags.map((tag: string, i: number) => {
+      return (
+        <Tag key={i}>{tag}</Tag>
+      )
+    });
+  }
+
+  renderMarkdown = () => {
+    let { currentTemplate } = this.props;
+    if (currentTemplate.Markdown) {
+      return (
+        <Markdown>{currentTemplate.Markdown}</Markdown>
+      );
+    } else if (currentTemplate.Form.Description) {
+      return currentTemplate.Form.Description;
+    }
+
+    return currentTemplate.Description;
+  }
+
+  render() {
+    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.setCurrentTemplate(null)}>
+              keyboard_backspace
+            </i>
+            {Icon ? this.renderIcon(Icon) : this.renderIcon(currentTemplate.Icon)}
+            <Title>{name}</Title>
+          </Flex>
+          <Button onClick={() => this.context.setCurrentModal('LaunchTemplateModal', { template: currentTemplate })}>
+            <img src={launch} />
+            Launch Template
+          </Button>
+        </TitleSection>
+        <TagSection>
+          <i className="material-icons">local_offer</i>
+          {this.renderTagList()}
+        </TagSection>
+        <ContentSection>
+          {this.renderMarkdown()}
+        </ContentSection>
+      </StyledExpandedTemplate>
+    );
+  }
+}
+
+ExpandedTemplate.contextType = Context;
+
+const ContentSection = styled.div`
+  margin-top: 50px;
+  font-size: 14px;
+  line-height: 1.8em;
+  padding-bottom: 100px;
+`;
+
+const Tag = styled.div`
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  display: flex;
+  margin-right: 7px;
+  align-items: center;
+  padding: 5px 10px;
+`;
+
+const TagSection = styled.div`
+  margin-top: 20px;
+  display: flex;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+  align-items: center;
+
+  > i {
+    font-size: 20px;
+    margin-right: 10px;
+    color: #aaaabb;
+  }
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+
+  > i {
+    cursor: pointer;
+    font-size 24px;
+    color: #969Fbbaa;
+    padding: 3px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const Button = styled.div`
+  height: 100%;
+  background: #616FEEcc;
+  :hover {
+    background: #505edddd;
+  }
+  color: white;
+  font-weight: 500;
+  font-size: 13px;
+  padding: 10px 15px;
+  border-radius: 3px;
+  cursor: pointer;
+  box-shadow: 0 5px 8px 0px #00000010;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  > img {
+    width: 20px;
+    height: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 10px;
+    justify-content: center;
+  }
+`;
+
+const Icon = styled.img`
+  width: 27px;
+  margin-left: 14px;
+  margin-right: 4px;
+  margin-bottom: -1px;
+`;
+
+
+const Polymer = styled.div`
+  margin-bottom: -3px;
+
+  > i {
+    color: ${props => props.theme.containerIcon};
+    font-size: 24px;
+    margin-left: 12px;
+    margin-right: 3px;
+  }
+`;
+
+const Description = styled.div`
+  font-size: 14px;
+  font-family: 'Work Sans', sans-serif;
+  margin-left: 30px;
+  width: calc(100% - 60px);
+  height: 4em;
+  border-radius: 2px;
+  color: #aaaabb;
+  padding: 5px 10px;
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: 'Work Sans', sans-serif;
+  margin-left: 10px;
+  border-radius: 2px;
+  color: #ffffff;
+`;
+
+const TitleSection = styled.div`
+  display: flex;
+  margin-left: -42px;
+  flex-direction: row;
+  justify-content: space-between;
+  width: calc(100% + 42px);
+  align-items: center;
+`;
+
+const StyledExpandedTemplate = styled.div`
+  width: calc(90% - 70px);
+  padding-top: 20px;
+`;

+ 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 });

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

@@ -96,12 +96,29 @@ const upgradeChartValues = baseApi<{
   return `/api/releases/${pathParams.name}/upgrade`;
 });
 
+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,
@@ -111,5 +128,8 @@ export default {
   getNamespaces,
   getRevisions,
   rollbackChart,
-  upgradeChartValues
+  upgradeChartValues,
+  getTemplates,
+  getBranches,
+  getBranchContents
 }

+ 5 - 0
dashboard/src/shared/images.d.ts

@@ -11,4 +11,9 @@ declare module "*.jpg" {
 declare module "*.png" {
   const value: any;
   export = value;
+}
+
+declare module "*.svg" {
+  const value: any;
+  export = value;
 }

+ 47 - 1
dashboard/src/shared/types.tsx

@@ -57,9 +57,55 @@ export interface EdgeType {
   target: number
 }
 
-
 export enum StorageType {
   Secret = 'secret',
   ConfigMap = 'configmap',
   Memory = 'memory'
 }
+
+// PorterChart represents a bundled Porter template
+export interface PorterChart {
+	Name: string,
+	Description: string,
+	Icon: string,
+  Form: FormYAML,
+  Markdown?: string,
+}
+
+// FormYAML represents a chart's values.yaml form abstraction
+export interface FormYAML {
+	Name?: string,  
+	Icon?: string,   
+	Description?: string,   
+	Tags?: string[],
+  Sections?: Section[]
+}
+
+export interface Section {
+  Name?: string,
+  ShowIf?: string,
+  Contents: FormElement[]
+}
+
+// FormElement represents a form element
+export interface FormElement {
+  Type: string,
+  Label: string,
+  Name?: string,
+  Variable?: string,
+  Settings?: {
+    Default?: number | string | boolean,
+    Options?: any[],
+    Unit?: string
+  }
+}
+
+export interface RepoType {
+  FullName: string,
+  kind: string
+}
+
+export interface FileType {
+  Path: string,
+  Type: string
+}

+ 1 - 1
dashboard/webpack.config.js

@@ -33,7 +33,7 @@ module.exports = () => {
         },
         { test: /\.css$/, use: [ 'css-loader' ] },
         {
-          test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
+          test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
           use: [
             {
               loader: 'file-loader',

+ 1 - 1
go.mod

@@ -25,7 +25,7 @@ require (
 	github.com/go-test/deep v1.0.7
 	github.com/google/go-cmp v0.5.1
 	github.com/google/go-github v17.0.0+incompatible
-	github.com/google/go-querystring v1.0.0 // indirect
+	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

+ 2 - 0
go.sum

@@ -403,6 +403,8 @@ 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 v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
+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=

+ 14 - 13
internal/models/repoclient.go

@@ -1,8 +1,6 @@
 package models
 
 import (
-	"strings"
-
 	"gorm.io/gorm"
 )
 
@@ -16,11 +14,12 @@ const (
 type RepoClient struct {
 	gorm.Model
 
-	ProjectID uint `json:"project_id"`
+	ProjectID  uint `json:"project_id"`
+	UserID     uint `json:"user_id"`
+	RepoUserID uint `json:"repo_id"`
 
 	// the kind can be one of the predefined repo kinds
-	Kind         string `json:"kind"`
-	Repositories string `json:"repositories"`
+	Kind string `json:"kind"`
 
 	// ------------------------------------------------------------------
 	// All fields below this line are encrypted before storage
@@ -33,18 +32,20 @@ type RepoClient struct {
 // RepoClientExternal is a RepoClient scrubbed of sensitive information to be
 // shared over REST
 type RepoClientExternal struct {
-	ID           uint     `json:"id"`
-	ProjectID    uint     `json:"project_id"`
-	Kind         string   `json:"kind"`
-	Repositories []string `json:"repositories"`
+	ID         uint   `json:"id"`
+	ProjectID  uint   `json:"project_id"`
+	UserID     uint   `json:"user_id"`
+	RepoUserID uint   `json:"repo_id"`
+	Kind       string `json:"kind"`
 }
 
 // Externalize generates an external RepoClient to be shared over REST
 func (r *RepoClient) Externalize() *RepoClientExternal {
 	return &RepoClientExternal{
-		ID:           r.Model.ID,
-		ProjectID:    r.ProjectID,
-		Kind:         r.Kind,
-		Repositories: strings.Split(r.Repositories, ","),
+		ID:         r.Model.ID,
+		ProjectID:  r.ProjectID,
+		UserID:     r.UserID,
+		RepoUserID: r.RepoUserID,
+		Kind:       r.Kind,
 	}
 }

+ 1 - 1
internal/oauth/config.go

@@ -22,7 +22,7 @@ func NewGithubClient(cfg *Config) *oauth2.Config {
 			AuthURL:  "https://github.com/login/oauth/authorize",
 			TokenURL: "https://github.com/login/oauth/access_token",
 		},
-		RedirectURL: cfg.BaseURL + "/api/auth/callback/github",
+		RedirectURL: cfg.BaseURL + "/api/oauth/github/callback",
 		Scopes:      cfg.Scopes,
 	}
 }

+ 178 - 0
internal/repository/gorm/helpers_test.go

@@ -0,0 +1,178 @@
+package gorm_test
+
+import (
+	"os"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/gorm"
+)
+
+type tester struct {
+	repo             *repository.Repository
+	key              *[32]byte
+	dbFileName       string
+	initUsers        []*models.User
+	initProjects     []*models.Project
+	initSACandidates []*models.ServiceAccountCandidate
+	initSAs          []*models.ServiceAccount
+	initRCs          []*models.RepoClient
+}
+
+func setupTestEnv(tester *tester, t *testing.T) {
+	t.Helper()
+
+	db, err := adapter.New(&config.DBConf{
+		EncryptionKey: "__random_strong_encryption_key__",
+		SQLLite:       true,
+		SQLLitePath:   tester.dbFileName,
+	})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	err = db.AutoMigrate(
+		&models.Project{},
+		&models.Role{},
+		&models.ServiceAccount{},
+		&models.ServiceAccountAction{},
+		&models.ServiceAccountCandidate{},
+		&models.Cluster{},
+		&models.User{},
+		&models.Session{},
+		&models.RepoClient{},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte("__random_strong_encryption_key__") {
+		key[i] = b
+	}
+
+	tester.key = &key
+
+	tester.repo = gorm.NewRepository(db, &key)
+}
+
+func cleanup(tester *tester, t *testing.T) {
+	t.Helper()
+
+	// remove the created file file
+	os.Remove(tester.dbFileName)
+}
+
+func initUser(tester *tester, t *testing.T) {
+	t.Helper()
+
+	user := &models.User{
+		Email:    "example@example.com",
+		Password: "hello1234",
+	}
+
+	user, err := tester.repo.User.CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+}
+
+func initProject(tester *tester, t *testing.T) {
+	t.Helper()
+
+	proj := &models.Project{
+		Name: "project-test",
+	}
+
+	proj, err := tester.repo.Project.CreateProject(proj)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initProjects = append(tester.initProjects, proj)
+}
+
+func initServiceAccountCandidate(tester *tester, t *testing.T) {
+	t.Helper()
+
+	saCandidate := &models.ServiceAccountCandidate{
+		ProjectID:       1,
+		Kind:            "connector",
+		ClusterName:     "cluster-test",
+		ClusterEndpoint: "https://localhost",
+		AuthMechanism:   models.X509,
+		Kubeconfig:      []byte("current-context: testing\n"),
+		Actions: []models.ServiceAccountAction{
+			models.ServiceAccountAction{
+				Name:     models.TokenDataAction,
+				Resolved: false,
+			},
+		},
+	}
+
+	saCandidate, err := tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initSACandidates = append(tester.initSACandidates, saCandidate)
+}
+
+func initServiceAccount(tester *tester, t *testing.T) {
+	t.Helper()
+
+	sa := &models.ServiceAccount{
+		ProjectID:             1,
+		Kind:                  "connector",
+		AuthMechanism:         models.X509,
+		ClientCertificateData: []byte("-----BEGIN"),
+		ClientKeyData:         []byte("-----BEGIN"),
+		Clusters: []models.Cluster{
+			models.Cluster{
+				Name:                     "cluster-test",
+				Server:                   "https://localhost",
+				CertificateAuthorityData: []byte("-----BEGIN"),
+			},
+		},
+	}
+
+	sa, err := tester.repo.ServiceAccount.CreateServiceAccount(sa)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initSAs = append(tester.initSAs, sa)
+}
+
+func initRepoClient(tester *tester, t *testing.T) {
+	t.Helper()
+
+	rc := &models.RepoClient{
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		RepoUserID:   1,
+		Kind:         models.RepoClientGithub,
+		AccessToken:  "accesstoken1234",
+		RefreshToken: "refreshtoken1234",
+	}
+
+	rc, err := tester.repo.RepoClient.CreateRepoClient(rc)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initRCs = append(tester.initRCs, rc)
+}

+ 0 - 77
internal/repository/gorm/project_test.go

@@ -1,91 +1,14 @@
 package gorm_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/go-test/deep"
 	"github.com/porter-dev/porter/internal/models"
 
-	"github.com/porter-dev/porter/internal/adapter"
-	"github.com/porter-dev/porter/internal/config"
-	"github.com/porter-dev/porter/internal/repository"
-	"github.com/porter-dev/porter/internal/repository/gorm"
-
 	orm "gorm.io/gorm"
 )
 
-type tester struct {
-	repo             *repository.Repository
-	key              *[32]byte
-	dbFileName       string
-	initProjects     []*models.Project
-	initSACandidates []*models.ServiceAccountCandidate
-	initSAs          []*models.ServiceAccount
-}
-
-func setupTestEnv(tester *tester, t *testing.T) {
-	t.Helper()
-
-	db, err := adapter.New(&config.DBConf{
-		EncryptionKey: "__random_strong_encryption_key__",
-		SQLLite:       true,
-		SQLLitePath:   tester.dbFileName,
-	})
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	err = db.AutoMigrate(
-		&models.Project{},
-		&models.Role{},
-		&models.ServiceAccount{},
-		&models.ServiceAccountAction{},
-		&models.ServiceAccountCandidate{},
-		&models.Cluster{},
-		&models.User{},
-		&models.Session{},
-	)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	var key [32]byte
-
-	for i, b := range []byte("__random_strong_encryption_key__") {
-		key[i] = b
-	}
-
-	tester.key = &key
-
-	tester.repo = gorm.NewRepository(db, &key)
-}
-
-func cleanup(tester *tester, t *testing.T) {
-	t.Helper()
-
-	// remove the created file file
-	os.Remove(tester.dbFileName)
-}
-
-func initProject(tester *tester, t *testing.T) {
-	t.Helper()
-
-	proj := &models.Project{
-		Name: "project-test",
-	}
-
-	proj, err := tester.repo.Project.CreateProject(proj)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	tester.initProjects = append(tester.initProjects, proj)
-}
-
 func TestCreateProject(t *testing.T) {
 	tester := &tester{
 		dbFileName: "./porter_create_proj.db",

+ 134 - 0
internal/repository/gorm/repoclient.go

@@ -0,0 +1,134 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// RepoClientRepository uses gorm.DB for querying the database
+type RepoClientRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewRepoClientRepository returns a RepoClientRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewRepoClientRepository(db *gorm.DB, key *[32]byte) repository.RepoClientRepository {
+	return &RepoClientRepository{db, key}
+}
+
+// CreateRepoClient creates a new repo client and appends it to the in-memory list
+func (repo *RepoClientRepository) CreateRepoClient(rc *models.RepoClient) (*models.RepoClient, error) {
+	err := repo.EncryptRepoClientData(rc, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", rc.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("RepoClients")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(rc); err != nil {
+		return nil, err
+	}
+
+	return rc, nil
+}
+
+// ReadRepoClient returns a repo client by id
+func (repo *RepoClientRepository) ReadRepoClient(id uint) (*models.RepoClient, error) {
+	rc := &models.RepoClient{}
+
+	// preload Clusters association
+	if err := repo.db.Where("id = ?", id).First(&rc).Error; err != nil {
+		return nil, err
+	}
+
+	repo.DecryptRepoClientData(rc, repo.key)
+
+	return rc, nil
+}
+
+// ListRepoClientsByProjectID returns a list of repo clients that match a project id
+func (repo *RepoClientRepository) ListRepoClientsByProjectID(projectID uint) ([]*models.RepoClient, error) {
+	rcs := []*models.RepoClient{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&rcs).Error; err != nil {
+		return nil, err
+	}
+
+	for _, rc := range rcs {
+		repo.DecryptRepoClientData(rc, repo.key)
+	}
+
+	return rcs, nil
+}
+
+// EncryptRepoClientData will encrypt the repo client tokens before writing
+// to the DB
+func (repo *RepoClientRepository) EncryptRepoClientData(
+	rc *models.RepoClient,
+	key *[32]byte,
+) error {
+	if rc.AccessToken != "" {
+		cipherData, err := repository.Encrypt([]byte(rc.AccessToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		rc.AccessToken = string(cipherData)
+	}
+
+	if rc.RefreshToken != "" {
+		cipherData, err := repository.Encrypt([]byte(rc.RefreshToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		rc.RefreshToken = string(cipherData)
+	}
+
+	return nil
+}
+
+// DecryptRepoClientData will decrypt the repo client tokens before
+// returning it from the DB
+func (repo *RepoClientRepository) DecryptRepoClientData(
+	rc *models.RepoClient,
+	key *[32]byte,
+) error {
+	if rc.AccessToken != "" {
+		plaintext, err := repository.Decrypt([]byte(rc.AccessToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		rc.AccessToken = string(plaintext)
+	}
+
+	if rc.RefreshToken != "" {
+		plaintext, err := repository.Decrypt([]byte(rc.RefreshToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		rc.RefreshToken = string(plaintext)
+	}
+
+	return nil
+}

+ 112 - 0
internal/repository/gorm/repoclient_test.go

@@ -0,0 +1,112 @@
+package gorm_test
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	orm "gorm.io/gorm"
+)
+
+func TestCreateRepoClient(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_rc.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	repoClient := &models.RepoClient{
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		RepoUserID:   1,
+		Kind:         models.RepoClientGithub,
+		AccessToken:  "accesstoken1234",
+		RefreshToken: "refreshtoken1234",
+	}
+
+	repoClient, err := tester.repo.RepoClient.CreateRepoClient(repoClient)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	repoClient, err = tester.repo.RepoClient.ReadRepoClient(repoClient.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if repoClient.Model.ID != 1 {
+		t.Errorf("incorrect repo client ID: expected %d, got %d\n", 1, repoClient.Model.ID)
+	}
+
+	// make sure data is correct
+	expRepoClient := &models.RepoClient{
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		RepoUserID:   1,
+		Kind:         models.RepoClientGithub,
+		AccessToken:  "accesstoken1234",
+		RefreshToken: "refreshtoken1234",
+	}
+
+	copyRepoClient := repoClient
+
+	// reset fields for reflect.DeepEqual
+	copyRepoClient.Model = orm.Model{}
+
+	if diff := deep.Equal(copyRepoClient, expRepoClient); diff != nil {
+		t.Errorf("incorrect repo client")
+		t.Error(diff)
+	}
+}
+
+func TestListRepoClientsByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_rcs.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	initServiceAccount(tester, t)
+	initRepoClient(tester, t)
+	defer cleanup(tester, t)
+
+	rcs, err := tester.repo.RepoClient.ListRepoClientsByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(rcs) != 1 {
+		t.Fatalf("length of rcs incorrect: expected %d, got %d\n", 1, len(rcs))
+	}
+
+	// make sure data is correct
+	// make sure data is correct
+	expRepoClient := &models.RepoClient{
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		RepoUserID:   1,
+		Kind:         models.RepoClientGithub,
+		AccessToken:  "accesstoken1234",
+		RefreshToken: "refreshtoken1234",
+	}
+
+	copyRepoClient := rcs[0]
+
+	// reset fields for reflect.DeepEqual
+	copyRepoClient.Model = orm.Model{}
+
+	if diff := deep.Equal(copyRepoClient, expRepoClient); diff != nil {
+		t.Errorf("incorrect repo client")
+		t.Error(diff)
+	}
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -13,5 +13,6 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		Session:        NewSessionRepository(db),
 		Project:        NewProjectRepository(db),
 		ServiceAccount: NewServiceAccountRepository(db, key),
+		RepoClient:     NewRepoClientRepository(db, key),
 	}
 }

+ 0 - 54
internal/repository/gorm/serviceaccount_test.go

@@ -8,60 +8,6 @@ import (
 	orm "gorm.io/gorm"
 )
 
-func initServiceAccountCandidate(tester *tester, t *testing.T) {
-	t.Helper()
-
-	saCandidate := &models.ServiceAccountCandidate{
-		ProjectID:       1,
-		Kind:            "connector",
-		ClusterName:     "cluster-test",
-		ClusterEndpoint: "https://localhost",
-		AuthMechanism:   models.X509,
-		Kubeconfig:      []byte("current-context: testing\n"),
-		Actions: []models.ServiceAccountAction{
-			models.ServiceAccountAction{
-				Name:     models.TokenDataAction,
-				Resolved: false,
-			},
-		},
-	}
-
-	saCandidate, err := tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	tester.initSACandidates = append(tester.initSACandidates, saCandidate)
-}
-
-func initServiceAccount(tester *tester, t *testing.T) {
-	t.Helper()
-
-	sa := &models.ServiceAccount{
-		ProjectID:             1,
-		Kind:                  "connector",
-		AuthMechanism:         models.X509,
-		ClientCertificateData: []byte("-----BEGIN"),
-		ClientKeyData:         []byte("-----BEGIN"),
-		Clusters: []models.Cluster{
-			models.Cluster{
-				Name:                     "cluster-test",
-				Server:                   "https://localhost",
-				CertificateAuthorityData: []byte("-----BEGIN"),
-			},
-		},
-	}
-
-	sa, err := tester.repo.ServiceAccount.CreateServiceAccount(sa)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	tester.initSAs = append(tester.initSAs, sa)
-}
-
 func TestCreateServiceAccountCandidate(t *testing.T) {
 	tester := &tester{
 		dbFileName: "./porter_create_sa_candidate.db",

+ 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")
+	}
+}

+ 196 - 0
server/api/template_handler.go

@@ -0,0 +1,196 @@
+package api
+
+import (
+	"archive/tar"
+	"bytes"
+	"compress/gzip"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"strings"
+
+	"gopkg.in/yaml.v2"
+)
+
+// IndexYAML represents a chart repo's index.yaml
+type IndexYAML struct {
+	APIVersion string                    `yaml:"apiVersion"`
+	Generated  string                    `yaml:"generated"`
+	Entries    map[interface{}]ChartYAML `yaml:"entries"`
+}
+
+// ChartYAML represents the data for chart in index.yaml
+type ChartYAML []struct {
+	APIVersion  string   `yaml:"apiVersion"`
+	AppVersion  string   `yaml:"appVersion"`
+	Created     string   `yaml:"created"`
+	Description string   `yaml:"description"`
+	Digest      string   `yaml:"digest"`
+	Icon        string   `yaml:"icon"`
+	Name        string   `yaml:"name"`
+	Type        string   `yaml:"type"`
+	Urls        []string `yaml:"urls"`
+	Version     string   `yaml:"version"`
+}
+
+// PorterChart represents a bundled Porter template
+type PorterChart struct {
+	Name        string
+	Description string
+	Icon        string
+	Form        FormYAML
+	Markdown    string
+}
+
+// FormYAML represents a chart's values.yaml form abstraction
+type FormYAML struct {
+	Name        string   `yaml:"name"`
+	Icon        string   `yaml:"icon"`
+	Description string   `yaml:"description"`
+	Tags        []string `yaml:"tags"`
+	Sections    []struct {
+		Name     string `yaml:"name"`
+		ShowIf   string `yaml:"show_if"`
+		Contents []struct {
+			Type     string `yaml:"type"`
+			Label    string `yaml:"label"`
+			Name     string `yaml:"name,omitempty"`
+			Variable string `yaml:"variable,omitempty"`
+			Settings struct {
+				Default interface{}
+			} `yaml:"settings,omitempty"`
+		} `yaml:"contents"`
+	} `yaml:"sections"`
+}
+
+// HandleListTemplates retrieves a list of Porter templates
+// 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)
+		return
+	}
+
+	defer resp.Body.Close()
+	body, _ := ioutil.ReadAll(resp.Body)
+
+	form := IndexYAML{}
+	if err := yaml.Unmarshal([]byte(body), &form); err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	// Loop over charts in index.yaml
+	porterCharts := []PorterChart{}
+	for k := range form.Entries {
+		indexChart := form.Entries[k][0]
+		tarURL := indexChart.Urls[0]
+		if !strings.Contains(tarURL, "http://") {
+			tarURL = baseURL + tarURL
+		}
+
+		formData, markdown, err := processTarball(tarURL)
+		if err != nil {
+			fmt.Println(err)
+			return
+		}
+
+		porterChart := PorterChart{}
+		porterChart.Name = indexChart.Name
+		porterChart.Description = indexChart.Description
+		porterChart.Icon = indexChart.Icon
+		porterChart.Form = *formData
+		if markdown != "" {
+			porterChart.Markdown = markdown
+		}
+
+		porterCharts = append(porterCharts, porterChart)
+	}
+
+	json.NewEncoder(w).Encode(porterCharts)
+}
+
+func processTarball(tarURL string) (*FormYAML, string, error) {
+	resp, err := http.Get(tarURL)
+	if err != nil {
+		fmt.Println(err)
+		return nil, "", err
+	}
+
+	defer resp.Body.Close()
+	body, _ := ioutil.ReadAll(resp.Body)
+	buf := bytes.NewBuffer(body)
+
+	gzf, err := gzip.NewReader(buf)
+	if err != nil {
+		fmt.Println(err)
+		return nil, "", err
+	}
+
+	// Process tarball to generate FormYAML and retrieve markdown
+	tarReader := tar.NewReader(gzf)
+	markdown := ""
+	for {
+		header, err := tarReader.Next()
+		if err == io.EOF {
+			break
+		} else if err != nil {
+			fmt.Println(err)
+			return nil, "", err
+		}
+
+		name := header.Name
+		switch header.Typeflag {
+		case tar.TypeDir:
+			continue
+		case tar.TypeReg:
+
+			// Handle info.md if found
+			if strings.Contains(name, "README.md") {
+				bufMd := new(bytes.Buffer)
+
+				_, err := io.Copy(bufMd, tarReader)
+				if err != nil {
+					fmt.Println(err)
+					return nil, "", err
+				}
+
+				markdown = string(bufMd.Bytes())
+			}
+
+			// Handle form.yaml located in archive
+			if strings.Contains(name, "form.yaml") {
+				bufForm := new(bytes.Buffer)
+
+				_, err := io.Copy(bufForm, tarReader)
+				if err != nil {
+					fmt.Println(err)
+					return nil, "", err
+				}
+
+				// Unmarshal yaml byte buffer
+				form := FormYAML{}
+				if err := yaml.Unmarshal(bufForm.Bytes(), &form); err != nil {
+					fmt.Println(err)
+					return nil, "", err
+				}
+				return &form, markdown, nil
+			}
+		default:
+			fmt.Printf("%s : %c %s %s\n",
+				"Unknown type",
+				header.Typeflag,
+				"in file",
+				name,
+			)
+		}
+	}
+	return nil, "", errors.New("no form.yaml found")
+}

+ 113 - 0
server/api/template_handler_test.go

@@ -0,0 +1,113 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+)
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type templatesTest struct {
+	initializers []func(tester *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *templatesTest, tester *tester, t *testing.T)
+}
+
+func testTemplatesRequests(t *testing.T, tests []*templatesTest, 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 listTemplatesTests = []*templatesTest{
+	&templatesTest{
+		initializers: []func(tester *tester){
+			initDefaultTemplates,
+		},
+		msg:       "List templates",
+		method:    "GET",
+		endpoint:  "/api/templates",
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   "unimplemented",
+		useCookie: true,
+		validators: []func(c *templatesTest, tester *tester, t *testing.T){
+			templatesListValidator,
+		},
+	},
+}
+
+func TestHandleListTemplates(t *testing.T) {
+	testTemplatesRequests(t, listTemplatesTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initDefaultTemplates(tester *tester) {
+	initUserDefault(tester)
+
+	agent := kubernetes.GetAgentTesting(defaultObjects...)
+
+	// overwrite the test agent with new resources
+	tester.app.TestAgents.K8sAgent = agent
+}
+
+func templatesListValidator(c *templatesTest, 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")
+	}
+}

+ 40 - 0
server/router/router.go

@@ -180,6 +180,46 @@ func New(
 			),
 		)
 
+		// /api/projects/{project_id}/repos routes
+		r.Method(
+			"GET",
+			"/projects/{project_id}/repos",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListRepos, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/repos/{kind}/{name}/branches",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleGetBranches, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/repos/{kind}/{name}/{branch}/contents",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleGetBranchContents, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		// /api/templates routes
+		r.Method(
+			"GET",
+			"/templates",
+			auth.BasicAuthenticate(
+				requestlog.NewHandler(a.HandleListTemplates, l),
+			),
+		)
+
 		// /api/projects/{project_id}/k8s routes
 		r.Method(
 			"GET",