From 45c0d97873933ce602b43754eb264f5f44ae23a5 Mon Sep 17 00:00:00 2001 From: Rikki Schulte Date: Sun, 12 Nov 2023 02:00:34 +0100 Subject: [PATCH 1/8] context menu, json5 toggle --- app/layout.tsx | 2 +- app/page.tsx | 6 +- components/editor/editor-pane.tsx | 148 ++++++++++++ components/editor/json-editor.tsx | 56 +++-- components/editor/json-schema-editor.tsx | 40 ++-- components/editor/json-value-editor.tsx | 32 +-- components/editor/theme.ts | 4 +- components/icons.tsx | 1 + components/nav/site-header.tsx | 2 +- components/schema/schema-selector.tsx | 2 + components/ui/autocomplete.tsx | 5 + components/ui/dropdown-menu.tsx | 198 ++++++++++++++++ components/ui/input.tsx | 25 ++ package-lock.json | 31 ++- package.json | 2 +- store/idb-store.ts | 3 +- store/main.ts | 276 ++++++++++++++--------- types/editor.ts | 4 + 18 files changed, 654 insertions(+), 183 deletions(-) create mode 100644 components/editor/editor-pane.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/input.tsx create mode 100644 types/editor.ts 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..74a6d69 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,11 +4,11 @@ import { JSONValueEditor } from "@/components/editor/json-value-editor" export default function IndexPage() { return ( -
-
+
+
-
+
diff --git a/components/editor/editor-pane.tsx b/components/editor/editor-pane.tsx new file mode 100644 index 0000000..f0a7d7c --- /dev/null +++ b/components/editor/editor-pane.tsx @@ -0,0 +1,148 @@ +"use client" + +import { useState } from "react" +import dynamic from "next/dynamic" +import { SchemaState, useMainStore } from "@/store/main" +import json5 from "json5" +import { MoreVertical } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Command, CommandInput } from "@/components/ui/command" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { Input } from "../ui/input" +import { JSONEditorProps } from "./json-editor" + +export interface EditorPane extends Omit, "on"> { + heading: string + editorKey: keyof SchemaState["editors"] + value?: Record +} + +const JSONEditor = dynamic( + async () => (await import("./json-editor")).JSONEditor, + { ssr: false } +) + +export const EditorPane = ({ + onValueChange, + value, + schema, + editorKey, + heading, + ...props +}: EditorPane) => { + const [editorValue, setEditorValue] = useState(value) + const editorMode = useMainStore( + (state) => state.editors[editorKey].mode ?? state.userSettings.mode + ) + const setEditorSetting = useMainStore((state) => state.setEditorSetting) + + return ( + <> +
+

{heading}

+
+ + + + + + + + setEditorSetting(editorKey, "mode", val) + } + > + + JSON4 + + + JSON5 + + + + + + + {/* */} + Import + + + + + {/* */} +
From File
+
+ { + const file = document.getElementById("fromFile") + if (file) { + const reader = new FileReader() + reader.onload = (e) => { + if (!e.target?.result) return + if (isArrayBuffer(e.target.result)) { + const contents = new TextDecoder( + "utf-8" + ).decode(e.target.result) + if (contents) { + setEditorValue(JSON.parse(contents)) + } + return + } + + reader.readAsText(file.files[0]) + } + } + }} + /> +
+
+ + {/* */} + From URL + + +
+
+
+ Export +
+
+
+
+ + + ) +} diff --git a/components/editor/json-editor.tsx b/components/editor/json-editor.tsx index f9de89a..b66cb66 100644 --- a/components/editor/json-editor.tsx +++ b/components/editor/json-editor.tsx @@ -1,6 +1,8 @@ "use client" -import { useEffect, useRef, useState } from "react" +import { json } from "stream/consumers" +import { use, useEffect, useRef, useState } from "react" +import { SchemaState, useMainStore } from "@/store/main" import { autocompletion, closeBrackets } from "@codemirror/autocomplete" import { history } from "@codemirror/commands" import { bracketMatching, syntaxHighlighting } from "@codemirror/language" @@ -8,17 +10,22 @@ import { lintGutter } from "@codemirror/lint" 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 CodeMirror, { + ReactCodeMirrorProps, + ReactCodeMirrorRef, +} from "@uiw/react-codemirror" import { basicSetup } from "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 { debounce } from "@/lib/utils" import { jsonDark, jsonDarkTheme } from "./theme" const jsonText = `{ - "example": true }` /** @@ -26,36 +33,38 @@ const jsonText = `{ * 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 { + value?: Record + onValueChange?: (newValue: string) => void + schema?: Record + editorKey?: string } export const JSONEditor = ({ value, schema, onValueChange = () => {}, - mode = "json4", + editorKey, ...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 +74,20 @@ export const JSONEditor = ({ updateSchema(editorRef?.current.view, schema) }, [schema]) + const stringValue = useMainStore((state) => { + const editorMode = state.editors.schema.mode ?? state.userSettings.mode + return editorMode === "json4" + ? JSON.stringify(value, null, 2) + : json5.stringify(value, null, 2) + }) return ( ) diff --git a/components/editor/json-schema-editor.tsx b/components/editor/json-schema-editor.tsx index ae60d9b..5349135 100644 --- a/components/editor/json-schema-editor.tsx +++ b/components/editor/json-schema-editor.tsx @@ -3,14 +3,12 @@ import { useEffect } from "react" import dynamic from "next/dynamic" import { useMainStore } from "@/store/main" +import json5 from "json5" +import { Droplets, MoreVertical } from "lucide-react" 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 JSONSchemaEditor = () => { const schemaSpec = useMainStore((state) => state.schemaSpec) @@ -19,28 +17,24 @@ export const JSONSchemaEditor = () => { state.loadIndex, state.setSchema, ]) + const editorMode = useMainStore( + (state) => state.editors.schema.mode ?? state.userSettings.mode + ) useEffect(() => { loadIndex() }, [loadIndex]) + 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%" - /> - + { + setSchema(json5.parse(val)) + }} + value={pristineSchema} + // json schema spec v? allow spec selection + schema={schemaSpec} + /> ) } diff --git a/components/editor/json-value-editor.tsx b/components/editor/json-value-editor.tsx index afa28b7..e4f062f 100644 --- a/components/editor/json-value-editor.tsx +++ b/components/editor/json-value-editor.tsx @@ -1,35 +1,19 @@ "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

