Skip to content

Commit 429effd

Browse files
authored
POR-2109 support selecting required apps on frontend (#4096)
1 parent a67ad76 commit 429effd

File tree

5 files changed

+233
-11
lines changed

5 files changed

+233
-11
lines changed

dashboard/package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dashboard/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@
9999
"@babel/preset-typescript": "^7.15.0",
100100
"@ianvs/prettier-plugin-sort-imports": "^4.1.1",
101101
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
102-
"@porter-dev/api-contracts": "^0.2.68",
102+
"@porter-dev/api-contracts": "^0.2.71",
103103
"@testing-library/jest-dom": "^4.2.4",
104104
"@testing-library/react": "^9.3.2",
105105
"@testing-library/user-event": "^7.1.2",

dashboard/src/lib/porter-apps/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export const clientAppValidator = z.object({
9898
.default([]),
9999
build: buildValidator,
100100
helmOverrides: z.string().optional(),
101+
requiredApps: z.object({ name: z.string() }).array().default([]),
101102
});
102103
export type ClientPorterApp = z.infer<typeof clientAppValidator>;
103104

@@ -316,6 +317,9 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
316317
efsStorage: new EFS({
317318
enabled: app.efsStorage.enabled,
318319
}),
320+
requiredApps: app.requiredApps.map((app) => ({
321+
name: app.name,
322+
})),
319323
})
320324
)
321325
.with(
@@ -339,6 +343,9 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
339343
efsStorage: new EFS({
340344
enabled: app.efsStorage.enabled,
341345
}),
346+
requiredApps: app.requiredApps.map((app) => ({
347+
name: app.name,
348+
})),
342349
})
343350
)
344351
.exhaustive();
@@ -486,6 +493,9 @@ export function clientAppFromProto({
486493
efsStorage: new EFS({
487494
enabled: proto.efsStorage?.enabled ?? false,
488495
}),
496+
requiredApps: proto.requiredApps.map((app) => ({
497+
name: app.name,
498+
})),
489499
};
490500
}
491501

@@ -525,6 +535,9 @@ export function clientAppFromProto({
525535
},
526536
helmOverrides,
527537
efsStorage: { enabled: proto.efsStorage?.enabled ?? false },
538+
requiredApps: proto.requiredApps.map((app) => ({
539+
name: app.name,
540+
})),
528541
};
529542
}
530543

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import React, { useCallback, useEffect, useMemo, useState } from "react";
1+
import React, {
2+
useCallback,
3+
useContext,
4+
useEffect,
5+
useMemo,
6+
useState,
7+
} from "react";
28
import { zodResolver } from "@hookform/resolvers/zod";
39
import { type PorterApp } from "@porter-dev/api-contracts";
410
import axios from "axios";
@@ -22,8 +28,10 @@ import {
2228
} from "lib/porter-apps";
2329

2430
import api from "shared/api";
31+
import { Context } from "shared/Context";
2532

2633
import { type ExistingTemplateWithEnv } from "../types";
34+
import { RequiredApps } from "./RequiredApps";
2735
import { ServiceSettings } from "./ServiceSettings";
2836

