From 06b7fba31bb31d6dfa67fd42419f23970eb4de0a Mon Sep 17 00:00:00 2001 From: Rikki Schulte Date: Mon, 13 Nov 2023 11:30:56 +0100 Subject: [PATCH] context menu, json5 toggle (#5) * context menu, json5 toggle * cleanup file import UX * more import cases supported, fix toggle for value pane * cleanup menus and state * cleanup log * json4 to 5 and vv for schema as well * persist schema, show to users * handle query params, fix serialization bugs --- app/api/schema/route.ts | 8 +- app/api/schemas/route.ts | 5 +- app/layout.tsx | 2 +- app/page.tsx | 21 +- components/editor/editor-pane.tsx | 51 ++++ components/editor/json-editor.tsx | 64 ++-- components/editor/json-schema-editor.tsx | 56 ++-- components/editor/json-value-editor.tsx | 36 +-- components/editor/menu.tsx | 192 ++++++++++++ components/editor/theme.ts | 4 +- components/icons.tsx | 1 + components/nav/site-header.tsx | 2 +- components/schema/schema-selector.tsx | 10 +- components/ui/autocomplete.tsx | 11 +- components/ui/dropdown-menu.tsx | 198 ++++++++++++ components/ui/input.tsx | 25 ++ components/ui/label.tsx | 24 ++ lib/json.ts | 33 ++ package-lock.json | 66 +++- package.json | 5 +- store/idb-store.ts | 6 +- store/main.ts | 367 ++++++++++++++++------- types/editor.ts | 4 + 23 files changed, 972 insertions(+), 219 deletions(-) create mode 100644 components/editor/editor-pane.tsx create mode 100644 components/editor/menu.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 lib/json.ts create mode 100644 types/editor.ts diff --git a/app/api/schema/route.ts b/app/api/schema/route.ts index 12d662f..5345f0e 100644 --- a/app/api/schema/route.ts +++ b/app/api/schema/route.ts @@ -12,19 +12,21 @@ async function getSchema(url: string) { } export async function GET(request: Request) { - const {searchParams} = new URL(request.url); + const { searchParams } = new URL(request.url) try { const url = searchParams.get("url") if (!url) { - return new Response("No schema key provided", { status: 400, }) } const schema = await getSchema(url) return new Response(schema, { - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + // "cache-control": "s-maxage=1440000", + }, }) } catch (e) { return new Response( diff --git a/app/api/schemas/route.ts b/app/api/schemas/route.ts index 4560819..52015cc 100644 --- a/app/api/schemas/route.ts +++ b/app/api/schemas/route.ts @@ -13,6 +13,9 @@ async function getSchemas() { export async function GET(request: Request) { return new Response(await getSchemas(), { - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + // "cache-control": "s-maxage=1440000", + }, }) } diff --git a/app/layout.tsx b/app/layout.tsx index 2cb7b07..6ff13ee 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -42,7 +42,7 @@ export default function RootLayout({ children }: RootLayoutProps) { )} > -
+
{children}
diff --git a/app/page.tsx b/app/page.tsx index 1cb2cca..58aa557 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,14 +1,23 @@ - import { JSONSchemaEditor } from "@/components/editor/json-schema-editor" import { JSONValueEditor } from "@/components/editor/json-value-editor" -export default function IndexPage() { +export default function IndexPage({ + searchParams, +}: { + searchParams: Record +}) { return ( -
-
- +
+
+
-
+
diff --git a/components/editor/editor-pane.tsx b/components/editor/editor-pane.tsx new file mode 100644 index 0000000..4ddf60b --- /dev/null +++ b/components/editor/editor-pane.tsx @@ -0,0 +1,51 @@ +import dynamic from "next/dynamic" +import { SchemaState } from "@/store/main" + +import { EditorMenu } from "./menu" + +export interface EditorPane { + heading: string + editorKey: keyof SchemaState["editors"] + schema?: Record + value?: string + setValueString: (val: string) => void +} + +const JSONEditor = dynamic( + async () => (await import("./json-editor")).JSONEditor, + { ssr: false } +) + +export const EditorPane = ({ + schema, + editorKey, + heading, + value, + setValueString, + ...props +}: EditorPane) => { + return ( + <> +
+

{heading}

+ +
+ + + ) +} diff --git a/components/editor/json-editor.tsx b/components/editor/json-editor.tsx index f9de89a..2019882 100644 --- a/components/editor/json-editor.tsx +++ b/components/editor/json-editor.tsx @@ -1,61 +1,65 @@ "use client" -import { useEffect, useRef, useState } from "react" -import { autocompletion, closeBrackets } from "@codemirror/autocomplete" +import { useEffect, useRef } from "react" +import { SchemaState, useMainStore } from "@/store/main" +import { autocompletion } from "@codemirror/autocomplete" import { history } from "@codemirror/commands" -import { bracketMatching, syntaxHighlighting } from "@codemirror/language" -import { lintGutter } from "@codemirror/lint" +import { syntaxHighlighting } from "@codemirror/language" import { EditorState } from "@codemirror/state" -import { oneDark, oneDarkHighlightStyle } from "@codemirror/theme-one-dark" -import { EditorView, ViewUpdate, gutter, lineNumbers } from "@codemirror/view" -import CodeMirror, { ReactCodeMirrorProps, ReactCodeMirrorRef } from "@uiw/react-codemirror" -import { basicSetup } from "codemirror" +import { oneDarkHighlightStyle } from "@codemirror/theme-one-dark" +import { EditorView } from "@codemirror/view" +import CodeMirror, { + ReactCodeMirrorProps, + ReactCodeMirrorRef, +} from "@uiw/react-codemirror" import { jsonSchema, updateSchema } from "codemirror-json-schema" // @ts-expect-error TODO: fix this in the lib! import { json5Schema } from "codemirror-json-schema/json5" +import json5 from "json5" + +import { JSONModes } from "@/types/editor" +import { serialize } from "@/lib/json" // import { debounce } from "@/lib/utils" import { jsonDark, jsonDarkTheme } from "./theme" -const jsonText = `{ - "example": true -}` - /** * none of these are required for json4 or 5 * but they will improve the DX */ const commonExtensions = [ - bracketMatching(), - closeBrackets(), history(), autocompletion(), - lineNumbers(), - lintGutter(), jsonDark, EditorView.lineWrapping, EditorState.tabSize.of(2), syntaxHighlighting(oneDarkHighlightStyle), ] -interface JSONEditorProps extends ReactCodeMirrorProps { - value: string; - onValueChange?: (newValue: string) => void; - schema?: Record; - mode?: "json5" | "json4"; +const languageExtensions = { + json4: jsonSchema, + json5: json5Schema, +} + +export interface JSONEditorProps extends Omit { + onValueChange?: (newValue: string) => void + schema?: Record + editorKey: keyof SchemaState["editors"] + value?: string } export const JSONEditor = ({ - value, schema, onValueChange = () => {}, - mode = "json4", + editorKey, + value, ...rest }: JSONEditorProps) => { - const isJson5 = mode === "json5" - const defaultExtensions = [ - ...commonExtensions, - isJson5 ? json5Schema(schema) : jsonSchema(schema), - ] + const editorMode = useMainStore( + (state) => + state.editors[editorKey as keyof SchemaState["editors"]].mode ?? + state.userSettings.mode + ) + const languageExtension = languageExtensions[editorMode](schema) const editorRef = useRef(null) useEffect(() => { @@ -65,13 +69,15 @@ export const JSONEditor = ({ updateSchema(editorRef?.current.view, schema) }, [schema]) + return ( ) diff --git a/components/editor/json-schema-editor.tsx b/components/editor/json-schema-editor.tsx index ae60d9b..5229218 100644 --- a/components/editor/json-schema-editor.tsx +++ b/components/editor/json-schema-editor.tsx @@ -1,46 +1,38 @@ "use client" import { useEffect } from "react" -import dynamic from "next/dynamic" import { useMainStore } from "@/store/main" -import { Icons } from "../icons" -import { Button } from "../ui/button" +import { EditorPane } from "./editor-pane" -const JSONEditor = dynamic( - async () => (await import("./json-editor")).JSONEditor, - { ssr: false } -) - -export const JSONSchemaEditor = () => { +export const JSONSchemaEditor = ({ url }: { url: string | null }) => { const schemaSpec = useMainStore((state) => state.schemaSpec) - const pristineSchema = useMainStore((state) => state.pristineSchema) - const [loadIndex, setSchema] = useMainStore((state) => [ - state.loadIndex, - state.setSchema, - ]) + const loadIndex = useMainStore((state) => state.loadIndex) + + const setValueString = useMainStore((state) => state.setSchemaString) + const value = useMainStore((state) => state.schemaString) + const setSelectedSchema = useMainStore( + (state) => state.setSelectedSchemaFromUrl + ) useEffect(() => { loadIndex() }, [loadIndex]) + + useEffect(() => { + if (url && url?.length && url.startsWith("http")) { + setSelectedSchema(url) + } + }, [url]) + return ( - <> -
-

Schema

-
- -
-
- setSchema(JSON.parse(val))} - value={JSON.stringify(pristineSchema, null, 2)} - // json schema spec v? allow spec selection - schema={schemaSpec} - className="flex-1 overflow-auto" - height="100%" - /> - + ) } diff --git a/components/editor/json-value-editor.tsx b/components/editor/json-value-editor.tsx index afa28b7..05e06ab 100644 --- a/components/editor/json-value-editor.tsx +++ b/components/editor/json-value-editor.tsx @@ -1,35 +1,21 @@ "use client" -import dynamic from "next/dynamic" import { useMainStore } from "@/store/main" -import { Icons } from "../icons" -import { Button } from "../ui/button" - -const JSONEditor = dynamic( - async () => (await import("./json-editor")).JSONEditor, - { ssr: false } -) +import { EditorPane } from "./editor-pane" export const JSONValueEditor = () => { const schema = useMainStore((state) => state.schema) - return ( - <> -
-

Value

-
- -
-
+ const setValueString = useMainStore((state) => state.setTestValueString) + const value = useMainStore((state) => state.testValueString) - - + return ( + ) } diff --git a/components/editor/menu.tsx b/components/editor/menu.tsx new file mode 100644 index 0000000..4560353 --- /dev/null +++ b/components/editor/menu.tsx @@ -0,0 +1,192 @@ +import React, { useState } from "react" +import { SchemaState, useMainStore } from "@/store/main" +import { DialogClose } from "@radix-ui/react-dialog" +import { load as parseYaml } from "js-yaml" +import json5 from "json5" +import { Check, CheckIcon, MoreVertical } from "lucide-react" + +import { JSONModes } from "@/types/editor" +import { serialize } from "@/lib/json" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTrigger, +} from "../ui/dialog" + +export interface EditorMenu { + heading: string + editorKey: keyof SchemaState["editors"] + value?: string + setValueString: (val: string) => void + menuPrefix?: React.ReactNode + menuSuffix?: React.ReactNode +} + +export const EditorMenu = ({ + editorKey, + heading, + setValueString, + menuPrefix, + menuSuffix, + value, +}: EditorMenu) => { + const [imported, setImported] = useState(undefined) + + const setEditorSetting = useMainStore((state) => state.setEditorSetting) + + const editorMode = useMainStore( + (state) => + state.editors[editorKey as keyof SchemaState["editors"]].mode ?? + state.userSettings.mode + ) + return ( + + + + + + {menuPrefix && menuPrefix} + + setEditorSetting(editorKey, "mode", val)} + > + + JSON4 + + + JSON5 + + + + + + e.preventDefault()}> + + + Import + + + +
Import {heading} File...
+ +
+
+ + e.stopPropagation()} + onChange={async (e) => { + // TODO: move to zustand + const file = e?.target?.files?.[0] + if (file) { + const fileText = await file.text() + if (file.type.includes(JSONModes.JSON5)) { + setImported(json5.parse(fileText)) + } else if (file.type.includes('json')) { + setImported(JSON.parse(fileText)) + } + + if (file.type.includes("yaml")) { + setImported(parseYaml(fileText)) + } + } + }} + /> + + + { + // console.log(e.target.value) + // }} + /> + {imported ? ( +
+ + This file can be imported{" "} +
+ ) : null} +
+ + + + + + + + +
+
+
+ + Export +
+ + + value && setValueString(value)}> + Format + + + {menuSuffix && menuSuffix} +
+
+ ) +} diff --git a/components/editor/theme.ts b/components/editor/theme.ts index 0357771..40e90d6 100644 --- a/components/editor/theme.ts +++ b/components/editor/theme.ts @@ -22,7 +22,7 @@ const chalky = "#e5c07b", tooltipBackground = "rgb(30 41 59 / 1)", selection = "#3E4451", cursor = "#528bff", - borderRadius = '10px'; + borderRadius = '0px'; // --tw-bg-opacity: 1; // background-color: rgb(30 41 59 / var(--tw-bg-opacity)); @@ -209,4 +209,4 @@ export const jsonDarkTheme = createTheme({ {tag: t.invalid, color: invalid}, ], -}); \ No newline at end of file +}); diff --git a/components/icons.tsx b/components/icons.tsx index a1c513e..38c7cb0 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -17,6 +17,7 @@ export const Icons = { twitter: Settings, Logo: CurlyBraces, Hamburger: MenuIcon, + Settings, logo: (props: LucideProps) => ( +
diff --git a/components/schema/schema-selector.tsx b/components/schema/schema-selector.tsx index 74572ff..baeabb2 100644 --- a/components/schema/schema-selector.tsx +++ b/components/schema/schema-selector.tsx @@ -1,7 +1,7 @@ "use client" import { useMainStore } from "@/store/main" -import { Check } from "lucide-react" +import { Check, Save } from "lucide-react" import { AutoComplete } from "@/components/ui/autocomplete" @@ -27,14 +27,18 @@ export type SchemaResponse = { export const SchemaSelector = () => { const index = useMainStore((state) => state.index) const setSelectedSchema = useMainStore((state) => state.setSelectedSchema) + const selectedSchema = useMainStore((state) => state.selectedSchema) + const schemas = useMainStore((state) => state.schemas) return ( - emptyMessage="SchemaStore.org schemas loading..." + emptyMessage="No schemas on SchemaStore.org matched your search." options={index ?? []} placeholder="choose a schema..." onValueChange={setSelectedSchema} + value={selectedSchema} Results={({ option, selected }) => (
+ {selected && }
{option.label} {option.description && ( @@ -55,7 +59,7 @@ export const SchemaSelector = () => { )}
- {selected && } + {schemas[option.value] && }
)} /> diff --git a/components/ui/autocomplete.tsx b/components/ui/autocomplete.tsx index 76b7779..88ea651 100644 --- a/components/ui/autocomplete.tsx +++ b/components/ui/autocomplete.tsx @@ -1,3 +1,5 @@ +// custom!!! +// based on a user suggestion in an issue "use client" import { CommandGroup, CommandItem, CommandList, CommandInput } from "@/components/ui/command" @@ -112,14 +114,16 @@ export const AutoComplete = ({ onFocus={() => setOpen(true)} placeholder={placeholder} disabled={disabled} - className="w-full border-none p-2 text-base dark:bg-slate-800" + className="w-full border-none p-2 text-base dark:bg-slate-800 bg-slate-200" role="combobox" aria-haspopup="listbox" + tabIndex={0} + autoFocus />
{isOpen ? ( -
+
{isLoading ? ( @@ -140,8 +144,9 @@ export const AutoComplete = ({ event.preventDefault() event.stopPropagation() }} + tabIndex={0} onSelect={() => handleSelectOption(option)} - className={cn("flex w-full items-center gap-2", !isSelected ? "pl-8" : null)} + className={cn("flex w-full items-center gap-2 hover:dark:bg-slate-900", !isSelected ? "pl-8" : null)} > {isSelected ? : null} diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..769ff7a --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..677d05f --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..683faa7 --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/lib/json.ts b/lib/json.ts new file mode 100644 index 0000000..dec1c9e --- /dev/null +++ b/lib/json.ts @@ -0,0 +1,33 @@ +import json5 from "json5" + +import { JSONModes } from "@/types/editor" + +const parsers = { + [JSONModes.JSON4]: JSON.parse, + [JSONModes.JSON5]: json5.parse, +} + +const serializers = { + [JSONModes.JSON4]: JSON.stringify, + [JSONModes.JSON5]: json5.stringify, +} + +export const parse = (editorMode: JSONModes, value: string): Record => { + try { + return parsers[editorMode](value) + } catch (e) { + return value ? JSON.parse(value) : {} + } +} + +export const serialize = ( + editorMode: JSONModes, + value?: Record +): string => { + try { + // @ts-expect-error + return serializers[editorMode](value, null, 2) + } catch (e) { + return value?.toString() ?? "{}" + } +} diff --git a/package-lock.json b/package-lock.json index b905df0..7ca0677 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,8 @@ "@codemirror/view": "^6.22.0", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-direction": "^1.0.1", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-menubar": "^1.0.4", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-scroll-area": "^1.0.5", @@ -27,14 +29,15 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toggle": "^1.0.3", + "@types/js-yaml": "^4.0.9", "@uiw/codemirror-themes": "^4.21.20", "@uiw/react-codemirror": "^4.21.20", "class-variance-authority": "^0.4.0", "clsx": "^1.2.1", "cmdk": "^0.2.0", - "codemirror": "^6.0.1", "codemirror-json-schema": "^0.5.0", "idb-keyval": "^6.2.1", + "js-yaml": "^4.1.0", "json5": "^2.2.3", "lodash-es": "^4.17.21", "lucide-react": "^0.105.0-alpha.4", @@ -1115,6 +1118,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz", + "integrity": "sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-menu": "2.0.6", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", @@ -1175,6 +1207,29 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.0.2.tgz", + "integrity": "sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menu": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.0.6.tgz", @@ -1773,6 +1828,11 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2120,8 +2180,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/aria-hidden": { "version": "1.2.3", @@ -4801,7 +4860,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, diff --git a/package.json b/package.json index 6906c48..e36fb19 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "@codemirror/view": "^6.22.0", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-direction": "^1.0.1", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-menubar": "^1.0.4", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-scroll-area": "^1.0.5", @@ -33,14 +35,15 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toggle": "^1.0.3", + "@types/js-yaml": "^4.0.9", "@uiw/codemirror-themes": "^4.21.20", "@uiw/react-codemirror": "^4.21.20", "class-variance-authority": "^0.4.0", "clsx": "^1.2.1", "cmdk": "^0.2.0", - "codemirror": "^6.0.1", "codemirror-json-schema": "^0.5.0", "idb-keyval": "^6.2.1", + "js-yaml": "^4.1.0", "json5": "^2.2.3", "lodash-es": "^4.17.21", "lucide-react": "^0.105.0-alpha.4", diff --git a/store/idb-store.ts b/store/idb-store.ts index 2f44d08..bab9de4 100644 --- a/store/idb-store.ts +++ b/store/idb-store.ts @@ -1,6 +1,6 @@ -import { create } from 'zustand' -import { persist, createJSONStorage, StateStorage } from 'zustand/middleware' -import { get, set, del } from 'idb-keyval' // can use anything: IndexedDB, Ionic Storage, etc. +import { del, get, set } from "idb-keyval" +import { StateStorage } from "zustand/middleware" + // Custom storage object export const storage: StateStorage = { diff --git a/store/main.ts b/store/main.ts index 61c8cee..c3a7b03 100644 --- a/store/main.ts +++ b/store/main.ts @@ -1,6 +1,16 @@ +import { EditorView } from "codemirror" +import { JSONMode } from "codemirror-json-schema" +import json5 from "json5" import { UseBoundStore, create } from "zustand" -import { createJSONStorage, devtools, persist } from "zustand/middleware" +import { + PersistOptions, + createJSONStorage, + devtools, + persist, +} from "zustand/middleware" +import { JSONModes } from "@/types/editor" +import { parse, serialize } from "@/lib/json" import { toast } from "@/components/ui/use-toast" import { SchemaResponse, @@ -9,133 +19,280 @@ import { import { storage } from "./idb-store" +type JsonEditorState = { + mode?: JSONModes + theme?: string + instance?: EditorView +} + export type SchemaState = { // metadata about the selected schema, formatted for autocomplete component selectedSchema?: SchemaSelectorValue // the actual schema object schema?: Record + schemaString?: string + + testValueString?: string // the initial schema value on change for the editor to set - pristineSchema?: Record + // pristineSchema?: Record schemaError?: string // an index of available schemas from SchemaStore.org index: SchemaSelectorValue[] indexError?: string // the base $schema spec for the current `schema` schemaSpec?: Record + // user settings + userSettings: { + mode: JSONModes + } + // editors state + editors: { + schema: JsonEditorState + testValue: JsonEditorState + } + schemas: Record> } export type SchemaActions = { - setIndex: (indexPayload: SchemaResponse) => void - setSelectedSchema: (selectedSchema: SchemaSelectorValue) => void + setSelectedSchema: ( + selectedSchema: Partial & { value: string } + ) => Promise + setSelectedSchemaFromUrl: (url: string) => Promise setSchema: (schema: Record) => void + setSchemaString: (schema: string) => void clearSelectedSchema: () => void - loadIndex: () => void + loadIndex: () => Promise + setEditorSetting: ( + editor: keyof SchemaState["editors"], + setting: keyof JsonEditorState, + value: T + ) => void + setEditorMode: (editor: keyof SchemaState["editors"], mode: JSONModes) => void + setTestValueString: (testValue: string) => void + getMode: (editorKey?: keyof SchemaState["editors"]) => JSONModes + fetchSchema: ( + url: string + ) => Promise<{ schemaString: string; schema: Record }> } -// TODO; throws ts error -const middlewares = (f: any) => - devtools( - persist(f, { - name: "jsonWorkbench", - storage: createJSONStorage(() => storage), - }) - ) -export const useMainStore = create()( - (set, get) => ({ - index: [], - setIndex: (indexPayload: SchemaResponse) => { - set({ - index: indexPayload.schemas.map((schema) => ({ - value: schema.url, - label: schema.name, - ...schema, - })), - }) - }, - clearSelectedSchema: () => { - set({ - selectedSchema: undefined, - schema: undefined, - schemaError: undefined, - pristineSchema: undefined, - }) - }, - setSchema: (schema: Record) => { - set({ schema }) - }, - setSelectedSchema: async (selectedSchema: SchemaSelectorValue) => { - try { - set({ selectedSchema, schemaError: undefined }) - const data = await ( - await fetch( - `/api/schema?${new URLSearchParams({ url: selectedSchema.value })}` - ) - ).json() - // though it appears we are setting schema state twice, - // pristineSchema only changes on selecting a new schema +const persistOptions: PersistOptions = { + name: "jsonWorkBench", + storage: createJSONStorage(() => storage), +} + +const initialState = { + userSettings: { + // theme: "system", + mode: JSONModes.JSON4, + // "editor.theme": "one-dark", + // "editor.keymap": "default", + // "editor.tabSize": 2, + // "editor.indentWithTabs": false, + }, + editors: { + schema: {}, + testValue: {}, + }, + schemas: {}, +} + +export const useMainStore = create()< + [["zustand/persist", unknown], ["zustand/devtools", never]] +>( + persist( + devtools((set, get) => ({ + ...initialState, + index: [], + clearSelectedSchema: () => { set({ - schema: data, - schemaError: data.error, - pristineSchema: data, - }) - toast({ - title: "Schema loaded", - description: selectedSchema.label, + selectedSchema: undefined, + schema: undefined, + schemaError: undefined, }) - } catch (err) { - // @ts-expect-error - const errMessage = err?.message || err - set({ schemaError: errMessage }) - toast({ - title: "Error loading schema", - description: errMessage, - variant: "destructive", - }) - } - try { - const schema = get().schema - const schemaUrl = - schema && schema["$schema"] - ? (schema["$schema"] as string) - : "https://json-schema.org/draft/2020-12/schema" - const data = await (await fetch(schemaUrl)).json() - set({ schemaSpec: data }) - } catch (err) { - // @ts-expect-error - const errMessage = err?.message || err - set({ schemaError: errMessage }) - toast({ - title: "Error loading schema spec", - description: errMessage, - variant: "destructive", + }, + getMode: (editorKey?: keyof SchemaState["editors"]) => { + if (editorKey) { + return get().editors[editorKey].mode ?? get().userSettings.mode + } + return get().userSettings.mode + }, + // don't set pristine schema here to avoid triggering updates + setSchema: (schema: Record) => { + set({ + schema, + schemaError: undefined, + schemaString: serialize(get().getMode("schema"), schema), }) - } - }, - // this should only need to be called on render, and ideally be persisted - loadIndex: async () => { - try { - const indexPayload: SchemaResponse = await ( - await fetch("/api/schemas") - ).json() - + }, + setSchemaString: (schema: string) => { set({ - indexError: undefined, - index: indexPayload.schemas.map((schema) => ({ - value: schema.url, - label: schema.name, - ...schema, - })), + schema: parse(get().getMode("schema"), schema), + schemaString: schema, + schemaError: undefined, }) - } catch (err) { - // @ts-expect-error - const errMessage = err?.message || err - set({ indexError: errMessage }) - toast({ - title: "Error loading schema index", - description: errMessage, - variant: "destructive", + }, + setTestValueString: (testValue) => { + set({ + testValueString: testValue, }) - } - }, - }) + }, + setEditorSetting: (editor, setting, value) => { + set((state) => ({ + editors: { + ...state.editors, + [editor]: { + ...state.editors[editor], + [setting]: value, + }, + }, + })) + if (setting === "mode") { + const editorString = get()[`${editor}String`] ?? "{}" + set({ + [`${editor}String`]: + value === "json5" + ? json5.stringify(JSON.parse(editorString), null, 2) + : JSON.stringify(json5.parse(editorString), null, 2), + }) + } + }, + setEditorMode: (editor, mode) => { + set((state) => ({ + editors: { + ...state.editors, + [editor]: { + ...state.editors[editor], + mode, + }, + }, + })) + }, + fetchSchema: async (url: string) => { + const schemas = get().schemas + // serialize them to the json4/5 the schema editor is configured for + const mode = get().getMode("schema") + if (schemas[url]) { + const schema = schemas[url]! + return { + schemaString: serialize(mode, schema), + schema, + } + } + const data = await ( + await fetch( + `/api/schema?${new URLSearchParams({ + url, + })}` + ) + ).text() + const parsed = parse(mode, data) + schemas[url] = parsed + return { schemaString: data, schema: parsed } + }, + setSelectedSchema: async (selectedSchema) => { + try { + let selected = selectedSchema + const { schemaString: data, schema } = await get().fetchSchema( + selectedSchema.value + ) + if (!selectedSchema.label) { + selected = { + label: + schema.title ?? + schema.description ?? + (selectedSchema.value as string), + value: selectedSchema.value, + description: schema.title + ? (schema.description as string) + : undefined, + } as SchemaSelectorValue + } + set({ + selectedSchema: selected as SchemaSelectorValue, + schemaError: undefined, + }) + + // though it appears we are setting schema state twice, + // pristineSchema only changes on selecting a new schema + set({ + schema: schema, + schemaString: data, + schemaError: undefined, + }) + + toast({ + title: "Schema loaded", + description: selectedSchema.label, + }) + } catch (err) { + // @ts-expect-error + const errMessage = err?.message || err + set({ schemaError: errMessage }) + toast({ + title: "Error loading schema", + description: errMessage, + variant: "destructive", + }) + } + try { + const schema = get().schema + const schemaUrl = + schema && schema["$schema"] + ? (schema["$schema"] as string) + : "https://json-schema.org/draft/2020-12/schema" + const { schema: schemaSpec } = await get().fetchSchema(schemaUrl) + set({ schemaSpec }) + } catch (err) { + // @ts-expect-error + const errMessage = err?.message || err + set({ schemaError: errMessage }) + toast({ + title: "Error loading schema spec", + description: errMessage, + variant: "destructive", + }) + } + }, + setSelectedSchemaFromUrl: async (url) => { + const index = get().index + if (index) { + const selectedSchema = index.find((schema) => schema?.value === url) + if (selectedSchema) { + await get().setSelectedSchema(selectedSchema) + } else { + await get().setSelectedSchema({ value: url }) + } + } + }, + // this should only need to be called on render + loadIndex: async () => { + try { + if (!get().index?.length) { + const indexPayload: SchemaResponse = await ( + await fetch("/api/schemas") + ).json() + + set({ + indexError: undefined, + index: indexPayload.schemas.map((schema) => ({ + value: schema.url, + label: schema.name, + ...schema, + })), + }) + } + } catch (err) { + // @ts-expect-error + const errMessage = err?.message || err + set({ indexError: errMessage }) + toast({ + title: "Error loading schema index", + description: errMessage, + variant: "destructive", + }) + } + }, + })), + persistOptions + ) ) diff --git a/types/editor.ts b/types/editor.ts new file mode 100644 index 0000000..a480bfd --- /dev/null +++ b/types/editor.ts @@ -0,0 +1,4 @@ +export enum JSONModes { + JSON4 = "json4", + JSON5 = "json5", +}