mirror of
https://github.com/komodorio/helm-dashboard.git
synced 2026-03-26 06:18:04 +00:00
Rename frontend directory (#472)
* Rename directory * Cleanup * Recover lost images * remove lint
This commit is contained in:
25
frontend/src/components/modal/AddRepositoryModal.stories.tsx
Normal file
25
frontend/src/components/modal/AddRepositoryModal.stories.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// Modal.stories.ts|tsx
|
||||
|
||||
import { ComponentStory, ComponentMeta } from "@storybook/react";
|
||||
import AddRepositoryModal from "./AddRepositoryModal";
|
||||
|
||||
//👇 This default export determines where your story goes in the story list
|
||||
export default {
|
||||
/* 👇 The title prop is optional.
|
||||
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
|
||||
* to learn how to generate automatic titles
|
||||
*/
|
||||
title: "AddRepositoryModal",
|
||||
component: AddRepositoryModal,
|
||||
} as ComponentMeta<typeof AddRepositoryModal>;
|
||||
|
||||
//👇 We create a “template” of how args map to rendering
|
||||
const Template: ComponentStory<typeof AddRepositoryModal> = (args) => (
|
||||
<AddRepositoryModal {...args} isOpen={true} />
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
|
||||
Default.args = {
|
||||
isOpen: true,
|
||||
};
|
||||
166
frontend/src/components/modal/AddRepositoryModal.tsx
Normal file
166
frontend/src/components/modal/AddRepositoryModal.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "./Modal";
|
||||
import Spinner from "../Spinner";
|
||||
import useAlertError from "../../hooks/useAlertError";
|
||||
import useCustomSearchParams from "../../hooks/useCustomSearchParams";
|
||||
import { useAppContext } from "../../context/AppContext";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import apiService from "../../API/apiService";
|
||||
|
||||
interface FormKeys {
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
type AddRepositoryModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
|
||||
const [formData, setFormData] = useState<FormKeys>({} as FormKeys);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const alertError = useAlertError();
|
||||
const { searchParamsObject } = useCustomSearchParams();
|
||||
const { repo_url, repo_name } = searchParamsObject;
|
||||
const { setSelectedRepo } = useAppContext();
|
||||
const { context } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!repo_url || !repo_name) return;
|
||||
setFormData({ ...formData, name: repo_name, url: repo_url });
|
||||
}, [repo_url, repo_name, formData]);
|
||||
|
||||
const addRepository = () => {
|
||||
const body = new FormData();
|
||||
body.append("name", formData.name ?? "");
|
||||
body.append("url", formData.url ?? "");
|
||||
body.append("username", formData.username ?? "");
|
||||
body.append("password", formData.password ?? "");
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
apiService.fetchWithDefaults<void>("/api/helm/repositories", {
|
||||
method: "POST",
|
||||
body,
|
||||
})
|
||||
.then(() => {
|
||||
setIsLoading(false);
|
||||
onClose();
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["helm", "repositories"],
|
||||
});
|
||||
setSelectedRepo(formData.name || "");
|
||||
navigate(`/${context}/repository/${formData.name}`, {
|
||||
replace: true,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
alertError.setShowErrorModal({
|
||||
title: "Failed to add repo",
|
||||
msg: error.message,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
containerClassNames={"w-full max-w-5xl"}
|
||||
title="Add Chart Repository"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
bottomContent={
|
||||
<div className="flex justify-end p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600">
|
||||
<button
|
||||
className="flex items-center text-white font-medium px-3 py-1.5 bg-primary hover:bg-add-repo focus:ring-4 focus:outline-none focus:ring-blue-300 disabled:bg-blue-300 rounded-lg text-base text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
onClick={addRepository}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Spinner size={4} />}
|
||||
Add Repository
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-x-3">
|
||||
<label className="flex-1" htmlFor="name">
|
||||
<div className="mb-2 text-sm require">Name</div>
|
||||
<input
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Komodorio"
|
||||
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1" htmlFor="url">
|
||||
<div className="mb-2 text-sm require">URL</div>
|
||||
<input
|
||||
value={formData.url}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
id="url"
|
||||
type="text"
|
||||
placeholder="https://helm-charts.komodor.io"
|
||||
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-x-3">
|
||||
<label className="flex-1 " htmlFor="username">
|
||||
<div className="mb-2 text-sm">Username</div>
|
||||
<input
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
id="username"
|
||||
type="text"
|
||||
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1" htmlFor="password">
|
||||
<div className="mb-2 text-sm">Password</div>
|
||||
<input
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
id="password"
|
||||
type="text"
|
||||
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddRepositoryModal;
|
||||
31
frontend/src/components/modal/ErrorModal.stories.tsx
Normal file
31
frontend/src/components/modal/ErrorModal.stories.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/* eslint-disable no-console */
|
||||
// Modal.stories.ts|tsx
|
||||
|
||||
import { ComponentStory, ComponentMeta } from "@storybook/react";
|
||||
import ErrorModal from "./ErrorModal";
|
||||
|
||||
//👇 This default export determines where your story goes in the story list
|
||||
export default {
|
||||
/* 👇 The title prop is optional.
|
||||
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
|
||||
* to learn how to generate automatic titles
|
||||
*/
|
||||
title: "ErrorModal",
|
||||
component: ErrorModal,
|
||||
} as ComponentMeta<typeof ErrorModal>;
|
||||
|
||||
//👇 We create a “template” of how args map to rendering
|
||||
const Template: ComponentStory<typeof ErrorModal> = (args) => (
|
||||
<ErrorModal {...args} />
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
|
||||
Default.args = {
|
||||
onClose: () => {
|
||||
console.log("on Close clicked");
|
||||
},
|
||||
titleText: "Failed to get list of charts",
|
||||
contentText:
|
||||
"failed to get list of releases, cause: Kubernetes cluster unreachable: Get "https://kubernetes.docker.internal:6443/version": dial tcp 127.0.0.1:6443: connectex: No connection could be made because the target machine actively refused it.",
|
||||
};
|
||||
63
frontend/src/components/modal/ErrorModal.tsx
Normal file
63
frontend/src/components/modal/ErrorModal.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Modal from "./Modal";
|
||||
|
||||
interface ErrorModalProps {
|
||||
isOpen: boolean;
|
||||
titleText: string;
|
||||
contentText: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ErrorModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
titleText,
|
||||
contentText,
|
||||
}: ErrorModalProps) {
|
||||
const ErrorTitle = (
|
||||
<div className="font-medium text-2xl text-error-color">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="26"
|
||||
height="26"
|
||||
fill="currentColor"
|
||||
className="bi bi-exclamation-triangle-fill mt-1"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
|
||||
</svg>
|
||||
{titleText}
|
||||
</div>
|
||||
<h4 className="alert-heading" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const bottomContent = (
|
||||
<div className="flex py-6 px-4 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600">
|
||||
<span className="text-sm text-muted fs-80 text-gray-500">
|
||||
Hint: Komodor has the same HELM capabilities, with enterprise features
|
||||
and support.{" "}
|
||||
<a
|
||||
href="https://www.komodor.com/helm-dash/?utm_campaign=Helm%20Dashboard%20%7C%20CTA&utm_source=helm-dash&utm_medium=cta&utm_content=helm-dash"
|
||||
target="_blank" rel="noreferrer"
|
||||
>
|
||||
<span className="text-link-color underline">Sign up for free.</span>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
containerClassNames={
|
||||
"border-2 border-error-border-color bg-error-background w-2/3"
|
||||
}
|
||||
title={ErrorTitle}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
bottomContent={bottomContent}
|
||||
>
|
||||
<p className="text-error-color border-green-400">{contentText}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/modal/GlobalErrorModal.tsx
Normal file
66
frontend/src/components/modal/GlobalErrorModal.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import Modal from "./Modal";
|
||||
|
||||
interface ErrorModalProps {
|
||||
isOpen: boolean;
|
||||
titleText: string;
|
||||
contentText: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function GlobalErrorModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
titleText,
|
||||
contentText,
|
||||
}: ErrorModalProps) {
|
||||
const ErrorTitle = (
|
||||
<div className="font-medium text-2xl text-error-color">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="26"
|
||||
height="26"
|
||||
fill="currentColor"
|
||||
className="bi bi-exclamation-triangle-fill mt-1"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
|
||||
</svg>
|
||||
{titleText}
|
||||
</div>
|
||||
<h4 className="alert-heading" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
containerClassNames={
|
||||
"border-2 border-error-border-color bg-error-background w-3/5 "
|
||||
}
|
||||
title={ErrorTitle}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
bottomContent={
|
||||
<div className="text-xs">
|
||||
Hint: Komodor has the same HELM capabilities, with enterprise features
|
||||
and support.{" "}
|
||||
<a
|
||||
className="text-blue-500"
|
||||
href="https://komodor.com/helm-dash/?utm_campaign=Helm+Dashboard+%7C+CTA&utm_source=helm-dash&utm_medium=cta&utm_content=helm-dash"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Sign up for free.
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p
|
||||
style={{ minWidth: "500px" }}
|
||||
className="text-error-color border-green-400 text-sm"
|
||||
>
|
||||
{contentText}
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import hljs from "highlight.js";
|
||||
import Spinner from "../../Spinner";
|
||||
|
||||
export const ChartValues = ({
|
||||
chartValues,
|
||||
loading,
|
||||
}: {
|
||||
chartValues: string;
|
||||
loading: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-1/2">
|
||||
<label
|
||||
className="block tracking-wide text-gray-700 text-xl font-medium mb-2"
|
||||
htmlFor="grid-user-defined-values"
|
||||
>
|
||||
Chart Value Reference:
|
||||
</label>
|
||||
<pre
|
||||
className="text-base bg-chart-values p-2 rounded font-medium w-full max-h-[330px] block overflow-y-auto font-sf-mono"
|
||||
dangerouslySetInnerHTML={
|
||||
chartValues && !loading
|
||||
? {
|
||||
__html: hljs.highlight(chartValues, {
|
||||
language: "yaml",
|
||||
}).value,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : !chartValues && !loading ? (
|
||||
"No original values information found"
|
||||
) : null}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChartValues } from "./ChartValues";
|
||||
import { UserDefinedValues } from "./UserDefinedValues";
|
||||
|
||||
interface DefinedValuesProps {
|
||||
initialValue: string;
|
||||
onUserValuesChange: (values: string) => void;
|
||||
chartValues: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const DefinedValues = ({
|
||||
initialValue,
|
||||
chartValues,
|
||||
onUserValuesChange,
|
||||
loading,
|
||||
}: DefinedValuesProps) => {
|
||||
return (
|
||||
<div className="flex w-full gap-6 mt-4">
|
||||
<UserDefinedValues
|
||||
initialValue={initialValue}
|
||||
onValuesChange={onUserValuesChange}
|
||||
/>
|
||||
<ChartValues chartValues={chartValues} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import useDebounce from "../../../hooks/useDebounce";
|
||||
|
||||
export const GeneralDetails = ({
|
||||
releaseName,
|
||||
namespace = "",
|
||||
disabled,
|
||||
onNamespaceInput,
|
||||
onReleaseNameInput,
|
||||
}: {
|
||||
releaseName: string;
|
||||
namespace?: string;
|
||||
disabled: boolean;
|
||||
|
||||
onNamespaceInput: (namespace: string) => void;
|
||||
onReleaseNameInput: (chartName: string) => void;
|
||||
}) => {
|
||||
const [namespaceInputValue, setNamespaceInputValue] = useState(namespace);
|
||||
const namespaceInputValueDebounced = useDebounce<string>(namespaceInputValue, 500);
|
||||
useEffect(() => {
|
||||
onNamespaceInput(namespaceInputValueDebounced);
|
||||
}, [namespaceInputValueDebounced, onNamespaceInput]);
|
||||
const { context } = useParams();
|
||||
const inputClassName = ` text-lg py-1 px-2 border border-1 border-gray-300 ${
|
||||
disabled ? "bg-gray-200" : "bg-white "
|
||||
} rounded`;
|
||||
return (
|
||||
<div className="flex gap-8">
|
||||
<div>
|
||||
<h4 className="text-lg">Release name:</h4>
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={releaseName}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onReleaseNameInput(e.target.value)}
|
||||
></input>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg">Namespace (optional):</h4>
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={namespaceInputValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setNamespaceInputValue(e.target.value)}
|
||||
></input>
|
||||
</div>
|
||||
{context ? (
|
||||
<div className="flex">
|
||||
<h4 className="text-lg">Cluster:</h4>
|
||||
<p className="text-lg">{context}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import useAlertError from "../../../hooks/useAlertError";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useChartReleaseValues,
|
||||
useGetReleaseManifest,
|
||||
useGetVersions,
|
||||
useVersionData,
|
||||
} from "../../../API/releases";
|
||||
import Modal, { ModalButtonStyle } from "../Modal";
|
||||
import { GeneralDetails } from "./GeneralDetails";
|
||||
import { ManifestDiff } from "./ManifestDiff";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import useNavigateWithSearchParams from "../../../hooks/useNavigateWithSearchParams";
|
||||
import { VersionToInstall } from "./VersionToInstall";
|
||||
import { isNewerVersion, isNoneEmptyArray } from "../../../utils";
|
||||
import useCustomSearchParams from "../../../hooks/useCustomSearchParams";
|
||||
import { useChartRepoValues } from "../../../API/repositories";
|
||||
import { useDiffData } from "../../../API/shared";
|
||||
import { InstallChartModalProps } from "../../../data/types";
|
||||
import { DefinedValues } from "./DefinedValues";
|
||||
|
||||
export const InstallReleaseChartModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
chartName,
|
||||
currentlyInstalledChartVersion,
|
||||
latestVersion,
|
||||
isUpgrade = false,
|
||||
latestRevision,
|
||||
}: InstallChartModalProps) => {
|
||||
const navigate = useNavigateWithSearchParams();
|
||||
const { setShowErrorModal } = useAlertError();
|
||||
const [userValues, setUserValues] = useState<string>();
|
||||
const [installError, setInstallError] = useState("");
|
||||
|
||||
const {
|
||||
namespace: queryNamespace,
|
||||
chart: _releaseName,
|
||||
context: selectedCluster,
|
||||
} = useParams();
|
||||
const { searchParamsObject } = useCustomSearchParams();
|
||||
const { filteredNamespace } = searchParamsObject;
|
||||
const [namespace, setNamespace] = useState(queryNamespace || "");
|
||||
const [releaseName, setReleaseName] = useState(_releaseName || "");
|
||||
|
||||
const { error: versionsError, data: _versions } = useGetVersions(chartName, {
|
||||
select: (data) => {
|
||||
return data?.sort((a, b) =>
|
||||
isNewerVersion(a.version, b.version) ? 1 : -1
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const empty = { version: "", repository: "", urls: [] };
|
||||
return setSelectedVersionData(data[0] ?? empty);
|
||||
},
|
||||
});
|
||||
|
||||
const versions = _versions?.map((v) => ({
|
||||
...v,
|
||||
isChartVersion: v.version === currentlyInstalledChartVersion,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
latestVersion = latestVersion ?? currentlyInstalledChartVersion; // a guard for typescript, latestVersion is always defined
|
||||
const [selectedVersionData, setSelectedVersionData] = useState<{
|
||||
version: string;
|
||||
repository?: string;
|
||||
urls: string[];
|
||||
}>();
|
||||
|
||||
const selectedVersion = useMemo(() => {
|
||||
return selectedVersionData?.version;
|
||||
}, [selectedVersionData]);
|
||||
|
||||
const selectedRepo = useMemo(() => {
|
||||
return selectedVersionData?.repository || "";
|
||||
}, [selectedVersionData]);
|
||||
|
||||
const chartAddress = useMemo(() => {
|
||||
if (!selectedVersionData || !selectedVersionData.repository) return "";
|
||||
|
||||
return selectedVersionData.urls?.[0]?.startsWith("file://")
|
||||
? selectedVersionData.urls[0]
|
||||
: `${selectedVersionData.repository}/${chartName}`;
|
||||
}, [selectedVersionData, chartName]);
|
||||
|
||||
// the original chart values
|
||||
const { data: chartValues } = useChartRepoValues({
|
||||
version: selectedVersion || "",
|
||||
chart: chartAddress,
|
||||
});
|
||||
|
||||
// The user defined values (if any we're set)
|
||||
const { data: releaseValues, isLoading: loadingReleaseValues } =
|
||||
useChartReleaseValues({
|
||||
namespace,
|
||||
release: String(releaseName),
|
||||
revision: latestRevision ? latestRevision : undefined,
|
||||
});
|
||||
|
||||
// This hold the selected version manifest, we use it for the diff
|
||||
const { data: selectedVerData, error: selectedVerDataError } = useVersionData(
|
||||
{
|
||||
version: selectedVersion || "",
|
||||
userValues: userValues || "",
|
||||
chartAddress,
|
||||
releaseValues,
|
||||
namespace,
|
||||
releaseName,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: currentVerManifest, error: currentVerManifestError } =
|
||||
useGetReleaseManifest({
|
||||
namespace,
|
||||
chartName: _releaseName || "",
|
||||
});
|
||||
|
||||
const {
|
||||
data: diffData,
|
||||
isLoading: isLoadingDiff,
|
||||
error: diffError,
|
||||
} = useDiffData({
|
||||
selectedRepo,
|
||||
versionsError: versionsError as string,
|
||||
currentVerManifest,
|
||||
selectedVerData,
|
||||
chart: chartAddress,
|
||||
});
|
||||
|
||||
// Confirm method (install)
|
||||
const setReleaseVersionMutation = useMutation(
|
||||
[
|
||||
"setVersion",
|
||||
namespace,
|
||||
releaseName,
|
||||
selectedVersion,
|
||||
selectedRepo,
|
||||
selectedCluster,
|
||||
chartAddress,
|
||||
],
|
||||
async () => {
|
||||
setInstallError("");
|
||||
const formData = new FormData();
|
||||
formData.append("preview", "false");
|
||||
if (chartAddress) {
|
||||
formData.append("chart", chartAddress);
|
||||
}
|
||||
formData.append("version", selectedVersion || "");
|
||||
formData.append("values", userValues || releaseValues || ""); // if userValues is empty, we use the release values
|
||||
|
||||
const res = await fetch(
|
||||
// Todo: Change to BASE_URL from env
|
||||
`/api/helm/releases/${
|
||||
namespace ? namespace : "default"
|
||||
}${`/${releaseName}`}`,
|
||||
{
|
||||
method: "post",
|
||||
body: formData,
|
||||
headers: {
|
||||
"X-Kubecontext": selectedCluster as string,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
setShowErrorModal({
|
||||
title: "Failed to upgrade the chart",
|
||||
msg: String(await res.text()),
|
||||
});
|
||||
}
|
||||
|
||||
return res.json();
|
||||
},
|
||||
{
|
||||
onSuccess: async (response) => {
|
||||
onClose();
|
||||
setSelectedVersionData({ version: "", urls: [] }); //cleanup
|
||||
navigate(
|
||||
`/${selectedCluster}/${
|
||||
namespace ? namespace : "default"
|
||||
}/${releaseName}/installed/revision/${response.version}`
|
||||
);
|
||||
window.location.reload();
|
||||
},
|
||||
onError: (error) => {
|
||||
setInstallError((error as Error)?.message || "Failed to update");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
setSelectedVersionData({ version: "", urls: [] });
|
||||
setUserValues(releaseValues);
|
||||
onClose();
|
||||
}}
|
||||
title={
|
||||
<div className="font-bold">
|
||||
{`${isUpgrade ? "Upgrade" : "Install"} `}
|
||||
{(isUpgrade || releaseValues) && (
|
||||
<span className="text-green-700 ">{chartName}</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
containerClassNames="w-full text-2xl h-2/3"
|
||||
actions={[
|
||||
{
|
||||
id: "1",
|
||||
callback: setReleaseVersionMutation.mutate,
|
||||
variant: ModalButtonStyle.info,
|
||||
isLoading: setReleaseVersionMutation.isLoading,
|
||||
disabled:
|
||||
loadingReleaseValues ||
|
||||
isLoadingDiff ||
|
||||
setReleaseVersionMutation.isLoading,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{versions && isNoneEmptyArray(versions) && (
|
||||
<VersionToInstall
|
||||
versions={versions}
|
||||
initialVersion={selectedVersionData}
|
||||
onSelectVersion={setSelectedVersionData}
|
||||
showCurrentVersion
|
||||
/>
|
||||
)}
|
||||
|
||||
<GeneralDetails
|
||||
releaseName={releaseName}
|
||||
disabled
|
||||
namespace={namespace ? namespace : filteredNamespace}
|
||||
onReleaseNameInput={setReleaseName}
|
||||
onNamespaceInput={setNamespace}
|
||||
/>
|
||||
|
||||
<DefinedValues
|
||||
initialValue={releaseValues}
|
||||
onUserValuesChange={(values: string) => setUserValues(values)}
|
||||
chartValues={chartValues}
|
||||
loading={loadingReleaseValues}
|
||||
/>
|
||||
|
||||
<ManifestDiff
|
||||
diff={diffData as string}
|
||||
isLoading={isLoadingDiff}
|
||||
error={
|
||||
(currentVerManifestError as string) ||
|
||||
(selectedVerDataError as string) ||
|
||||
(diffError as string) ||
|
||||
installError ||
|
||||
(versionsError as string)
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import useAlertError from "../../../hooks/useAlertError";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useGetVersions, useVersionData } from "../../../API/releases";
|
||||
import Modal, { ModalButtonStyle } from "../Modal";
|
||||
import { GeneralDetails } from "./GeneralDetails";
|
||||
import { ManifestDiff } from "./ManifestDiff";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useChartRepoValues } from "../../../API/repositories";
|
||||
import useNavigateWithSearchParams from "../../../hooks/useNavigateWithSearchParams";
|
||||
import { VersionToInstall } from "./VersionToInstall";
|
||||
import { isNewerVersion, isNoneEmptyArray } from "../../../utils";
|
||||
import { useDiffData } from "../../../API/shared";
|
||||
import { InstallChartModalProps } from "../../../data/types";
|
||||
import { DefinedValues } from "./DefinedValues";
|
||||
|
||||
export const InstallRepoChartModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
chartName,
|
||||
currentlyInstalledChartVersion,
|
||||
latestVersion,
|
||||
}: InstallChartModalProps) => {
|
||||
const navigate = useNavigateWithSearchParams();
|
||||
const { setShowErrorModal } = useAlertError();
|
||||
const [userValues, setUserValues] = useState("");
|
||||
const [installError, setInstallError] = useState("");
|
||||
|
||||
const { context: selectedCluster, selectedRepo: currentRepoCtx } =
|
||||
useParams();
|
||||
const [namespace, setNamespace] = useState("");
|
||||
const [releaseName, setReleaseName] = useState(chartName);
|
||||
|
||||
const { error: versionsError, data: _versions } = useGetVersions(chartName, {
|
||||
select: (data) => {
|
||||
return data?.sort((a, b) =>
|
||||
isNewerVersion(a.version, b.version) ? 1 : -1
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const empty = { version: "", repository: "", urls: [] };
|
||||
const versionsToRepo = data.filter(
|
||||
(v) => v.repository === currentRepoCtx
|
||||
);
|
||||
|
||||
return setSelectedVersionData(versionsToRepo[0] ?? empty);
|
||||
},
|
||||
});
|
||||
|
||||
const versions = _versions?.map((v) => ({
|
||||
...v,
|
||||
isChartVersion: v.version === currentlyInstalledChartVersion,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
latestVersion = latestVersion ?? currentlyInstalledChartVersion; // a guard for typescript, latestVersion is always defined
|
||||
const [selectedVersionData, setSelectedVersionData] = useState<{
|
||||
version: string;
|
||||
repository?: string;
|
||||
urls: string[];
|
||||
}>();
|
||||
|
||||
const selectedVersion = useMemo(() => {
|
||||
return selectedVersionData?.version;
|
||||
}, [selectedVersionData]);
|
||||
|
||||
const selectedRepo = useMemo(() => {
|
||||
return selectedVersionData?.repository;
|
||||
}, [selectedVersionData]);
|
||||
|
||||
const chartAddress = useMemo(() => {
|
||||
if (!selectedVersionData || !selectedVersionData?.repository) {
|
||||
return "";
|
||||
}
|
||||
return selectedVersionData?.urls?.[0]?.startsWith("file://")
|
||||
? selectedVersionData?.urls[0]
|
||||
: `${selectedVersionData?.repository}/${chartName}`;
|
||||
}, [selectedVersionData, chartName]);
|
||||
|
||||
const { data: chartValues, isLoading: loadingChartValues } =
|
||||
useChartRepoValues({
|
||||
version: selectedVersion || "",
|
||||
chart: chartAddress,
|
||||
});
|
||||
|
||||
// This hold the selected version manifest, we use it for the diff
|
||||
const { data: selectedVerData, error: selectedVerDataError } = useVersionData(
|
||||
{
|
||||
version: selectedVersion || "",
|
||||
userValues,
|
||||
chartAddress,
|
||||
releaseValues: userValues,
|
||||
namespace,
|
||||
releaseName,
|
||||
isInstallRepoChart: true,
|
||||
options: {
|
||||
enabled: Boolean(chartAddress),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: diffData,
|
||||
isLoading: isLoadingDiff,
|
||||
error: diffError,
|
||||
} = useDiffData({
|
||||
selectedRepo: selectedRepo || "",
|
||||
versionsError: versionsError as string,
|
||||
currentVerManifest: "", // current version manifest should always be empty since its a fresh install
|
||||
selectedVerData,
|
||||
chart: chartAddress,
|
||||
});
|
||||
|
||||
// Confirm method (install)
|
||||
const setReleaseVersionMutation = useMutation(
|
||||
[
|
||||
"setVersion",
|
||||
namespace,
|
||||
releaseName,
|
||||
selectedVersion,
|
||||
selectedRepo,
|
||||
selectedCluster,
|
||||
chartAddress,
|
||||
],
|
||||
async () => {
|
||||
setInstallError("");
|
||||
const formData = new FormData();
|
||||
formData.append("preview", "false");
|
||||
formData.append("chart", chartAddress);
|
||||
formData.append("version", selectedVersion || "");
|
||||
formData.append("values", userValues);
|
||||
formData.append("name", releaseName || "");
|
||||
const res = await fetch(
|
||||
// Todo: Change to BASE_URL from env
|
||||
`/api/helm/releases/${namespace ? namespace : "default"}`,
|
||||
{
|
||||
method: "post",
|
||||
body: formData,
|
||||
headers: {
|
||||
"X-Kubecontext": selectedCluster as string,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
setShowErrorModal({
|
||||
title: "Failed to install the chart",
|
||||
msg: String(await res.text()),
|
||||
});
|
||||
}
|
||||
|
||||
return res.json();
|
||||
},
|
||||
{
|
||||
onSuccess: async (response) => {
|
||||
onClose();
|
||||
navigate(
|
||||
`/${selectedCluster}/${response.namespace}/${response.name}/installed/revision/1`
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
setInstallError((error as Error)?.message || "Failed to update");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
setSelectedVersionData({ version: "", urls: [] });
|
||||
onClose();
|
||||
}}
|
||||
title={
|
||||
<div className="font-bold">
|
||||
Install <span className="text-green-700 ">{chartName}</span>
|
||||
</div>
|
||||
}
|
||||
containerClassNames="w-full text-2xl h-2/3"
|
||||
actions={[
|
||||
{
|
||||
id: "1",
|
||||
callback: setReleaseVersionMutation.mutate,
|
||||
variant: ModalButtonStyle.info,
|
||||
isLoading: setReleaseVersionMutation.isLoading,
|
||||
disabled:
|
||||
loadingChartValues ||
|
||||
isLoadingDiff ||
|
||||
setReleaseVersionMutation.isLoading,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{versions && isNoneEmptyArray(versions) && (
|
||||
<VersionToInstall
|
||||
versions={versions}
|
||||
initialVersion={selectedVersionData}
|
||||
onSelectVersion={setSelectedVersionData}
|
||||
showCurrentVersion={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<GeneralDetails
|
||||
releaseName={releaseName ?? ""}
|
||||
disabled={false}
|
||||
namespace={namespace}
|
||||
onReleaseNameInput={setReleaseName}
|
||||
onNamespaceInput={setNamespace}
|
||||
/>
|
||||
|
||||
<DefinedValues
|
||||
initialValue={""}
|
||||
onUserValuesChange={setUserValues}
|
||||
chartValues={chartValues}
|
||||
loading={loadingChartValues}
|
||||
/>
|
||||
|
||||
<ManifestDiff
|
||||
diff={diffData as string}
|
||||
isLoading={isLoadingDiff}
|
||||
error={
|
||||
(selectedVerDataError as string) ||
|
||||
(diffError as string) ||
|
||||
installError ||
|
||||
(versionsError as string)
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Diff2HtmlUI } from "diff2html/lib/ui/js/diff2html-ui-base";
|
||||
import hljs from "highlight.js";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Spinner from "../../Spinner";
|
||||
import { diffConfiguration } from "../../../utils";
|
||||
|
||||
interface ManifestDiffProps {
|
||||
diff?: string;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export const ManifestDiff = ({ diff, isLoading, error }: ManifestDiffProps) => {
|
||||
const diffContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
// we're listening to isLoading to draw new diffs which are not
|
||||
// always rerender, probably because of the use of ref
|
||||
return;
|
||||
}
|
||||
|
||||
if (diff && diffContainerRef.current) {
|
||||
const diff2htmlUi = new Diff2HtmlUI(
|
||||
diffContainerRef.current,
|
||||
diff,
|
||||
diffConfiguration,
|
||||
hljs
|
||||
);
|
||||
diff2htmlUi.draw();
|
||||
diff2htmlUi.highlightCode();
|
||||
}
|
||||
}, [diff, isLoading]);
|
||||
|
||||
if (isLoading && !error) {
|
||||
return (
|
||||
<div className="flex text-lg items-end">
|
||||
<Spinner />
|
||||
Calculating diff...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-xl">Manifest changes:</h4>
|
||||
|
||||
{error ? (
|
||||
<p className="text-red-600 text-lg">
|
||||
Failed to get upgrade info: {error.toString()}
|
||||
</p>
|
||||
) : diff ? (
|
||||
<div
|
||||
ref={diffContainerRef}
|
||||
className="relative overflow-y-auto leading-5"
|
||||
></div>
|
||||
) : (
|
||||
<pre className="font-roboto text-base">
|
||||
No changes will happen to the cluster
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import useDebounce from "../../../hooks/useDebounce";
|
||||
|
||||
export const UserDefinedValues = ({
|
||||
initialValue,
|
||||
onValuesChange,
|
||||
}: {
|
||||
initialValue: string;
|
||||
onValuesChange: (val: string) => void;
|
||||
}) => {
|
||||
const [userDefinedValues, setUserDefinedValues] = useState(initialValue);
|
||||
const debouncedValue = useDebounce<string>(userDefinedValues, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedValue || debouncedValue === initialValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
onValuesChange(debouncedValue);
|
||||
}, [debouncedValue, onValuesChange, initialValue]);
|
||||
|
||||
return (
|
||||
<div className="w-1/2 ">
|
||||
<label
|
||||
className="block tracking-wide text-gray-700 text-xl font-medium mb-2"
|
||||
htmlFor="grid-user-defined-values"
|
||||
>
|
||||
User-Defined Values:
|
||||
</label>
|
||||
<textarea
|
||||
value={userDefinedValues}
|
||||
defaultValue={initialValue}
|
||||
onChange={(e) => setUserDefinedValues(e.target.value)}
|
||||
rows={14}
|
||||
className="block p-2.5 w-full text-md text-gray-900 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 resize-none font-monospace"
|
||||
></textarea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Select, { components } from "react-select";
|
||||
import { BsCheck2 } from "react-icons/bs";
|
||||
import { NonEmptyArray } from "../../../data/types";
|
||||
|
||||
interface Version {
|
||||
repository: string;
|
||||
version: string;
|
||||
isChartVersion: boolean;
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
export const VersionToInstall: React.FC<{
|
||||
versions: NonEmptyArray<Version>;
|
||||
initialVersion?: {
|
||||
repository?: string;
|
||||
version?: string;
|
||||
};
|
||||
onSelectVersion: (props: {
|
||||
version: string;
|
||||
repository: string;
|
||||
urls: string[];
|
||||
}) => void;
|
||||
showCurrentVersion: boolean;
|
||||
}> = ({ versions, onSelectVersion, showCurrentVersion, initialVersion }) => {
|
||||
const chartVersion = useMemo(
|
||||
() => versions.find(({ isChartVersion }) => isChartVersion)?.version,
|
||||
[versions]
|
||||
);
|
||||
|
||||
const currentVersion =
|
||||
chartVersion && showCurrentVersion ? (
|
||||
<p className="text-xl text-muted ml-2">
|
||||
{"(current version is "}
|
||||
<span className="text-green-700">{`${chartVersion}`}</span>
|
||||
{")"}
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
// Prepare your options for react-select
|
||||
const options = useMemo(
|
||||
() =>
|
||||
versions.map(({ repository, version, urls }) => ({
|
||||
value: { repository, version, urls },
|
||||
label: `${repository} @ ${version}`,
|
||||
check: chartVersion === version,
|
||||
})) || [],
|
||||
[chartVersion, versions]
|
||||
);
|
||||
const [selectedOption, setSelectedOption] =
|
||||
useState<(typeof options)[number]>();
|
||||
const initOpt = useMemo(
|
||||
() =>
|
||||
options.find(
|
||||
({ value }) =>
|
||||
value.version === initialVersion?.version &&
|
||||
value.repository === initialVersion?.repository
|
||||
),
|
||||
[options, initialVersion]
|
||||
);
|
||||
return (
|
||||
<div className="flex gap-2 text-xl items-center">
|
||||
{versions?.length && (selectedOption || initOpt) ? (
|
||||
<>
|
||||
Version to install:{" "}
|
||||
<Select
|
||||
className="basic-single cursor-pointer min-w-[272px]"
|
||||
classNamePrefix="select"
|
||||
isClearable={false}
|
||||
isSearchable={false}
|
||||
name="version"
|
||||
options={options}
|
||||
onChange={(selectedOption) => {
|
||||
if (selectedOption) {
|
||||
setSelectedOption(selectedOption);
|
||||
onSelectVersion(selectedOption.value);
|
||||
}
|
||||
}}
|
||||
value={selectedOption ?? initOpt}
|
||||
components={{
|
||||
SingleValue: ({ children, ...props }) => (
|
||||
<components.SingleValue {...props}>
|
||||
<span className="text-green-700 font-bold">{children}</span>
|
||||
{props.data.check && showCurrentVersion && (
|
||||
<BsCheck2 className="inline-block ml-2 text-green-700 font-bold" />
|
||||
)}
|
||||
</components.SingleValue>
|
||||
),
|
||||
Option: ({ children, innerProps, data }) => (
|
||||
<div
|
||||
className={
|
||||
"flex items-center py-2 pl-4 pr-2 text-green-700 hover:bg-blue-100"
|
||||
}
|
||||
{...innerProps}
|
||||
>
|
||||
<div className="width-auto">{children}</div>
|
||||
{data.check && showCurrentVersion && (
|
||||
<BsCheck2
|
||||
fontWeight={"bold"}
|
||||
className="inline-block ml-2 text-green-700 font-bold"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}} // Use the custom Option component
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{currentVersion}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
116
frontend/src/components/modal/Modal.stories.tsx
Normal file
116
frontend/src/components/modal/Modal.stories.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/* eslint-disable no-console */
|
||||
// Modal.stories.ts|tsx
|
||||
|
||||
import { ComponentStory, ComponentMeta } from "@storybook/react";
|
||||
import Modal, { ModalAction, ModalButtonStyle } from "./Modal";
|
||||
|
||||
//👇 This default export determines where your story goes in the story list
|
||||
export default {
|
||||
/* 👇 The title prop is optional.
|
||||
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
|
||||
* to learn how to generate automatic titles
|
||||
*/
|
||||
title: "Modal",
|
||||
component: Modal,
|
||||
} as ComponentMeta<typeof Modal>;
|
||||
|
||||
//👇 We create a “template” of how args map to rendering
|
||||
const Template: ComponentStory<typeof Modal> = (args) => (
|
||||
<Modal {...args}>Basic text content</Modal>
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
|
||||
const confirmModalActions: ModalAction[] = [
|
||||
{
|
||||
id: "1",
|
||||
text: "Cancel",
|
||||
callback: () => {
|
||||
console.log("confirmModal: clicked Cancel");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
text: "Confirm",
|
||||
callback: () => {
|
||||
console.log("confirmModal: clicked Confirm");
|
||||
},
|
||||
variant: ModalButtonStyle.info,
|
||||
},
|
||||
];
|
||||
|
||||
Default.args = {
|
||||
title: "Basic text title",
|
||||
isOpen: true,
|
||||
actions: confirmModalActions,
|
||||
};
|
||||
|
||||
const customModalActions: ModalAction[] = [
|
||||
{
|
||||
id: "1",
|
||||
text: "custom button 1",
|
||||
className:
|
||||
"text-white bg-green-700 hover:bg-green-800 focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-green-600 dark:hover:bg-green-700 dark:focus:ring-green-800",
|
||||
callback: () => {
|
||||
console.log("confirmModal: clicked custom button 1");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
text: "custom button 2",
|
||||
callback: () => {
|
||||
console.log("confirmModal: clicked custom button 2");
|
||||
},
|
||||
variant: ModalButtonStyle.error,
|
||||
},
|
||||
];
|
||||
|
||||
export const CustomModal: ComponentStory<typeof Modal> = (args) => (
|
||||
<Modal {...args}>
|
||||
<div>
|
||||
<p className="text-base leading-relaxed text-gray-500 dark:text-gray-400">
|
||||
Custom Modal Content
|
||||
</p>
|
||||
<button
|
||||
className="bg-cyan-500 p-2"
|
||||
type="button"
|
||||
onClick={() => console.log("just a button")}
|
||||
>
|
||||
Just a button
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
CustomModal.args = {
|
||||
title: (
|
||||
<div>
|
||||
Custom <span className="text-red-500"> Title</span>
|
||||
</div>
|
||||
),
|
||||
isOpen: true,
|
||||
actions: customModalActions,
|
||||
};
|
||||
|
||||
export const AutoScrollWhenContentIsMoreThan500Height: ComponentStory<
|
||||
typeof Modal
|
||||
> = (args) => (
|
||||
<Modal {...args}>
|
||||
<div
|
||||
style={{
|
||||
height: "1000px",
|
||||
width: "50%",
|
||||
backgroundColor: "skyblue",
|
||||
}}
|
||||
>
|
||||
This div height is 1000 px so we can see a vertical scroll to the right of
|
||||
it.
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
AutoScrollWhenContentIsMoreThan500Height.args = {
|
||||
title: "Auto Scroll when content is more than 500px height",
|
||||
isOpen: true,
|
||||
actions: confirmModalActions,
|
||||
};
|
||||
158
frontend/src/components/modal/Modal.tsx
Normal file
158
frontend/src/components/modal/Modal.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { PropsWithChildren, ReactNode } from "react";
|
||||
import ReactDom from "react-dom";
|
||||
import Spinner from "../Spinner";
|
||||
|
||||
export enum ModalButtonStyle {
|
||||
default,
|
||||
info,
|
||||
error,
|
||||
success,
|
||||
disabled,
|
||||
}
|
||||
|
||||
export interface ModalAction {
|
||||
id: string;
|
||||
callback: () => void;
|
||||
text?: string;
|
||||
variant?: ModalButtonStyle;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface ModalProps extends PropsWithChildren {
|
||||
title?: string | ReactNode;
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
containerClassNames?: string;
|
||||
actions?: ModalAction[];
|
||||
bottomContent?: ReactNode;
|
||||
}
|
||||
|
||||
const Modal = ({
|
||||
title,
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
actions,
|
||||
containerClassNames,
|
||||
bottomContent,
|
||||
}: ModalProps) => {
|
||||
const colorVariants = new Map<ModalButtonStyle, string>([
|
||||
[
|
||||
ModalButtonStyle.default,
|
||||
"text-base font-semibold text-gray-500 bg-white hover:bg-gray-100 disabled:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 font-medium px-5 py-1 hover:text-gray-900 focus:z-10 ",
|
||||
],
|
||||
[
|
||||
ModalButtonStyle.info,
|
||||
"font-semibold text-white bg-blue-700 hover:bg-blue-800 disabled:bg-blue-700/80 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-base px-3 py-1.5 text-center ",
|
||||
],
|
||||
[
|
||||
ModalButtonStyle.success,
|
||||
"font-semibold text-white bg-green-700 hover:bg-green-800 disabled:bg-green-700/80 focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-base px-3 py-1.5 text-center ",
|
||||
],
|
||||
[
|
||||
ModalButtonStyle.error,
|
||||
"font-semibold text-white bg-red-700 hover:bg-red-800 disabled:bg-red-700/80 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-base px-3 py-1.5 text-center ",
|
||||
],
|
||||
[
|
||||
ModalButtonStyle.disabled,
|
||||
"font-semibold text-gray-500 bg-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-base font-medium px-3 py-1.5 hover:text-gray-900 focus:z-10 ",
|
||||
],
|
||||
]);
|
||||
|
||||
const getClassName = (action: ModalAction) => {
|
||||
if (action.className) return action.className;
|
||||
|
||||
return action.variant
|
||||
? colorVariants.get(action.variant)
|
||||
: colorVariants.get(ModalButtonStyle.default);
|
||||
};
|
||||
|
||||
const getTitle = (title: string | ReactNode) => {
|
||||
if (typeof title === "string")
|
||||
return <h3 className="text-xl font-medium text-grey">{title}</h3>;
|
||||
else return title;
|
||||
};
|
||||
|
||||
return ReactDom.createPortal(
|
||||
<>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity ">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
style={{
|
||||
maxHeight: "95vh",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className={`relative rounded-lg shadow m-7 w-2/5 max-w-[1300px] ${
|
||||
!containerClassNames ||
|
||||
(containerClassNames && !containerClassNames.includes("bg-"))
|
||||
? "bg-white"
|
||||
: ""
|
||||
} ${containerClassNames ?? ""}`}
|
||||
>
|
||||
{title && (
|
||||
<div className="flex items-start justify-between p-4 border-b rounded-t ">
|
||||
{getTitle(title)}
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
data-modal-hide="staticModal"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 space-y-6 overflow-y-auto max-h-[calc(100vh_-_200px)]">
|
||||
{children}
|
||||
</div>
|
||||
{bottomContent ? (
|
||||
<div className="p-5 text-sm">{bottomContent}</div>
|
||||
) : (
|
||||
<div className="flex justify-end p-6 space-x-2 border-t border-gray-200 rounded-b ">
|
||||
{actions?.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
className={
|
||||
action.isLoading
|
||||
? `flex items-center font-bold justify-around space-x-1 ${getClassName(
|
||||
action
|
||||
)}`
|
||||
: `${getClassName(action)} `
|
||||
}
|
||||
onClick={action.callback}
|
||||
disabled={action.disabled || action.isLoading}
|
||||
>
|
||||
{action.isLoading ? <Spinner size={4} /> : null}
|
||||
{action.text ?? "Confirm"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
document.getElementById("portal")!
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
Reference in New Issue
Block a user