diff --git a/app/__test__/ui/json-form.spec.ts b/app/__test__/ui/json-form.spec.ts new file mode 100644 index 0000000..3a2df82 --- /dev/null +++ b/app/__test__/ui/json-form.spec.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { Draft2019 } from "json-schema-library"; +import type { JSONSchema } from "json-schema-to-ts"; +import * as utils from "../../src/ui/components/form/json-schema-form/utils"; + +describe("json form", () => { + test("normalize path", () => { + const examples = [ + ["description", "#/description"], + ["/description", "#/description"], + ["nested/property", "#/nested/property"], + ["nested.property", "#/nested/property"], + ["nested.property[0]", "#/nested/property/0"], + ["nested.property[0].name", "#/nested/property/0/name"] + ]; + + for (const [input, output] of examples) { + expect(utils.normalizePath(input)).toBe(output); + } + }); + + test("coerse", () => { + const examples = [ + ["test", { type: "string" }, "test"], + ["1", { type: "integer" }, 1], + ["1", { type: "number" }, 1], + ["true", { type: "boolean" }, true], + ["false", { type: "boolean" }, false], + ["1", { type: "boolean" }, true], + ["0", { type: "boolean" }, false], + ["on", { type: "boolean" }, true], + ["off", { type: "boolean" }, false], + ["null", { type: "null" }, null] + ] satisfies [string, Exclude, any][]; + + for (const [input, schema, output] of examples) { + expect(utils.coerce(input, schema)).toBe(output); + } + }); + + test("getParentPointer", () => { + const examples = [ + ["#/nested/property/0/name", "#/nested/property/0"], + ["#/nested/property/0", "#/nested/property"], + ["#/nested/property", "#/nested"], + ["#/nested", "#"] + ]; + + for (const [input, output] of examples) { + expect(utils.getParentPointer(input)).toBe(output); + } + }); + + test("isRequired", () => { + const examples = [ + [ + "#/description", + { type: "object", properties: { description: { type: "string" } } }, + false + ], + [ + "#/description", + { + type: "object", + required: ["description"], + properties: { description: { type: "string" } } + }, + true + ], + [ + "#/nested/property", + { + type: "object", + properties: { + nested: { + type: "object", + properties: { property: { type: "string" } } + } + } + }, + false + ], + [ + "#/nested/property", + { + type: "object", + properties: { + nested: { + type: "object", + required: ["property"], + properties: { property: { type: "string" } } + } + } + }, + true + ] + ] satisfies [string, Exclude, boolean][]; + + for (const [pointer, schema, output] of examples) { + expect(utils.isRequired(pointer, schema)).toBe(output); + } + }); + + test("unflatten", () => { + const examples = [ + [ + { "#/description": "test" }, + { + type: "object", + properties: { + description: { type: "string" } + } + }, + { + description: "test" + } + ] + ] satisfies [Record, Exclude, object][]; + + for (const [input, schema, output] of examples) { + expect(utils.unflatten(input, schema)).toEqual(output); + } + }); + + test("...", () => { + const schema = { + type: "object", + properties: { + name: { type: "string", maxLength: 2 }, + description: { type: "string", maxLength: 2 }, + age: { type: "number", description: "Age of you" }, + deep: { + type: "object", + properties: { + nested: { type: "string", maxLength: 2 } + } + } + }, + required: ["description"] + }; + + const lib = new Draft2019(schema); + + lib.eachSchema(console.log); + }); +}); diff --git a/app/package.json b/app/package.json index 9baffe0..2b56aab 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.7.0-rc.4", + "version": "0.7.0-rc.5", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { @@ -33,26 +33,29 @@ "license": "FSL-1.1-MIT", "dependencies": { "@cfworker/json-schema": "^2.0.1", + "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-liquid": "^6.2.1", + "@hello-pangea/dnd": "^17.0.0", "@libsql/client": "^0.14.0", + "@mantine/core": "^7.13.4", "@sinclair/typebox": "^0.32.34", "@tanstack/react-form": "0.19.2", + "@uiw/react-codemirror": "^4.23.6", + "@xyflow/react": "^12.3.2", "aws4fetch": "^1.0.18", "dayjs": "^1.11.13", "fast-xml-parser": "^4.4.0", "hono": "^4.6.12", + "json-schema-form-react": "^0.0.2", + "json-schema-library": "^10.0.0-rc7", "kysely": "^0.27.4", "liquidjs": "^10.15.0", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", - "swr": "^2.2.5", - "json-schema-form-react": "^0.0.2", - "@uiw/react-codemirror": "^4.23.6", - "@codemirror/lang-html": "^6.4.9", - "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-liquid": "^6.2.1", - "@xyflow/react": "^12.3.2", - "@mantine/core": "^7.13.4", - "@hello-pangea/dnd": "^17.0.0" + "object-path-immutable": "^4.1.2", + "radix-ui": "^1.1.2", + "swr": "^2.2.5" }, "devDependencies": { "@aws-sdk/client-s3": "^3.613.0", @@ -62,7 +65,6 @@ "@hono/zod-validator": "^0.4.1", "@hookform/resolvers": "^3.9.1", "@libsql/kysely-libsql": "^0.4.1", - "@radix-ui/react-scroll-area": "^1.2.0", "@rjsf/core": "^5.22.2", "@tabler/icons-react": "3.18.0", "@types/node": "^22.10.0", @@ -73,6 +75,7 @@ "clsx": "^2.1.1", "esbuild-postcss": "^0.0.4", "jotai": "^2.10.1", + "json-schema-to-ts": "^3.1.1", "open": "^10.1.0", "openapi-types": "^12.1.3", "postcss": "^8.4.47", diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index c7789dd..a28d7ea 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -81,9 +81,12 @@ export function identifierToHumanReadable(str: string) { case "SCREAMING_SNAKE_CASE": return snakeToPascalWithSpaces(str.toLowerCase()); case "unknown": - return str; + return ucFirst(str); } } +export function autoFormatString(str: string) { + return identifierToHumanReadable(str); +} export function kebabToPascalWithSpaces(str: string): string { return str.split("-").map(ucFirst).join(" "); diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index 64a52ba..f1d793f 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -16,7 +16,8 @@ export function buildMediaSchema() { config: adapter.schema }, { - title: name, + title: adapter.schema.title ?? name, + description: adapter.schema.description, additionalProperties: false } ); @@ -32,6 +33,7 @@ export function buildMediaSchema() { { body_max_size: Type.Optional( Type.Number({ + minimum: 0, description: "Max size of the body in bytes. Leave blank for unlimited." }) ) diff --git a/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts b/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts index 771b389..cfb4100 100644 --- a/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts +++ b/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts @@ -9,7 +9,7 @@ export const cloudinaryAdapterConfig = Type.Object( api_secret: Type.String(), upload_preset: Type.Optional(Type.String()) }, - { title: "Cloudinary" } + { title: "Cloudinary", description: "Cloudinary media storage" } ); export type CloudinaryConfig = Static; diff --git a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts b/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts index b6c2650..2c142ff 100644 --- a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts +++ b/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts @@ -7,7 +7,7 @@ export const localAdapterConfig = Type.Object( { path: Type.String({ default: "./" }) }, - { title: "Local" } + { title: "Local", description: "Local file system storage" } ); export type LocalAdapterConfig = Static; diff --git a/app/src/media/storage/adapters/StorageS3Adapter.ts b/app/src/media/storage/adapters/StorageS3Adapter.ts index 90c3cb2..b330d64 100644 --- a/app/src/media/storage/adapters/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/StorageS3Adapter.ts @@ -25,7 +25,8 @@ export const s3AdapterConfig = Type.Object( }) }, { - title: "S3" + title: "AWS S3", + description: "AWS S3 or compatible storage" } ); diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index 813fd4d..cb2c110 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -1,10 +1,7 @@ -import { IconAlertHexagon } from "@tabler/icons-react"; import type { ModuleConfigs, ModuleSchemas } from "modules"; import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager"; import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react"; import { useApi } from "ui/client"; -import { Button } from "ui/components/buttons/Button"; -import { Alert } from "ui/components/display/Alert"; import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { AppReduced } from "./utils/AppReduced"; diff --git a/app/src/ui/client/schema/media/use-bknd-media.ts b/app/src/ui/client/schema/media/use-bknd-media.ts new file mode 100644 index 0000000..426bfb8 --- /dev/null +++ b/app/src/ui/client/schema/media/use-bknd-media.ts @@ -0,0 +1,22 @@ +import type { TAppMediaConfig } from "media/media-schema"; +import { useBknd } from "ui/client/BkndProvider"; + +export function useBkndMedia() { + const { config, schema, actions: bkndActions } = useBknd(); + + const actions = { + config: { + patch: async (data: Partial) => { + return await bkndActions.set("media", data, true); + } + } + }; + const $media = {}; + + return { + $media, + config: config.media, + schema: schema.media, + actions + }; +} diff --git a/app/src/ui/components/buttons/Button.tsx b/app/src/ui/components/buttons/Button.tsx index a9a55e2..89ee795 100644 --- a/app/src/ui/components/buttons/Button.tsx +++ b/app/src/ui/components/buttons/Button.tsx @@ -1,4 +1,5 @@ import type React from "react"; +import { Children } from "react"; import { forwardRef } from "react"; import { twMerge } from "tailwind-merge"; import { Link } from "ui/components/wouter/Link"; @@ -19,7 +20,7 @@ const styles = { default: "bg-primary/5 hover:bg-primary/10 link text-primary/70", primary: "bg-primary hover:bg-primary/80 link text-background", ghost: "bg-transparent hover:bg-primary/5 link text-primary/70", - outline: "border border-primary/70 bg-transparent hover:bg-primary/5 link text-primary/70", + outline: "border border-primary/20 bg-transparent hover:bg-primary/5 link text-primary/80", red: "dark:bg-red-950 dark:hover:bg-red-900 bg-red-100 hover:bg-red-200 link text-primary/70", subtlered: "dark:text-red-950 text-red-700 dark:hover:bg-red-900 bg-transparent hover:bg-red-50 link" @@ -50,7 +51,7 @@ const Base = ({ }: BaseProps) => ({ ...props, className: twMerge( - "flex flex-row flex-nowrap items-center font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed", + "flex flex-row flex-nowrap items-center font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed transition-[opacity,background-color,color,border-color]", sizes[size ?? "default"], styles[variant ?? "default"], props.className @@ -58,7 +59,11 @@ const Base = ({ children: ( <> {IconLeft && } - {children && {children}} + {children && Children.count(children) === 1 ? ( + {children} + ) : ( + children + )} {IconRight && } ) diff --git a/app/src/ui/components/form/Formy/BooleanInputMantine.tsx b/app/src/ui/components/form/Formy/BooleanInputMantine.tsx index 3f3c77a..48bd5e5 100644 --- a/app/src/ui/components/form/Formy/BooleanInputMantine.tsx +++ b/app/src/ui/components/form/Formy/BooleanInputMantine.tsx @@ -3,9 +3,10 @@ import { forwardRef, useEffect, useState } from "react"; export const BooleanInputMantine = forwardRef>( (props, ref) => { - const [checked, setChecked] = useState(Boolean(props.value)); + const [checked, setChecked] = useState(Boolean(props.value ?? props.defaultValue)); useEffect(() => { + console.log("value change", props.value); setChecked(Boolean(props.value)); }, [props.value]); diff --git a/app/src/ui/components/form/Formy/components.tsx b/app/src/ui/components/form/Formy/components.tsx index d725238..74d4717 100644 --- a/app/src/ui/components/form/Formy/components.tsx +++ b/app/src/ui/components/form/Formy/components.tsx @@ -1,25 +1,41 @@ import { getBrowser } from "core/utils"; import type { Field } from "data"; -import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { Switch as RadixSwitch } from "radix-ui"; +import { + type ChangeEventHandler, + type ComponentPropsWithoutRef, + type ElementType, + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState +} from "react"; import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb"; import { twMerge } from "tailwind-merge"; import { IconButton } from "ui/components/buttons/IconButton"; import { useEvent } from "ui/hooks/use-event"; -export const Group: React.FC & { error?: boolean }> = ({ +export const Group = ({ error, + as, ...props -}) => ( -
& { error?: boolean; as?: E }) => { + const Tag = as || "div"; - error && "text-red-500", - props.className - )} - /> -); + return ( + + ); +}; export const formElementFactory = (element: string, props: any) => { switch (element) { @@ -34,7 +50,21 @@ export const formElementFactory = (element: string, props: any) => { } }; -export const Label: React.FC> = (props) =>