-
- -
-
- - - + ) } 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..c2a0b20 100644 --- a/components/schema/schema-selector.tsx +++ b/components/schema/schema-selector.tsx @@ -27,12 +27,14 @@ export type SchemaResponse = { export const SchemaSelector = () => { const index = useMainStore((state) => state.index) const setSelectedSchema = useMainStore((state) => state.setSelectedSchema) + const selectedSchema = useMainStore((state) => state.selectedSchema) return ( emptyMessage="SchemaStore.org schemas loading..." options={index ?? []} placeholder="choose a schema..." onValueChange={setSelectedSchema} + value={selectedSchema} Results={({ option, selected }) => (
diff --git a/components/ui/autocomplete.tsx b/components/ui/autocomplete.tsx index 76b7779..3df0fa5 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" @@ -115,6 +117,8 @@ export const AutoComplete = ({ className="w-full border-none p-2 text-base dark:bg-slate-800" role="combobox" aria-haspopup="listbox" + tabIndex={0} + autoFocus />
@@ -140,6 +144,7 @@ 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)} 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/package-lock.json b/package-lock.json index b905df0..5305afa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@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-menubar": "^1.0.4", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-scroll-area": "^1.0.5", @@ -32,7 +33,6 @@ "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", "json5": "^2.2.3", @@ -1115,6 +1115,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", diff --git a/package.json b/package.json index 6906c48..826ff87 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@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-menubar": "^1.0.4", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-scroll-area": "^1.0.5", @@ -38,7 +39,6 @@ "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", "json5": "^2.2.3", diff --git a/store/idb-store.ts b/store/idb-store.ts index 2f44d08..2b92eda 100644 --- a/store/idb-store.ts +++ b/store/idb-store.ts @@ -1,5 +1,4 @@ -import { create } from 'zustand' -import { persist, createJSONStorage, StateStorage } from 'zustand/middleware' +import { StateStorage } from 'zustand/middleware' import { get, set, del } from 'idb-keyval' // can use anything: IndexedDB, Ionic Storage, etc. // Custom storage object diff --git a/store/main.ts b/store/main.ts index 61c8cee..9adcd66 100644 --- a/store/main.ts +++ b/store/main.ts @@ -1,6 +1,14 @@ +import { EditorView } from "codemirror" +import { JSONMode } from "codemirror-json-schema" 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 { toast } from "@/components/ui/use-toast" import { SchemaResponse, @@ -9,6 +17,12 @@ 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 @@ -22,120 +36,172 @@ export type SchemaState = { indexError?: string // the base $schema spec for the current `schema` schemaSpec?: Record + // user settings + userSettings: { + mode: JSONModes + } + // editors state + editors: { + schema: JsonEditorState + value: JsonEditorState + } } export type SchemaActions = { - setIndex: (indexPayload: SchemaResponse) => void setSelectedSchema: (selectedSchema: SchemaSelectorValue) => void setSchema: (schema: Record) => void clearSelectedSchema: () => void loadIndex: () => void + setEditorSetting: ( + editor: keyof SchemaState["editors"], + setting: keyof JsonEditorState, + value: T + ) => void + setEditorMode: ( + editor: keyof SchemaState["editors"], + mode: JSONModes + ) => void } -// 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 - set({ - schema: data, - schemaError: data.error, - pristineSchema: data, - }) - 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 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", - }) - } - }, - // 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() +const persistOptions: PersistOptions = { + name: "jsonWorkbench", + storage: createJSONStorage(() => storage), +} + +const initialState = { + index: [], + userSettings: { + // theme: "system", + mode: JSONModes.JSON4, + // "editor.theme": "one-dark", + // "editor.keymap": "default", + // "editor.tabSize": 2, + // "editor.indentWithTabs": false, + }, + editors: { + schema: {}, + value: {}, + }, +} +export const useMainStore = create()< + [["zustand/persist", unknown], ["zustand/devtools", never]] +>( + persist( + devtools((set, get) => ({ + ...initialState, + clearSelectedSchema: () => { set({ - indexError: undefined, - index: indexPayload.schemas.map((schema) => ({ - value: schema.url, - label: schema.name, - ...schema, - })), + selectedSchema: undefined, + schema: undefined, + schemaError: undefined, + pristineSchema: undefined, }) - } catch (err) { - // @ts-expect-error - const errMessage = err?.message || err - set({ indexError: errMessage }) - toast({ - title: "Error loading schema index", - description: errMessage, - variant: "destructive", - }) - } - }, - }) + }, + setSchema: (schema: Record) => { + set({ schema }) + }, + setEditorSetting: (editor, setting, value) => { + set((state) => ({ + editors: { + ...state.editors, + [editor]: { + ...state.editors[editor], + [setting]: value, + }, + }, + })) + }, + setEditorMode: (editor, mode) => { + set((state) => ({ + editors: { + ...state.editors, + [editor]: { + ...state.editors[editor], + mode, + }, + }, + })) + }, + 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 + set({ + schema: data, + schemaError: data.error, + pristineSchema: data, + }) + 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 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", + }) + } + }, + // this should only need to be called on render, and ideally be persisted + loadIndex: async () => { + try { + if (get().index?.length > 0) { + 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", +} From 9c34986cdcd9439680289ceed0445f6ef2f299cc Mon Sep 17 00:00:00 2001 From: Rikki Schulte Date: Sun, 12 Nov 2023 10:22:53 +0100 Subject: [PATCH 2/8] cleanup file import UX --- components/editor/editor-pane.tsx | 108 +++++++++++++++--------------- components/ui/label.tsx | 24 +++++++ package-lock.json | 24 +++++++ package.json | 1 + 4 files changed, 102 insertions(+), 55 deletions(-) create mode 100644 components/ui/label.tsx diff --git a/components/editor/editor-pane.tsx b/components/editor/editor-pane.tsx index f0a7d7c..4c62683 100644 --- a/components/editor/editor-pane.tsx +++ b/components/editor/editor-pane.tsx @@ -3,29 +3,33 @@ import { useState } from "react" import dynamic from "next/dynamic" import { SchemaState, useMainStore } from "@/store/main" -import json5 from "json5" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + + import { MoreVertical } from "lucide-react" import { Button } from "@/components/ui/button" -import { Command, CommandInput } from "@/components/ui/command" import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuPortal, + DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" -import { Input } from "../ui/input" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTrigger, +} from "../ui/dialog" import { JSONEditorProps } from "./json-editor" export interface EditorPane extends Omit, "on"> { @@ -81,53 +85,47 @@ export const EditorPane = ({ - - - {/* */} - Import - - - - - {/* */} -
From File
-
- { - const file = document.getElementById("fromFile") - if (file) { - const reader = new FileReader() - reader.onload = (e) => { - if (!e.target?.result) return - if (isArrayBuffer(e.target.result)) { - const contents = new TextDecoder( - "utf-8" - ).decode(e.target.result) - if (contents) { - setEditorValue(JSON.parse(contents)) - } - return - } - reader.readAsText(file.files[0]) - } - } - }} - /> -
-
- - {/* */} - From URL - - -
-
-
+ e.preventDefault()}> + + Import + + Import {heading} File... + + + { + console.log(e.target.files) + }} + autoFocus + /> + + { + console.log(e.target.value) + }} + /> +
+ + +
+
+
+
+ Export 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/package-lock.json b/package-lock.json index 5305afa..0357409 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@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", @@ -1204,6 +1205,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", diff --git a/package.json b/package.json index 826ff87..9bf3960 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@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", From eb0b608405a1c485be9d66bcbda325624a8313bb Mon Sep 17 00:00:00 2001 From: Rikki Schulte Date: Sun, 12 Nov 2023 13:19:53 +0100 Subject: [PATCH 3/8] more import cases supported, fix toggle for value pane --- app/api/schema/route.ts | 8 +- app/api/schemas/route.ts | 5 +- components/editor/editor-pane.tsx | 118 +++-------------------- components/editor/json-editor.tsx | 24 ++--- components/editor/json-schema-editor.tsx | 6 +- components/editor/json-value-editor.tsx | 11 ++- package-lock.json | 11 ++- package.json | 2 + store/main.ts | 21 ++-- 9 files changed, 65 insertions(+), 141 deletions(-) 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/components/editor/editor-pane.tsx b/components/editor/editor-pane.tsx index 4c62683..593aa7c 100644 --- a/components/editor/editor-pane.tsx +++ b/components/editor/editor-pane.tsx @@ -1,36 +1,11 @@ "use client" -import { useState } from "react" +import { useEffect, useState } from "react" import dynamic from "next/dynamic" -import { SchemaState, useMainStore } from "@/store/main" -import { Label } from "@/components/ui/label" -import { Separator } from "@/components/ui/separator" +import { SchemaState } from "@/store/main" - -import { MoreVertical } from "lucide-react" - -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 { - Dialog, - DialogContent, - DialogHeader, - DialogTrigger, -} from "../ui/dialog" import { JSONEditorProps } from "./json-editor" +import { EditorMenu } from "./menu" export interface EditorPane extends Omit, "on"> { heading: string @@ -51,85 +26,22 @@ export const EditorPane = ({ heading, ...props }: EditorPane) => { + // TODO: move both value states to store const [editorValue, setEditorValue] = useState(value) - const editorMode = useMainStore( - (state) => state.editors[editorKey].mode ?? state.userSettings.mode - ) - const setEditorSetting = useMainStore((state) => state.setEditorSetting) + useEffect(() => { + setEditorValue(value) + }, [value]) return ( <> -
-

