Skip to content

Commit a9208dd

Browse files
committed
feat: optional yaml libraries
1 parent a13a7ba commit a9208dd

File tree

11 files changed

+773
-10
lines changed

11 files changed

+773
-10
lines changed

src/components/shared/GitHubLibrary/ManageLibrariesDialog.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,19 @@ import { Separator } from "@/components/ui/separator";
1515
import type { StoredLibrary } from "@/providers/ComponentLibraryProvider/libraries/storage";
1616

1717
import { AddGitHubLibraryDialogContent } from "./components/AddGitHubLibraryDialogContent";
18+
import { AddYamlLibraryDialogContent } from "./components/AddYamlLibraryDialogContent";
1819
import { LibraryList } from "./components/LibraryList";
1920
import { UpdateGitHubLibrary } from "./components/UpdateGitHubLibrary";
2021

2122
export function ManageLibrariesDialog({
2223
defaultMode = "manage",
2324
}: {
24-
defaultMode?: "add" | "manage" | "update";
25+
defaultMode?: "add" | "addYaml" | "manage" | "update";
2526
}) {
2627
const [open, setOpen] = useState(false);
27-
const [mode, setMode] = useState<"add" | "manage" | "update">(defaultMode);
28+
const [mode, setMode] = useState<"add" | "addYaml" | "manage" | "update">(
29+
defaultMode,
30+
);
2831
const [libraryToUpdate, setLibraryToUpdate] = useState<
2932
StoredLibrary | undefined
3033
>();
@@ -75,6 +78,29 @@ export function ManageLibrariesDialog({
7578
</>
7679
)}
7780

81+
{mode === "addYaml" && (
82+
<>
83+
<DialogHeader>
84+
<DialogTitle>
85+
<InlineStack align="start" gap="1">
86+
<Button
87+
variant="ghost"
88+
size="sm"
89+
onClick={() => setMode("manage")}
90+
>
91+
<Icon name="ArrowLeft" />
92+
</Button>
93+
Add Library
94+
</InlineStack>
95+
</DialogTitle>
96+
</DialogHeader>
97+
98+
<AddYamlLibraryDialogContent
99+
onOkClick={() => setMode("manage")}
100+
/>
101+
</>
102+
)}
103+
78104
{mode === "manage" && (
79105
<>
80106
<DialogHeader>
@@ -93,6 +119,15 @@ export function ManageLibrariesDialog({
93119
Link Library from GitHub
94120
</InlineStack>
95121
</Button>
122+
<Button
123+
variant="secondary"
124+
onClick={() => setMode("addYaml")}
125+
>
126+
<InlineStack align="center" gap="1">
127+
<Icon name="FolderGit" />
128+
Link Library from Yaml
129+
</InlineStack>
130+
</Button>
96131
</BlockStack>
97132
</BlockStack>
98133
</>
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { useMutation } from "@tanstack/react-query";
2+
import { useReducer, useState } from "react";
3+
4+
import { Button } from "@/components/ui/button";
5+
import { BlockStack, InlineStack } from "@/components/ui/layout";
6+
import { Spinner } from "@/components/ui/spinner";
7+
import { Paragraph, Text } from "@/components/ui/typography";
8+
import useToastNotification from "@/hooks/useToastNotification";
9+
import { cn } from "@/lib/utils";
10+
import { fetchWithCache } from "@/providers/ComponentLibraryProvider/libraries/utils";
11+
import { isValidComponentLibrary } from "@/types/componentLibrary";
12+
import { loadObjectFromYamlData } from "@/utils/cache";
13+
14+
import { ensureYamlLibrary } from "../utils/ensureYamlLibrary";
15+
import { validatePAT } from "../utils/validatePAT";
16+
import { InputField } from "./InputField";
17+
18+
export const AddYamlLibraryDialogContent = ({
19+
onOkClick,
20+
}: {
21+
onOkClick: () => void;
22+
}) => {
23+
return (
24+
<BlockStack gap="2">
25+
<ConfigureImport onSuccess={onOkClick} />
26+
</BlockStack>
27+
);
28+
};
29+
30+
interface YamlLibraryProcessState {
31+
yamlUrl: string;
32+
accessToken: string;
33+
hasErrors: boolean;
34+
}
35+
36+
type YamlLibraryFormAction = {
37+
type: "UPDATE";
38+
payload: Partial<Omit<YamlLibraryProcessState, "hasErrors">>;
39+
};
40+
41+
function addGithubLibraryReducer(
42+
state: YamlLibraryProcessState,
43+
action: YamlLibraryFormAction,
44+
): YamlLibraryProcessState {
45+
switch (action.type) {
46+
case "UPDATE": {
47+
const newState = { ...state, ...action.payload };
48+
return {
49+
...newState,
50+
hasErrors: !isStateValid(newState),
51+
};
52+
}
53+
default:
54+
return state;
55+
}
56+
}
57+
58+
function isStateValid(state: YamlLibraryProcessState): boolean {
59+
return (
60+
!validateYamlUrl(state.yamlUrl)?.length &&
61+
(!state.accessToken || !validatePAT(state.accessToken)?.length)
62+
);
63+
}
64+
65+
function validateYamlUrl(url: string): string[] | null {
66+
if (!url) return ["YAML URL is required"];
67+
// todo: add more validation for YAML URL
68+
return url.startsWith("http")
69+
? null
70+
: ["Invalid YAML URL. Must start with http"];
71+
}
72+
73+
function ConfigureImport({ onSuccess }: { onSuccess: () => void }) {
74+
const notify = useToastNotification();
75+
76+
const initialState: YamlLibraryProcessState = {
77+
yamlUrl: "",
78+
accessToken: "",
79+
hasErrors: true,
80+
};
81+
82+
const [state, dispatch] = useReducer(addGithubLibraryReducer, initialState);
83+
const [processError, setProcessError] = useState<string | null>(null);
84+
85+
const { mutate: importYamlLibrary, isPending } = useMutation({
86+
mutationFn: async (state: YamlLibraryProcessState) => {
87+
const response = await fetchWithCache(state.yamlUrl);
88+
89+
if (!response.ok) {
90+
throw new Error(`Failed to fetch ${state.yamlUrl}: ${response.status}`);
91+
}
92+
93+
const data = await response.arrayBuffer();
94+
95+
const yamlData = loadObjectFromYamlData(data);
96+
97+
if (!isValidComponentLibrary(yamlData)) {
98+
throw new Error(`Invalid component library: ${state.yamlUrl}`);
99+
}
100+
101+
const name =
102+
(yamlData.annotations?.name as string) ??
103+
state.yamlUrl.split("/").pop()?.split(".")[0] ??
104+
"YAML Library";
105+
106+
await ensureYamlLibrary({
107+
name,
108+
yamlUrl: state.yamlUrl,
109+
accessToken: state.accessToken,
110+
});
111+
112+
return yamlData;
113+
},
114+
onSuccess: () => {
115+
notify(`Successfully fetched library`, "success");
116+
onSuccess();
117+
},
118+
onError: (error) => {
119+
notify(`Error importing YAML library: ${error.message}`, "error");
120+
setProcessError(error.message);
121+
},
122+
});
123+
124+
const handleSubmit = async () => {
125+
if (state.hasErrors) {
126+
notify("Please fill in all fields", "error");
127+
return;
128+
}
129+
130+
setProcessError(null);
131+
132+
await importYamlLibrary(state);
133+
};
134+
135+
return (
136+
<BlockStack
137+
gap="4"
138+
className={cn(isPending && "pointer-events-none opacity-50")}
139+
>
140+
<Paragraph tone="subdued" size="xs">
141+
You can use a Personal Access Token to access private repositories.
142+
Connect a YAML file to import components from your library.
143+
</Paragraph>
144+
{processError && (
145+
<Text size="xs" tone="critical">
146+
{processError}
147+
</Text>
148+
)}
149+
<InputField
150+
id="yaml-url"
151+
label="YAML URL"
152+
placeholder="https://example.com/library.yaml"
153+
value={state.yamlUrl}
154+
validate={validateYamlUrl}
155+
onChange={(value) => {
156+
dispatch({ type: "UPDATE", payload: { yamlUrl: value ?? "" } });
157+
}}
158+
/>
159+
<InputField
160+
id="pat"
161+
label="Personal Access Token"
162+
placeholder="ghp_..."
163+
value={state.accessToken}
164+
validate={validatePAT}
165+
onChange={(value) => {
166+
dispatch({ type: "UPDATE", payload: { accessToken: value ?? "" } });
167+
}}
168+
/>
169+
170+
<InlineStack gap="2" className="w-full" align="end">
171+
<Button
172+
type="button"
173+
disabled={state.hasErrors || isPending}
174+
onClick={handleSubmit}
175+
>
176+
Add Library {isPending && <Spinner />}
177+
</Button>
178+
</InlineStack>
179+
</BlockStack>
180+
);
181+
}

src/components/shared/GitHubLibrary/components/TokenStatusButton.tsx

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import TooltipButton from "@/components/shared/Buttons/TooltipButton";
44
import { HOURS } from "@/components/shared/ComponentEditor/constants";
55
import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper";
66
import { Icon } from "@/components/ui/icon";
7+
import { Spinner } from "@/components/ui/spinner";
78
import { cn } from "@/lib/utils";
89
import type { StoredLibrary } from "@/providers/ComponentLibraryProvider/libraries/storage";
910

10-
import { isGitHubLibraryConfiguration } from "../types";
11+
import {
12+
isGitHubLibraryConfiguration,
13+
isYamlLibraryConfiguration,
14+
} from "../types";
1115
import { checkPATStatus } from "../utils/checkPATStatus";
1216

1317
export const TokenStatusButton = withSuspenseWrapper(
@@ -21,17 +25,39 @@ export const TokenStatusButton = withSuspenseWrapper(
2125
const { data: tokenStatus } = useSuspenseQuery({
2226
queryKey: ["github-token-status", library.id],
2327
queryFn: async () => {
24-
if (!isGitHubLibraryConfiguration(library.configuration)) {
25-
throw new Error("Invalid library configuration");
28+
if (isGitHubLibraryConfiguration(library.configuration)) {
29+
return checkPATStatus(
30+
library.configuration.repo_name,
31+
library.configuration.access_token,
32+
);
2633
}
2734

28-
return checkPATStatus(
29-
library.configuration.repo_name,
30-
library.configuration.access_token,
31-
);
35+
if (isYamlLibraryConfiguration(library.configuration)) {
36+
// todo: check PAT status for YAML library
37+
return true;
38+
}
39+
40+
throw new Error("Invalid library configuration");
3241
},
3342
staleTime: 1 * HOURS,
3443
});
44+
45+
if (
46+
isYamlLibraryConfiguration(library.configuration) &&
47+
!library.configuration.access_token
48+
) {
49+
return (
50+
<TooltipButton
51+
tooltip="No Personal Access Token provided"
52+
variant="ghost"
53+
size="sm"
54+
disabled
55+
>
56+
<Icon name="CircleSlash" />
57+
</TooltipButton>
58+
);
59+
}
60+
3561
return (
3662
<TooltipButton
3763
tooltip={`Token is ${tokenStatus ? "valid" : "invalid. Click to update."}`}
@@ -47,4 +73,15 @@ export const TokenStatusButton = withSuspenseWrapper(
4773
</TooltipButton>
4874
);
4975
},
76+
() => <Spinner />,
77+
({ error }) => (
78+
<TooltipButton
79+
tooltip={`Error checking token status: ${error.message}`}
80+
variant="ghost"
81+
size="sm"
82+
disabled
83+
>
84+
<Icon name="CircleAlert" />
85+
</TooltipButton>
86+
),
5087
);

src/components/shared/GitHubLibrary/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,24 @@ export function isGitHubLibraryConfiguration(
1919
"auto_update" in configuration
2020
);
2121
}
22+
23+
interface YamlLibraryConfiguration {
24+
created_at: string;
25+
last_updated_at: string;
26+
yaml_url: string;
27+
access_token: string | undefined;
28+
auto_update: boolean;
29+
}
30+
31+
export function isYamlLibraryConfiguration(
32+
configuration: any,
33+
): configuration is YamlLibraryConfiguration {
34+
return (
35+
typeof configuration === "object" &&
36+
configuration !== null &&
37+
"created_at" in configuration &&
38+
"last_updated_at" in configuration &&
39+
"yaml_url" in configuration &&
40+
"auto_update" in configuration
41+
);
42+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { LibraryDB } from "@/providers/ComponentLibraryProvider/libraries/storage";
2+
3+
import { getYamlLibraryId } from "./libraryId";
4+
5+
interface CreateYamlLibraryOptions {
6+
name: string;
7+
yamlUrl: string;
8+
accessToken: string;
9+
}
10+
11+
export async function ensureYamlLibrary({
12+
name,
13+
yamlUrl,
14+
accessToken,
15+
}: CreateYamlLibraryOptions) {
16+
const id = getYamlLibraryId(yamlUrl);
17+
18+
const existingLibrary = await LibraryDB.component_libraries.get(id);
19+
20+
if (existingLibrary) {
21+
return existingLibrary;
22+
}
23+
24+
await LibraryDB.component_libraries.put({
25+
id,
26+
name,
27+
icon: "FolderGit",
28+
type: "yaml",
29+
knownDigests: [],
30+
configuration: {
31+
created_at: new Date().toISOString(),
32+
last_updated_at: new Date().toISOString(),
33+
yaml_url: yamlUrl,
34+
access_token: accessToken,
35+
auto_update: true,
36+
},
37+
components: [],
38+
});
39+
40+
return await LibraryDB.component_libraries.get(id);
41+
}

0 commit comments

Comments
 (0)