Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions frontend/src/components/modal/AddRepositoryModal.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { mount } from "cypress/react";
import { BrowserRouter } from "react-router";

import { AppContextProvider } from "../../context/AppContext";

import AddRepositoryModal from "./AddRepositoryModal";

function renderModal() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

mount(
<AppContextProvider>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<AddRepositoryModal isOpen={true} onClose={() => {}} />
</QueryClientProvider>
</BrowserRouter>
</AppContextProvider>
);
}

describe("AddRepositoryModal preset suggestions", () => {
beforeEach(() => {
if (!document.getElementById("portal")) {
const portal = document.createElement("div");
portal.id = "portal";
document.body.appendChild(portal);
}
});

it("renders popular repository suggestions and auto-fills fields when clicked", () => {
renderModal();

cy.get("[data-cy='preset-repo-bitnami']").should("exist");
cy.get("[data-cy='preset-repo-jetstack']").should("exist");

cy.get("[data-cy='add-chart-name']").should("have.value", "");
cy.get("[data-cy='add-chart-url']").should("have.value", "");

cy.get("[data-cy='preset-repo-bitnami']").click();

cy.get("[data-cy='add-chart-name']").should("have.value", "bitnami");
cy.get("[data-cy='add-chart-url']").should(
"have.value",
"https://charts.bitnami.com/bitnami"
);
});
});
45 changes: 45 additions & 0 deletions frontend/src/components/modal/AddRepositoryModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { useState } from "react";

import apiService from "../../API/apiService";
import { useAppContext } from "../../context/AppContext";
import {
POPULAR_REPOSITORIES,
type SuggestedRepository,
} from "../../data/popularRepositories";
import useAlertError from "../../hooks/useAlertError";
import useCustomSearchParams from "../../hooks/useCustomSearchParams";
import useNavigateWithSearchParams from "../../hooks/useNavigateWithSearchParams";
Expand Down Expand Up @@ -32,12 +36,34 @@ function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
username: "",
password: "",
});

const [prevRepoName, setPrevRepoName] = useState(repo_name);
const [prevRepoUrl, setPrevRepoUrl] = useState(repo_url);

if (repo_name !== prevRepoName || repo_url !== prevRepoUrl) {
setPrevRepoName(repo_name);
setPrevRepoUrl(repo_url);
setFormData((prev) => ({
...prev,
name: repo_name ?? prev.name,
url: repo_url ?? prev.url,
}));
}

const [isLoading, setIsLoading] = useState(false);
const alertError = useAlertError();
const { setSelectedRepo } = useAppContext();
const navigate = useNavigateWithSearchParams();
const queryClient = useQueryClient();

const handleSelectPreset = (repo: SuggestedRepository) => {
setFormData((prev) => ({
...prev,
name: repo.name,
url: repo.url,
}));
};

const addRepository = async () => {
const body = new FormData();
body.append("name", formData.name ?? "");
Expand Down Expand Up @@ -105,6 +131,25 @@ function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
</div>
}
>
<div className="mb-5">
<div className="mb-2 text-xs font-semibold tracking-wider text-gray-500 uppercase">
Popular Repositories (click to auto-fill)
</div>
<div className="flex flex-wrap gap-2">
{POPULAR_REPOSITORIES.map((repo) => (
<button
key={repo.name}
type="button"
data-cy={`preset-repo-${repo.name}`}
onClick={() => handleSelectPreset(repo)}
className="inline-flex cursor-pointer items-center rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-700 transition-colors hover:border-sky-500 hover:bg-sky-50 hover:text-sky-700"
title={`${repo.name}: ${repo.description}`}
>
+ {repo.name}
</button>
))}
</div>
</div>
<div className="flex gap-x-3">
<label className="flex-1" htmlFor="name">
<div className="require mb-2 text-sm">Name</div>
Expand Down
55 changes: 55 additions & 0 deletions frontend/src/components/repository/RepositoriesList.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
POPULAR_REPOSITORIES,
type SuggestedRepository,
} from "../../data/popularRepositories";
import type { Repository } from "../../data/types";
import useCustomSearchParams from "../../hooks/useCustomSearchParams";
import AddRepositoryModal from "../modal/AddRepositoryModal";
Expand All @@ -22,6 +26,8 @@ function RepositoriesList({
upsertSearchParams("add_repo", "true");
} else {
removeSearchParam("add_repo");
removeSearchParam("repo_name");
removeSearchParam("repo_url");
}
};
const showInstallURLModal = searchParamsObject["install_url"] === "true";
Expand All @@ -33,6 +39,19 @@ function RepositoriesList({
}
};

const installedRepoUrls = new Set(
repositories?.map((r) => r.url.toLowerCase().replace(/\/$/, "")) ?? []
);
const installedRepoNames = new Set(
repositories?.map((r) => r.name.toLowerCase()) ?? []
);

const suggestedRepos = POPULAR_REPOSITORIES.filter(
(preset: SuggestedRepository) =>
!installedRepoNames.has(preset.name.toLowerCase()) &&
!installedRepoUrls.has(preset.url.toLowerCase().replace(/\/$/, ""))
);

