Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
39 changes: 37 additions & 2 deletions src/components/shared/GitHubLibrary/ManageLibrariesDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,19 @@ import { Separator } from "@/components/ui/separator";
import type { StoredLibrary } from "@/providers/ComponentLibraryProvider/libraries/storage";

import { AddGitHubLibraryDialogContent } from "./components/AddGitHubLibraryDialogContent";
import { AddYamlLibraryDialogContent } from "./components/AddYamlLibraryDialogContent";
import { LibraryList } from "./components/LibraryList";
import { UpdateGitHubLibrary } from "./components/UpdateGitHubLibrary";

export function ManageLibrariesDialog({
defaultMode = "manage",
}: {
defaultMode?: "add" | "manage" | "update";
defaultMode?: "add" | "addYaml" | "manage" | "update";
}) {
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"add" | "manage" | "update">(defaultMode);
const [mode, setMode] = useState<"add" | "addYaml" | "manage" | "update">(
defaultMode,
);
const [libraryToUpdate, setLibraryToUpdate] = useState<
StoredLibrary | undefined
>();
Expand Down Expand Up @@ -75,6 +78,29 @@ export function ManageLibrariesDialog({
</>
)}

{mode === "addYaml" && (
<>
<DialogHeader>
<DialogTitle>
<InlineStack align="start" gap="1">
<Button
variant="ghost"
size="sm"
onClick={() => setMode("manage")}
>
<Icon name="ArrowLeft" />
</Button>
Add Library
</InlineStack>
</DialogTitle>
</DialogHeader>

<AddYamlLibraryDialogContent
onOkClick={() => setMode("manage")}
/>
</>
)}

{mode === "manage" && (
<>
<DialogHeader>
Expand All @@ -93,6 +119,15 @@ export function ManageLibrariesDialog({
Link Library from GitHub
</InlineStack>
</Button>
<Button
variant="secondary"
onClick={() => setMode("addYaml")}
>
<InlineStack align="center" gap="1">
<Icon name="FolderGit" />
Link Library from Yaml
</InlineStack>
</Button>
</BlockStack>
</BlockStack>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { useMutation } from "@tanstack/react-query";
import { useReducer, useState } from "react";

import { Button } from "@/components/ui/button";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Spinner } from "@/components/ui/spinner";
import { Paragraph, Text } from "@/components/ui/typography";
import useToastNotification from "@/hooks/useToastNotification";
import { cn } from "@/lib/utils";
import { fetchWithCache } from "@/providers/ComponentLibraryProvider/libraries/utils";
import { isValidComponentLibrary } from "@/types/componentLibrary";
import { loadObjectFromYamlData } from "@/utils/cache";

import { ensureYamlLibrary } from "../utils/ensureYamlLibrary";
import { validatePAT } from "../utils/validatePAT";
import { InputField } from "./InputField";

export const AddYamlLibraryDialogContent = ({
onOkClick,
}: {
onOkClick: () => void;
}) => {
return (
<BlockStack gap="2">
<ConfigureImport onSuccess={onOkClick} />
</BlockStack>
);
};

interface YamlLibraryProcessState {
yamlUrl: string;
accessToken: string;
hasErrors: boolean;
}

type YamlLibraryFormAction = {
type: "UPDATE";
payload: Partial<Omit<YamlLibraryProcessState, "hasErrors">>;
};

function addGithubLibraryReducer(
state: YamlLibraryProcessState,
action: YamlLibraryFormAction,
): YamlLibraryProcessState {
switch (action.type) {
case "UPDATE": {
const newState = { ...state, ...action.payload };
return {
...newState,
hasErrors: !isStateValid(newState),
};
}
default:
return state;
}
}

function isStateValid(state: YamlLibraryProcessState): boolean {
return (
!validateYamlUrl(state.yamlUrl)?.length &&
(!state.accessToken || !validatePAT(state.accessToken)?.length)
);
}

function validateYamlUrl(url: string): string[] | null {
if (!url) return ["YAML URL is required"];
// todo: add more validation for YAML URL
return url.startsWith("http")
? null
: ["Invalid YAML URL. Must start with http"];
}

function ConfigureImport({ onSuccess }: { onSuccess: () => void }) {
const notify = useToastNotification();

const initialState: YamlLibraryProcessState = {
yamlUrl: "",
accessToken: "",
hasErrors: true,
};

const [state, dispatch] = useReducer(addGithubLibraryReducer, initialState);
const [processError, setProcessError] = useState<string | null>(null);

const { mutate: importYamlLibrary, isPending } = useMutation({
mutationFn: async (state: YamlLibraryProcessState) => {
const response = await fetchWithCache(state.yamlUrl);

if (!response.ok) {
throw new Error(`Failed to fetch ${state.yamlUrl}: ${response.status}`);
}

const data = await response.arrayBuffer();

const yamlData = loadObjectFromYamlData(data);

if (!isValidComponentLibrary(yamlData)) {
throw new Error(`Invalid component library: ${state.yamlUrl}`);
}

const name =
(yamlData.annotations?.name as string) ??
state.yamlUrl.split("/").pop()?.split(".")[0] ??
"YAML Library";

await ensureYamlLibrary({
name,
yamlUrl: state.yamlUrl,
accessToken: state.accessToken,
});

return yamlData;
},
onSuccess: () => {
notify(`Successfully fetched library`, "success");
onSuccess();
},
onError: (error) => {
notify(`Error importing YAML library: ${error.message}`, "error");
setProcessError(error.message);
},
});

const handleSubmit = async () => {
if (state.hasErrors) {
notify("Please fill in all fields", "error");
return;
}

setProcessError(null);

await importYamlLibrary(state);
};

return (
<BlockStack
gap="4"
className={cn(isPending && "pointer-events-none opacity-50")}
>
<Paragraph tone="subdued" size="xs">
You can use a Personal Access Token to access private repositories.
Connect a YAML file to import components from your library.
</Paragraph>
{processError && (
<Text size="xs" tone="critical">
{processError}
</Text>
)}
<InputField
id="yaml-url"
label="YAML URL"
placeholder="https://example.com/library.yaml"
value={state.yamlUrl}
validate={validateYamlUrl}
onChange={(value) => {
dispatch({ type: "UPDATE", payload: { yamlUrl: value ?? "" } });
}}
/>
<InputField
id="pat"
label="Personal Access Token"
placeholder="ghp_..."
value={state.accessToken}
validate={validatePAT}
onChange={(value) => {
dispatch({ type: "UPDATE", payload: { accessToken: value ?? "" } });
}}
/>

<InlineStack gap="2" className="w-full" align="end">
<Button
type="button"
disabled={state.hasErrors || isPending}
onClick={handleSubmit}
>
Add Library {isPending && <Spinner />}
</Button>
</InlineStack>
</BlockStack>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import TooltipButton from "@/components/shared/Buttons/TooltipButton";
import { HOURS } from "@/components/shared/ComponentEditor/constants";
import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper";
import { Icon } from "@/components/ui/icon";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import type { StoredLibrary } from "@/providers/ComponentLibraryProvider/libraries/storage";

import { isGitHubLibraryConfiguration } from "../types";
import {
isGitHubLibraryConfiguration,
isYamlLibraryConfiguration,
} from "../types";
import { checkPATStatus } from "../utils/checkPATStatus";

export const TokenStatusButton = withSuspenseWrapper(
Expand All @@ -21,17 +25,39 @@ export const TokenStatusButton = withSuspenseWrapper(
const { data: tokenStatus } = useSuspenseQuery({
queryKey: ["github-token-status", library.id],
queryFn: async () => {
if (!isGitHubLibraryConfiguration(library.configuration)) {
throw new Error("Invalid library configuration");
if (isGitHubLibraryConfiguration(library.configuration)) {
return checkPATStatus(
library.configuration.repo_name,
library.configuration.access_token,
);
}

return checkPATStatus(
library.configuration.repo_name,
library.configuration.access_token,
);
if (isYamlLibraryConfiguration(library.configuration)) {
// todo: check PAT status for YAML library
return true;
}

throw new Error("Invalid library configuration");
},
staleTime: 1 * HOURS,
});

if (
isYamlLibraryConfiguration(library.configuration) &&
!library.configuration.access_token
) {
return (
<TooltipButton
tooltip="No Personal Access Token provided"
variant="ghost"
size="sm"
disabled
>
<Icon name="CircleSlash" />
</TooltipButton>
);
}

return (
<TooltipButton
tooltip={`Token is ${tokenStatus ? "valid" : "invalid. Click to update."}`}
Expand All @@ -47,4 +73,15 @@ export const TokenStatusButton = withSuspenseWrapper(
</TooltipButton>
);
},
() => <Spinner />,
({ error }) => (
<TooltipButton
tooltip={`Error checking token status: ${error.message}`}
variant="ghost"
size="sm"
disabled
>
<Icon name="CircleAlert" />
</TooltipButton>
),
);
21 changes: 21 additions & 0 deletions src/components/shared/GitHubLibrary/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,24 @@ export function isGitHubLibraryConfiguration(
"auto_update" in configuration
);
}

interface YamlLibraryConfiguration {
created_at: string;
last_updated_at: string;
yaml_url: string;
access_token: string | undefined;
auto_update: boolean;
}

export function isYamlLibraryConfiguration(
configuration: any,
): configuration is YamlLibraryConfiguration {
return (
typeof configuration === "object" &&
configuration !== null &&
"created_at" in configuration &&
"last_updated_at" in configuration &&
"yaml_url" in configuration &&
"auto_update" in configuration
);
}
41 changes: 41 additions & 0 deletions src/components/shared/GitHubLibrary/utils/ensureYamlLibrary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { LibraryDB } from "@/providers/ComponentLibraryProvider/libraries/storage";

import { getYamlLibraryId } from "./libraryId";

interface CreateYamlLibraryOptions {
name: string;
yamlUrl: string;
accessToken: string;
}

export async function ensureYamlLibrary({
name,
yamlUrl,
accessToken,
}: CreateYamlLibraryOptions) {
const id = getYamlLibraryId(yamlUrl);

const existingLibrary = await LibraryDB.component_libraries.get(id);

if (existingLibrary) {
return existingLibrary;
}

await LibraryDB.component_libraries.put({
id,
name,
icon: "FolderGit",
type: "yaml",
knownDigests: [],
configuration: {
created_at: new Date().toISOString(),
last_updated_at: new Date().toISOString(),
yaml_url: yamlUrl,
access_token: accessToken,
auto_update: true,
},
components: [],
});

return await LibraryDB.component_libraries.get(id);
}
Loading