Skip to content

Commit

Permalink
POR-2109 support selecting required apps on frontend (#4096)
Browse files Browse the repository at this point in the history
  • Loading branch information
ianedwards authored Dec 14, 2023
1 parent a67ad76 commit 429effd
Show file tree
Hide file tree
Showing 5 changed files with 233 additions and 11 deletions.
14 changes: 7 additions & 7 deletions dashboard/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions dashboard/src/lib/porter-apps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;

Expand Down Expand Up @@ -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(
Expand All @@ -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();
Expand Down Expand Up @@ -486,6 +493,9 @@ export function clientAppFromProto({
efsStorage: new EFS({
enabled: proto.efsStorage?.enabled ?? false,
}),
requiredApps: proto.requiredApps.map((app) => ({
name: app.name,
})),
};
}

Expand Down Expand Up @@ -525,6 +535,9 @@ export function clientAppFromProto({
},
helmOverrides,
efsStorage: { enabled: proto.efsStorage?.enabled ?? false },
requiredApps: proto.requiredApps.map((app) => ({
name: app.name,
})),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 = {
Expand All @@ -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>(
Expand Down Expand Up @@ -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) => {
Expand All @@ -280,6 +293,9 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
buttonStatus={buttonStatus}
/>
))
.with("required-apps", () => (
<RequiredApps buttonStatus={buttonStatus} />
))
.otherwise(() => null)}
</form>
{showGHAModal && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
`;

0 comments on commit 429effd

Please sign in to comment.