return (
<>
<div className="custom-shadow flex h-fit w-72 flex-col gap-3 rounded-sm bg-white p-3 text-dark">
Expand Down Expand Up @@ -78,6 +97,42 @@ function RepositoriesList({
>
Install from URL
</button>

{suggestedRepos.length > 0 && (
<div className="border-t border-gray-100 pt-3">
<span className="text-xs font-bold tracking-wider text-gray-500 uppercase">
Popular Suggestions
</span>
<div className="mt-2 flex flex-col gap-2">
{suggestedRepos.slice(0, 4).map((suggested) => (
<div
key={suggested.name}
className="flex items-center justify-between rounded border border-gray-100 bg-gray-50 p-1.5 text-xs"
>
<span
className="truncate font-medium text-gray-700"
title={suggested.description}
>
{suggested.name}
</span>
<button
type="button"
data-cy={`quick-add-${suggested.name}`}
onClick={() => {
upsertSearchParams("repo_name", suggested.name);
upsertSearchParams("repo_url", suggested.url);
upsertSearchParams("add_repo", "true");
}}
className="cursor-pointer rounded border border-sky-600 px-2 py-0.5 text-[11px] font-semibold text-sky-600 hover:bg-sky-50"
>
+ Add
</button>
</div>
))}
</div>
</div>
)}

<p className="text-xs">
Charts developers: you can also add local directories as chart source.
Use{" "}
Expand Down
54 changes: 51 additions & 3 deletions frontend/src/components/repository/RepositoryViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { useNavigate } from "react-router";
import apiService from "../../API/apiService";
import { useUpdateRepo } from "../../API/repositories";
import { useAppContext } from "../../context/AppContext";
import { POPULAR_REPOSITORIES } from "../../data/popularRepositories";
import type { Chart, Repository } from "../../data/types";
import useCustomSearchParams from "../../hooks/useCustomSearchParams";
import Spinner from "../Spinner";

import ChartViewer from "./ChartViewer";
Expand All @@ -19,6 +21,7 @@ function RepositoryViewer({ repository }: RepositoryViewerProps) {
const [searchValue, setSearchValue] = useState("");
const [isRemoveLoading, setIsRemove] = useState(false);
const { setSelectedRepo, selectedRepo } = useAppContext();
const { upsertSearchParams } = useCustomSearchParams();
const queryClient = useQueryClient();

const navigate = useNavigate();
Expand Down Expand Up @@ -75,9 +78,54 @@ function RepositoryViewer({ repository }: RepositoryViewerProps) {

if (repository === undefined) {
return (
<div className="display-none no-charts mt-3 rounded-sm bg-white p-4 text-sm shadow-sm">
Looks like you don&apos;t have any repositories installed. You can add
one with the &quot;Add Repository&quot; button on the left side bar.
<div className="custom-shadow flex flex-col gap-4 rounded-md border bg-white p-6 font-inter">
<div>
<h2 className="text-xl font-semibold text-gray-900">
Chart Repositories
</h2>
<p className="mt-1 text-sm text-gray-600">
No repository is currently selected. Add one of the popular Helm
chart repositories below to start browsing and installing charts:
</p>
</div>

<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{POPULAR_REPOSITORIES.map((repo) => (
<div
key={repo.name}
className="flex flex-col justify-between rounded-lg border border-gray-200 bg-gray-50/50 p-4 transition-all hover:border-sky-400 hover:shadow-xs"
>
<div>
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-900">{repo.name}</h3>
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
helm
</span>
</div>
<p className="mt-1 text-xs text-gray-500">{repo.description}</p>
<div
className="mt-2 truncate font-mono text-[11px] text-gray-400"
title={repo.url}
>
{repo.url}
</div>
</div>

<button
type="button"
data-cy={`empty-state-add-${repo.name}`}
onClick={() => {
upsertSearchParams("repo_name", repo.name);
upsertSearchParams("repo_url", repo.url);
upsertSearchParams("add_repo", "true");
}}
className="mt-4 inline-flex w-full cursor-pointer items-center justify-center rounded bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-sky-700"
>
+ Add {repo.name}
</button>
</div>
))}
</div>
</div>
);
}
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/data/popularRepositories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Repository } from "./types";

export interface SuggestedRepository extends Repository {
description: string;
}

export const POPULAR_REPOSITORIES: SuggestedRepository[] = [
{
name: "bitnami",
url: "https://charts.bitnami.com/bitnami",
description: "Production-ready open source application charts",
},
{
name: "jetstack",
url: "https://charts.jetstack.io",
description: "cert-manager and PKI management tools",
},
{
name: "ingress-nginx",
url: "https://kubernetes.github.io/ingress-nginx",
description: "Community Ingress controller for Kubernetes using NGINX",
},
{
name: "prometheus-community",
url: "https://prometheus-community.github.io/helm-charts",
description: "Prometheus, Alertmanager, and monitoring stack",
},
{
name: "grafana",
url: "https://grafana.github.io/helm-charts",
description: "Grafana, Loki, Tempo, and observability tooling",
},
{
name: "argo",
url: "https://argoproj.github.io/helm-charts",
description: "Argo Workflows, Argo CD, Argo Events, and Rollouts",
},
{
name: "komodorio",
url: "https://helm-charts.komodor.io",
description: "Komodor platform agent and tools",
},
];
Loading