Ver código fonte

Consolidate & refactor env groups (#4237)

jusrhee 2 anos atrás
pai
commit
f720fcc5bf
51 arquivos alterados com 2192 adições e 286 exclusões
  1. 9 0
      dashboard/src/assets/add-on-grad.svg
  2. 9 0
      dashboard/src/assets/application-grad.svg
  3. 9 0
      dashboard/src/assets/compliance-grad.svg
  4. 9 0
      dashboard/src/assets/database-grad.svg
  5. 9 0
      dashboard/src/assets/env-group-grad.svg
  6. 29 0
      dashboard/src/assets/infra-grad.svg
  7. 9 0
      dashboard/src/assets/integration-grad.svg
  8. 2 0
      dashboard/src/assets/pr-grad.svg
  9. 14 0
      dashboard/src/assets/settings-grad.svg
  10. 40 0
      dashboard/src/components/porter/Clickable.tsx
  11. 3 2
      dashboard/src/components/porter/ControlledInput.tsx
  12. 1 9
      dashboard/src/components/porter/Error.tsx
  13. 1 0
      dashboard/src/components/porter/Input.tsx
  14. 5 1
      dashboard/src/components/porter/SearchBar.tsx
  15. 44 68
      dashboard/src/components/porter/Select.tsx
  16. 2 1
      dashboard/src/components/porter/Toggle.tsx
  17. 24 0
      dashboard/src/lib/env-groups/types.ts
  18. 16 0
      dashboard/src/main/home/Home.tsx
  19. 4 5
      dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx
  20. 3 3
      dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx
  21. 1 1
      dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx
  22. 1 1
      dashboard/src/main/home/app-dashboard/app-view/tabs/ImageSettingsTab.tsx
  23. 2 0
      dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx
  24. 6 10
      dashboard/src/main/home/app-dashboard/apps/Apps.tsx
  25. 22 20
      dashboard/src/main/home/app-dashboard/apps/SelectableAppList.tsx
  26. 5 5
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  27. 3 3
      dashboard/src/main/home/app-dashboard/new-app-flow/GithubActionModal.tsx
  28. 1 1
      dashboard/src/main/home/app-dashboard/new-app-flow/SourceSelector.tsx
  29. 60 70
      dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroupModal.tsx
  30. 10 12
      dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx
  31. 1 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx
  32. 1 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx
  33. 3 54
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  34. 2 2
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx
  35. 2 2
      dashboard/src/main/home/compliance-dashboard/ComplianceDashboard.tsx
  36. 2 2
      dashboard/src/main/home/database-dashboard/CreateDatabase.tsx
  37. 5 5
      dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx
  38. 1 1
      dashboard/src/main/home/database-dashboard/forms/DatabaseForm.tsx
  39. 272 0
      dashboard/src/main/home/env-dashboard/CreateEnvGroup.tsx
  40. 330 0
      dashboard/src/main/home/env-dashboard/EnvDashboard.tsx
  41. 211 0
      dashboard/src/main/home/env-dashboard/EnvGroup.tsx
  42. 355 0
      dashboard/src/main/home/env-dashboard/EnvGroupArray.tsx
  43. 30 0
      dashboard/src/main/home/env-dashboard/EnvGroupList.tsx
  44. 169 0
      dashboard/src/main/home/env-dashboard/ExpandedEnv.tsx
  45. 218 0
      dashboard/src/main/home/env-dashboard/tabs/EnvVarsTab.tsx
  46. 130 0
      dashboard/src/main/home/env-dashboard/tabs/SettingsTab.tsx
  47. 97 0
      dashboard/src/main/home/env-dashboard/tabs/SyncedAppsTab.tsx
  48. 2 2
      dashboard/src/main/home/integrations/Integrations.tsx
  49. 2 2
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  50. 4 4
      dashboard/src/main/home/sidebar/Sidebar.tsx
  51. 2 0
      dashboard/src/shared/routing.tsx

+ 9 - 0
dashboard/src/assets/add-on-grad.svg

@@ -0,0 +1,9 @@
+<svg width="17" height="18" viewBox="0 0 17 18" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M1 12.875L8.5 17.25L16 12.875M1 9.125L8.5 13.5L16 9.125M1 5.375L8.5 9.75L16 5.375L8.5 1L1 5.375Z" stroke="url(#paint0_linear_851_144)" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<defs>
+<linearGradient id="paint0_linear_851_144" x1="3" y1="1" x2="16" y2="19.5" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#3C3C3C"/>
+</linearGradient>
+</defs>
+</svg>

+ 9 - 0
dashboard/src/assets/application-grad.svg

@@ -0,0 +1,9 @@
+<svg width="35" height="34" viewBox="0 0 35 34" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.2654 10.332V9.23203H10.0654V10.332H12.2654ZM10.0654 31.9987V33.0987H12.2654V31.9987H10.0654ZM2 9.23398C1.39249 9.23398 0.9 9.72647 0.9 10.334C0.9 10.9415 1.39249 11.434 2 11.434V9.23398ZM33.6667 11.434C34.2742 11.434 34.7667 10.9415 34.7667 10.334C34.7667 9.72647 34.2742 9.23398 33.6667 9.23398V11.434ZM30.3333 30.9H5.33333V33.1H30.3333V30.9ZM5.33333 30.9C4.09918 30.9 3.1 29.9008 3.1 28.6667H0.9C0.9 31.1158 2.88415 33.1 5.33333 33.1V30.9ZM3.1 28.6667V5.33333H0.9V28.6667H3.1ZM3.1 5.33333C3.1 4.09918 4.09918 3.1 5.33333 3.1V0.9C2.88415 0.9 0.9 2.88415 0.9 5.33333H3.1ZM5.33333 3.1H30.3333V0.9H5.33333V3.1ZM30.3333 3.1C31.5675 3.1 32.5667 4.09918 32.5667 5.33333H34.7667C34.7667 2.88415 32.7825 0.9 30.3333 0.9V3.1ZM32.5667 5.33333V28.6667H34.7667V5.33333H32.5667ZM32.5667 28.6667C32.5667 29.9008 31.5675 30.9 30.3333 30.9V33.1C32.7825 33.1 34.7667 31.1158 34.7667 28.6667H32.5667ZM10.0654 10.332V31.9987H12.2654V10.332H10.0654ZM2 11.434H33.6667V9.23398H2V11.434Z" fill="url(#paint0_linear_813_134)"/>
+<defs>
+<linearGradient id="paint0_linear_813_134" x1="2" y1="2" x2="34" y2="32" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849"/>
+</linearGradient>
+</defs>
+</svg>

+ 9 - 0
dashboard/src/assets/compliance-grad.svg

@@ -0,0 +1,9 @@
+<svg width="28" height="34" viewBox="0 0 28 34" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10 15.7018L13 18.578L19 12.8256M2 7.07334L11.3167 2.60729C13.0059 1.79757 14.9941 1.79757 16.6833 2.60729L26 7.07334C26 7.07334 26 15.4717 26 19.901C26 24.3303 21.7293 27.3202 14 32C6.27067 27.3202 2 23.3715 2 19.901V7.07334Z" stroke="url(#paint0_linear_1298_6)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
+<defs>
+<linearGradient id="paint0_linear_1298_6" x1="2" y1="2" x2="45" y2="64.5" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849" stop-opacity="0"/>
+</linearGradient>
+</defs>
+</svg>

+ 9 - 0
dashboard/src/assets/database-grad.svg

@@ -0,0 +1,9 @@
+<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M14.75 4.75C14.75 6.82107 11.672 8.5 7.875 8.5C4.07804 8.5 1 6.82107 1 4.75M14.75 4.75C14.75 2.67893 11.672 1 7.875 1C4.07804 1 1 2.67893 1 4.75M14.75 4.75V8.5M1 4.75V8.5M14.75 8.5C14.75 10.5703 11.6719 12.25 7.875 12.25C4.07813 12.25 1 10.5703 1 8.5M14.75 8.5V12.25C14.75 14.3203 11.6719 16 7.875 16C4.07813 16 1 14.3203 1 12.25V8.5" stroke="url(#paint0_linear_849_138)" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<defs>
+<linearGradient id="paint0_linear_849_138" x1="1" y1="1" x2="26" y2="27.5" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849" stop-opacity="0"/>
+</linearGradient>
+</defs>
+</svg>

+ 9 - 0
dashboard/src/assets/env-group-grad.svg

@@ -0,0 +1,9 @@
+<svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.1484 4.85156L5.60156 7.77344M5.60156 10.4766L10.1484 13.3984M6 9.125C6 10.5057 4.88071 11.625 3.5 11.625C2.11929 11.625 1 10.5057 1 9.125C1 7.74429 2.11929 6.625 3.5 6.625C4.88071 6.625 6 7.74429 6 9.125ZM14.75 14.75C14.75 16.1307 13.6307 17.25 12.25 17.25C10.8693 17.25 9.75 16.1307 9.75 14.75C9.75 13.3693 10.8693 12.25 12.25 12.25C13.6307 12.25 14.75 13.3693 14.75 14.75ZM14.75 3.5C14.75 4.88071 13.6307 6 12.25 6C10.8693 6 9.75 4.88071 9.75 3.5C9.75 2.11929 10.8693 1 12.25 1C13.6307 1 14.75 2.11929 14.75 3.5Z" stroke="url(#paint0_linear_851_147)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<defs>
+<linearGradient id="paint0_linear_851_147" x1="3.5" y1="1" x2="19" y2="22" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#464646"/>
+</linearGradient>
+</defs>
+</svg>

+ 29 - 0
dashboard/src/assets/infra-grad.svg

@@ -0,0 +1,29 @@
+<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M29 23C31.7607 23 34 20.7615 34 18C34 15.2385 31.7607 13 29 13" stroke="url(#paint0_linear_1298_26)" stroke-width="2.2"/>
+<path d="M7 23C4.23941 23 2 20.7615 2 18C2 15.2385 4.23941 13 7 13" stroke="url(#paint1_linear_1298_26)" stroke-width="2.2"/>
+<path d="M13 29C13 31.7607 15.2385 34 18 34C20.7613 34 23 31.7607 23 29" stroke="url(#paint2_linear_1298_26)" stroke-width="2.2"/>
+<path d="M13 7C13 4.23941 15.2385 2 18 2C20.7613 2 23 4.23941 23 7" stroke="url(#paint3_linear_1298_26)" stroke-width="2.2"/>
+<path d="M11 18C11 14.115 14.1146 11 18.0009 11C21.8854 11 25 14.115 25 18C25 21.885 21.8854 25 18.0009 25C14.1146 25 11 21.885 11 18Z" stroke="url(#paint4_linear_1298_26)" stroke-width="2.2"/>
+<defs>
+<linearGradient id="paint0_linear_1298_26" x1="5.5" y1="2.5" x2="41.273" y2="50.416" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849"/>
+</linearGradient>
+<linearGradient id="paint1_linear_1298_26" x1="7" y1="4" x2="32.5" y2="35" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849"/>
+</linearGradient>
+<linearGradient id="paint2_linear_1298_26" x1="1.5" y1="2.5" x2="43.5" y2="55" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849"/>
+</linearGradient>
+<linearGradient id="paint3_linear_1298_26" x1="6.5" y1="2" x2="33.5" y2="34" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849"/>
+</linearGradient>
+<linearGradient id="paint4_linear_1298_26" x1="11" y1="6.5" x2="29.5" y2="35.5" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849"/>
+</linearGradient>
+</defs>
+</svg>

+ 9 - 0
dashboard/src/assets/integration-grad.svg

@@ -0,0 +1,9 @@
+<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M16.9042 16.9032V2C19.5158 2.0003 22.0812 2.68676 24.3441 3.99065C26.6067 5.29454 28.4872 7.17009 29.7967 9.42937C31.1065 11.6887 31.7994 14.2525 31.8064 16.8639C31.8134 19.4753 31.1339 22.0426 29.8361 24.3089C28.5384 26.575 26.6679 28.4606 24.4122 29.7762C22.1564 31.0921 19.5945 31.7921 16.9829 31.8062C14.3713 31.8203 11.802 31.1479 9.5321 29.8565C7.26222 28.5651 5.37156 26.7 4.04945 24.448M29.8055 9.45161L4.00287 24.3548M2.24855 19.6355C2.07911 18.7347 1.99592 17.8198 2.00015 16.9032C1.99819 13.821 2.95294 10.8142 4.73267 8.2977C6.51239 5.7812 9.02932 3.87908 11.9362 2.85384V14.0312L2.24855 19.6355Z" stroke="url(#paint0_linear_1298_9)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
+<defs>
+<linearGradient id="paint0_linear_1298_9" x1="2" y1="2" x2="34.5" y2="39" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849"/>
+</linearGradient>
+</defs>
+</svg>

Diferenças do arquivo suprimidas por serem muito extensas
+ 2 - 0
dashboard/src/assets/pr-grad.svg


+ 14 - 0
dashboard/src/assets/settings-grad.svg

@@ -0,0 +1,14 @@
+<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M18 26C22.4183 26 26 22.4183 26 18C26 13.5817 22.4183 10 18 10C13.5817 10 10 13.5817 10 18C10 22.4183 13.5817 26 18 26Z" stroke="url(#paint0_linear_1298_13)" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M26.8766 7.97612C27.2803 8.34796 27.6628 8.73043 28.0241 9.12353L32.3746 9.74503C33.0835 10.9761 33.6298 12.2939 34 13.6654L31.3547 17.1873C31.3547 17.1873 31.4025 18.271 31.3547 18.8128L34 22.3347C33.6316 23.7067 33.0851 25.0247 32.3746 26.2551L28.0241 26.8766C28.0241 26.8766 27.275 27.6575 26.8766 28.0241L26.2551 32.3746C25.0241 33.0835 23.7063 33.6298 22.3347 34L18.8128 31.3547C18.272 31.4025 17.7281 31.4025 17.1873 31.3547L13.6654 34C12.2933 33.6316 10.9753 33.0851 9.74503 32.3746L9.12353 28.0241C8.73043 27.6522 8.34796 27.2697 7.97612 26.8766L3.62549 26.2551C2.91663 25.0241 2.37025 23.7063 2 22.3347L4.64544 18.8128C4.64544 18.8128 4.59762 17.7291 4.64544 17.1873L2 13.6654C2.36854 12.2933 2.91499 10.9753 3.62549 9.74503L7.97612 9.12353C8.34796 8.73043 8.73043 8.34796 9.12353 7.97612L9.74503 3.62549C10.9761 2.91663 12.2939 2.37025 13.6654 2L17.1873 4.64544C17.7281 4.5976 18.272 4.5976 18.8128 4.64544L22.3347 2C23.7067 2.36854 25.0247 2.91499 26.2551 3.62549L26.8766 7.97612Z" stroke="url(#paint1_linear_1298_13)" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
+<defs>
+<linearGradient id="paint0_linear_1298_13" x1="10" y1="6" x2="29" y2="35" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849"/>
+</linearGradient>
+<linearGradient id="paint1_linear_1298_13" x1="2" y1="2" x2="34" y2="42" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849"/>
+</linearGradient>
+</defs>
+</svg>

+ 40 - 0
dashboard/src/components/porter/Clickable.tsx

@@ -0,0 +1,40 @@
+import React from "react";
+import styled from "styled-components";
+import Container from "./Container";
+
+type Props = {
+  children: React.ReactNode;
+  row?: boolean;
+  column?: boolean;
+  spaced?: boolean;
+  alignItems?: string;
+  style?: React.CSSProperties;
+  onClick?: () => void;
+};
+
+const Clickable: React.FC<Props> = ({
+  children,
+  style,
+  onClick,
+}) => {
+  return (
+    <StyledClickable onClick={onClick} style={style}>
+      {children}
+    </StyledClickable>
+  );
+};
+
+export default Clickable;
+
+const StyledClickable = styled.div`
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid ${(props) => props.theme.border};
+  border-radius: 5px;
+  font-size: 13px;
+  padding: 20px;
+  cursor: pointer;
+  transition: all 0.2s;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;

+ 3 - 2
dashboard/src/components/porter/ControlledInput.tsx

@@ -136,11 +136,12 @@ const StyledInput = styled.input<{
   height: ${(props) => props.height || "35px"};
   padding: 5px 10px;
   width: ${(props) => props.width || "200px"};
-  color: ${(props) => (props.disabled ? "#aaaabb" : "#ffffff")};
+  color: ${(props) => (props.disabled ? "#aaaabb" : "#fefefe")};
   font-size: 13px;
   outline: none;
+  transition: all 0.2s;
   border-radius: 5px;
-  background: #26292e;
+  background: ${(props) => props.theme.fg};
   cursor: ${(props) => (props.disabled ? "not-allowed" : "")};
   border: 1px solid ${(props) => (props.hasError ? "#ff3b62" : "#494b4f")};
   ${(props) =>

+ 1 - 9
dashboard/src/components/porter/Error.tsx

@@ -26,8 +26,7 @@ export const Error: React.FC<Props> = ({
       <StyledError maxWidth={maxWidth}>
         <i className="material-icons">error_outline</i>
         <Block>
-          <Bold>Error:</Bold>
-          <Text>{message}</Text>
+          <Text>Error: {message}</Text>
           {ctaText && (errorModalContents != null || ctaOnClick != null) && (
             <Cta onClick={() => {
               errorModalContents ? setErrorModalOpen(true) : ctaOnClick();
@@ -73,12 +72,6 @@ const Cta = styled.span`
   }
 `;
 
-const Bold = styled.span`
-  font-weight: 600;
-  display: inline-block;
-  margin-right: 5px;
-`;
-
 const StyledError = styled.div<{ maxWidth?: string }>`
   line-height: 1.5;
   color: #ff385d;
@@ -92,7 +85,6 @@ const StyledError = styled.div<{ maxWidth?: string }>`
     margin-top: -1px;
     margin-right: 7px;
     float: left;
-    font-weight: 600;
     position: absolute;
     top: 1px;
     left: 0;

+ 1 - 0
dashboard/src/components/porter/Input.tsx

@@ -139,6 +139,7 @@ const StyledInput = styled.input<{
   border-radius: 5px;
   background: #26292e;
   cursor: ${(props) => (props.disabled ? "not-allowed" : "")};
+  transition: all 0.2s;
 
   border: 1px solid ${(props) => (props.hasError ? "#ff3b62" : "#494b4f")};
   ${(props) =>

+ 5 - 1
dashboard/src/components/porter/SearchBar.tsx

@@ -15,6 +15,7 @@ type Props = {
   children?: React.ReactNode;
   autoFocus?: boolean;
   onEnter?: () => void;
+  style?: React.CSSProperties;
 };
 
 const SearchBar: React.FC<Props> = ({
@@ -29,15 +30,17 @@ const SearchBar: React.FC<Props> = ({
   children,
   autoFocus,
   onEnter,
+  style,
 }) => {
   return (
-    <Block width={width}>
+    <Block width={width} style={style}>
       {
         label && (
           <Label>{label}</Label>
         )
       }
       <StyledSearchBar
+        style={style}
         width={width}
         height={height}
         hasError={(error && true) || (error === "")}
@@ -122,6 +125,7 @@ const StyledSearchBar = styled.div<{
   font-size: 13px;
   border-radius: 5px;
   background: ${props => props.theme.fg};
+  transition: all 0.2s;
 
   border: 1px solid ${props => props.hasError ? "#ff3b62" : "#494b4f"};
   :hover {

+ 44 - 68
dashboard/src/components/porter/Select.tsx

@@ -7,7 +7,6 @@ import Container from "./Container";
 import Spacer from "./Spacer";
 
 type Props = {
-  width?: string;
   options: Array<{
     label: string;
     value: string;
@@ -16,9 +15,7 @@ type Props = {
   }>;
   label?: string | React.ReactNode;
   labelColor?: string;
-  height?: string;
   error?: string;
-  children?: React.ReactNode;
   disabled?: boolean;
   value?: string;
   setValue?: (value: string) => void;
@@ -30,48 +27,40 @@ const Select: React.FC<Props> = ({
   label,
   labelColor,
   error,
-  children,
   disabled,
   value,
   setValue,
   prefix,
-  width = "200px",
-  height = "35px",
 }) => {
   return (
-    <Block width={width}>
+    <div>
       {label && <Label color={labelColor}>{label}</Label>}
       <SelectWrapper>
-        <AbsoluteWrapper>
-          <Spacer inline width="10px" />
-          {prefix && (
-            <>
-              <Prefix>{prefix}</Prefix>
-              <Bar />
-            </>
-          )}
-          {options.map((option) => {
-            if (option.value === value) {
-              return (
-                <Container key={1} row>
-                  {option.icon && <Img src={option?.icon} />}
-                  {option.label}
-                </Container>
-              );
-            }
-            return null;
-          })}
-          <img src={arrow} />
-        </AbsoluteWrapper>
-        <StyledSelect
+        {prefix && (
+          <>
+            <Prefix>{prefix}</Prefix>
+            <Bar />
+          </>
+        )}
+        {options.map((option) => {
+          if (option.value === value) {
+            return (
+              <Container key={1} row>
+                {option.icon && <Img src={option?.icon} />}
+                {option.label}
+              </Container>
+            );
+          }
+          return null;
+        })}
+        <img src={arrow} />
+        <SelectLayer
+          value={value}
           onChange={(e) => {
             setValue?.(e.target.value);
           }}
-          width={width}
-          height={height}
           hasError={(error && true) || error === ""}
           disabled={disabled || false}
-          value={value}
         >
           {options.map((option, i) => {
             return (
@@ -80,7 +69,7 @@ const Select: React.FC<Props> = ({
               </option>
             );
           })}
-        </StyledSelect>
+        </SelectLayer>
       </SelectWrapper>
       {error && (
         <Error>
@@ -88,8 +77,7 @@ const Select: React.FC<Props> = ({
           {error}
         </Error>
       )}
-      {children}
-    </Block>
+    </div>
   );
 };
 
@@ -100,21 +88,6 @@ const Img = styled.img`
   margin-right: 10px;
 `;
 
-const AbsoluteWrapper = styled.div`
-  position: absolute;
-  display: flex;
-  align-items: center;
-  width: 100%;
-  height: 100%;
-  > img {
-    width: 8px;
-    position: absolute;
-    right: 10px;
-    top: calc(50% - 3px);
-    z-index: -1;
-  }
-`;
-
 const Bar = styled.div`
   width: 1px;
   height: 15px;
@@ -131,14 +104,6 @@ const Prefix = styled.div`
   z-index: -1;
 `;
 
-const Block = styled.div<{
-  width: string;
-}>`
-  display: block;
-  position: relative;
-  width: ${(props) => props.width || "200px"};
-`;
-
 const Label = styled.div<{ color?: string }>`
   font-size: 13px;
   color: ${({ color = "#aaaabb" }) => color};
@@ -160,6 +125,10 @@ const Error = styled.div`
 
 const SelectWrapper = styled.div`
   position: relative;
+  padding-left: 10px;
+  padding-right: 28px;
+  height: 30px;
+  transition: all 0.2s;
   background: ${(props) => props.theme.fg};
   border: 1px solid #494b4f;
   :hover {
@@ -171,24 +140,31 @@ const SelectWrapper = styled.div`
   border-radius: 5px;
   font-size: 13px;
   overflow: hidden;
+
+  display: flex;
+  align-items: center;
+  > img {
+    width: 8px;
+    position: absolute;
+    right: 10px;
+    top: calc(50% - 3px);
+    z-index: -1;
+  }
 `;
 
-const StyledSelect = styled.select<{
-  width: string;
-  height: string;
+const SelectLayer = styled.select<{
+  disabled?: boolean;
   hasError: boolean;
 }>`
-  height: ${(props) => props.height};
-  padding: 5px 10px;
-  width: ${(props) => props.width};
-  color: #ffffff;
-  font-size: 13px;
   outline: none;
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
   cursor: pointer;
-  border-radius: 5px;
   background: none;
   appearance: none;
-  overflow: hidden;
   opacity: 0;
   z-index: 1;
 `;

+ 2 - 1
dashboard/src/components/porter/Toggle.tsx

@@ -53,6 +53,7 @@ const Item = styled.div<{ active: boolean; activeColor?: string; inactiveColor?:
   cursor: pointer;
   justify-content: center;
   padding: 10px;
+  opacity: ${(props) => props.active ? "1" : "0.4"};
   background: ${(props) =>
     props.active ? props.activeColor ?? "#ffffff11" : props.inactiveColor ?? "transparent"};
-`;
+`;

+ 24 - 0
dashboard/src/lib/env-groups/types.ts

@@ -0,0 +1,24 @@
+import { z } from "zod";
+
+export const envGroupFormValidator = z.object({
+  name: z
+    .string()
+    .min(1, { message: "A service name is required" })
+    .max(30)
+    .regex(/^[a-z0-9-]+$/, {
+      message: 'Lowercase letters, numbers, and " - " only.',
+    }),
+  envVariables: z
+    .array(
+      z.object({
+        key: z.string().min(1, { message: "Key cannot be empty" }),
+        value: z.string().min(1, { message: "Value cannot be empty" }),
+        deleted: z.boolean(),
+        hidden: z.boolean(),
+        locked: z.boolean(),
+      })
+    )
+    .min(1, { message: "At least one environment variable is required" })
+});
+
+export type EnvGroupFormData = z.infer<typeof envGroupFormValidator>;

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

@@ -38,6 +38,7 @@ import NewAddOnFlow from "./add-on-dashboard/NewAddOnFlow";
 import AppView from "./app-dashboard/app-view/AppView";
 import AppDashboard from "./app-dashboard/AppDashboard";
 import Apps from "./app-dashboard/apps/Apps";
+import CreateEnvGroup from "./env-dashboard/CreateEnvGroup";
 import CreateApp from "./app-dashboard/create-app/CreateApp";
 import ExpandedApp from "./app-dashboard/expanded-app/ExpandedApp";
 import NewAppFlow from "./app-dashboard/new-app-flow/NewAppFlow";
@@ -58,6 +59,8 @@ import { NewProjectFC } from "./new-project/NewProject";
 import Onboarding from "./onboarding/Onboarding";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
+import ExpandedEnv from "./env-dashboard/ExpandedEnv";
+import EnvDashboard from "./env-dashboard/EnvDashboard";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -472,6 +475,19 @@ const Home: React.FC<Props> = (props) => {
                   )}
                 </Route>
 
+                <Route path="/environment-groups/new">
+                  <CreateEnvGroup />
+                </Route>
+                <Route path="/environment-groups/:envGroupName/:tab">
+                  <ExpandedEnv />
+                </Route>
+                <Route path="/environment-groups/:envGroupName">
+                  <ExpandedEnv />
+                </Route>
+                <Route path="/environment-groups">
+                  <EnvDashboard />
+                </Route>
+
                 <Route path="/datastores/new/:type/:engine">
                   <CreateDatabase />
                 </Route>

+ 4 - 5
dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx

@@ -8,7 +8,7 @@ import React, {
 import styled from "styled-components";
 import _ from "lodash";
 
-import addOn from "assets/add-ons.svg";
+import addOnGrad from "assets/add-on-grad.svg";
 import time from "assets/time.png";
 import healthy from "assets/status-healthy.png";
 import grid from "assets/grid.png";
@@ -32,7 +32,6 @@ import { readableDate } from "shared/string_utils";
 import Loading from "components/Loading";
 import { Link } from "react-router-dom";
 import Fieldset from "components/porter/Fieldset";
-import Select from "components/porter/Select";
 import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
 import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
 import { useAuthState } from "main/auth/context";
@@ -150,7 +149,7 @@ const AddOnDashboard: React.FC<Props> = ({
   return (
     <StyledAppDashboard>
       <DashboardHeader
-        image={addOn}
+        image={addOnGrad}
         title="Add-ons"
         capitalize={false}
         description="Add-ons and supporting workloads for this project."
@@ -166,7 +165,7 @@ const AddOnDashboard: React.FC<Props> = ({
             (<Loading offset="-150px" />) : (
               <DashboardPlaceholder>
                 <Text size={16}>
-                  No add-ons have been deployed yet
+                  No add-ons have been created yet
                 </Text>
                 <Spacer y={0.5} />
                 <Text color={"helper"}>
@@ -175,7 +174,7 @@ const AddOnDashboard: React.FC<Props> = ({
                 <Spacer y={1} />
                 <Link to="/addons/new">
                   <Button alt onClick={() => { }} height="35px" >
-                    Deploy add-ons <Spacer inline x={1} /> <i className="material-icons" style={{ fontSize: '18px' }}>east</i>
+                    Deploy a new add-on <Spacer inline x={1} /> <i className="material-icons" style={{ fontSize: '18px' }}>east</i>
                   </Button>
                 </Link>
               </DashboardPlaceholder>

+ 3 - 3
dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx

@@ -4,7 +4,7 @@ import DashboardHeader from "../cluster-dashboard/DashboardHeader";
 import semver from "semver";
 import _ from "lodash";
 
-import addOn from "assets/add-ons.svg";
+import addOnGrad from "assets/add-on-grad.svg";
 import notFound from "assets/not-found.png";
 
 import { Context } from "shared/Context";
@@ -148,8 +148,8 @@ const NewAddOnFlow: React.FC<Props> = ({
           <>
             <Back to="/addons" />
             <DashboardHeader
-              image={addOn}
-              title="Deploy a new add-on"
+              image={addOnGrad}
+              title="Create a new add-on"
               capitalize={false}
               description="Select an add-on to deploy to this project."
               disableLineBreak

+ 1 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx

@@ -70,7 +70,7 @@ const Environment: React.FC<Props> = ({ latestSource, buttonStatus }) => {
         latestSource={latestSource}
         attachedEnvGroups={attachedEnvGroups}
       />
-      <Spacer y={0.5} />
+      <Spacer y={1} />
       <AppSaveButton
         status={buttonStatus}
         isDisabled={isSubmitting}

+ 1 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/ImageSettingsTab.tsx

@@ -50,7 +50,7 @@ const ImageSettingsTab: React.FC<Props> = ({ buttonStatus }) => {
             setValue("source.image", { repository: "", tag: "" });
           }}
         />
-        <Spacer y={1} />
+        <Spacer y={1.4} />
         <AppSaveButton
           status={buttonStatus}
           isDisabled={

+ 2 - 0
dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx

@@ -232,6 +232,7 @@ export const Block = styled.div<{ locked?: boolean; appId?: string }>`
   padding: 20px;
   color: ${(props) => props.theme.text.primary};
   position: relative;
+  transition: all 0.2s;
   border-radius: 5px;
   background: ${(props) =>
     props.locked ? props.theme.fg : props.theme.clickable.bg};
@@ -280,6 +281,7 @@ export const Row = styled.div<{ isAtBottom?: boolean; locked?: boolean }>`
   border-radius: 5px;
   margin-bottom: 15px;
   animation: fadeIn 0.3s 0s;
+  transition: all 0.2s;
 `;
 
 const SmallIcon = styled.img<{ opacity?: string; height?: string }>`

+ 6 - 10
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -28,7 +28,7 @@ import grid from "assets/grid.png";
 import list from "assets/list.png";
 import pull_request from "assets/pull_request_icon.svg";
 import letter from "assets/vector.svg";
-import web from "assets/web.png";
+import applicationGrad from "assets/application-grad.svg";
 
 import AppGrid from "./AppGrid";
 import { appRevisionWithSourceValidator } from "./types";
@@ -204,9 +204,9 @@ const Apps: React.FC = () => {
     if (apps.length === 0) {
       return (
         <DashboardPlaceholder>
-          <Text size={16}>No apps have been deployed yet</Text>
+          <Text size={16}>No applications have been created yet</Text>
           <Spacer y={0.5} />
-          <Text color={"helper"}>Get started by deploying your app.</Text>
+          <Text color={"helper"}>Get started by creating an application.</Text>
           <Spacer y={1} />
           <PorterLink to="/apps/new/app">
             <Button
@@ -216,7 +216,7 @@ const Apps: React.FC = () => {
               }}
               height="35px"
             >
-              Deploy app <Spacer inline x={1} />{" "}
+              Create a new application <Spacer inline x={1} />{" "}
               <i className="material-icons" style={{ fontSize: "18px" }}>
                 east
               </i>
@@ -273,8 +273,6 @@ const Apps: React.FC = () => {
                 setSort("letter");
               }
             }}
-            inactiveColor={"#ffffff11"}
-            activeColor={"transparent"}
           />
           <Spacer inline x={1} />
           <Toggle
@@ -290,8 +288,6 @@ const Apps: React.FC = () => {
                 setView("list");
               }
             }}
-            inactiveColor={"#ffffff11"}
-            activeColor={"transparent"}
           />
           <Spacer inline x={2} />
           {currentDeploymentTarget?.is_preview ? (
@@ -335,7 +331,7 @@ const Apps: React.FC = () => {
     <StyledAppDashboard>
       {!currentDeploymentTarget?.is_preview && (
         <DashboardHeader
-          image={web}
+          image={applicationGrad}
           title="Applications"
           description="Web services, workers, and jobs for this project."
           disableLineBreak
@@ -386,4 +382,4 @@ const Badge = styled.div`
   text-align: center;
   border-radius: 3px;
   font-size: 12px;
-`;
+`;

+ 22 - 20
dashboard/src/main/home/app-dashboard/apps/SelectableAppList.tsx

@@ -43,20 +43,21 @@ const SelectableAppRow: React.FC<SelectableAppRowProps> = ({
       }}
       isHoverable={onSelect != null || onDeselect != null}
     >
-      <div>
-        <Container row>
-          <Spacer inline width="1px" />
-          <AppIcon buildpacks={proto.build?.buildpacks ?? []} />
-          <Spacer inline width="12px" />
-          <Text size={14}>{proto.name}</Text>
-          <Spacer inline x={1} />
-        </Container>
-        <Spacer height="15px" />
-        <Container row>
-          <AppSource source={app.source} />
-          <Spacer inline x={1} />
-        </Container>
-      </div>
+      <Container row>
+        <Spacer inline width="1px" />
+        <AppIcon 
+          buildpacks={proto.build?.buildpacks ?? []} 
+          size="larger"
+        />
+        <Spacer inline width="12px" />
+        <Text size={14}>{proto.name}</Text>
+        <Spacer inline x={1} />
+      </Container>
+      <Spacer height="15px" />
+      <Container row>
+        <AppSource source={app.source} />
+        <Spacer inline x={1} />
+      </Container>
       {selected && <Icon height="18px" src={healthy} />}
     </ResourceOption>
   );
@@ -94,10 +95,11 @@ export default SelectableAppList;
 
 const StyledSelectableAppList = styled.div`
   display: flex;
-  row-gap: 10px;
+  row-gap: 15px;
   flex-direction: column;
   max-height: 400px;
-  overflow-y: scroll;
+  overflow-y: auto;
+  transition: all 0.2s;
 `;
 
 const ResourceOption = styled.div<{ selected?: boolean; isHoverable: boolean }>`
@@ -105,11 +107,11 @@ const ResourceOption = styled.div<{ selected?: boolean; isHoverable: boolean }>`
   border: 1px solid
     ${(props) => (props.selected ? "#ffffff" : props.theme.border)};
   width: 100%;
-  padding: 10px 15px;
+  padding: 15px;
+  margin-botton: 15px;
   border-radius: 5px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
+  animation: fadeIn 0.3s 0s;
+  transition: all 0.2s;
   ${(props) => props.isHoverable && "cursor: pointer;"}
   ${(props) =>
     props.isHoverable &&

+ 5 - 5
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -44,7 +44,7 @@ import api from "shared/api";
 import { useClusterResources } from "shared/ClusterResourcesContext";
 import { Context } from "shared/Context";
 import { valueExists } from "shared/util";
-import web from "assets/web.png";
+import applicationGrad from "assets/application-grad.svg";
 
 import ImageSettings from "../image-settings/ImageSettings";
 import GithubActionModal from "../new-app-flow/GithubActionModal";
@@ -225,7 +225,6 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   }, [defaultDeploymentTarget]);
 
   const resetAllExceptName = (): void => {
-    setIsNameHighlight(true);
 
     // Get the current name value before the reset
     setStep(0);
@@ -436,6 +435,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
       setIsNameHighlight(false); // Reset highlight when the name is valid
       setStep((prev) => Math.max(prev, 1));
     } else {
+      setIsNameHighlight(true);
       resetAllExceptName();
     }
 
@@ -593,8 +593,8 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         <StyledConfigureTemplate>
           <Back to="/apps" />
           <DashboardHeader
-            prefix={<Icon src={web} />}
-            title="Deploy a new application"
+            prefix={<Icon src={applicationGrad} />}
+            title="Create a new application"
             capitalize={false}
             disableLineBreak
           />
@@ -607,7 +607,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                   <>
                     <Text size={16}>Application name</Text>
                     <Spacer y={0.5} />
-                    <Text color={isNameHighlight ? "#FFCC00" : "helper"}>
+                    <Text color={isNameHighlight && porterAppFormMethods.getValues("app.name.value").length > 0 ? "#FFCC00" : "helper"}>
                       Lowercase letters, numbers, and &quot;-&quot; only.
                     </Text>
                     <Spacer y={0.5} />

+ 3 - 3
dashboard/src/main/home/app-dashboard/new-app-flow/GithubActionModal.tsx

@@ -157,8 +157,7 @@ const GithubActionModal: React.FC<Props> = ({
           <Select
             options={[
               {
-                label:
-                  "I authorize Porter to open a PR on my behalf (recommended)",
+                label: "I authorize Porter to open a PR on my behalf (recommended)",
                 value: "open_pr",
               },
               {
@@ -166,6 +165,7 @@ const GithubActionModal: React.FC<Props> = ({
                 value: "copy",
               },
             ]}
+            value={choice}
             setValue={(x: string) => {
               setChoice(x as Choice);
             }}
@@ -228,4 +228,4 @@ const ModalHeader = styled.div`
   height: 40px;
   display: flex;
   align-items: center;
-`;
+`;

+ 1 - 1
dashboard/src/main/home/app-dashboard/new-app-flow/SourceSelector.tsx

@@ -56,7 +56,7 @@ const Block = styled.div<{ selected?: boolean }>`
   cursor: pointer;
   color: #ffffff;
   position: relative;
-
+  transition: all 0.2s;
   border-radius: 5px;
   background: ${props => props.theme.clickable.bg};
   border: ${props => props.selected ? "2px solid #8590ff" : "1px solid #494b4f"};

+ 60 - 70
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroupModal.tsx

@@ -55,73 +55,67 @@ const EnvGroupModal: React.FC<Props> = ({ append, setOpen, baseEnvGroups }) => {
     <Modal closeModal={() => { setOpen(false); }}>
       <Text size={16}>Load env group</Text>
       <Spacer height="15px" />
-      <ColumnContainer>
-        <ScrollableContainer>
-          {remainingEnvGroupOptions.length ? (
-            <>
-              <Text color="helper">
-                Select an Env Group to load into your application.
-              </Text>
-              <Spacer y={1} />
-              <GroupModalSections>
-                <SidebarSection $expanded={!selectedEnvGroup}>
-                  <EnvGroupList>
-                    {remainingEnvGroupOptions.map((eg, i) => (
-                      <EnvGroupRow
-                        key={eg.name}
-                        isSelected={
-                          Boolean(selectedEnvGroup) &&
-                          selectedEnvGroup?.name === eg.name
-                        }
-                        lastItem={i === remainingEnvGroupOptions?.length - 1}
-                        onClick={() => {
-                          setSelectedEnvGroup(eg);
-                        }}
-                      >
-                        {eg.type === "doppler" ? (
-                          <img src={doppler} />
-                            ) : (
-                        <img src={sliders} />
-                        )}
-                        {eg.name}
-                      </EnvGroupRow>
+      {remainingEnvGroupOptions.length ? (
+        <>
+          <Text color="helper">
+            Select an Env Group to load into your application.
+          </Text>
+          <Spacer y={1} />
+          <GroupModalSections>
+            <SidebarSection $expanded={!selectedEnvGroup}>
+              <EnvGroupList>
+                {remainingEnvGroupOptions.map((eg, i) => (
+                  <EnvGroupRow
+                    key={eg.name}
+                    isSelected={
+                      Boolean(selectedEnvGroup) &&
+                      selectedEnvGroup?.name === eg.name
+                    }
+                    lastItem={i === remainingEnvGroupOptions?.length - 1}
+                    onClick={() => {
+                      setSelectedEnvGroup(eg);
+                    }}
+                  >
+                    {eg.type === "doppler" ? (
+                      <img src={doppler} />
+                        ) : (
+                    <img src={sliders} />
+                    )}
+                    {eg.name}
+                  </EnvGroupRow>
+                ))}
+              </EnvGroupList>
+            </SidebarSection>
+            {selectedEnvGroup && (
+              <>
+                <SidebarSection>
+                  <GroupEnvPreview>
+                    {Object.entries(selectedEnvGroup?.variables || {}).map(
+                      ([key, value]) => (
+                        <div key={key}>
+                          <span className="key">{key} = </span>
+                          <span className="value">{value}</span>
+                        </div>
+                      )
+                    )}
+                    {Object.entries(
+                      selectedEnvGroup?.secret_variables || {}
+                    ).map(([key, value]) => (
+                      <div key={key}>
+                        <span className="key">{key} = </span>
+                        <span className="value">{value}</span>
+                      </div>
                     ))}
-                  </EnvGroupList>
+                  </GroupEnvPreview>
                 </SidebarSection>
-                {selectedEnvGroup && (
-                  <>
-                    <SidebarSection>
-                      <GroupEnvPreview>
-                        {Object.entries(selectedEnvGroup?.variables || {}).map(
-                          ([key, value]) => (
-                            <div key={key}>
-                              <span className="key">{key} = </span>
-                              <span className="value">{value}</span>
-                            </div>
-                          )
-                        )}
-                        {Object.entries(
-                          selectedEnvGroup?.secret_variables || {}
-                        ).map(([key, value]) => (
-                          <div key={key}>
-                            <span className="key">{key} = </span>
-                            <span className="value">{value}</span>
-                          </div>
-                        ))}
-                      </GroupEnvPreview>
-                    </SidebarSection>
-                  </>
-                )}
-              </GroupModalSections>
-              <Spacer y={1} />
-
-              <Spacer y={1} />
-            </>
-          ) : (
-            <Text>No selectable Env Groups</Text>
-          )}
-        </ScrollableContainer>
-      </ColumnContainer>
+              </>
+            )}
+          </GroupModalSections>
+        </>
+      ) : (
+        <Text>No selectable Env Groups</Text>
+      )}
+      <Spacer y={1} />
       <Button onClick={onSubmit} disabled={!selectedEnvGroup}>
         Load env group
       </Button>
@@ -162,6 +156,7 @@ const EnvGroupList = styled.div`
   background: #ffffff11;
   border: 1px solid #ffffff44;
   overflow-y: auto;
+  max-height: 340px;
 `;
 
 const SidebarSection = styled.section<{ $expanded?: boolean }>`
@@ -195,11 +190,6 @@ const GroupModalSections = styled.div`
   grid-template-columns: 1fr 1fr;
   max-height: 365px;
 `;
-const ColumnContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  align-items: stretch;
-`;
 
 const ScrollableContainer = styled.div`
   flex: 1;

+ 10 - 12
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx

@@ -165,18 +165,16 @@ const BuildpackSettings: React.FC<Props> = ({
           control={control}
           name="app.build.builder"
           render={({ field: { onChange } }) => (
-            <>
-              <Select
-                value={build.builder}
-                width="300px"
-                options={builderOptions}
-                setValue={(val) => {
-                  onChange(val);
-                }}
-                label={"Builder"}
-                labelColor="#DFDFE1"
-              />
-            </>
+            <Select
+              value={build.builder}
+              width="300px"
+              options={builderOptions}
+              setValue={(val) => {
+                onChange(val);
+              }}
+              label={"Builder"}
+              labelColor="#DFDFE1"
+            />
           )}
         />
       )}

+ 1 - 0
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx

@@ -265,6 +265,7 @@ const ServiceHeader = styled.div<{
   padding: 20px;
   color: ${(props) => props.theme.text.primary};
   position: relative;
+  transition: all 0.2s;
   border-radius: 5px;
   background: ${(props) => props.theme.clickable.bg};
   border: 1px solid #494b4f;

+ 1 - 0
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx

@@ -368,6 +368,7 @@ const AddServiceButton = styled.div`
   display: flex;
   align-items: center;
   border-radius: 5px;
+  transition: all 0.2s;
   height: 40px;
   font-size: 13px;
   width: 100%;

+ 3 - 54
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -13,6 +13,7 @@ import useAuth from "shared/auth/useAuth";
 import { Context } from "shared/Context";
 import { getQueryParam } from "shared/routing";
 import editIcon from "assets/edit-button.svg";
+import infraGrad from "assets/infra-grad.svg";
 
 import DashboardHeader from "../DashboardHeader";
 import ClusterRevisionSelector from "./ClusterRevisionSelector";
@@ -22,6 +23,7 @@ import Metrics from "./Metrics";
 import { NamespaceList } from "./NamespaceList";
 import NodeList from "./NodeList";
 import ProvisionerStatus from "./ProvisionerStatus";
+import Image from "components/porter/Image";
 
 type TabEnum =
   | "nodes"
@@ -231,60 +233,7 @@ export const Dashboard: React.FunctionComponent = () => {
         title={
           <Flex>
             <Flex>
-              <svg
-                width="23"
-                height="23"
-                viewBox="0 0 19 19"
-                fill="none"
-                xmlns="http://www.w3.org/2000/svg"
-              >
-                <path
-                  d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
-                  stroke="white"
-                  strokeWidth="1.5"
-                  strokeLinecap="round"
-                  strokeLinejoin="round"
-                />
-                <path
-                  d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
-                  stroke="white"
-                  strokeWidth="1.5"
-                  strokeLinecap="round"
-                  strokeLinejoin="round"
-                />
-                <path
-                  fillRule="evenodd"
-                  clipRule="evenodd"
-                  d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
-                  stroke="white"
-                  strokeWidth="1.5"
-                  strokeLinecap="round"
-                  strokeLinejoin="round"
-                />
-                <path
-                  d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
-                  stroke="white"
-                  strokeWidth="1.5"
-                  strokeLinecap="round"
-                  strokeLinejoin="round"
-                />
-                <path
-                  d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
-                  stroke="white"
-                  strokeWidth="1.5"
-                  strokeLinecap="round"
-                  strokeLinejoin="round"
-                />
-                <path
-                  fillRule="evenodd"
-                  clipRule="evenodd"
-                  d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
-                  stroke="white"
-                  strokeWidth="1.5"
-                  strokeLinecap="round"
-                  strokeLinejoin="round"
-                />
-              </svg>
+              <Image size={25} src={infraGrad} />
               <Spacer inline />
               {context.currentCluster.vanity_name ||
                 context.currentCluster.name}

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx

@@ -7,7 +7,7 @@ import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 import { useDeploymentTargetList } from "lib/hooks/useDeploymentTarget";
 
-import PullRequestIcon from "assets/pull_request_icon.svg";
+import prGrad from "assets/pr-grad.svg";
 
 import DashboardHeader from "../../DashboardHeader";
 import { ConfigurableAppList } from "./ConfigurableAppList";
@@ -115,7 +115,7 @@ const PreviewEnvs: React.FC = () => {
   return (
     <StyledAppDashboard>
       <DashboardHeader
-        image={PullRequestIcon}
+        image={prGrad}
         title="Preview apps"
         capitalize={false}
         description="Preview apps are created for each pull request. They are automatically deleted when the pull request is closed."

+ 2 - 2
dashboard/src/main/home/compliance-dashboard/ComplianceDashboard.tsx

@@ -6,7 +6,7 @@ import Spacer from "components/porter/Spacer";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 
 import { Context } from "shared/Context";
-import compliance from "assets/compliance.svg";
+import complianceGrad from "assets/compliance-grad.svg";
 
 import { ActionBanner } from "./ActionBanner";
 import { ProjectComplianceProvider } from "./ComplianceContext";
@@ -34,7 +34,7 @@ const ComplianceDashboard: React.FC = () => {
     >
       <StyledComplianceDashboard>
         <DashboardHeader
-          image={compliance}
+          image={complianceGrad}
           title="Compliance"
           description="Configure your Porter infrastructure for various compliance frameworks."
           disableLineBreak

+ 2 - 2
dashboard/src/main/home/database-dashboard/CreateDatabase.tsx

@@ -16,7 +16,7 @@ import {
   type DatastoreTemplate,
 } from "lib/databases/types";
 
-import database from "assets/database.svg";
+import databaseGrad from "assets/database-grad.svg";
 
 import DashboardHeader from "../cluster-dashboard/DashboardHeader";
 import { SUPPORTED_DATASTORE_TEMPLATES } from "./constants";
@@ -61,7 +61,7 @@ const CreateDatabase: React.FC = () => {
           <>
             <Back to="/datastores" />
             <DashboardHeader
-              image={database}
+              image={databaseGrad}
               title="Create a new datastore"
               capitalize={false}
               disableLineBreak

+ 5 - 5
dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx

@@ -27,7 +27,7 @@ import { Context } from "shared/Context";
 import { search } from "shared/search";
 import { readableDate } from "shared/string_utils";
 import engine from "assets/computer-chip.svg";
-import database from "assets/database.svg";
+import databaseGrad from "assets/database-grad.svg";
 import grid from "assets/grid.png";
 import list from "assets/list.png";
 import notFound from "assets/not-found.png";
@@ -161,7 +161,7 @@ const DatabaseDashboard: React.FC = () => {
           <Spacer y={1} />
           <PorterLink to="/datastores/new">
             <Button onClick={() => ({})} height="35px" alt>
-              Create datastore <Spacer inline x={1} />{" "}
+              Create a new datastore <Spacer inline x={1} />{" "}
               <i className="material-icons" style={{ fontSize: "18px" }}>
                 east
               </i>
@@ -203,7 +203,7 @@ const DatabaseDashboard: React.FC = () => {
             }}
             prefix={
               <Container row>
-                <Image src={database} size={15} opacity={0.6} />
+                <Image src={databaseGrad} size={15} opacity={0.6} />
                 <Spacer inline x={0.5} />
                 Type
               </Container>
@@ -290,7 +290,7 @@ const DatabaseDashboard: React.FC = () => {
             <Container row>
               <PlaceholderIcon src={notFound} />
               <Text color="helper">
-                No datastores matching filters were found.
+                No matching datastores were found.
               </Text>
             </Container>
           </Fieldset>
@@ -376,7 +376,7 @@ const DatabaseDashboard: React.FC = () => {
   return (
     <StyledAppDashboard>
       <DashboardHeader
-        image={database}
+        image={databaseGrad}
         title="Datastores"
         description="Storage, caches, and stateful workloads for this project."
         disableLineBreak

+ 1 - 1
dashboard/src/main/home/database-dashboard/forms/DatabaseForm.tsx

@@ -140,7 +140,7 @@ const DatabaseForm: React.FC<Props> = ({
               <Button
                 type="submit"
                 status={submitButtonStatus}
-                loadingText={"Creating..."}
+                loadingText={"Creating . . ."}
                 disabled={isSubmitting || isValidating}
               >
                 Create

+ 272 - 0
dashboard/src/main/home/env-dashboard/CreateEnvGroup.tsx

@@ -0,0 +1,272 @@
+import React, { useState, useEffect, useContext, useMemo } from 'react';
+import styled from 'styled-components';
+import { zodResolver } from "@hookform/resolvers/zod";
+import { FormProvider, useForm } from "react-hook-form";
+import { withRouter, type RouteComponentProps } from "react-router";
+
+import api from 'shared/api';
+import { Context } from 'shared/Context';
+import { type EnvGroupFormData, envGroupFormValidator } from 'lib/env-groups/types';
+
+import envGrad from 'assets/env-group-grad.svg';
+
+import Error from "components/porter/Error";
+import Back from "components/porter/Back";
+import DashboardHeader from '../cluster-dashboard/DashboardHeader';
+import VerticalSteps from 'components/porter/VerticalSteps';
+import Text from 'components/porter/Text';
+import Spacer from 'components/porter/Spacer';
+import { ControlledInput } from 'components/porter/ControlledInput';
+import Button from 'components/porter/Button';
+import EnvGroupArray, { type KeyValueType } from './EnvGroupArray';
+import axios from 'axios';
+
+const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const envGroupFormMethods = useForm<EnvGroupFormData>({
+    resolver: zodResolver(envGroupFormValidator),
+    reValidateMode: "onSubmit",
+    defaultValues: {
+      name: "",
+      envVariables: [
+        {
+          key: "",
+          value: "",
+          hidden: false,
+          locked: false,
+          deleted: false,
+        }
+      ]
+    }
+  });
+
+  const { 
+    formState: { isValidating, isSubmitting, errors },
+    register,
+    watch,
+    trigger,
+    handleSubmit,
+    setValue,
+  } = envGroupFormMethods;
+
+  const [submitErrorMessage, setSubmitErrorMessage] = useState<string>("");
+  const [step, setStep] = React.useState(0);
+  const name = watch("name");
+  const envVariables = watch("envVariables");
+
+  useEffect(() => {
+    const validate = async (): Promise<void> => {
+      const isNameValid = await trigger("name");
+      const isEnvVariablesValid = await trigger("envVariables");
+      if (isNameValid && isEnvVariablesValid) {
+        setStep(2);
+      } else if (isNameValid) {
+        setStep(1);
+      } else {
+        setStep(0);
+      }
+    };
+    void validate();
+  }, [name, envVariables]);
+
+  const onSubmit = handleSubmit(async (data) => {
+    setSubmitErrorMessage("");
+    const apiEnvVariables: Record<string, string> = {};
+    const secretEnvVariables: Record<string, string> = {};
+    const envVariable = data.envVariables;
+    try {
+
+      // Create env group namespace if it doesn't exist
+      const res = await api.getNamespaces(
+        "<token>",
+        {},
+        {
+          id: currentProject?.id ?? -1,
+          cluster_id: currentCluster?.id ?? -1,
+        }
+      );
+      const namespaceExists = res.data.some((n: { name: string }) => n.name === "porter-env-group");
+      if (!namespaceExists) {
+        await api.createNamespace(
+          "<token>",
+          {
+            name: "porter-env-group",
+          },
+          {
+            id: currentProject?.id ?? -1,
+            cluster_id: currentCluster?.id ?? -1,
+          }
+        );
+      }
+
+      // Old env var create logic
+      envVariable
+        .filter((envVar: KeyValueType, index: number, self: KeyValueType[]) => {
+          // remove any collisions that are marked as deleted and are duplicates
+          const numCollisions = self.reduce((n, _envVar: KeyValueType) => {
+            return n + (_envVar.key === envVar.key ? 1 : 0);
+          }, 0);
+
+          if (numCollisions === 1) {
+            return true;
+          } else {
+            return (
+              index ===
+              self.findIndex(
+                (_envVar: KeyValueType) =>
+                  _envVar.key === envVar.key && !_envVar.deleted
+              )
+            );
+          }
+        })
+        .forEach((envVar: KeyValueType) => {
+          if (!envVar.deleted) {
+            if (envVar.hidden) {
+              secretEnvVariables[envVar.key] = envVar.value;
+            } else {
+              apiEnvVariables[envVar.key] = envVar.value;
+            }
+          }
+        });
+
+      await api.createEnvironmentGroups(
+        "<token>",
+        {
+          name: data.name,
+          variables: apiEnvVariables,
+          secret_variables: secretEnvVariables,
+        },
+        {
+          id: currentProject?.id ?? -1,
+          cluster_id: currentCluster?.id ?? -1,
+        }
+      )
+        
+      history.push(`/environment-groups/${data.name}/env-vars`);
+    } catch (err) {
+      const errorMessage =
+        axios.isAxiosError(err) && err.response?.data?.error
+          ? err.response.data.error
+          : "An error occurred while creating your env group. Please try again.";
+      setSubmitErrorMessage(errorMessage);
+    }
+  });
+
+  const submitButtonStatus = useMemo(() => {
+    if (isSubmitting || isValidating) {
+      return "loading";
+    }
+    if (submitErrorMessage) {
+      return <Error message={submitErrorMessage} />;
+    }
+    return undefined;
+  }, [isSubmitting, submitErrorMessage, isValidating]);
+
+  return (
+    <CenterWrapper>
+      <Div>
+        <StyledConfigureTemplate>
+          <Back to="/environment-groups" />
+          <DashboardHeader
+            prefix={<Icon src={envGrad} />}
+            title="Create a new env group"
+            capitalize={false}
+            disableLineBreak
+          />
+          <DarkMatter />
+          <FormProvider {...envGroupFormMethods}>
+            <form onSubmit={onSubmit}>
+              <VerticalSteps
+                currentStep={step}
+                steps={[
+                  <>
+                    <Text size={16}>Environment group name</Text>
+                    <Spacer y={0.5} />
+                    <Text color="helper">
+                      Lowercase letters, numbers, and &quot;-&quot; only.
+                    </Text>
+                    <Spacer height="20px" />
+                    <ControlledInput
+                      placeholder="ex: academic-sophon-db"
+                      type="text"
+                      width="320px"
+                      error={name?.length > 0 ? errors.name?.message : undefined}
+                      {...register("name")}
+                    />
+                  </>,
+                  <>
+                    <Text size={16}>Environment variables</Text>
+                    <Spacer y={0.5} />
+                    <Text color="helper">
+                      Set environment-specific configuration including evironment variables and secrets.
+                    </Text>
+                    <Spacer height="15px" />
+                    <EnvGroupArray
+                      values={envVariables}
+                      setValues={(x) => {
+                        setValue("envVariables", x);
+                      }}
+                      fileUpload={true}
+                      secretOption={true}
+                    />
+                  </>,
+                  <Button
+                    key={2}
+                    type="submit"
+                    status={submitButtonStatus}
+                    loadingText="Creating env group . . ."
+                    width="140px"
+                  >
+                    Deploy env group
+                  </Button>
+                ]}
+              />
+            </form>
+          </FormProvider>
+          <Spacer y={3} />
+        </StyledConfigureTemplate>
+      </Div>
+    </CenterWrapper>
+  );
+}
+
+export default withRouter(CreateEnvGroup);
+
+const Icon = styled.img`
+  margin-right: 15px;
+  height: 28px;
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const StyledConfigureTemplate = styled.div`
+  height: 100%;
+`;
+
+const Div = styled.div`
+  width: 100%;
+  max-width: 900px;
+`;
+
+const CenterWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-5px"};
+`;

+ 330 - 0
dashboard/src/main/home/env-dashboard/EnvDashboard.tsx

@@ -0,0 +1,330 @@
+import React, { useContext, useEffect, useState, useMemo } from "react";
+import _ from "lodash";
+import styled from "styled-components";
+import { Link } from "react-router-dom";
+import { withRouter, type RouteComponentProps } from "react-router";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { search } from "shared/search";
+import { readableDate } from "shared/string_utils";
+import { withAuth, type WithAuthProps } from "shared/auth/AuthorizationHoc";
+
+import grid from "assets/grid.png";
+import list from "assets/list.png";
+import notFound from "assets/not-found.png";
+import time from "assets/time.png";
+import key from "assets/key.svg";
+import doppler from "assets/doppler.png";
+import envGroupGrad from "assets/env-group-grad.svg";
+
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import Spacer from "components/porter/Spacer";
+import Loading from "components/Loading";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
+import Text from "components/porter/Text";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Image from "components/porter/Image";
+import SearchBar from "components/porter/SearchBar";
+import Toggle from "components/porter/Toggle";
+import Fieldset from "components/porter/Fieldset";
+
+type Props = RouteComponentProps & WithAuthProps;
+
+const EnvDashboard: React.FC<Props> = (props) => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [searchValue, setSearchValue] = useState("");
+  const [envGroups, setEnvGroups] = useState<[]>([]);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [view, setView] = useState<"grid" | "list">("grid");
+  const [hasError, setHasError] = useState<boolean>(false);
+
+  const filteredEnvGroups = useMemo(() => {
+    const filteredBySearch = search(envGroups, searchValue, {
+      keys: ["name"],
+      isCaseSensitive: false,
+    });
+
+    const sortedFilteredBySearch = _.sortBy(filteredBySearch, ["name"]);
+    return sortedFilteredBySearch;
+  }, [envGroups, searchValue]);
+
+  const updateEnvGroups = async (): Promise<void> => {
+    try {
+      const res = await api.getAllEnvGroups(
+        "<token>",
+        {},
+        {
+          id: currentProject?.id || -1,
+          cluster_id: currentCluster?.id || -1,
+        }
+      );
+      setEnvGroups(res.data.environment_groups);
+      setIsLoading(false);
+    } catch (err) {
+      setHasError(true);
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    setIsLoading(true);
+    if ((currentProject?.id ?? -1) > -1 && (currentCluster?.id ?? -1) > -1) {
+      void updateEnvGroups();
+    }
+  }, [currentProject, currentCluster]);
+
+  const renderContents = (): React.ReactNode => {
+    if (currentCluster?.status === "UPDATING_UNAVAILABLE") {
+      return <ClusterProvisioningPlaceholder />
+    }
+
+    if (!isLoading && (!envGroups || envGroups.length === 0)) {
+      return (
+        <DashboardPlaceholder>
+          <Text size={16}>No environment groups found</Text>
+          <Spacer y={0.5} />
+          <Text color={"helper"}>Get started by creating an environment group.</Text>
+          <Spacer y={1} />
+          <Link to={`/environment-groups/new`}>
+            <Button
+              height="35px"
+              alt
+            >
+              Create a new env group <Spacer inline x={1} />{" "}
+              <i className="material-icons" style={{ fontSize: "18px" }}>
+                east
+              </i>
+            </Button>
+          </Link>
+        </DashboardPlaceholder>
+      )
+    }
+
+    const isAuthorizedToAdd = props.isAuthorized("env_group", "", [
+      "get",
+      "create",
+    ]);
+
+    return (
+      <>
+        <Container row spaced>
+          <SearchBar
+            value={searchValue}
+            style={{ display: "flex", flex: 1 }}
+            setValue={(x) => {
+              setSearchValue(x);
+            }}
+            placeholder="Search environment groups . . ."
+          />
+          <Spacer inline x={1} />
+          <Toggle
+            items={[
+              { 
+                label: (
+                  <Image 
+                    src={grid}
+                    size={12}
+                    style={{ margin: "0 5px" }}
+                  />
+                ),
+                value: "grid"
+              },
+              { 
+                label: (
+                  <Image
+                    src={list}
+                    size={12}
+                    style={{ margin: "0 5px" }}
+                  />
+                ),
+                value: "list"
+              },
+            ]}
+            active={view}
+            setActive={(x) => {
+              if (x === "grid") {
+                setView("grid");
+              } else {
+                setView("list");
+              }
+            }}
+          />
+          <Spacer inline x={1} />
+          {isAuthorizedToAdd && (
+            <Link to={`/environment-groups/new`}>
+              <Button
+                height="30px"
+              >
+                <I className="material-icons">add</I> New env group
+              </Button>
+            </Link>
+          )}
+        </Container>
+        <Spacer y={1} />
+
+        {!isLoading && filteredEnvGroups.length === 0 ? (
+          <Fieldset>
+            <Container row>
+              <Image 
+                src={notFound}
+                size={13}
+                opacity={0.65}
+              />
+              <Spacer inline x={1} />
+              <Text color="helper">
+                No matching environment groups were found.
+              </Text>
+            </Container>
+          </Fieldset>
+        ) : isLoading ? (
+          <Loading offset="-150px" />
+        ) : view === "grid" ? (
+          <GridList>
+            {(filteredEnvGroups ?? []).map(
+              (envGroup, i: number) => {
+                return (
+                    <Block to={`/environment-groups/${envGroup.name}`} key={i}>
+                      <Container row>
+                        <Image
+                          src={envGroup.type === "doppler" ? doppler : key} 
+                          size={20} 
+                        />
+                        <Spacer inline x={.7} />
+                        <Text size={14}>{envGroup.name}</Text>
+                      </Container>
+                      <Container row>
+                        <Image opacity={0.4} src={time} size={14} />
+                        <Spacer inline x={.5} />
+                        <Text size={13} color="#ffffff44">
+                          {readableDate(envGroup.created_at)}
+                        </Text>
+                      </Container>
+                    </Block>
+                );
+              }
+            )}
+          </GridList>
+        ) : (
+          <List>
+            {(filteredEnvGroups ?? []).map((envGroup: any, i: number) => {
+              return (
+                <Row to={`/environment-groups/${envGroup.name}`} key={i}>
+                  <Container row>
+                    <Image
+                      src={envGroup.type === "doppler" ? doppler : key}
+                    />
+                    <Spacer inline x={.7} />
+                    <Text size={14}>{envGroup.name}</Text>
+                  </Container>
+                  <Spacer height="15px" />
+                  <Container row>
+                    <Image opacity={0.4} src={time} size={14} />
+                    <Spacer inline x={.5} />
+                    <Text size={13} color="#ffffff44">
+                      {readableDate(envGroup.created_at)}
+                    </Text>
+                  </Container>
+                </Row>
+              );
+            })}
+          </List>
+        )}
+      </>
+    );
+  };
+
+  return (
+    <DashboardWrapper>
+      <DashboardHeader
+        image={envGroupGrad}
+        title="Environment groups"
+        description="Groups of environment variables for storing secrets and configuration."
+        disableLineBreak
+        capitalize={false}
+      />
+      {renderContents()}
+    </DashboardWrapper>
+  );
+};
+
+export default withRouter(withAuth(EnvDashboard));
+
+const Row = styled(Link) <{ isAtBottom?: boolean }>`
+  cursor: pointer;
+  display: block;
+  padding: 15px;
+  border-bottom: ${props => props.isAtBottom ? "none" : "1px solid #494b4f"};
+  background: ${props => props.theme.clickable.bg};
+  position: relative;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  margin-bottom: 15px;
+  animation: fadeIn 0.3s 0s;
+`;
+
+const List = styled.div`
+  overflow: hidden;
+`;
+
+const Block = styled(Link)`
+  height: 110px;
+  flex-direction: column;
+  display: flex;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${props => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${props => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  transition: all 0.2s;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const GridList = styled.div`
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+  justify-content: center;
+`;
+
+const DashboardWrapper = styled.div`
+  width: 100%;
+  height: 100%;
+
+  animation: fadeIn 0.5s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 211 - 0
dashboard/src/main/home/env-dashboard/EnvGroup.tsx

@@ -0,0 +1,211 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import sliders from "assets/sliders.svg";
+import doppler from "assets/doppler.png";
+
+import { Context } from "shared/Context";
+import { readableDate } from "shared/string_utils";
+import { Link } from "react-router-dom";
+import _ from "lodash";
+
+export type EnvGroupData = {
+  name: string;
+  type?: string;
+  namespace: string;
+  created_at?: string;
+  version: number;
+};
+
+type PropsType = {
+  envGroup: EnvGroupData;
+};
+
+type StateType = {
+  update: any[];
+};
+
+export default class EnvGroup extends Component<PropsType, StateType> {
+  state = {
+    update: [] as any[],
+  };
+
+  render() {
+    const { envGroup } = this.props;
+    const name = envGroup?.name;
+    const timestamp = envGroup?.created_at;
+    const namespace = envGroup?.namespace;
+    const version = this.context?.currentProject.simplified_view_enabled ? envGroup?.latest_version : envGroup?.version ;
+
+    return (
+      <Link to={`/env-groups/${name}${window.location.search}`} target="_self">
+        <StyledEnvGroup>
+          <Title>
+            <IconWrapper>
+              <Icon src={envGroup.type === "doppler" ? doppler : sliders} />
+            </IconWrapper>
+            {name}
+          </Title>
+
+          <BottomWrapper>
+            <InfoWrapper>
+              <LastDeployed>
+                Last updated {readableDate(timestamp)}
+              </LastDeployed>
+            </InfoWrapper>
+
+            {!this.context?.currentProject.simplified_view_enabled && <TagWrapper>
+              Namespace
+              <NamespaceTag>{namespace.startsWith("porter-stack-") ? namespace.replace("porter-stack-", "") : namespace}</NamespaceTag>
+            </TagWrapper>}
+          </BottomWrapper>
+
+          <Version>v{version}</Version>
+        </StyledEnvGroup>
+      </Link>
+    );
+  }
+}
+
+export function formattedEnvironmentValue(value: string) {
+  if (value.startsWith("PORTERSECRET_")) {
+    return "••••";
+  }
+  return value;
+}
+
+EnvGroup.contextType = Context;
+
+const BottomWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding-right: 11px;
+  margin-top: 3px;
+`;
+
+const Version = styled.div`
+  position: absolute;
+  top: 12px;
+  right: 12px;
+  font-size: 12px;
+  color: #aaaabb;
+`;
+
+const Dot = styled.div`
+  margin-right: 9px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 8px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 14px;
+  margin-bottom: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const TagWrapper = styled.div`
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #ffffff22;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const IconWrapper = styled.div`
+  color: #efefef;
+  background: none;
+  font-size: 16px;
+  top: 11px;
+  left: 14px;
+  height: 20px;
+  width: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  position: absolute;
+
+  > i {
+    font-size: 17px;
+    margin-top: -1px;
+  }
+`;
+
+const Title = styled.div`
+  position: relative;
+  text-decoration: none;
+  padding: 12px 35px 12px 45px;
+  font-size: 14px;
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  width: 80%;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  animation: fadeIn 0.5s;
+
+  > img {
+    background: none;
+    top: 12px;
+    left: 13px;
+
+    padding: 5px 4px;
+    width: 24px;
+    position: absolute;
+  }
+`;
+
+const StyledEnvGroup = styled.div`
+  cursor: pointer;
+  margin-bottom: 15px;
+  padding-top: 2px;
+  padding-bottom: 13px;
+  position: relative;
+  width: calc(100% + 2px);
+  height: calc(100% + 2px);
+  border-radius: 5px;
+  background: ${props => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;

+ 355 - 0
dashboard/src/main/home/env-dashboard/EnvGroupArray.tsx

@@ -0,0 +1,355 @@
+import EnvEditorModal from "main/home/modals/EnvEditorModal";
+import Modal from "main/home/modals/Modal";
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+import upload from "assets/upload.svg";
+import { dotenv_parse } from "shared/string_utils";
+
+import Button from "components/porter/Button";
+import Image from "components/porter/Image";
+import Spacer from "components/porter/Spacer";
+
+export type KeyValueType = {
+  key: string;
+  value: string;
+  hidden: boolean;
+  locked: boolean;
+  deleted: boolean;
+};
+
+type PropsType = {
+  label?: string;
+  values: KeyValueType[];
+  setValues: (x: KeyValueType[]) => void;
+  disabled?: boolean;
+  fileUpload?: boolean;
+  secretOption?: boolean;
+  setButtonDisabled?: (x: boolean) => void;
+};
+
+const EnvGroupArray = ({
+  label,
+  values,
+  setValues,
+  disabled,
+  fileUpload,
+  secretOption,
+  setButtonDisabled,
+}: PropsType) => {
+  const [showEditorModal, setShowEditorModal] = useState(false);
+  const blankValues = (): void => {
+    const isAnyEnvVariableBlank = values.some(
+      (envVar) => !envVar.key.trim() || !envVar.value.trim()
+    );
+    if (setButtonDisabled) {
+      setButtonDisabled(isAnyEnvVariableBlank);
+    }
+  };
+  const blankValue = (key: string): boolean => {
+    if (key === "" && setButtonDisabled) {
+      return true
+    }
+    return false
+  };
+
+  const incorrectRegex = (key: string) => {
+    const pattern = /^[a-zA-Z0-9._-]+$/;
+    if (setButtonDisabled) {
+      setButtonDisabled(!pattern.test(key))
+      blankValues();
+    }
+    if (key) {
+      // The test() method tests for a match in a string
+      return !pattern.test(key);
+    }
+    return false;
+  };
+  useEffect(() => {
+    if (!values) {
+      setValues([]);
+    }
+  }, [values]);
+
+  const readFile = (env: string) => {
+    const envObj = dotenv_parse(env);
+    const _values = [...values];
+
+    for (const key in envObj) {
+      let push = true;
+
+      for (let i = 0; i < values.length; i++) {
+        const existingKey = values[i].key;
+        const isExistingKeyDeleted = values[i].deleted;
+        if (key === existingKey && !isExistingKeyDeleted) {
+          _values[i].value = envObj[key];
+          push = false;
+        }
+      }
+
+      if (push) {
+        _values.push({
+          key,
+          value: envObj[key],
+          hidden: false,
+          locked: false,
+          deleted: false,
+        });
+      }
+    }
+
+    setValues(_values);
+  };
+
+  if (!values) {
+    return null;
+  }
+
+  return (
+    <>
+      <Label>{label}</Label>
+      {!!values?.length &&
+        values.map((entry: KeyValueType, i: number) => {
+          if (!entry.deleted) {
+            return (
+              <InputWrapper key={i}>
+                <Input
+                  placeholder="ex: key"
+                  width="270px"
+                  value={entry.key}
+                  onChange={(e: any) => {
+                    const _values = [...values];
+                    _values[i].key = e.target.value;
+                    setValues(_values);
+                  }}
+                  disabled={disabled || entry.locked}
+                  spellCheck={false}
+                  override={incorrectRegex(entry.key)}
+                />
+                <Spacer inline width="10px" />
+
+                {entry.hidden ? (
+                  <Input
+                    placeholder="ex: value"
+                    width="270px"
+                    value={entry.value}
+                    flex
+                    onChange={(e: any) => {
+                      const _values = [...values];
+                      _values[i].value = e.target.value;
+                      setValues(_values);
+                    }}
+                    disabled={disabled || entry.locked}
+                    type={entry.hidden ? "password" : "text"}
+                    spellCheck={false}
+                    override={incorrectRegex(entry.key)}
+                  />
+                ) : (
+                  <MultiLineInputer
+                    placeholder={blankValue(entry.value) ? "value cannot be blank" : "ex: value"}
+                    width="270px"
+                    value={entry.value}
+                    onChange={(e: any) => {
+                      const _values = [...values];
+                      _values[i].value = e.target.value;
+                      setValues(_values);
+                    }}
+                    rows={entry.value?.split("\n").length}
+                    disabled={disabled || entry.locked}
+                    spellCheck={false}
+                    override={blankValue(entry.value)}
+                  />
+                )}
+                {secretOption && (
+                  <HideButton
+                    onClick={() => {
+                      if (!entry.locked) {
+                        const _values = [...values];
+                        _values[i].hidden = !_values[i].hidden;
+                        setValues(_values);
+                      }
+                    }}
+                    disabled={entry.locked}
+                  >
+                    {entry.hidden ? (
+                      <i className="material-icons">lock</i>
+                    ) : (
+                      <i className="material-icons">lock_open</i>
+                    )}
+                  </HideButton>
+                )}
+
+                {!disabled && (
+                  <DeleteButton
+                    onClick={() => {
+                      setValues(values.filter((val, index) => index !== i));
+                    }}
+                  >
+                    <i className="material-icons">cancel</i>
+                  </DeleteButton>
+                )}
+              </InputWrapper>
+            );
+          }
+        })}
+      {!disabled && (
+        <>
+          {values.length > 0 && <Spacer y={.5} />}
+          <InputWrapper>
+            <Button
+              alt
+              onClick={() => {
+                const _values = [
+                  ...values,
+                  {
+                    key: "",
+                    value: "",
+                    hidden: false,
+                    locked: false,
+                    deleted: false,
+                  },
+                ];
+                setValues(_values);
+              }}
+            >
+              <I className="material-icons">add</I> Add row
+            </Button>
+            <Spacer inline x={.5} />
+            {fileUpload && (
+              <Button
+                alt
+                onClick={() => {
+                  setShowEditorModal(true);
+                }}
+              >
+                <Image size={16} src={upload} />
+                <Spacer inline x={0.5} />
+                Copy from file
+              </Button>
+            )}
+          </InputWrapper>
+        </>
+      )}
+      {showEditorModal && (
+        <Modal
+          onRequestClose={() => { setShowEditorModal(false); }}
+          width="60%"
+          height="650px"
+        >
+          <EnvEditorModal
+            closeModal={() => { setShowEditorModal(false); }}
+            setEnvVariables={(envFile: string) => { readFile(envFile); }}
+          />
+        </Modal>
+      )}
+    </>
+  );
+};
+
+const I = styled.i`
+  font-size: 16px;
+  margin-right: 7px;
+`;
+
+export default EnvGroupArray;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const HideButton = styled(DeleteButton)`
+  margin-top: -5px;
+  > i {
+    font-size: 19px;
+    cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "default" : "pointer"};
+    :hover {
+      color: ${(props: { disabled: boolean }) =>
+    props.disabled ? "#ffffff44" : "#ffffff88"};
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 5px;
+`;
+const Input = styled.input<{ flex?: boolean }>`
+  outline: none;
+  display: ${(props) => (props.flex ? "flex" : "block")};
+  ${(props) => (props.flex && 'flex: 1;')}
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: ${(props) => props.theme.fg};
+  border: ${(props) => (props.override ? '2px solid #f4cb42' : ' 1px solid #494b4f')};
+  border-radius: 5px;
+  width: ${(props) => props.width ? props.width : "270px"};
+  color: ${(props) => props.disabled ? "#ffffff44" : "#fefefe"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+export const MultiLineInputer = styled.textarea<InputProps>`
+  outline: none;
+  border: none;
+  display: flex;
+  flex: 1;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: ${(props) => props.theme.fg};
+  border: ${(props) => (props.override ? '2px solid #f4cb42' : ' 1px solid #494b4f')};
+  border-radius: 5px;
+  color: ${(props) => (props.disabled ? "#ffffff44" : "#fefefe")};
+  padding: 8px 10px 5px 10px;
+  min-height: 35px;
+  max-height: 100px;
+  white-space: nowrap;
+
+  ::-webkit-scrollbar {
+    width: 8px;
+    :horizontal {
+      height: 8px;
+    }
+  }
+
+  ::-webkit-scrollbar-corner {
+    width: 10px;
+    background: #ffffff11;
+    color: white;
+  }
+
+  ::-webkit-scrollbar-track {
+    width: 10px;
+    -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+    box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+  }
+
+  ::-webkit-scrollbar-thumb {
+    background-color: darkgrey;
+    outline: 1px solid slategrey;
+  }
+`;

+ 30 - 0
dashboard/src/main/home/env-dashboard/EnvGroupList.tsx

@@ -0,0 +1,30 @@
+import React from "react";
+import styled from "styled-components";
+
+import EnvGroup from "./EnvGroup";
+import { type RouteComponentProps, withRouter } from "react-router";
+
+type Props = RouteComponentProps & {
+  envGroups: any[];
+};
+
+const EnvGroupList: React.FunctionComponent<Props> = ({ envGroups }) => {
+  return (
+    <StyledEnvGroupList>
+      {envGroups.map((envGroup: any, i: number) => {
+        return (
+          <EnvGroup
+            key={i}
+            envGroup={envGroup}
+          />
+        );
+      })}
+    </StyledEnvGroupList>
+  );
+};
+
+export default withRouter(EnvGroupList);
+
+const StyledEnvGroupList = styled.div`
+  padding-bottom: 85px;
+`;

+ 169 - 0
dashboard/src/main/home/env-dashboard/ExpandedEnv.tsx

@@ -0,0 +1,169 @@
+import React, { useMemo, useState, useContext, useEffect } from "react";
+import styled from "styled-components";
+import { useHistory, useParams } from "react-router";
+import { match } from "ts-pattern";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import notFound from "assets/not-found.png";
+import doppler from "assets/doppler.png";
+import key from "assets/key.svg";
+import time from "assets/time.png";
+
+import Back from "components/porter/Back";
+import Loading from "components/Loading";
+import Container from "components/porter/Container";
+import Image from "components/porter/Image";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Link from "components/porter/Link";
+import TabSelector from "components/TabSelector";
+import EnvVarsTab from "./tabs/EnvVarsTab";
+import SettingsTab from "./tabs/SettingsTab";
+import SyncedAppsTab from "./tabs/SyncedAppsTab";
+
+const getReadableDate = (s: string): string => {
+  const ts = new Date(s);
+  const date = ts.toLocaleDateString();
+  const time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} on ${date}`;
+};
+
+const ExpandedEnv: React.FC = () => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const { envGroupName, tab } = useParams<{
+    envGroupName: string;
+    tab: string;
+  }>();
+  const history = useHistory();
+  
+  const [isLoading, setIsLoading] = useState(true);
+  const [envGroup, setEnvGroup] = useState(null);
+
+  const tabs = useMemo(() => {
+    return [
+      { label: "Environment variables", value: "env-vars" },
+      { label: "Synced applications", value: "synced-apps" },
+      { label: "Settings", value: "settings" },
+    ];
+  }, []);
+
+  const fetchEnvGroup = async () => {
+    try {
+      const res = await api.getAllEnvGroups(
+        "<token>",
+        {},
+        { 
+          id: currentProject?.id ?? -1,
+          cluster_id: currentCluster?.id ?? -1,
+        }
+      );
+      const matchedEnvGroup = res.data.environment_groups.find((x) => {
+        return x.name === envGroupName
+      });
+      setIsLoading(false);
+      setEnvGroup(matchedEnvGroup);
+    } catch (err) {
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    setIsLoading(true);
+    void fetchEnvGroup();
+  }, [currentProject, currentCluster, envGroupName]);
+
+  useEffect(() => {
+    if (!tab) {
+      history.push(`/environment-groups/${envGroupName}/env-vars`);
+    }
+  }, [tab])
+
+  return (
+    <>
+      {isLoading && <Loading />}
+      {!isLoading && !envGroup && (
+        <Placeholder>
+          <Container row>
+            <Image src={notFound} size={13} opacity={0.65} />
+            <Spacer inline x={.5} />
+            <Text color="helper">
+              No env group matching &quot;{envGroupName}&quot;
+              was found.
+            </Text>
+          </Container>
+          <Spacer y={1} />
+          <Link hasunderline to="/environment-groups">Return to dashboard</Link>
+        </Placeholder>
+      )}
+      {!isLoading && envGroup && (
+        <StyledExpandedApp>
+          <Back to="/environment-groups" />
+
+          <Container row>
+            <Image src={envGroup.type === "doppler" ? doppler : key} size={28} />
+            <Spacer inline x={1} />
+            <Text size={21}>{envGroupName}</Text>
+          </Container>
+          <Spacer y={0.5} />
+          <Container row>
+            <Spacer inline x={.2} />
+            <Image opacity={0.3} src={time} size={14} />
+            <Spacer inline x={.5} />
+            <Text color="#aaaabb66">
+              Last deployed {getReadableDate(envGroup.created_at)}
+            </Text>
+          </Container>
+          <Spacer y={1} />
+
+          <TabSelector
+            noBuffer
+            options={tabs}
+            currentTab={tab}
+            setCurrentTab={(t) => {
+              history.push(`/environment-groups/${envGroupName}/${t}`);
+            }}
+          />
+          <Spacer y={1} />
+          {match(tab)
+            .with("env-vars", () => <EnvVarsTab envGroup={envGroup} />)
+            .with("synced-apps", () => <SyncedAppsTab envGroup={envGroup} />)
+            .with("settings", () => <SettingsTab envGroup={envGroup} />)
+            .otherwise(() => null)}
+          <Spacer y={2} />
+        </StyledExpandedApp>
+      )}
+    </>
+  );
+};
+
+export default ExpandedEnv;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+`;
+
+const StyledExpandedApp = styled.div`
+  width: 100%;
+  height: 100%;
+
+  animation: fadeIn 0.5s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 218 - 0
dashboard/src/main/home/env-dashboard/tabs/EnvVarsTab.tsx

@@ -0,0 +1,218 @@
+import React, { useEffect, useState, useMemo, useContext } from "react";
+import { FormProvider, useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import axios from "axios";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { type EnvGroupFormData, envGroupFormValidator } from "lib/env-groups/types";
+
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import EnvGroupArray from "../EnvGroupArray";
+import Button from "components/porter/Button";
+import Error from "components/porter/Error";
+
+type Props = {
+  envGroup: {
+    name: string;
+    variables: Record<string, string>;
+    secret_variables?: Record<string, string>;
+    type?: string;
+  };
+}
+
+const EnvVarsTab: React.FC<Props> = ({ envGroup }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [buttonStatus, setButtonStatus] = useState<string | React.ReactNode>("");
+
+  const envGroupFormMethods = useForm<EnvGroupFormData>({
+    resolver: zodResolver(envGroupFormValidator),
+    reValidateMode: "onSubmit",
+  });
+  const { 
+    formState: { isValidating, isSubmitting },
+    watch,
+    trigger,
+    handleSubmit,
+    setValue,
+  } = envGroupFormMethods;
+
+  const [submitErrorMessage, setSubmitErrorMessage] = useState<string>("");
+  const [isValid, setIsValid] = useState<boolean>(false);
+  const envVariables = watch("envVariables");
+
+  useEffect(() => {
+    if (buttonStatus === "success") {
+      setButtonStatus("");
+    }
+    const validate = async (): Promise<void> => {
+      const isEnvVariablesValid = await trigger("envVariables");
+      if (isEnvVariablesValid) {
+        setIsValid(true);
+      } else {
+        setIsValid(false);
+      }
+    };
+    void validate();
+  }, [envVariables]);
+
+  useEffect(() => {
+    const normalVariables = Object.entries(
+      envGroup.variables || {}
+    ).map(([key, value]) => ({
+      key,
+      value,
+      hidden: (value as string).includes("PORTERSECRET"),
+      locked: (value as string).includes("PORTERSECRET"),
+      deleted: false,
+    }));
+    const secretVariables = Object.entries(
+      envGroup.secret_variables || {}
+    ).map(([key, value]) => ({
+      key,
+      value,
+      hidden: true,
+      locked: true,
+      deleted: false,
+    }));
+    const variables = [...normalVariables, ...secretVariables];
+    setValue("envVariables", variables as Array<{ key: string; value: string; hidden: boolean; locked: boolean; deleted: boolean }>);
+    setValue("name", envGroup.name);
+  }, [envGroup]);
+
+  const onSubmit = handleSubmit(async (data) => {
+    setButtonStatus("loading");
+    setSubmitErrorMessage("");
+    const apiEnvVariables: Record<string, string> = {};
+    const secretEnvVariables: Record<string, string> = {};
+    const envVariables = data.envVariables;
+    try {
+      // Old env var create logic
+      const filtered = envVariables.filter((
+        envVar: KeyValueType, 
+        index: number,
+        self: KeyValueType[]
+      ) => {
+        // remove any collisions that are marked as deleted and are duplicates
+        const numCollisions = self.reduce((n, _envVar: KeyValueType) => {
+          return n + (_envVar.key === envVar.key ? 1 : 0);
+        }, 0);
+
+        if (numCollisions === 1) {
+          return true;
+        } else {
+          return (
+            index ===
+            self.findIndex(
+              (_envVar: KeyValueType) =>
+                _envVar.key === envVar.key && !_envVar.deleted
+            )
+          );
+        }
+      })
+        
+      filtered
+        .filter((envVar) => !envVar.deleted && envVar.hidden)
+        .forEach((envVar) => {
+          secretEnvVariables[envVar.key] = envVar.value;
+        });
+      
+      filtered
+        .filter((envVar) => !envVar.deleted && !envVar.hidden)
+        .forEach((envVar) => {
+          apiEnvVariables[envVar.key] = envVar.value;
+        });
+
+      if (envGroup?.type !== "doppler") {
+        await api.createEnvironmentGroups(
+          "<token>",
+          {
+            name: envGroup.name,
+            variables: apiEnvVariables,
+            secret_variables: secretEnvVariables,
+          },
+          {
+            id: currentProject?.id ?? -1,
+            cluster_id: currentCluster?.id ?? -1,
+          }
+        );
+      };
+     
+      await api.updateAppsLinkedToEnvironmentGroup(
+        "<token>",
+        {
+          name: envGroup?.name,
+        },
+        {
+          id: currentProject?.id || -1,
+          cluster_id: currentCluster?.id || -1,
+        }
+      );
+
+      setButtonStatus("success");
+    } catch (err) {
+      const errorMessage =
+        axios.isAxiosError(err) && err.response?.data?.error
+          ? err.response.data.error
+          : "An error occurred while creating your env group. Please try again.";
+      setSubmitErrorMessage(errorMessage);
+      setButtonStatus(<Error message={errorMessage} />);
+    }
+  });
+
+  const submitButtonStatus = useMemo(() => {
+    if (isSubmitting || isValidating) {
+      return "loading";
+    }
+    if (submitErrorMessage) {
+      return <Error message={submitErrorMessage} />;
+    }
+    return undefined;
+  }, [isSubmitting, submitErrorMessage, isValidating]);
+
+  return (
+    <>
+      <Text size={16}>Environment variables</Text>
+      <Spacer y={0.5} />
+      {envGroup.type === "doppler" ? (
+        <Text color="helper">
+          Doppler environment variables can only be updated from the Doppler dashboard.
+        </Text>
+      ) : (
+        <Text color="helper">
+          Set secret values and environment-specific configuration for your applications.
+        </Text>
+      )}
+      <Spacer height="15px" />
+      <FormProvider {...envGroupFormMethods}>
+        <form onSubmit={onSubmit}>
+          <EnvGroupArray
+            values={envVariables}
+            setValues={(x) => {
+              setValue("envVariables", x);
+            }}
+            fileUpload={true}
+            secretOption={true}
+            disabled={envGroup.type === "doppler"}
+          />
+          {envGroup.type !== "doppler" && (
+            <>
+              <Spacer y={1} />
+              <Button
+                type="submit"
+                status={buttonStatus}
+                loadingText="Updating env group . . ."
+                disabled={!isValid}
+              >
+                Update
+              </Button>
+            </>
+          )}
+        </form>
+      </FormProvider>
+    </>
+  );
+};
+
+export default EnvVarsTab;

+ 130 - 0
dashboard/src/main/home/env-dashboard/tabs/SettingsTab.tsx

@@ -0,0 +1,130 @@
+import React, { useContext, useState } from "react";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import loading from "assets/loading.gif";
+
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Image from "components/porter/Image";
+import Error from "components/porter/Error";
+
+type Props = {
+  envGroup: {
+    name: string;
+    type: string;
+    linked_applications: string[];
+  }
+};
+
+const SettingsTab: React.FC<Props> = ({ envGroup }) => {
+  const { currentProject, currentCluster, setCurrentOverlay } = useContext(Context);
+  const history = useHistory();
+
+  const [isDeleting, setIsDeleting] = useState<boolean>(false);
+  const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
+
+  const deleteEnvGroup = async (): Promise<void> => {
+    try {
+      await api.deleteNewEnvGroup(
+        "<token>",
+        {
+          name: envGroup.name,
+          type: envGroup.type
+        },
+        {
+          id: currentProject?.id ?? -1,
+          cluster_id: currentCluster?.id ?? -1
+        },
+      );
+    } catch (error) {
+    }
+  };
+  
+  const handleDeletionSubmit = async (): Promise<void> => {
+    if (envGroup?.linked_applications) {
+      setButtonStatus(<Error message="Remove this env group from all synced applications to delete." />);
+      setCurrentOverlay && setCurrentOverlay(null);
+      return;
+    }
+    setIsDeleting(true);
+    if (setCurrentOverlay == null) {
+      return;
+    }
+
+    try {
+      await deleteEnvGroup();
+      setCurrentOverlay(null);
+      history.push("/environment-groups");
+    } catch (error) {
+      setIsDeleting(false);
+      setButtonStatus(<Error message="Env group deletion failed" />);
+    }
+  };
+
+  const handleDeletionClick = async (): Promise<void> => {
+    if (setCurrentOverlay === undefined) {
+      return;
+    }
+    setCurrentOverlay({
+      message: `Are you sure you want to delete ${envGroup.name}?`,
+      onYes: handleDeletionSubmit,
+      onNo: () => {
+        setCurrentOverlay(null);
+      },
+    });
+  };
+
+  return (
+    <StyledTemplateComponent>
+      {isDeleting && (
+        <>
+          <Container row>
+            <Image src={loading} size={15} />
+            <Spacer inline x={1} />
+            <Text size={16}>Deleting {envGroup.name}</Text>
+          </Container>
+          <Spacer y={0.5} />
+          <Text color="helper">Please wait while we delete this datastore.</Text>
+        </>
+      )}
+      {!isDeleting && (
+        <>
+          <Text size={16}>Delete {envGroup.name}</Text>
+          <Spacer y={1} />
+          <Text color="helper">
+            Delete this environment group including all secrets and environment-specific configuration.
+          </Text>
+          <Spacer y={1.2} />
+          <Button
+            color="#b91133"
+            onClick={handleDeletionClick}
+            status={buttonStatus}
+          >
+            Delete {envGroup.name}
+          </Button>
+        </>
+      )}
+    </StyledTemplateComponent>
+  );
+};
+
+export default SettingsTab;
+
+const StyledTemplateComponent = styled.div`
+  width: 100%;
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 97 - 0
dashboard/src/main/home/env-dashboard/tabs/SyncedAppsTab.tsx

@@ -0,0 +1,97 @@
+import React, { useContext, useMemo } from "react";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+import _ from "lodash";
+
+import { Context } from "shared/Context";
+import { useLatestAppRevisions } from "lib/hooks/useLatestAppRevisions";
+
+import box from "assets/box.png";
+
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Image from "components/porter/Image";
+import Clickable from "components/porter/Clickable";
+import Fieldset from "components/porter/Fieldset";
+import SelectableAppList from "main/home/app-dashboard/apps/SelectableAppList";
+
+type Props = {
+  envGroup: {
+    linked_applications: string[];
+  }
+};
+
+const SyncedAppsTab: React.FC<Props> = ({ envGroup }) => {
+  const history = useHistory();
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const { revisions } = useLatestAppRevisions({
+    projectId: currentProject?.id || -1,
+    clusterId: currentCluster?.id || -1,
+  });
+
+  const { connectedApps } = useMemo(() => {
+    const [connected, remaining] = _.partition(
+      revisions,
+      (r) => envGroup.linked_applications?.includes(r.source.name)
+    );
+    return {
+      connectedApps: connected.sort((a, b) =>
+        a.source.name.localeCompare(b.source.name)
+      ),
+    }
+  }, [revisions, envGroup.linked_applications]);
+
+  return (
+    <FadeWrapper>
+      <Text size={16}>
+        Synced applications
+      </Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        The following applications will be automatically redeployed when this env group is updated.
+      </Text>
+      <Spacer y={1} />
+      {(!envGroup?.linked_applications || envGroup.linked_applications.length === 0) && (
+        <Fieldset>
+          <Text size={16}>
+            No synced applications were found
+          </Text>
+          <Spacer y={0.5} />
+          <Text color="helper">
+            Navigate to the &quot;Environment&quot; tab of an application on Porter to sync this environment group.
+          </Text>
+        </Fieldset>
+      )}
+      {connectedApps && (
+        <SelectableAppList
+          appListItems={connectedApps?.map((ra) => ({
+            app: ra,
+            key: ra.source.name,
+            onSelect: () => {
+              history.push(
+                `/apps/${ra.source.name}?target=${ra.app_revision.deployment_target.id}`
+              );
+            },
+          }))}
+        />
+      )}
+    </FadeWrapper>
+  );
+};
+
+export default SyncedAppsTab;
+
+const FadeWrapper = styled.div`
+  width: 100%;
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 2 - 2
dashboard/src/main/home/integrations/Integrations.tsx

@@ -4,7 +4,7 @@ import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
 import { integrationList } from "shared/common";
 import styled from "styled-components";
 import { pushFiltered } from "shared/routing";
-import integrations from "assets/integrations.svg";
+import integrationGrad from "assets/integration-grad.svg";
 
 import CreateIntegrationForm from "./create-integration/CreateIntegrationForm";
 import IntegrationCategories from "./IntegrationCategories";
@@ -78,7 +78,7 @@ const Integrations: React.FC<PropsType> = (props) => {
         <Route>
           <>
             <DashboardHeader
-              image={integrations}
+              image={integrationGrad}
               title="Integrations"
               description="Manage third-party integrations for your Porter project."
               disableLineBreak

+ 2 - 2
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -2,7 +2,7 @@ import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
-import settings from "assets/settings.svg";
+import settingsGrad from "assets/settings-grad.svg";
 
 import InvitePage from "./InviteList";
 import TabRegion from "components/TabRegion";
@@ -238,7 +238,7 @@ function ProjectSettings(props: any) {
   return (
     <StyledProjectSettings>
       <DashboardHeader
-        image={settings}
+        image={settingsGrad}
         title="Project settings"
         description="Configure access permissions and additional project settings."
         disableLineBreak

+ 4 - 4
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -236,8 +236,8 @@ class Sidebar extends Component<PropsType, StateType> {
               Add-ons
             </NavButton>
             <NavButton
-              path="/env-groups"
-              active={window.location.pathname.startsWith("/env-groups")}
+              path="/environment-groups"
+              active={window.location.pathname.startsWith("/environment-groups")}
             >
               <Img src={sliders} />
               Env groups
@@ -311,8 +311,8 @@ class Sidebar extends Component<PropsType, StateType> {
               Add-ons
             </NavButton>
             <NavButton
-              path="/env-groups"
-              active={window.location.pathname.startsWith("/env-groups")}
+              path="/environment-groups"
+              active={window.location.pathname.startsWith("/environment-groups")}
             >
               <Img src={sliders} />
               Env groups

+ 2 - 0
dashboard/src/shared/routing.tsx

@@ -17,6 +17,7 @@ export type PorterUrl =
   | "apps"
   | "addons"
   | "compliance"
+  | "environment-groups"
   | "stacks";
 
 export const PorterUrls = [
@@ -37,6 +38,7 @@ export const PorterUrls = [
   "apps",
   "addons",
   "compliance",
+  "environment-groups",
   "stacks",
 ];
 

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff