Procházet zdrojové kódy

POR-2109 support selecting required apps on frontend (#4096)

ianedwards před 2 roky
rodič
revize
429effd48b

+ 7 - 7
dashboard/package-lock.json

@@ -92,7 +92,7 @@
         "@babel/preset-typescript": "^7.15.0",
         "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
         "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-        "@porter-dev/api-contracts": "^0.2.68",
+        "@porter-dev/api-contracts": "^0.2.71",
         "@testing-library/jest-dom": "^4.2.4",
         "@testing-library/react": "^9.3.2",
         "@testing-library/user-event": "^7.1.2",
@@ -2659,9 +2659,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.2.68",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.68.tgz",
-      "integrity": "sha512-cNexjW/HD1O68e9anCrAF9TR4HSqmKFiz2dB9E2exO0IFAG/pWW6DvfFF+0TSCstmtHOnE+x/K2F7MNmaMocqQ==",
+      "version": "0.2.71",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.71.tgz",
+      "integrity": "sha512-fVYGLym26GAEcvOw0hPsbhDkge1InN8+a15papXkOA4R/AOmkUsVA1Hn2zgrFosTwF2matXKh+MPYoQVbbXTYA==",
       "dev": true,
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
@@ -19870,9 +19870,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.2.68",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.68.tgz",
-      "integrity": "sha512-cNexjW/HD1O68e9anCrAF9TR4HSqmKFiz2dB9E2exO0IFAG/pWW6DvfFF+0TSCstmtHOnE+x/K2F7MNmaMocqQ==",
+      "version": "0.2.71",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.71.tgz",
+      "integrity": "sha512-fVYGLym26GAEcvOw0hPsbhDkge1InN8+a15papXkOA4R/AOmkUsVA1Hn2zgrFosTwF2matXKh+MPYoQVbbXTYA==",
       "dev": true,
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"

+ 1 - 1
dashboard/package.json

@@ -99,7 +99,7 @@
     "@babel/preset-typescript": "^7.15.0",
     "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
     "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-    "@porter-dev/api-contracts": "^0.2.68",
+    "@porter-dev/api-contracts": "^0.2.71",
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",

+ 13 - 0
dashboard/src/lib/porter-apps/index.ts

@@ -98,6 +98,7 @@ export const clientAppValidator = z.object({
     .default([]),
   build: buildValidator,
   helmOverrides: z.string().optional(),
+  requiredApps: z.object({ name: z.string() }).array().default([]),
 });
 export type ClientPorterApp = z.infer<typeof clientAppValidator>;
 
@@ -316,6 +317,9 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
           efsStorage: new EFS({
             enabled: app.efsStorage.enabled,
           }),
+          requiredApps: app.requiredApps.map((app) => ({
+            name: app.name,
+          })),
         })
     )
     .with(
@@ -339,6 +343,9 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
           efsStorage: new EFS({
             enabled: app.efsStorage.enabled,
           }),
+          requiredApps: app.requiredApps.map((app) => ({
+            name: app.name,
+          })),
         })
     )
     .exhaustive();
@@ -486,6 +493,9 @@ export function clientAppFromProto({
       efsStorage: new EFS({
         enabled: proto.efsStorage?.enabled ?? false,
       }),
+      requiredApps: proto.requiredApps.map((app) => ({
+        name: app.name,
+      })),
     };
   }
 
@@ -525,6 +535,9 @@ export function clientAppFromProto({
     },
     helmOverrides,
     efsStorage: { enabled: proto.efsStorage?.enabled ?? false },
+    requiredApps: proto.requiredApps.map((app) => ({
+      name: app.name,
+    })),
   };
 }
 

+ 19 - 3
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer.tsx

@@ -1,4 +1,10 @@
-import React, { useCallback, useEffect, useMemo, useState } from "react";
+import React, {
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
 import { zodResolver } from "@hookform/resolvers/zod";
 import { type PorterApp } from "@porter-dev/api-contracts";
 import axios from "axios";
@@ -22,8 +28,10 @@ import {
 } from "lib/porter-apps";
 
 import api from "shared/api";
+import { Context } from "shared/Context";
 
 import { type ExistingTemplateWithEnv } from "../types";
+import { RequiredApps } from "./RequiredApps";
 import { ServiceSettings } from "./ServiceSettings";
 
 type Props = {
@@ -43,6 +51,7 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
   existingTemplate,
 }) => {
   const history = useHistory();
+  const { currentProject } = useContext(Context);
 
   const [tab, setTab] = useState<PreviewEnvSettingsTab>("services");
   const [validatedAppProto, setValidatedAppProto] = useState<PorterApp | null>(
@@ -252,8 +261,12 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
         options={[
           { label: "App Services", value: "services" },
           { label: "Environment Variables", value: "variables" },
-          // { label: "Required Apps", value: "required-apps" },
-          // { label: "Add-ons", value: "addons" },
+          ...(currentProject?.beta_features_enabled
+            ? [
+                { label: "Required Apps", value: "required-apps" },
+                // { label: "Add-ons", value: "addons" },
+              ]
+            : []),
         ]}
         currentTab={tab}
         setCurrentTab={(tab: string) => {
@@ -280,6 +293,9 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
               buttonStatus={buttonStatus}
             />
           ))
+          .with("required-apps", () => (
+            <RequiredApps buttonStatus={buttonStatus} />
+          ))
           .otherwise(() => null)}
       </form>
       {showGHAModal && (

+ 193 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/RequiredApps.tsx

@@ -0,0 +1,193 @@
+import React, { useContext, useMemo } from "react";
+import { PorterApp } from "@porter-dev/api-contracts";
+import { useQuery } from "@tanstack/react-query";
+import {
+  useFieldArray,
+  useFormContext,
+  type UseFieldArrayAppend,
+} from "react-hook-form";
+import styled from "styled-components";
+import { z } from "zod";
+
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ButtonStatus } from "main/home/app-dashboard/app-view/AppDataContainer";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import { AppIcon, AppSource } from "main/home/app-dashboard/apps/AppMeta";
+import {
+  appRevisionWithSourceValidator,
+  type AppRevisionWithSource,
+} from "main/home/app-dashboard/apps/types";
+import { type PorterAppFormData } from "lib/porter-apps";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import healthy from "assets/status-healthy.png";
+
+type RowProps = {
+  idx: number;
+  app: AppRevisionWithSource;
+  append: UseFieldArrayAppend<PorterAppFormData, "app.requiredApps">;
+  remove: (index: number) => void;
+  selected?: boolean;
+};
+
+const RequiredAppRow: React.FC<RowProps> = ({
+  idx,
+  app,
+  selected,
+  append,
+  remove,
+}) => {
+  const proto = useMemo(() => {
+    return PorterApp.fromJsonString(atob(app.app_revision.b64_app_proto), {
+      ignoreUnknownFields: true,
+    });
+  }, [app.app_revision.b64_app_proto]);
+
+  return (
+    <ResourceOption
+      selected={selected}
+      onClick={() => {
+        if (selected) {
+          remove(idx);
+        } else {
+          append({ name: app.source.name });
+        }
+      }}
+    >
+      <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>
+      {selected && <Icon height="18px" src={healthy} />}
+    </ResourceOption>
+  );
+};
+
+type Props = {
+  buttonStatus: ButtonStatus;
+};
+
+export const RequiredApps: React.FC<Props> = ({ buttonStatus }) => {
+  const { currentCluster, currentProject } = useContext(Context);
+
+  const {
+    control,
+    formState: { isSubmitting },
+  } = useFormContext<PorterAppFormData>();
+  const { append, remove, fields } = useFieldArray({
+    control,
+    name: "app.requiredApps",
+  });
+
+  const { porterApp } = useLatestRevision();
+
+  const { data: apps = [] } = useQuery(
+    [
+      "getLatestAppRevisions",
+      {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+      },
+    ],
+    async () => {
+      if (
+        !currentCluster ||
+        !currentProject ||
+        currentCluster.id === -1 ||
+        currentProject.id === -1
+      ) {
+        return;
+      }
+
+      const res = await api.getLatestAppRevisions(
+        "<token>",
+        {
+          deployment_target_id: undefined,
+          ignore_preview_apps: true,
+        },
+        { cluster_id: currentCluster.id, project_id: currentProject.id }
+      );
+
+      const apps = await z
+        .object({
+          app_revisions: z.array(appRevisionWithSourceValidator),
+        })
+        .parseAsync(res.data);
+
+      return apps.app_revisions;
+    },
+    {
+      refetchOnWindowFocus: false,
+      enabled: !!currentCluster && !!currentProject,
+    }
+  );
+
+  const remainingApps = useMemo(() => {
+    return apps.filter((a) => a.source.name !== porterApp.name);
+  }, [apps, porterApp]);
+
+  return (
+    <div>
+      <Text size={16}>Required Apps</Text>
+      <Spacer y={0.5} />
+      <RequiredAppList>
+        {remainingApps.map((ra, i) => (
+          <RequiredAppRow
+            idx={i}
+            key={ra.source.name}
+            app={ra}
+            selected={fields.some((f) => f.name === ra.source.name)}
+            append={append}
+            remove={remove}
+          />
+        ))}
+      </RequiredAppList>
+      <Spacer y={0.75} />
+      <Button
+        type="submit"
+        status={buttonStatus}
+        loadingText={"Updating..."}
+        disabled={isSubmitting}
+      >
+        Update app
+      </Button>
+    </div>
+  );
+};
+
+const RequiredAppList = styled.div`
+  display: flex;
+  row-gap: 10px;
+  flex-direction: column;
+`;
+
+const ResourceOption = styled.div<{ selected?: boolean }>`
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid
+    ${(props) => (props.selected ? "#ffffff" : props.theme.border)};
+  width: 100%;
+  padding: 10px 15px;
+  border-radius: 5px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  cursor: pointer;
+  :hover {
+    border: 1px solid #ffffff;
+  }
+`;