{heading}

-
- - - - - - - - setEditorSetting(editorKey, "mode", val) - } - > - - JSON4 - - - JSON5 - - - - - - e.preventDefault()}> - - Import - - Import {heading} File... - - - { - console.log(e.target.files) - }} - autoFocus - /> - - { - console.log(e.target.value) - }} - /> -
- - -
-
-
-
- - Export -
-
-
+
+

{heading}

+
{ value?: Record onValueChange?: (newValue: string) => void schema?: Record - editorKey?: string + editorKey: keyof SchemaState["editors"] } export const JSONEditor = ({ value, @@ -75,8 +69,8 @@ export const JSONEditor = ({ }, [schema]) const stringValue = useMainStore((state) => { - const editorMode = state.editors.schema.mode ?? state.userSettings.mode - return editorMode === "json4" + const editorMode = state.editors[editorKey].mode ?? state.userSettings.mode + return editorMode === JSONModes.JSON4 ? JSON.stringify(value, null, 2) : json5.stringify(value, null, 2) }) diff --git a/components/editor/json-schema-editor.tsx b/components/editor/json-schema-editor.tsx index 5349135..0a61a7c 100644 --- a/components/editor/json-schema-editor.tsx +++ b/components/editor/json-schema-editor.tsx @@ -1,13 +1,9 @@ "use client" import { useEffect } from "react" -import dynamic from "next/dynamic" import { useMainStore } from "@/store/main" import json5 from "json5" -import { Droplets, MoreVertical } from "lucide-react" -import { Icons } from "../icons" -import { Button } from "../ui/button" import { EditorPane } from "./editor-pane" export const JSONSchemaEditor = () => { @@ -32,7 +28,7 @@ export const JSONSchemaEditor = () => { onValueChange={(val) => { setSchema(json5.parse(val)) }} - value={pristineSchema} + value={pristineSchema ?? {}} // json schema spec v? allow spec selection schema={schemaSpec} /> diff --git a/components/editor/json-value-editor.tsx b/components/editor/json-value-editor.tsx index e4f062f..d23caff 100644 --- a/components/editor/json-value-editor.tsx +++ b/components/editor/json-value-editor.tsx @@ -2,18 +2,19 @@ import { useMainStore } from "@/store/main" - import { EditorPane } from "./editor-pane" - export const JSONValueEditor = () => { const schema = useMainStore((state) => state.schema) + const testValue = useMainStore((state) => state.testValue) + const setTestValue = useMainStore((state) => state.setTestValue) return ( ) } diff --git a/package-lock.json b/package-lock.json index 0357409..7ca0677 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@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", @@ -36,6 +37,7 @@ "cmdk": "^0.2.0", "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", @@ -1826,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", @@ -2173,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", @@ -4854,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 9bf3960..e36fb19 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@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", @@ -42,6 +43,7 @@ "cmdk": "^0.2.0", "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/main.ts b/store/main.ts index 9adcd66..0dc9125 100644 --- a/store/main.ts +++ b/store/main.ts @@ -16,6 +16,7 @@ import { } from "@/components/schema/schema-selector" import { storage } from "./idb-store" +import json5 from "json5" type JsonEditorState = { mode?: JSONModes @@ -28,6 +29,8 @@ export type SchemaState = { selectedSchema?: SchemaSelectorValue // the actual schema object schema?: Record + // the test data as string + testValue?: Record // the initial schema value on change for the editor to set pristineSchema?: Record schemaError?: string @@ -43,15 +46,15 @@ export type SchemaState = { // editors state editors: { schema: JsonEditorState - value: JsonEditorState + testValue: JsonEditorState } } export type SchemaActions = { - setSelectedSchema: (selectedSchema: SchemaSelectorValue) => void + setSelectedSchema: (selectedSchema: SchemaSelectorValue) => Promise setSchema: (schema: Record) => void clearSelectedSchema: () => void - loadIndex: () => void + loadIndex: () => Promise setEditorSetting: ( editor: keyof SchemaState["editors"], setting: keyof JsonEditorState, @@ -61,6 +64,7 @@ export type SchemaActions = { editor: keyof SchemaState["editors"], mode: JSONModes ) => void + setTestValue: (testValue: string) => void } const persistOptions: PersistOptions = { @@ -80,7 +84,7 @@ const initialState = { }, editors: { schema: {}, - value: {}, + testValue: {}, }, } @@ -98,10 +102,15 @@ export const useMainStore = create()< pristineSchema: undefined, }) }, + // don't set pristine schema here to avoid triggering updates setSchema: (schema: Record) => { - set({ schema }) + set({ schema, schemaError: undefined }) + }, + setTestValue: (testValue) => { + set({ testValue: json5.parse(testValue) }) }, setEditorSetting: (editor, setting, value) => { + console.log({ editor, setting, value }) set((state) => ({ editors: { ...state.editors, @@ -176,7 +185,7 @@ export const useMainStore = create()< // this should only need to be called on render, and ideally be persisted loadIndex: async () => { try { - if (get().index?.length > 0) { + if (!get().index?.length) { const indexPayload: SchemaResponse = await ( await fetch("/api/schemas") ).json() From 3c2e1f3517e927d1d32ec5a7e8cdd06a7dbbe04f Mon Sep 17 00:00:00 2001 From: Rikki Schulte Date: Sun, 12 Nov 2023 16:14:55 +0100 Subject: [PATCH 4/8] cleanup menus and state --- components/editor/editor-pane.tsx | 27 ++-- components/editor/json-editor.tsx | 20 ++- components/editor/json-schema-editor.tsx | 20 +-- components/editor/json-value-editor.tsx | 9 +- components/editor/menu.tsx | 174 +++++++++++++++++++++++ lib/json.ts | 34 +++++ store/main.ts | 71 ++++++--- 7 files changed, 290 insertions(+), 65 deletions(-) create mode 100644 components/editor/menu.tsx create mode 100644 lib/json.ts diff --git a/components/editor/editor-pane.tsx b/components/editor/editor-pane.tsx index 593aa7c..4da6991 100644 --- a/components/editor/editor-pane.tsx +++ b/components/editor/editor-pane.tsx @@ -1,16 +1,14 @@ -"use client" - -import { useEffect, useState } from "react" import dynamic from "next/dynamic" import { SchemaState } from "@/store/main" -import { JSONEditorProps } from "./json-editor" import { EditorMenu } from "./menu" -export interface EditorPane extends Omit, "on"> { +export interface EditorPane { heading: string editorKey: keyof SchemaState["editors"] - value?: Record + schema?: Record + value?: string + setValueString: (val: string) => void } const JSONEditor = dynamic( @@ -19,19 +17,13 @@ const JSONEditor = dynamic( ) export const EditorPane = ({ - onValueChange, - value, schema, editorKey, heading, + value, + setValueString, ...props }: EditorPane) => { - // TODO: move both value states to store - const [editorValue, setEditorValue] = useState(value) - - useEffect(() => { - setEditorValue(value) - }, [value]) return ( <>
@@ -40,17 +32,18 @@ export const EditorPane = ({ heading={heading} editorKey={editorKey} value={value} - setValue={setEditorValue} + setValueString={setValueString} />
diff --git a/components/editor/json-editor.tsx b/components/editor/json-editor.tsx index 4373611..2019882 100644 --- a/components/editor/json-editor.tsx +++ b/components/editor/json-editor.tsx @@ -17,10 +17,11 @@ import { jsonSchema, updateSchema } from "codemirror-json-schema" import { json5Schema } from "codemirror-json-schema/json5" import json5 from "json5" -// import { debounce } from "@/lib/utils" -import { jsonDark, jsonDarkTheme } from "./theme" import { JSONModes } from "@/types/editor" +import { serialize } from "@/lib/json" +// import { debounce } from "@/lib/utils" +import { jsonDark, jsonDarkTheme } from "./theme" /** * none of these are required for json4 or 5 @@ -40,17 +41,17 @@ const languageExtensions = { json5: json5Schema, } -export interface JSONEditorProps extends Omit { - value?: Record +export interface JSONEditorProps extends Omit { onValueChange?: (newValue: string) => void schema?: Record editorKey: keyof SchemaState["editors"] + value?: string } export const JSONEditor = ({ - value, schema, onValueChange = () => {}, editorKey, + value, ...rest }: JSONEditorProps) => { const editorMode = useMainStore( @@ -68,15 +69,10 @@ export const JSONEditor = ({ updateSchema(editorRef?.current.view, schema) }, [schema]) - const stringValue = useMainStore((state) => { - const editorMode = state.editors[editorKey].mode ?? state.userSettings.mode - return editorMode === JSONModes.JSON4 - ? JSON.stringify(value, null, 2) - : json5.stringify(value, null, 2) - }) + return ( { const schemaSpec = useMainStore((state) => state.schemaSpec) - const pristineSchema = useMainStore((state) => state.pristineSchema) - const [loadIndex, setSchema] = useMainStore((state) => [ - state.loadIndex, - state.setSchema, - ]) - const editorMode = useMainStore( - (state) => state.editors.schema.mode ?? state.userSettings.mode - ) + const loadIndex = useMainStore((state) => state.loadIndex) + + const setValueString = useMainStore((state) => state.setSchemaString) + const value = useMainStore((state) => state.schemaString) useEffect(() => { loadIndex() @@ -25,12 +21,10 @@ export const JSONSchemaEditor = () => { { - setSchema(json5.parse(val)) - }} - value={pristineSchema ?? {}} // json schema spec v? allow spec selection schema={schemaSpec} + setValueString={setValueString} + value={value} /> ) } diff --git a/components/editor/json-value-editor.tsx b/components/editor/json-value-editor.tsx index d23caff..05e06ab 100644 --- a/components/editor/json-value-editor.tsx +++ b/components/editor/json-value-editor.tsx @@ -6,15 +6,16 @@ import { EditorPane } from "./editor-pane" export const JSONValueEditor = () => { const schema = useMainStore((state) => state.schema) - const testValue = useMainStore((state) => state.testValue) - const setTestValue = useMainStore((state) => state.setTestValue) + 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..fe71bf9 --- /dev/null +++ b/components/editor/menu.tsx @@ -0,0 +1,174 @@ +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 { 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" +import { serialize } from "@/lib/json" + +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(JSONModes.JSON4)) { + 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/lib/json.ts b/lib/json.ts new file mode 100644 index 0000000..42cc793 --- /dev/null +++ b/lib/json.ts @@ -0,0 +1,34 @@ +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 => { + console.log('serializing') + try { + // @ts-expect-error + return serializers[editorMode](value, null, 2) + } catch (e) { + return value?.toString() ?? "{}" + } +} diff --git a/store/main.ts b/store/main.ts index 0dc9125..7971ae0 100644 --- a/store/main.ts +++ b/store/main.ts @@ -1,5 +1,6 @@ import { EditorView } from "codemirror" import { JSONMode } from "codemirror-json-schema" +import json5 from "json5" import { UseBoundStore, create } from "zustand" import { PersistOptions, @@ -9,6 +10,7 @@ import { } from "zustand/middleware" import { JSONModes } from "@/types/editor" +import { parse, serialize } from "@/lib/json" import { toast } from "@/components/ui/use-toast" import { SchemaResponse, @@ -16,7 +18,6 @@ import { } from "@/components/schema/schema-selector" import { storage } from "./idb-store" -import json5 from "json5" type JsonEditorState = { mode?: JSONModes @@ -29,10 +30,11 @@ export type SchemaState = { selectedSchema?: SchemaSelectorValue // the actual schema object schema?: Record - // the test data as string - testValue?: 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[] @@ -53,6 +55,7 @@ export type SchemaState = { export type SchemaActions = { setSelectedSchema: (selectedSchema: SchemaSelectorValue) => Promise setSchema: (schema: Record) => void + setSchemaString: (schema: string) => void clearSelectedSchema: () => void loadIndex: () => Promise setEditorSetting: ( @@ -60,11 +63,9 @@ export type SchemaActions = { setting: keyof JsonEditorState, value: T ) => void - setEditorMode: ( - editor: keyof SchemaState["editors"], - mode: JSONModes - ) => void - setTestValue: (testValue: string) => void + setEditorMode: (editor: keyof SchemaState["editors"], mode: JSONModes) => void + setTestValueString: (testValue: string) => void + getMode: (editorKey?: keyof SchemaState["editors"]) => JSONModes } const persistOptions: PersistOptions = { @@ -99,18 +100,35 @@ export const useMainStore = create()< selectedSchema: undefined, schema: undefined, schemaError: undefined, - pristineSchema: undefined, }) }, + 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 }) + set({ + schema, + schemaError: undefined, + schemaString: serialize(get().getMode(), schema), + }) }, - setTestValue: (testValue) => { - set({ testValue: json5.parse(testValue) }) + setSchemaString: (schema: string) => { + set({ + schema: parse(get().getMode(), schema), + schemaString: schema, + schemaError: undefined, + }) + }, + setTestValueString: (testValue) => { + set({ + testValueString: testValue, + }) }, setEditorSetting: (editor, setting, value) => { - console.log({ editor, setting, value }) set((state) => ({ editors: { ...state.editors, @@ -120,6 +138,20 @@ export const useMainStore = create()< }, }, })) + if (setting === "mode") { + if (editor === "testValue") { + set({ + testValueString: + value === "json5" + ? json5.stringify(JSON.parse(get().testValueString ?? "{}"), null, 2) + : JSON.stringify( + json5.parse(get().testValueString ?? "{}"), + null, + 2 + ), + }) + } + } }, setEditorMode: (editor, mode) => { set((state) => ({ @@ -132,7 +164,7 @@ export const useMainStore = create()< }, })) }, - setSelectedSchema: async (selectedSchema: SchemaSelectorValue) => { + setSelectedSchema: async (selectedSchema) => { try { set({ selectedSchema, schemaError: undefined }) const data = await ( @@ -141,14 +173,15 @@ export const useMainStore = create()< url: selectedSchema.value, })}` ) - ).json() + ).text() // though it appears we are setting schema state twice, // pristineSchema only changes on selecting a new schema set({ - schema: data, - schemaError: data.error, - pristineSchema: data, + schema: parse(get().getMode(), data), + schemaString: data, + schemaError: undefined, }) + toast({ title: "Schema loaded", description: selectedSchema.label, From 40bd4a8958019758267641d0fa68b127517397de Mon Sep 17 00:00:00 2001 From: Rikki Schulte Date: Sun, 12 Nov 2023 16:16:28 +0100 Subject: [PATCH 5/8] cleanup log --- components/editor/menu.tsx | 6 +++--- lib/json.ts | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/components/editor/menu.tsx b/components/editor/menu.tsx index fe71bf9..d6756a3 100644 --- a/components/editor/menu.tsx +++ b/components/editor/menu.tsx @@ -123,9 +123,9 @@ export const EditorMenu = ({ type="url" disabled={false} placeholder="https://example.com/schema.json" - onChange={(e) => { - console.log(e.target.value) - }} + // onChange={(e) => { + // console.log(e.target.value) + // }} /> {imported ? (
diff --git a/lib/json.ts b/lib/json.ts index 42cc793..dec1c9e 100644 --- a/lib/json.ts +++ b/lib/json.ts @@ -24,7 +24,6 @@ export const serialize = ( editorMode: JSONModes, value?: Record ): string => { - console.log('serializing') try { // @ts-expect-error return serializers[editorMode](value, null, 2) From 26a6098b1b8b453b71c6cef896e7d7caf000a4e1 Mon Sep 17 00:00:00 2001 From: Rikki Schulte Date: Sun, 12 Nov 2023 16:26:59 +0100 Subject: [PATCH 6/8] json4 to 5 and vv for schema as well --- store/main.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/store/main.ts b/store/main.ts index 7971ae0..59efefe 100644 --- a/store/main.ts +++ b/store/main.ts @@ -139,18 +139,13 @@ export const useMainStore = create()< }, })) if (setting === "mode") { - if (editor === "testValue") { - set({ - testValueString: - value === "json5" - ? json5.stringify(JSON.parse(get().testValueString ?? "{}"), null, 2) - : JSON.stringify( - json5.parse(get().testValueString ?? "{}"), - null, - 2 - ), - }) - } + 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) => { From 294ac687107ca1dcec4ecad851bb54a5c90ad3be Mon Sep 17 00:00:00 2001 From: Rikki Schulte Date: Sun, 12 Nov 2023 18:02:06 +0100 Subject: [PATCH 7/8] persist schema, show to users --- components/schema/schema-selector.tsx | 6 ++-- store/idb-store.ts | 5 +-- store/main.ts | 49 +++++++++++++++++++-------- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/components/schema/schema-selector.tsx b/components/schema/schema-selector.tsx index c2a0b20..fa716ef 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" @@ -28,6 +28,7 @@ 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..." @@ -37,6 +38,7 @@ export const SchemaSelector = () => { value={selectedSchema} Results={({ option, selected }) => (
+ {selected && }
{option.label} {option.description && ( @@ -57,7 +59,7 @@ export const SchemaSelector = () => { )}
- {selected && } + {schemas[option.value] && }
)} /> diff --git a/store/idb-store.ts b/store/idb-store.ts index 2b92eda..bab9de4 100644 --- a/store/idb-store.ts +++ b/store/idb-store.ts @@ -1,5 +1,6 @@ -import { 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 59efefe..0d2fa96 100644 --- a/store/main.ts +++ b/store/main.ts @@ -37,7 +37,7 @@ export type SchemaState = { // pristineSchema?: Record schemaError?: string // an index of available schemas from SchemaStore.org - index: SchemaSelectorValue[] + index: SchemaSelectorValue[] | undefined[] indexError?: string // the base $schema spec for the current `schema` schemaSpec?: Record @@ -50,6 +50,7 @@ export type SchemaState = { schema: JsonEditorState testValue: JsonEditorState } + schemas: Record> } export type SchemaActions = { @@ -66,15 +67,17 @@ export type SchemaActions = { 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 }> } const persistOptions: PersistOptions = { - name: "jsonWorkbench", + name: "jsonWorkBench", storage: createJSONStorage(() => storage), } const initialState = { - index: [], userSettings: { // theme: "system", mode: JSONModes.JSON4, @@ -87,6 +90,7 @@ const initialState = { schema: {}, testValue: {}, }, + schemas: {}, } export const useMainStore = create()< @@ -95,6 +99,7 @@ export const useMainStore = create()< persist( devtools((set, get) => ({ ...initialState, + index: [], clearSelectedSchema: () => { set({ selectedSchema: undefined, @@ -159,20 +164,36 @@ export const useMainStore = create()< }, })) }, + fetchSchema: async (url: string) => { + const schemas = get().schemas + if (schemas[url]) { + const schema = schemas[url]! + return { + schemaString: serialize(JSONModes.JSON4, schema), + schema, + } + } + const data = await ( + await fetch( + `/api/schema?${new URLSearchParams({ + url, + })}` + ) + ).text() + const parsed = parse(JSONModes.JSON4, data) + schemas[url] = parsed + return { schemaString: data, schema: parsed } + }, setSelectedSchema: async (selectedSchema) => { try { set({ selectedSchema, schemaError: undefined }) - const data = await ( - await fetch( - `/api/schema?${new URLSearchParams({ - url: selectedSchema.value, - })}` - ) - ).text() + const { schemaString: data, schema } = await get().fetchSchema( + selectedSchema.value + ) // though it appears we are setting schema state twice, // pristineSchema only changes on selecting a new schema set({ - schema: parse(get().getMode(), data), + schema: schema, schemaString: data, schemaError: undefined, }) @@ -197,8 +218,8 @@ export const useMainStore = create()< 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 }) + const { schema: schemaSpec } = await get().fetchSchema(schemaUrl) + set({ schemaSpec }) } catch (err) { // @ts-expect-error const errMessage = err?.message || err @@ -210,7 +231,7 @@ export const useMainStore = create()< }) } }, - // this should only need to be called on render, and ideally be persisted + // this should only need to be called on render loadIndex: async () => { try { if (!get().index?.length) { From 0b515ede723a52efa20b2872f039e066968b23a4 Mon Sep 17 00:00:00 2001 From: Rikki Schulte Date: Sun, 12 Nov 2023 19:55:18 +0100 Subject: [PATCH 8/8] handle query params, fix serialization bugs --- app/page.tsx | 19 +++++-- components/editor/editor-pane.tsx | 2 +- components/editor/json-schema-editor.tsx | 12 +++- components/editor/menu.tsx | 72 +++++++++++++++--------- components/schema/schema-selector.tsx | 2 +- components/ui/autocomplete.tsx | 6 +- store/main.ts | 47 +++++++++++++--- 7 files changed, 114 insertions(+), 46 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 74a6d69..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 index 4da6991..4ddf60b 100644 --- a/components/editor/editor-pane.tsx +++ b/components/editor/editor-pane.tsx @@ -26,7 +26,7 @@ export const EditorPane = ({ }: EditorPane) => { return ( <> -
+

{heading}

{ +export const JSONSchemaEditor = ({ url }: { url: string | null }) => { const schemaSpec = useMainStore((state) => state.schemaSpec) 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 ( { const [imported, setImported] = useState(undefined) @@ -59,7 +59,11 @@ export const EditorMenu = ({ return ( - @@ -70,10 +74,16 @@ export const EditorMenu = ({ value={editorMode} onValueChange={(val) => setEditorSetting(editorKey, "mode", val)} > - + JSON4 - + JSON5 @@ -87,10 +97,10 @@ export const EditorMenu = ({ - Import {heading} File... +
Import {heading} File...
+
- + + + { + // console.log(e.target.value) + // }} + /> + {imported ? ( +
+ + This file can be imported{" "} +
+ ) : null}
- - { - // console.log(e.target.value) - // }} - /> - {imported ? ( -
- - This file can be imported{" "} -
- ) : null}
{isOpen ? ( -
+
{isLoading ? ( @@ -146,7 +146,7 @@ export const AutoComplete = ({ }} 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/store/main.ts b/store/main.ts index 0d2fa96..c3a7b03 100644 --- a/store/main.ts +++ b/store/main.ts @@ -37,7 +37,7 @@ export type SchemaState = { // pristineSchema?: Record schemaError?: string // an index of available schemas from SchemaStore.org - index: SchemaSelectorValue[] | undefined[] + index: SchemaSelectorValue[] indexError?: string // the base $schema spec for the current `schema` schemaSpec?: Record @@ -54,7 +54,10 @@ export type SchemaState = { } export type SchemaActions = { - setSelectedSchema: (selectedSchema: SchemaSelectorValue) => Promise + setSelectedSchema: ( + selectedSchema: Partial & { value: string } + ) => Promise + setSelectedSchemaFromUrl: (url: string) => Promise setSchema: (schema: Record) => void setSchemaString: (schema: string) => void clearSelectedSchema: () => void @@ -118,12 +121,12 @@ export const useMainStore = create()< set({ schema, schemaError: undefined, - schemaString: serialize(get().getMode(), schema), + schemaString: serialize(get().getMode("schema"), schema), }) }, setSchemaString: (schema: string) => { set({ - schema: parse(get().getMode(), schema), + schema: parse(get().getMode("schema"), schema), schemaString: schema, schemaError: undefined, }) @@ -166,10 +169,12 @@ export const useMainStore = create()< }, 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(JSONModes.JSON4, schema), + schemaString: serialize(mode, schema), schema, } } @@ -180,16 +185,33 @@ export const useMainStore = create()< })}` ) ).text() - const parsed = parse(JSONModes.JSON4, data) + const parsed = parse(mode, data) schemas[url] = parsed return { schemaString: data, schema: parsed } }, setSelectedSchema: async (selectedSchema) => { try { - set({ selectedSchema, schemaError: undefined }) + 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({ @@ -231,6 +253,17 @@ export const useMainStore = create()< }) } }, + 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 {