2937
type Props = {
@@ -43,6 +51,7 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
4351
existingTemplate,
4452
}) => {
4553
const history = useHistory();
54+
const { currentProject } = useContext(Context);
4655

4756
const [tab, setTab] = useState<PreviewEnvSettingsTab>("services");
4857
const [validatedAppProto, setValidatedAppProto] = useState<PorterApp | null>(
@@ -252,8 +261,12 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
252261
options={[
253262
{ label: "App Services", value: "services" },
254263
{ label: "Environment Variables", value: "variables" },
255-
// { label: "Required Apps", value: "required-apps" },
256-
// { label: "Add-ons", value: "addons" },
264+
...(currentProject?.beta_features_enabled
265+
? [
266+
{ label: "Required Apps", value: "required-apps" },
267+
// { label: "Add-ons", value: "addons" },
268+
]
269+
: []),
257270
]}
258271
currentTab={tab}
259272
setCurrentTab={(tab: string) => {
@@ -280,6 +293,9 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
280293
buttonStatus={buttonStatus}
281294
/>
282295
))
296+
.with("required-apps", () => (
297+
<RequiredApps buttonStatus={buttonStatus} />
298+
))
283299
.otherwise(() => null)}
284300
</form>
285301
{showGHAModal && (
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import React, { useContext, useMemo } from "react";
2+
import { PorterApp } from "@porter-dev/api-contracts";
3+
import { useQuery } from "@tanstack/react-query";
4+
import {
5+
useFieldArray,
6+
useFormContext,
7+
type UseFieldArrayAppend,
8+
} from "react-hook-form";
9+
import styled from "styled-components";
10+
import { z } from "zod";
11+
12+
import Button from "components/porter/Button";
13+
import Container from "components/porter/Container";
14+
import Icon from "components/porter/Icon";
15+
import Spacer from "components/porter/Spacer";
16+
import Text from "components/porter/Text";
17+
import { type ButtonStatus } from "main/home/app-dashboard/app-view/AppDataContainer";
18+
import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
19+
import { AppIcon, AppSource } from "main/home/app-dashboard/apps/AppMeta";
20+
import {
21+
appRevisionWithSourceValidator,
22+
type AppRevisionWithSource,
23+
} from "main/home/app-dashboard/apps/types";
24+
import { type PorterAppFormData } from "lib/porter-apps";
25+
26+
import api from "shared/api";
27+
import { Context } from "shared/Context";
28+
import healthy from "assets/status-healthy.png";
29+
30+
type RowProps = {
31+
idx: number;
32+
app: AppRevisionWithSource;
33+
append: UseFieldArrayAppend<PorterAppFormData, "app.requiredApps">;
34+
remove: (index: number) => void;
35+
selected?: boolean;
36+
};
37+
38+
const RequiredAppRow: React.FC<RowProps> = ({
39+
idx,
40+
app,
41+
selected,
42+
append,
43+
remove,
44+
}) => {
45+
const proto = useMemo(() => {
46+
return PorterApp.fromJsonString(atob(app.app_revision.b64_app_proto), {
47+
ignoreUnknownFields: true,
48+
});
49+
}, [app.app_revision.b64_app_proto]);
50+
51+
return (
52+
<ResourceOption
53+
selected={selected}
54+
onClick={() => {
55+
if (selected) {
56+
remove(idx);
57+
} else {
58+
append({ name: app.source.name });
59+
}
60+
}}
61+
>
62+
<div>
63+
<Container row>
64+
<Spacer inline width="1px" />
65+
<AppIcon buildpacks={proto.build?.buildpacks ?? []} />
66+
<Spacer inline width="12px" />
67+
<Text size={14}>{proto.name}</Text>
68+
<Spacer inline x={1} />
69+
</Container>
70+
<Spacer height="15px" />
71+
<Container row>
72+
<AppSource source={app.source} />
73+
<Spacer inline x={1} />
74+
</Container>
75+
</div>
76+
{selected && <Icon height="18px" src={healthy} />}
77+
</ResourceOption>
78+
);
79+
};
80+
81+
type Props = {
82+
buttonStatus: ButtonStatus;
83+
};
84+
85+
export const RequiredApps: React.FC<Props> = ({ buttonStatus }) => {
86+
const { currentCluster, currentProject } = useContext(Context);
87+
88+
const {
89+
control,
90+
formState: { isSubmitting },
91+
} = useFormContext<PorterAppFormData>();
92+
const { append, remove, fields } = useFieldArray({
93+
control,
94+
name: "app.requiredApps",
95+
});
96+
97+
const { porterApp } = useLatestRevision();
98+
99+
const { data: apps = [] } = useQuery(
100+
[
101+
"getLatestAppRevisions",
102+
{
103+
cluster_id: currentCluster?.id,
104+
project_id: currentProject?.id,
105+
},
106+
],
107+
async () => {
108+
if (
109+
!currentCluster ||
110+
!currentProject ||
111+
currentCluster.id === -1 ||
112+
currentProject.id === -1
113+
) {
114+
return;
115+
}
116+
117+
const res = await api.getLatestAppRevisions(
118+
"<token>",
119+
{
120+
deployment_target_id: undefined,
121+
ignore_preview_apps: true,
122+
},
123+
{ cluster_id: currentCluster.id, project_id: currentProject.id }
124+
);
125+
126+
const apps = await z
127+
.object({
128+
app_revisions: z.array(appRevisionWithSourceValidator),
129+
})
130+
.parseAsync(res.data);
131+
132+
return apps.app_revisions;
133+
},
134+
{
135+
refetchOnWindowFocus: false,
136+
enabled: !!currentCluster && !!currentProject,
137+
}
138+
);
139+
140+
const remainingApps = useMemo(() => {
141+
return apps.filter((a) => a.source.name !== porterApp.name);
142+
}, [apps, porterApp]);
143+
144+
return (
145+
<div>
146+
<Text size={16}>Required Apps</Text>
147+
<Spacer y={0.5} />
148+
<RequiredAppList>
149+
{remainingApps.map((ra, i) => (
150+
<RequiredAppRow
151+
idx={i}
152+
key={ra.source.name}
153+
app={ra}
154+
selected={fields.some((f) => f.name === ra.source.name)}
155+
append={append}
156+
remove={remove}
157+
/>
158+
))}
159+
</RequiredAppList>
160+
<Spacer y={0.75} />
161+
<Button
162+
type="submit"
163+
status={buttonStatus}
164+
loadingText={"Updating..."}
165+
disabled={isSubmitting}
166+
>
167+
Update app
168+
</Button>
169+
</div>
170+
);
171+
};
172+
173+
const RequiredAppList = styled.div`
174+
display: flex;
175+
row-gap: 10px;
176+
flex-direction: column;
177+
`;
178+
179+
const ResourceOption = styled.div<{ selected?: boolean }>`
180+
background: ${(props) => props.theme.clickable.bg};
181+
border: 1px solid
182+
${(props) => (props.selected ? "#ffffff" : props.theme.border)};
183+
width: 100%;
184+
padding: 10px 15px;
185+
border-radius: 5px;
186+
display: flex;
187+
justify-content: space-between;
188+
align-items: center;
189+
cursor: pointer;
190+
:hover {
191+
border: 1px solid #ffffff;
192+
}
193+
`;

0 commit comments

Comments
 (0)