Skip to content

Commit

Permalink
feat: handle invalid connection error (#5)
Browse files Browse the repository at this point in the history
* transform libsql:// to wss://

* add schema provider

* feat: handle error message when connecting to server

* fix: fixing the refresh

* make the story use client

* fixing sonarqube issue
  • Loading branch information
invisal authored Feb 9, 2024
1 parent a8bb030 commit 28704bd
Show file tree
Hide file tree
Showing 10 changed files with 214 additions and 40 deletions.
9 changes: 7 additions & 2 deletions src/app/(components)/ConnectionConfigScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { validateConnectionEndpoint } from "@/lib/validation";
import { appVersion } from "@/env";
import ErrorMessage from "@/components/custom/ErrorMessage";

interface ConnectionItem {
id: string;
Expand Down Expand Up @@ -173,11 +174,14 @@ export default function ConnectionConfigScreen() {
[router]
);

const onConnectClicked = () => {
const [valid, errorMessage] = validateConnectionEndpoint(url);

const onConnectClicked = useCallback(() => {
if (!valid) return;
if (url && token) {
connect(url, token);
}
};
}, [valid, connect, url, token]);

const onConnectionListChange = () => {
setConnections(getConnections());
Expand Down Expand Up @@ -256,6 +260,7 @@ export default function ConnectionConfigScreen() {
value={url}
onChange={(e) => setUrl(e.currentTarget.value)}
/>
{url && errorMessage && <ErrorMessage message={errorMessage} />}
</div>
<div className="flex flex-col space-y-1.5">
<Label htmlFor="token">Token</Label>
Expand Down
15 changes: 11 additions & 4 deletions src/app/(components)/MainScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { AutoCompleteProvider } from "@/context/AutoCompleteProvider";
import ContextMenuHandler from "./ContentMenuHandler";
import InternalPubSub from "@/lib/internal-pubsub";
import { useParams, useRouter } from "next/navigation";
import { Metadata } from "next";
import { useRouter } from "next/navigation";
import { normalizeConnectionEndpoint } from "@/lib/validation";
import { SchemaProvider } from "@/screens/DatabaseScreen/SchemaProvider";

function MainConnection({
credential,
Expand All @@ -30,7 +31,9 @@ function MainConnection({

return (
<DatabaseDriverProvider driver={database}>
<DatabaseGui />
<SchemaProvider>
<DatabaseGui />
</SchemaProvider>
</DatabaseDriverProvider>
);
}
Expand All @@ -48,7 +51,11 @@ function InvalidSession() {
export default function MainScreen() {
const router = useRouter();
const sessionCredential: { url: string; token: string } = useMemo(() => {
return JSON.parse(sessionStorage.getItem("connection") ?? "{}");
const config = JSON.parse(sessionStorage.getItem("connection") ?? "{}");
return {
url: normalizeConnectionEndpoint(config.url),
token: config.token,
};
}, []);

/**
Expand Down
37 changes: 6 additions & 31 deletions src/app/(components)/SchemaView.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { buttonVariants } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useAutoComplete } from "@/context/AutoCompleteProvider";
import { useDatabaseDriver } from "@/context/DatabaseDriverProvider";
import { DatabaseSchemaItem } from "@/drivers/DatabaseDriver";
import { cn } from "@/lib/utils";
import { openTabs } from "@/messages/openTabs";
import { LucideIcon, Table2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import {
OpenContextMenuList,
openContextMenuFromEvent,
} from "@/messages/openContextMenu";
import OpacityLoading from "./OpacityLoading";
import { useSchema } from "@/screens/DatabaseScreen/SchemaProvider";

interface SchemaViewItemProps {
icon: LucideIcon;
Expand Down Expand Up @@ -59,26 +57,8 @@ function SchemaViewmItem({
}

export default function SchemaView() {
const { updateTableList } = useAutoComplete();
const [schemaItems, setSchemaItems] = useState<DatabaseSchemaItem[]>([]);
const [loading, setLoading] = useState(false);
const { refresh, schema } = useSchema();
const [selectedIndex, setSelectedIndex] = useState(-1);
const { databaseDriver } = useDatabaseDriver();

const fetchSchema = useCallback(() => {
setLoading(true);

databaseDriver.getTableList().then((tableList) => {
const sortedTableList = [...tableList];
sortedTableList.sort((a, b) => {
return a.name.localeCompare(b.name);
});

setSchemaItems(sortedTableList);
updateTableList(tableList.map((table) => table.name));
setLoading(false);
});
}, [databaseDriver, updateTableList]);

const prepareContextMenu = useCallback(
(tableName?: string) => {
Expand All @@ -91,24 +71,19 @@ export default function SchemaView() {
},
},
{ separator: true },
{ title: "Refresh", onClick: fetchSchema },
{ title: "Refresh", onClick: () => refresh() },
] as OpenContextMenuList;
},
[fetchSchema]
[refresh]
);

useEffect(() => {
fetchSchema();
}, [fetchSchema]);

return (
<ScrollArea
className="h-full select-none"
onContextMenu={openContextMenuFromEvent(prepareContextMenu())}
>
{loading && <OpacityLoading />}
<div className="flex flex-col p-2 pr-4">
{schemaItems.map((item, schemaIndex) => {
{schema.map((item, schemaIndex) => {
return (
<SchemaViewmItem
onContextMenu={openContextMenuFromEvent(
Expand Down
11 changes: 11 additions & 0 deletions src/app/storybook/connection_error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"use client";
import ConnectingDialog from "@/screens/DatabaseScreen/ConnectingDialog";

export default function ConnectionErrorMessageStory() {
return (
<div className="flex flex-col gap-4">
<ConnectingDialog message="Authentication failed: The JWT is invalid" />
<ConnectingDialog loading url="wss://example.turso.io" />
</div>
);
}
7 changes: 7 additions & 0 deletions src/components/custom/ErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function ErrorMessage({
message,
}: {
readonly message: string;
}) {
return <div className="text-xs text-red-500">{message}</div>;
}
6 changes: 6 additions & 0 deletions src/drivers/DatabaseDriver.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ export interface DatabaseTableSchema {
export default class DatabaseDriver {
protected client: hrana.WsClient;
protected stream?: hrana.WsStream;
protected endpoint: string = "";

constructor(url: string, authToken: string) {
this.endpoint = url;
this.client = hrana.openWs(url, authToken);
}

Expand All @@ -46,6 +48,10 @@ export default class DatabaseDriver {
return this.stream;
}

getEndpoint() {
return this.endpoint;
}

async query(stmt: hrana.InStmt) {
const stream = this.getStream();

Expand Down
16 changes: 15 additions & 1 deletion src/lib/validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { validateConnectionEndpoint, validateOperation } from "./validation";
import {
normalizeConnectionEndpoint,
validateConnectionEndpoint,
validateOperation,
} from "./validation";

describe("Operation Validation", () => {
it("UPDATE with primary key SHOULD be valid operation", () => {
Expand Down Expand Up @@ -209,4 +213,14 @@ describe("Validate the connection endpoint", () => {
false
);
});

it("Transform libsql:// to wss://", () => {
expect(normalizeConnectionEndpoint("libsql://testing.example.com")).toBe(
"wss://testing.example.com"
);

expect(normalizeConnectionEndpoint("wss://testing.example.com")).toBe(
"wss://testing.example.com"
);
});
});
8 changes: 6 additions & 2 deletions src/lib/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,16 @@ export function validateConnectionEndpoint(
try {
const url = new URL(endpoint);

if (url.protocol !== "wss:") {
return [false, "We only support wss:// at the moment."];
if (url.protocol !== "wss:" && url.protocol !== "libsql:") {
return [false, "We only support wss:// or libsql:// at the moment."];
}

return [true, ""];
} catch {
return [false, "Your URL is not valid"];
}
}

export function normalizeConnectionEndpoint(endpoint: string) {
return endpoint.replace(/^libsql:\/\//, "wss://");
}
60 changes: 60 additions & 0 deletions src/screens/DatabaseScreen/ConnectingDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Button } from "@/components/ui/button";
import { LucideLoader } from "lucide-react";
import { useRouter } from "next/navigation";

export default function ConnectingDialog({
message,
url,
onRetry,
}: Readonly<{
loading?: boolean;
url?: string;
message?: string;
onRetry?: () => void;
}>) {
const router = useRouter();

let body = (
<div>
<p className="mt-4 flex gap-4">
<LucideLoader className="animate-spin" />
Connecting to <strong>{url}</strong>
</p>
</div>
);

if (message) {
body = (
<>
<div className="text-2xl font-semibold">
We have problem connecting to database
</div>
<p className="mt-4">
<pre>{message}</pre>
</p>
<div className="mt-4 flex gap-4">
<Button onClick={onRetry}>Retry</Button>
<Button variant={"secondary"} onClick={() => router.push("/")}>
Back
</Button>
</div>
</>
);
}

return (
<div className="p-8">
<div
className="mb-4 flex gap-2 items-center pl-8 pt-4 pb-4 rounded-lg select-none text-white"
style={{ background: "#2C5FC3", maxWidth: 300 }}
>
<img src="/libsql-logo.png" alt="LibSQL Studio" className="w-12 h-12" />
<div>
<h1 className="text-2xl font-semibold">LibSQL Studio</h1>
</div>
</div>

{body}
</div>
);
}
85 changes: 85 additions & 0 deletions src/screens/DatabaseScreen/SchemaProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useAutoComplete } from "@/context/AutoCompleteProvider";
import { useDatabaseDriver } from "@/context/DatabaseDriverProvider";
import { DatabaseSchemaItem } from "@/drivers/DatabaseDriver";
import {
PropsWithChildren,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import ConnectingDialog from "./ConnectingDialog";

const SchemaContext = createContext<{
schema: DatabaseSchemaItem[];
refresh: () => void;
}>({
schema: [],
refresh: () => {
throw new Error("Not implemented");
},
});

export function useSchema() {
return useContext(SchemaContext);
}

export function SchemaProvider({ children }: Readonly<PropsWithChildren>) {
const { updateTableList } = useAutoComplete();
const [error, setError] = useState<string>();
const [schemaItems, setSchemaItems] = useState<DatabaseSchemaItem[]>([]);
const [loading, setLoading] = useState(true);
const { databaseDriver } = useDatabaseDriver();

const fetchSchema = useCallback(
(refresh?: boolean) => {
if (refresh) {
setLoading(true);
}

databaseDriver
.getTableList()
.then((tableList) => {
const sortedTableList = [...tableList];
sortedTableList.sort((a, b) => {
return a.name.localeCompare(b.name);
});

setSchemaItems(sortedTableList);
updateTableList(tableList.map((table) => table.name));

setError(undefined);
setLoading(false);
})
.catch((e) => {
setError(e.message);
setLoading(false);
});
},
[databaseDriver, updateTableList, setError]
);

useEffect(() => {
fetchSchema(true);
}, [fetchSchema]);

const props = useMemo(() => {
return { schema: schemaItems, refresh: fetchSchema };
}, [schemaItems, fetchSchema]);

if (error || loading) {
return (
<ConnectingDialog
message={error}
loading={loading}
url={databaseDriver.getEndpoint()}
/>
);
}

return (
<SchemaContext.Provider value={props}>{children}</SchemaContext.Provider>
);
}

0 comments on commit 28704bd

Please sign in to comment.