Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(explorer): multi-line sql editor #3311

Merged
merged 15 commits into from
Oct 23, 2024
5 changes: 5 additions & 0 deletions .changeset/good-donuts-deliver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@latticexyz/explorer": patch
---

The SQL query editor now supports multi-line input.
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"use client";

import { PlayIcon } from "lucide-react";
import { editor } from "monaco-editor/esm/vs/editor/editor.api";
import { useQueryState } from "nuqs";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Table } from "@latticexyz/config";
import Editor from "@monaco-editor/react";
Expand All @@ -18,6 +19,8 @@ type Props = {
};

export function SQLEditor({ table }: Props) {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
const [query, setQuery] = useQueryState("query", { defaultValue: "" });
const validateQuery = useQueryValidator(table);
Expand All @@ -39,44 +42,54 @@ export function SQLEditor({ table }: Props) {
form.reset({ query });
}, [query, form]);

const updateHeight = () => {
if (editorRef.current) {
const contentHeight = Math.min(200, editorRef.current.getContentHeight());
if (containerRef.current) {
containerRef.current.style.height = `${contentHeight}px`;
}

editorRef.current.layout({
width: editorRef.current.getLayoutInfo().width,
height: contentHeight,
});
}
};

return (
<Form {...form}>
<form
className={cn(
"relative flex w-full flex-grow items-center justify-center bg-black align-middle",
"h-10 max-h-10 rounded-md border px-3 py-2 ring-offset-background",
{
"outline-none ring-2 ring-ring ring-offset-2": isFocused,
},
)}
className={cn("relative w-full rounded-md border bg-black px-3 py-2 ring-offset-background", {
"outline-none ring-2 ring-ring ring-offset-2": isFocused,
})}
onSubmit={handleSubmit}
>
<FormField
name="query"
render={({ field }) => (
<Editor
width="100%"
height="21px"
theme="hc-black"
value={field.value}
options={monacoOptions}
language="sql"
onChange={(value) => field.onChange(value)}
onMount={(editor) => {
editor.onDidFocusEditorText(() => {
setIsFocused(true);
});
<div ref={containerRef} className="min-h-[21px] w-full">
<Editor
width="100%"
theme="hc-black"
value={decodeURIComponent(field.value)}
options={monacoOptions}
language="sql"
onChange={(value) => field.onChange(encodeURIComponent(value ?? ""))}
onMount={(editor) => {
editorRef.current = editor;

editor.onDidBlurEditorText(() => {
setIsFocused(false);
});
}}
loading={null}
/>
updateHeight();
editor.onDidContentSizeChange(updateHeight);
editor.onDidFocusEditorText(() => setIsFocused(true));
editor.onDidBlurEditorText(() => setIsFocused(false));
}}
loading={null}
/>
</div>
)}
/>

<Button className="absolute right-1 top-1 h-8 px-4" type="submit">
<Button className="absolute bottom-1 right-1 h-8 px-4" type="submit">
Copy link
Member

@holic holic Oct 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no change needed here but just wanted to note that I've found using position relative/absolute to create stacking contexts ends up being more trouble than its worth, because you end up fighting z-index or weird layering of things like modals etc.

now that we have grids, I now tend to use an approach like

<div className="grid">
  <div className="col-start-1 row-start-1">
    ...
  </div>
  <div className="col-start-1 row-start-1">
    ...
  </div>
</div>

obviously you can get more nuanced with this where you define an actual grid with template rows/cols and allow the top layer to take up only as much room as needed, but I only reach for that when I need to be specific about placement/alignment/spacing

another thing to note about this strategy is that the top layer (if using a full-size layer) will take over as the click target, so also need to use something like pointer-events-none on the layer's container and pointer-events-auto in each child

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting, but still not sure how would you handle layouts where an element has to exit the normal flow? For example, in this case, I want the Run button to float above the editor. Or would you suggest to avoid "floating" layouts in general, and go for layouts with normal flow whenever possible?

<PlayIcon className="mr-1.5 h-3 w-3" /> Run
</Button>
</form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ export function TablesViewer({ table, query }: { table?: TableType; query?: stri
</div>

<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{tableData && `Total rows: ${tableData.rows.length.toLocaleString()}`}
</div>

<div className="space-x-2">
<Button
variant="outline"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { editor } from "monaco-editor/esm/vs/editor/editor.api";
export const monacoOptions: editor.IStandaloneEditorConstructionOptions = {
fontSize: 14,
fontWeight: "normal",
wordWrap: "off",
wordWrap: "on",
wrappingStrategy: "advanced",
lineNumbers: "off",
lineNumbersMinChars: 0,
overviewRulerLanes: 0,
Expand All @@ -13,6 +14,7 @@ export const monacoOptions: editor.IStandaloneEditorConstructionOptions = {
glyphMargin: false,
folding: false,
scrollBeyondLastColumn: 0,
scrollBeyondLastLine: false,
scrollbar: {
horizontal: "hidden",
vertical: "hidden",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,27 @@ import { useMonaco } from "@monaco-editor/react";
export function useMonacoErrorMarker() {
const monaco = useMonaco();
return useCallback(
({ message, startColumn, endColumn }: { message: string; startColumn: number; endColumn: number }) => {
({
message,
startLineNumber,
endLineNumber,
startColumn,
endColumn,
}: {
message: string;
startLineNumber: number;
endLineNumber: number;
startColumn: number;
endColumn: number;
}) => {
if (monaco) {
monaco.editor.setModelMarkers(monaco.editor.getModels()[0], "sql", [
{
severity: monaco.MarkerSeverity.Error,
message,
startLineNumber: 1,
startLineNumber,
endLineNumber,
startColumn,
endLineNumber: 1,
endColumn,
},
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,49 @@ import { useMonacoErrorMarker } from "./useMonacoErrorMarker";

const sqlParser = new Parser();

function findErrorPosition(query: string, target: string) {
const lines = query.split("\n");
let startLineNumber = 1;
let startColumn = 1;
let currentPosition = 0;

for (let i = 0; i < lines.length; i++) {
if (currentPosition + lines[i].length >= query.indexOf(target)) {
startLineNumber = i + 1;
startColumn = query.indexOf(target) - currentPosition + 1;
break;
}
currentPosition += lines[i].length + 1;
}

return {
startLineNumber,
endLineNumber: startLineNumber,
startColumn,
endColumn: startColumn + target.length,
};
}

export function useQueryValidator(table?: Table) {
const monaco = useMonaco();
const { worldAddress } = useParams();
const { id: chainId } = useChain();
const setErrorMarker = useMonacoErrorMarker();

return useCallback(
(value: string) => {
(query: string) => {
if (!monaco || !table) return true;

const decodedQuery = decodeURIComponent(query);
try {
const ast = sqlParser.astify(value);
const ast = sqlParser.astify(decodedQuery);
if ("columns" in ast && Array.isArray(ast.columns)) {
for (const column of ast.columns) {
const columnName = column.expr.column;
if (!Object.keys(table.schema).includes(columnName)) {
setErrorMarker({
message: `Column '${columnName}' does not exist in the table schema.`,
startColumn: value.indexOf(columnName) + 1,
endColumn: value.indexOf(columnName) + columnName.length + 1,
...findErrorPosition(decodedQuery, columnName),
});
return false;
}
Expand All @@ -45,8 +68,7 @@ export function useQueryValidator(table?: Table) {
if (selectedTableName !== tableName) {
setErrorMarker({
message: `Only '${tableName}' is available for this query.`,
startColumn: value.indexOf(selectedTableName) + 1,
endColumn: value.indexOf(selectedTableName) + selectedTableName.length + 1,
...findErrorPosition(decodedQuery, selectedTableName),
});
return false;
}
Expand All @@ -58,10 +80,13 @@ export function useQueryValidator(table?: Table) {
return true;
} catch (error) {
if (error instanceof Error) {
const lines = decodedQuery.split("\n");
setErrorMarker({
message: error.message,
startLineNumber: 1,
endLineNumber: lines.length,
startColumn: 1,
endColumn: value.length + 1,
endColumn: lines[lines.length - 1].length + 1,
});
}
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ export type TableData = {
export function useTableDataQuery({ table, query }: Props) {
const { chainName, worldAddress } = useParams();
const { id: chainId } = useChain();
const decodedQuery = decodeURIComponent(query ?? "");

return useQuery<DozerResponse, Error, TableData | undefined>({
queryKey: ["tableData", chainName, worldAddress, query],
queryKey: ["tableData", chainName, worldAddress, decodedQuery],
queryFn: async () => {
const indexer = indexerForChainId(chainId);
const response = await fetch(indexer.url, {
Expand All @@ -32,7 +33,7 @@ export function useTableDataQuery({ table, query }: Props) {
body: JSON.stringify([
{
address: worldAddress as Hex,
query,
query: decodedQuery,
},
]),
});
Expand Down
Loading