Compare commits

...

57 Commits

Author SHA1 Message Date
dswbx 09d35d3cc0 Merge pull request #60 from bknd-io/release/0.7
Release 0.7
2025-02-08 16:38:07 +01:00
dswbx f7e6928dba form: fix popover, improve form types 2025-02-08 15:43:02 +01:00
dswbx 3fa682bfe1 form: fix switch required, add root form error to media settings 2025-02-08 13:27:47 +01:00
dswbx 97b0ff24c8 Merge pull request #71 from bknd-io/feat/elements-native-form
feat/elements-native-form
2025-02-08 12:01:35 +01:00
dswbx bb964dc835 added native reactive form to elements 2025-02-08 11:59:43 +01:00
dswbx 5840ec6b89 Merge pull request #70 from bknd-io/feat/optimize-form-renders
Feat/optimize form renders
2025-02-08 11:59:13 +01:00
dswbx f29641c702 using path instead of pointer, replaced lodash usage 2025-02-08 09:18:24 +01:00
dswbx 2e3ee65aa7 added more input field types and improved typing 2025-02-07 17:28:01 +01:00
dswbx 11a6f5c9b9 hide rerender scanner 2025-02-07 16:12:39 +01:00
dswbx 324d641410 form rerenders optimized 2025-02-07 16:11:21 +01:00
dswbx 02e7e1ca95 initially reduced form rerenders 2025-02-06 17:12:43 +01:00
dswbx 78d4f928ba fix nextjs node adapter import 2025-02-06 15:19:25 +01:00
dswbx 0a5cc2ad98 fix bknd theme 2025-02-06 15:00:54 +01:00
dswbx e14d528386 Merge pull request #68 from bknd-io/feat/media-settings-ui
Improved media settings UI/UX
2025-02-06 13:59:24 +01:00
dswbx 15003b004b Merge pull request #67 from bknd-io/fix/api-query-with-string-object
fix/api-query-with-string-object
2025-02-06 13:58:50 +01:00
dswbx 9a4c2bd530 finalize new media settings ui 2025-02-06 13:58:29 +01:00
dswbx ab61a1392c Merge pull request #69 from bknd-io/fix/admin-config-secrets-missing
fix clearing schema on unauthorized secrets fetching
2025-02-06 09:01:47 +01:00
dswbx 53d55448e6 fix clearing schema on unauthorized secrets fetching 2025-02-06 09:01:11 +01:00
dswbx 46cf310ad6 finalize new media settings 2025-02-05 17:54:48 +01:00
dswbx 8418231c43 improved media settings implementation 2025-02-05 16:11:53 +01:00
dswbx 4b3493a6f5 changed media settings to new form 2025-02-05 11:04:37 +01:00
dswbx f432473ed9 initial json schema form implementation 2025-02-05 10:31:08 +01:00
dswbx 8f0cef1f37 clear console log 2025-02-01 12:15:48 +01:00
dswbx a8e78f984d fix repo stringified string parsing 2025-02-01 12:12:22 +01:00
dswbx babeaae8d1 Merge pull request #66 from bknd-io/fix/auth-api-include-cookie
also sending cookies on json auth requests
2025-02-01 09:15:16 +01:00
dswbx 45d44f8177 Merge remote-tracking branch 'origin/release/0.7' into fix/auth-api-include-cookie
# Conflicts:
#	app/src/auth/authenticate/Authenticator.ts
2025-02-01 09:15:01 +01:00
dswbx db476080ad also sending cookies on json auth requests 2025-02-01 09:13:42 +01:00
dswbx 158818520b Merge pull request #65 from bknd-io/feat/auth-redirect-param
feat/auth-redirect-param
2025-01-29 20:30:52 +01:00
dswbx 4755288fed improve fallbacks for redirection 2025-01-29 20:30:27 +01:00
dswbx 10db267164 added a redirect param to password strategy 2025-01-29 20:26:06 +01:00
dswbx dfe7d55057 Merge pull request #64 from bknd-io/feat/improved-api-construction
updated API instantiation, and update user on verify
2025-01-29 14:49:00 +01:00
dswbx c2b3316fcb updated API instantiation, and update user on verify 2025-01-29 14:44:32 +01:00
dswbx 86ba055f5e fix entity table select 2025-01-28 09:10:38 +01:00
dswbx 9d6ccda20d Merge pull request #63 from bknd-io/fix/api-and-adapter
fix/api-and-adapter
2025-01-28 08:16:56 +01:00
dswbx 39c419c320 add built state and skip auth cookie if not signed in for local calls 2025-01-25 19:30:03 +01:00
dswbx 2d6d83ccb2 add fetcher option to api to allow local calls 2025-01-25 19:29:36 +01:00
dswbx ba1eb5fe08 remove adapter re-exports 2025-01-25 19:28:39 +01:00
dswbx 123389786d bump 0.7 rc version 2025-01-25 14:19:23 +01:00
dswbx b65bb3b826 Merge pull request #62 from bknd-io/feat/adapter-optimize-imports
optimize adapter imports to deduplicate
2025-01-25 14:14:25 +01:00
dswbx 5b40d5eaf4 optimize adapter imports to deduplicate 2025-01-25 14:13:34 +01:00
dswbx 89b1fd3909 Merge pull request #61 from bknd-io/feat/cf-static-assets
adapter(cloudflare): add supports for the new assets feature
2025-01-25 13:48:52 +01:00
dswbx aa7eac258b adapter(cloudflare): add supports for the new assets feature 2025-01-25 13:40:28 +01:00
dswbx 392a06c486 Merge pull request #59 from bknd-io/feat/adapter-auth-env
Adapter: verified auth & env-specific construction
2025-01-25 13:36:59 +01:00
dswbx 1beac5043b updated cloudflare adapter docs to match new args 2025-01-25 12:33:05 +01:00
dswbx 475488c910 Merge remote-tracking branch 'origin/feat/adapter-api-async' into feat/adapter-auth-env 2025-01-25 11:55:15 +01:00
dswbx 9ddacb7ae5 added adapter exports, generalized env-depending config construction 2025-01-25 11:54:19 +01:00
dswbx 3d1d52cbf8 Merge pull request #58 from bknd-io/release/0.6.2
Release 0.6.2
2025-01-25 09:40:44 +01:00
dswbx acc504cdc9 release version 0.6.2 2025-01-25 09:40:15 +01:00
dswbx d98d3351c0 Merge pull request #57 from bknd-io/feat/elements-docs-and-custom
restructured elements for better customization
2025-01-25 09:17:39 +01:00
dswbx 3e606cb159 Merge pull request #54 from bknd-io/fix/nextjs-schema-update
fixed schema updates to fail in nextjs envs due to lodash's merge
2025-01-25 09:17:23 +01:00
dswbx cc76964d03 restructured elements slightly for better customization, updated readme and docs 2025-01-25 09:14:02 +01:00
dswbx b0c5f6307a updated adapters to automatically verify auth 2025-01-25 09:09:09 +01:00
dswbx b3019d7333 Merge pull request #55 from bknd-io/feat/nextjs-plasmic-catchall
bknd/plasmic: added helper functions for integration
2025-01-25 09:03:30 +01:00
dswbx 2265451d4b added handy nextjs-plasmic-bknd functions 2025-01-23 10:59:33 +01:00
dswbx ba936604a3 fix overwrite of entity config (e.g. empty label) 2025-01-23 09:02:58 +01:00
dswbx 7dd42fbff3 fixed schema updates to fail in nextjs envs due to lodash's merge 2025-01-23 08:34:11 +01:00
dswbx f64e5dac03 updated README and documentation (first part) to match 0.6 2025-01-21 18:30:01 +01:00
132 changed files with 4397 additions and 918 deletions
+115 -30
View File
@@ -1,13 +1,15 @@
[![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd
"View this project on NPM")
[![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd)
[![npm downloads](https://img.shields.io/npm/dm/bknd)](https://www.npmjs.com/package/bknd)
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png)
bknd simplifies app development by providing fully functional backend for data management,
authentication, workflows and media. Since it's lightweight and built on Web Standards, it can
be deployed nearly anywhere, including running inside your framework of choice. No more
deploying multiple separate services!
<p align="center" width="100%">
<a href="https://stackblitz.com/github/bknd-io/bknd-examples?hideExplorer=1&embed=1&view=preview&startScript=example-admin-rich&initialPath=%2Fdata%2Fschema">
<strong>⭐ Live Demo</strong>
</a>
</p>
bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
**For documentation and examples, please visit https://docs.bknd.io.**
@@ -15,33 +17,116 @@ deploying multiple separate services!
> Please keep in mind that **bknd** is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## ✨ Features
**📊 Data**: Define, query, and control your data with ease.
- Define entities with fields and relationships, synced directly to your database.
- Supported field types: `primary`, `text`, `number`, `date`, `boolean`, `enum`, `json`, `jsonschema`.
- Relationship types: `one-to-one`, `many-to-one`, `many-to-many`, and `polymorphic`.
- Advanced querying with the **Repository**: filtering, sorting, pagination, and relational data handling.
- Seamlessly manage data with mutators and a robust event system.
- Extend database functionality with batching, introspection, and support for multiple SQL dialects.
## Size
![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@0.6.1/dist/index.js?compression=gzip&label=bknd)
![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@0.6.1/dist/ui/client/index.js?compression=gzip&label=bknd/client)
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@0.6.1/dist/ui/elements/index.js?compression=gzip&label=bknd/elements)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@0.6.1/dist/ui/index.js?compression=gzip&label=bknd/ui)
**🔐 Auth**: Easily implement reliable authentication strategies.
- Built-in `user` entity with customizable fields.
- Supports multiple authentication strategies:
- Email/password (with hashed storage).
- OAuth/OIDC (Google, GitHub, and more).
- Secure JWT generation and session management.
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
**🖼️ Media**: Effortlessly manage and serve all your media files.
- Upload files with ease.
- Adapter-based support for S3, S3-compatible storage (e.g., R2, Tigris), and Cloudinary.
## Motivation
Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them.
**🔄 Flows** (UI integration coming soon!): Design and run workflows with seamless automation.
- Create and run workflows with trigger-based automation:
- Manual triggers or events from data, auth, media, or server actions.
- HTTP triggers for external integrations.
- Define tasks in sequence, parallel, or loops, with conditional execution.
- Use reusable sub-workflows to organize complex processes.
- Leverage OpenAPI specifications for API-based tasks.
**The solution:** A backend system that only assumes and implements primitive details, integrates into multiple environments, and adheres to industry standards.
## Features
* ⚡ Instant backend with full REST API:
* **Data**: Define, query, and control your data with ease.
* **Auth**: Easily implement reliable authentication strategies.
* **Media**: Effortlessly manage and serve all your media files.
* **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!)
* 🌐 Built on Web Standards for maximum compatibility
* 🏃‍♂️ Multiple run modes
* standalone using the CLI
* using a JavaScript runtime (Node, Bun, workerd)
* using a React framework (Astro, Remix, Next.js)
* 📦 Official API and React SDK with type-safety
* ⚛️ React elements for auto-configured authentication and media components
## Structure
The package is mainly split into 4 parts, each serving a specific purpose:
| Import | Purpose |
|-----------------------------|------------------------------------------------------|
| `bknd`<br/>`bknd/adapter/*` | Backend including APIs and adapters |
| `bknd/ui` | Admin UI components for react frameworks |
| `bknd/client` | TypeScript SDK and React hooks for the API endpoints |
| `bknd/elements` | React components for authentication and media |
### The backend (`bknd`)
Serve the backend as an API for any JS runtime or framework. The latter is especially handy, as it allows you to deploy your frontend and backend bundled together. Furthermore it allows adding additional logic in a way you're already familar with. Just add another route and you're good to go.
Here is an example of serving the API using node:
```js index.js
import { serve } from "bknd/adapter/node"
serve();
```
### Integrated admin UI (`bknd/ui`)
The admin UI allows to manage your data including full configuration of your backend using a graphical user interface. Using `vite`, your admin route looks like this:
```tsx
import { Admin } from "bknd/ui"
import "bknd/dist/styles.css";
export default function AdminPage() {
return <Admin />
}
```
### Using the REST API or TypeScript SDK (`bknd/client`)
If you're not using a JavaScript environment, you can still access any endpoint using the REST API:
```bash
curl -XGET <your-endpoint>/api/data/<entity>
{
"data": [
{ "id": 1, ... },
{ "id": 2, ... }
],
"meta": { /* ... */ }
}
```
In a JavaScript environment, you can use the TypeScript SDK with type-safety. The above example would look like this:
```ts
import { Api } from "bknd/client";
const api = new Api({ host: "<endpoint>" });
const { data } = await api.data.readMany("<entity>");
```
If you're using React, there are 2 hooks exposed (`useApi`, `useEntity`), as well as an `swr` wrapper around each (`useApiQuery`, `useEntityQuery`). The `swr` wrapped hooks automatically handled query invalidation:
```tsx
import { useState } from "react";
import { useEntityQuery } from "bknd/client";
export default function App() {
const { data } = useEntityQuery("todos");
return <ul>
{data?.map(todo => (
<li key={todo.id}>{todo.name}</li>
))}
</ul>
}
```
### React elements (`bknd/elements`)
You don't have to figure out API details to include media uploads to your app. For an user avatar upload, this is all you need:
```tsx
import { Media } from "bknd/elements"
import "bknd/dist/main.css"
export function UserAvatar() {
return <Media.Dropzone
entity={{ name: "users", id: 1, field: "avatar" }}
maxItems={1}
overwrite
/>
}
```
The import path also exports components for login and registration forms which are automatically pointed to the `bknd` defaults.
## 🚀 Quick start
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "bun:test";
import { sign } from "hono/jwt";
import { Api } from "../../src/Api";
describe("Api", async () => {
it("should construct without options", () => {
const api = new Api();
expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false);
});
it("should ignore force verify if no claims given", () => {
const api = new Api({ verified: true });
expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false);
});
it("should construct from request (token)", async () => {
const token = await sign({ foo: "bar" }, "1234");
const request = new Request("http://example.com/test", {
headers: {
Authorization: `Bearer ${token}`
}
});
const api = new Api({ request });
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
expect(params.token).toBe(token);
expect(params.token_transport).toBe("header");
expect(params.host).toBe("http://example.com");
});
it("should construct from request (cookie)", async () => {
const token = await sign({ foo: "bar" }, "1234");
const request = new Request("http://example.com/test", {
headers: {
Cookie: `auth=${token}`
}
});
const api = new Api({ request });
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
console.log(params);
expect(params.token).toBe(token);
expect(params.token_transport).toBe("cookie");
expect(params.host).toBe("http://example.com");
});
it("should construct from token", async () => {
const token = await sign({ foo: "bar" }, "1234");
const api = new Api({ token });
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
expect(params.token).toBe(token);
expect(params.token_transport).toBe("header");
expect(params.host).toBe("http://localhost");
});
it("should prefer host when request is given", async () => {
const request = new Request("http://example.com/test");
const api = new Api({ request, host: "http://another.com" });
const params = api.getParams();
expect(params.token).toBeUndefined();
expect(params.token_transport).toBe("header");
expect(params.host).toBe("http://another.com");
});
});
+2 -8
View File
@@ -27,10 +27,7 @@ describe("ModuleApi", () => {
it("fetches endpoint", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host });
// @ts-expect-error it's protected
api.fetcher = app.request as typeof fetch;
const api = new Api({ host }, app.request as typeof fetch);
const res = await api.get("/endpoint");
expect(res.res.ok).toEqual(true);
@@ -41,10 +38,7 @@ describe("ModuleApi", () => {
it("has accessible request", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host });
// @ts-expect-error it's protected
api.fetcher = app.request as typeof fetch;
const api = new Api({ host }, app.request as typeof fetch);
const promise = api.get("/endpoint");
expect(promise.request).toBeDefined();
+98 -12
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import { Perf } from "../../src/core/utils";
import * as reqres from "../../src/core/utils/reqres";
import * as strings from "../../src/core/utils/strings";
import * as utils from "../../src/core/utils";
async function wait(ms: number) {
return new Promise((resolve) => {
@@ -13,7 +12,7 @@ describe("Core Utils", async () => {
describe("[core] strings", async () => {
test("objectToKeyValueArray", async () => {
const obj = { a: 1, b: 2, c: 3 };
const result = strings.objectToKeyValueArray(obj);
const result = utils.objectToKeyValueArray(obj);
expect(result).toEqual([
{ key: "a", value: 1 },
{ key: "b", value: 2 },
@@ -22,24 +21,24 @@ describe("Core Utils", async () => {
});
test("snakeToPascalWithSpaces", async () => {
const result = strings.snakeToPascalWithSpaces("snake_to_pascal");
const result = utils.snakeToPascalWithSpaces("snake_to_pascal");
expect(result).toBe("Snake To Pascal");
});
test("randomString", async () => {
const result = strings.randomString(10);
const result = utils.randomString(10);
expect(result).toHaveLength(10);
});
test("pascalToKebab", async () => {
const result = strings.pascalToKebab("PascalCase");
const result = utils.pascalToKebab("PascalCase");
expect(result).toBe("pascal-case");
});
test("replaceSimplePlaceholders", async () => {
const str = "Hello, {$name}!";
const vars = { name: "John" };
const result = strings.replaceSimplePlaceholders(str, vars);
const result = utils.replaceSimplePlaceholders(str, vars);
expect(result).toBe("Hello, John!");
});
});
@@ -49,7 +48,7 @@ describe("Core Utils", async () => {
const headers = new Headers();
headers.append("Content-Type", "application/json");
headers.append("Authorization", "Bearer 123");
const obj = reqres.headersToObject(headers);
const obj = utils.headersToObject(headers);
expect(obj).toEqual({
"content-type": "application/json",
authorization: "Bearer 123"
@@ -59,21 +58,21 @@ describe("Core Utils", async () => {
test("replaceUrlParam", () => {
const url = "/api/:id/:name";
const params = { id: "123", name: "test" };
const result = reqres.replaceUrlParam(url, params);
const result = utils.replaceUrlParam(url, params);
expect(result).toBe("/api/123/test");
});
test("encode", () => {
const obj = { id: "123", name: "test" };
const result = reqres.encodeSearch(obj);
const result = utils.encodeSearch(obj);
expect(result).toBe("id=123&name=test");
const obj2 = { id: "123", name: ["test1", "test2"] };
const result2 = reqres.encodeSearch(obj2);
const result2 = utils.encodeSearch(obj2);
expect(result2).toBe("id=123&name=test1&name=test2");
const obj3 = { id: "123", name: { test: "test" } };
const result3 = reqres.encodeSearch(obj3, { encode: true });
const result3 = utils.encodeSearch(obj3, { encode: true });
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
});
});
@@ -108,4 +107,91 @@ describe("Core Utils", async () => {
expect(count).toBe(2);
});
});
describe("objects", () => {
test("omitKeys", () => {
const objects = [
[{ a: 1, b: 2, c: 3 }, ["a"], { b: 2, c: 3 }],
[{ a: 1, b: 2, c: 3 }, ["b"], { a: 1, c: 3 }],
[{ a: 1, b: 2, c: 3 }, ["c"], { a: 1, b: 2 }],
[{ a: 1, b: 2, c: 3 }, ["a", "b"], { c: 3 }],
[{ a: 1, b: 2, c: 3 }, ["a", "b", "c"], {}]
] as [object, string[], object][];
for (const [obj, keys, expected] of objects) {
const result = utils.omitKeys(obj, keys as any);
expect(result).toEqual(expected);
}
});
test("isEqual", () => {
const objects = [
[1, 1, true],
[1, "1", false],
[1, 2, false],
["1", "1", true],
["1", "2", false],
[true, true, true],
[true, false, false],
[false, false, true],
[1, NaN, false],
[NaN, NaN, true],
[null, null, true],
[null, undefined, false],
[undefined, undefined, true],
[new Map([["a", 1]]), new Map([["a", 1]]), true],
[new Map([["a", 1]]), new Map([["a", 2]]), false],
[new Map([["a", 1]]), new Map([["b", 1]]), false],
[
new Map([["a", 1]]),
new Map([
["a", 1],
["b", 2]
]),
false
],
[{ a: 1 }, { a: 1 }, true],
[{ a: 1 }, { a: 2 }, false],
[{ a: 1 }, { b: 1 }, false],
[{ a: "1" }, { a: "1" }, true],
[{ a: "1" }, { a: "2" }, false],
[{ a: "1" }, { b: "1" }, false],
[{ a: 1 }, { a: 1, b: 2 }, false],
[{ a: [1, 2, 3] }, { a: [1, 2, 3] }, true],
[{ a: [1, 2, 3] }, { a: [1, 2, 4] }, false],
[{ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }, false],
[{ a: { b: 1 } }, { a: { b: 1 } }, true],
[{ a: { b: 1 } }, { a: { b: 2 } }, false],
[{ a: { b: 1 } }, { a: { c: 1 } }, false],
[{ a: { b: 1 } }, { a: { b: 1, c: 2 } }, false],
[[1, 2, 3], [1, 2, 3], true],
[[1, 2, 3], [1, 2, 4], false],
[[1, 2, 3], [1, 2, 3, 4], false],
[[{ a: 1 }], [{ a: 1 }], true],
[[{ a: 1 }], [{ a: 2 }], false],
[[{ a: 1 }], [{ b: 1 }], false]
] as [any, any, boolean][];
for (const [a, b, expected] of objects) {
const result = utils.isEqual(a, b);
expect(result).toEqual(expected);
}
});
test("getPath", () => {
const tests = [
[{ a: 1, b: 2, c: 3 }, "a", 1],
[{ a: 1, b: 2, c: 3 }, "b", 2],
[{ a: { b: 1 } }, "a.b", 1],
[{ a: { b: 1 } }, "a.b.c", null, null],
[{ a: { b: 1 } }, "a.b.c", 1, 1],
[[[1]], "0.0", 1]
] as [object, string, any, any][];
for (const [obj, path, expected, defaultValue] of tests) {
const result = utils.getPath(obj, path, defaultValue);
expect(result).toEqual(expected);
}
});
});
});
+17
View File
@@ -123,6 +123,23 @@ describe("data-query-impl", () => {
}
}
);
// over http
{
const output = { with: { images: {} } };
decode({ with: "images" }, output);
decode({ with: '["images"]' }, output);
decode({ with: ["images"] }, output);
decode({ with: { images: {} } }, output);
}
{
const output = { with: { images: {}, comments: {} } };
decode({ with: "images,comments" }, output);
decode({ with: ["images", "comments"] }, output);
decode({ with: '["images", "comments"]' }, output);
decode({ with: { images: {}, comments: {} } }, output);
}
});
});
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { Guard } from "../../src/auth";
import { DebugLogger } from "../../src/core";
import { EventManager } from "../../src/core/events";
import { Default, stripMark } from "../../src/core/utils";
import { EntityManager } from "../../src/data";
@@ -17,6 +18,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
emgr: new EventManager(),
guard: new Guard(),
flags: Module.ctx_flags,
logger: new DebugLogger(false),
...overrides
};
}
+138
View File
@@ -0,0 +1,138 @@
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";
import type { IsTypeType } from "../../src/ui/components/form/json-schema-form/utils";
describe("json form", () => {
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<JSONSchema, boolean>, any][];
for (const [input, schema, output] of examples) {
expect(utils.coerce(input, schema)).toBe(output);
}
});
test("isType", () => {
const examples = [
["string", "string", true],
["integer", "number", false],
["number", "number", true],
["boolean", "boolean", true],
["null", "null", true],
["object", "object", true],
["array", "array", true],
["object", "array", false],
[["string", "number"], "number", true],
["number", ["string", "number"], true]
] satisfies [IsTypeType, IsTypeType, boolean][];
for (const [type, schemaType, output] of examples) {
expect(utils.isType(type, schemaType)).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<JSONSchema, boolean>, boolean][];
for (const [pointer, schema, output] of examples) {
expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output);
}
});
test("prefixPath", () => {
const examples = [
["normal", "", "normal"],
["", "prefix", "prefix"],
["tags", "0", "0.tags"],
["tags", 0, "0.tags"],
["nested.property", "prefix", "prefix.nested.property"],
["nested.property", "", "nested.property"]
] satisfies [string, any, string][];
for (const [path, prefix, output] of examples) {
expect(utils.prefixPath(path, prefix)).toBe(output);
}
});
test("suffixPath", () => {
const examples = [
["normal", "", "normal"],
["", "suffix", "suffix"],
["tags", "0", "tags.0"],
["tags", 0, "tags.0"],
["nested.property", "suffix", "nested.property.suffix"],
["nested.property", "", "nested.property"]
] satisfies [string, any, string][];
for (const [path, suffix, output] of examples) {
expect(utils.suffixPath(path, suffix)).toBe(output);
}
});
});
+8 -1
View File
@@ -173,13 +173,20 @@ function baseConfig(adapter: string): tsup.Options {
],
metafile: true,
splitting: false,
treeshake: true,
onSuccess: async () => {
delayTypes();
}
};
}
// base adapter handles
await tsup.build({
...baseConfig(""),
entry: ["src/adapter/index.ts"],
outDir: "dist/adapter"
});
// specific adatpers
await tsup.build(baseConfig("remix"));
await tsup.build(baseConfig("bun"));
await tsup.build(baseConfig("astro"));
+18 -11
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.6.1",
"version": "0.7.0-rc.11",
"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,30 @@
"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",
"json-schema-to-ts": "^3.1.1",
"swr": "^2.2.5"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.613.0",
@@ -62,7 +66,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",
@@ -148,6 +151,10 @@
"import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.cjs"
},
"./adapter": {
"types": "./dist/types/adapter/index.d.ts",
"import": "./dist/adapter/index.js"
},
"./adapter/vite": {
"types": "./dist/types/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js",
+70 -27
View File
@@ -17,13 +17,21 @@ declare global {
}
export type ApiOptions = {
host: string;
user?: TApiUser;
token?: string;
host?: string;
headers?: Headers;
key?: string;
localStorage?: boolean;
};
fetcher?: typeof fetch;
verified?: boolean;
} & (
| {
token?: string;
user?: TApiUser;
}
| {
request: Request;
}
);
export type AuthState = {
token?: string;
@@ -42,14 +50,26 @@ export class Api {
public auth!: AuthApi;
public media!: MediaApi;
constructor(private readonly options: ApiOptions) {
if (options.user) {
this.user = options.user;
this.token_transport = "none";
this.verified = true;
} else if (options.token) {
constructor(private options: ApiOptions = {}) {
// only mark verified if forced
this.verified = options.verified === true;
// prefer request if given
if ("request" in options) {
this.options.host = options.host ?? new URL(options.request.url).origin;
this.options.headers = options.headers ?? options.request.headers;
this.extractToken();
// then check for a token
} else if ("token" in options) {
this.token_transport = "header";
this.updateToken(options.token);
// then check for an user object
} else if ("user" in options) {
this.token_transport = "none";
this.user = options.user;
this.verified = options.verified !== false;
} else {
this.extractToken();
}
@@ -58,7 +78,7 @@ export class Api {
}
get baseUrl() {
return this.options.host;
return this.options.host ?? "http://localhost";
}
get tokenKey() {
@@ -66,13 +86,15 @@ export class Api {
}
private extractToken() {
// if token has to be extracted, it's never verified
this.verified = false;
if (this.options.headers) {
// try cookies
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
if (cookieToken) {
this.updateToken(cookieToken);
this.token_transport = "cookie";
this.verified = true;
this.updateToken(cookieToken);
return;
}
@@ -96,6 +118,8 @@ export class Api {
updateToken(token?: string, rebuild?: boolean) {
this.token = token;
this.verified = false;
if (token) {
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
} else {
@@ -115,11 +139,15 @@ export class Api {
if (rebuild) this.buildApis();
}
markAuthVerified(verfied: boolean) {
private markAuthVerified(verfied: boolean) {
this.verified = verfied;
return this;
}
isAuthVerified(): boolean {
return this.verified;
}
getAuthState(): AuthState {
return {
token: this.token,
@@ -128,6 +156,11 @@ export class Api {
};
}
isAuthenticated(): boolean {
const { token, user } = this.getAuthState();
return !!token && !!user;
}
async getVerifiedAuthState(): Promise<AuthState> {
await this.verifyAuth();
return this.getAuthState();
@@ -140,11 +173,13 @@ export class Api {
}
try {
const res = await this.auth.me();
if (!res.ok || !res.body.user) {
const { ok, data } = await this.auth.me();
const user = data?.user;
if (!ok || !user) {
throw new Error();
}
this.user = user;
this.markAuthVerified(true);
} catch (e) {
this.markAuthVerified(false);
@@ -156,21 +191,29 @@ export class Api {
return this.user || null;
}
private buildApis() {
const baseParams = {
host: this.options.host,
getParams() {
return Object.freeze({
host: this.baseUrl,
token: this.token,
headers: this.options.headers,
token_transport: this.token_transport
};
this.system = new SystemApi(baseParams);
this.data = new DataApi(baseParams);
this.auth = new AuthApi({
...baseParams,
onTokenUpdate: (token) => this.updateToken(token, true)
});
this.media = new MediaApi(baseParams);
}
private buildApis() {
const baseParams = this.getParams();
const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(baseParams, fetcher);
this.auth = new AuthApi(
{
...baseParams,
onTokenUpdate: (token) => this.updateToken(token, true)
},
fetcher
);
this.media = new MediaApi(baseParams, fetcher);
}
}
+6 -1
View File
@@ -1,6 +1,7 @@
import type { CreateUserPayload } from "auth/AppAuth";
import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono";
import {
type InitialModuleConfigs,
ModuleManager,
@@ -132,7 +133,7 @@ export class App {
return this.modules.ctx().em;
}
get fetch(): any {
get fetch(): Hono["fetch"] {
return this.server.fetch;
}
@@ -155,6 +156,10 @@ export class App {
return this.modules.version();
}
isBuilt(): boolean {
return this.modules.isBuilt();
}
registerAdminController(config?: AdminControllerOptions) {
// register admin
this.adminController = new AdminController(this, config);
+11 -8
View File
@@ -1,7 +1,8 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
import { Api, type ApiOptions, type App } from "bknd";
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api, type ApiOptions } from "bknd/client";
export type AstroBkndConfig = FrameworkBkndConfig;
export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>;
type TAstro = {
request: Request;
@@ -13,18 +14,20 @@ export type Options = {
host?: string;
};
export function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
return new Api({
export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
const api = new Api({
host: new URL(Astro.request.url).origin,
headers: options.mode === "dynamic" ? Astro.request.headers : undefined
});
await api.verifyAuth();
return api;
}
let app: App;
export function serve(config: AstroBkndConfig = {}) {
return async (args: TAstro) => {
export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) {
return async (args: Context) => {
if (!app) {
app = await createFrameworkApp(config);
app = await createFrameworkApp(config, args);
}
return app.fetch(args.request);
};
+4 -3
View File
@@ -2,10 +2,11 @@
import path from "node:path";
import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { config } from "bknd/core";
import type { ServeOptions } from "bun";
import { config } from "core";
import { serveStatic } from "hono/bun";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
let app: App;
@@ -15,9 +16,9 @@ export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {})
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({
...config,
registerLocalMedia: true,
serveStatic: serveStatic({ root })
});
}
@@ -1,18 +1,17 @@
import type { CreateAppConfig } from "bknd";
import type { FrameworkBkndConfig } from "bknd/adapter";
import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
import type { FrameworkBkndConfig } from "../index";
import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable";
import { getFresh, getWarm } from "./modes/fresh";
export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> & {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & {
mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (env: Env) => {
bindings?: (args: Context<Env>) => {
kv?: KVNamespace;
dobj?: DurableObjectNamespace;
};
static?: "kv" | "assets";
key?: string;
keepAliveSeconds?: number;
forceHttps?: boolean;
@@ -21,28 +20,33 @@ export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> &
html?: string;
};
export type Context = {
export type Context<Env = any> = {
request: Request;
env: any;
env: Env;
ctx: ExecutionContext;
};
export function serve(config: CloudflareBkndConfig) {
export function serve<Env = any>(config: CloudflareBkndConfig<Env>) {
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
const manifest = config.manifest;
if (manifest) {
if (config.manifest && config.static === "assets") {
console.warn("manifest is not useful with static 'assets'");
} else if (!config.manifest && config.static === "kv") {
throw new Error("manifest is required with static 'kv'");
}
if (config.manifest && config.static !== "assets") {
const pathname = url.pathname.slice(1);
const assetManifest = JSON.parse(manifest);
const assetManifest = JSON.parse(config.manifest);
if (pathname && pathname in assetManifest) {
const hono = new Hono();
hono.all("*", async (c, next) => {
const res = await serveStatic({
path: `./${pathname}`,
manifest
manifest: config.manifest!
})(c as any, next);
if (res instanceof Response) {
const ttl = 60 * 60 * 24 * 365;
+3 -3
View File
@@ -1,8 +1,8 @@
import { createRuntimeApp } from "adapter";
import { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index";
export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Context) {
export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) {
const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app";
@@ -37,7 +37,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Cont
},
adminOptions: { html: config.html }
},
env
{ env, ctx, ...args }
);
if (!cachedConfig) {
+3 -3
View File
@@ -1,7 +1,7 @@
import { DurableObject } from "cloudflare:workers";
import { createRuntimeApp } from "adapter";
import type { CloudflareBkndConfig, Context } from "adapter/cloudflare";
import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index";
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const { dobj } = config.bindings?.(ctx.env)!;
@@ -17,7 +17,7 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const id = dobj.idFromName(key);
const stub = dobj.get(id) as unknown as DurableBkndApp;
const create_config = typeof config.app === "function" ? config.app(ctx.env) : config.app;
const create_config = makeConfig(config, ctx);
const res = await stub.fire(ctx.request, {
config: create_config,
+3 -3
View File
@@ -1,14 +1,14 @@
import { createRuntimeApp } from "adapter";
import type { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index";
export async function makeApp(config: CloudflareBkndConfig, { env }: Context) {
export async function makeApp(config: CloudflareBkndConfig, ctx: Context) {
return await createRuntimeApp(
{
...config,
adminOptions: config.html ? { html: config.html } : undefined
},
env
ctx
);
}
+13 -49
View File
@@ -1,59 +1,29 @@
import type { IncomingMessage } from "node:http";
import { App, type CreateAppConfig, registries } from "bknd";
import { config as $config } from "core";
import { App, type CreateAppConfig } from "bknd";
import { config as $config } from "bknd/core";
import type { MiddlewareHandler } from "hono";
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
import type { AdminControllerOptions } from "modules/server/AdminController";
export type BkndConfig<Env = any> = CreateAppConfig & {
app?: CreateAppConfig | ((env: Env) => CreateAppConfig);
export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
onBuilt?: (app: App) => Promise<void>;
beforeBuild?: (app: App) => Promise<void>;
buildConfig?: Parameters<App["build"]>[0];
};
export type FrameworkBkndConfig<Env = any> = BkndConfig<Env>;
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
export type RuntimeBkndConfig<Env = any> = BkndConfig<Env> & {
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
distPath?: string;
};
export function nodeRequestToRequest(req: IncomingMessage): Request {
let protocol = "http";
try {
protocol = req.headers["x-forwarded-proto"] as string;
} catch (e) {}
const host = req.headers.host;
const url = `${protocol}://${host}${req.url}`;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
headers.append(key, value.join(", "));
} else if (value) {
headers.append(key, value);
}
}
const method = req.method || "GET";
return new Request(url, {
method,
headers
});
}
export function registerLocalMediaAdapter() {
registries.media.register("local", StorageLocalAdapter);
}
export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): CreateAppConfig {
export function makeConfig<Args = any>(config: BkndConfig<Args>, args?: Args): CreateAppConfig {
let additionalConfig: CreateAppConfig = {};
if ("app" in config && config.app) {
if (typeof config.app === "function") {
if (!env) {
throw new Error("env is required when config.app is a function");
if (!args) {
throw new Error("args is required when config.app is a function");
}
additionalConfig = config.app(env);
additionalConfig = config.app(args);
} else {
additionalConfig = config.app;
}
@@ -62,11 +32,11 @@ export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): Creat
return { ...config, ...additionalConfig };
}
export async function createFrameworkApp<Env = any>(
export async function createFrameworkApp<Args = any>(
config: FrameworkBkndConfig,
env?: Env
args?: Args
): Promise<App> {
const app = App.create(makeConfig(config, env));
const app = App.create(makeConfig(config, args));
if (config.onBuilt) {
app.emgr.onEvent(
@@ -87,20 +57,14 @@ export async function createFrameworkApp<Env = any>(
export async function createRuntimeApp<Env = any>(
{
serveStatic,
registerLocalMedia,
adminOptions,
...config
}: RuntimeBkndConfig & {
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
registerLocalMedia?: boolean;
adminOptions?: AdminControllerOptions | false;
},
env?: Env
): Promise<App> {
if (registerLocalMedia) {
registerLocalMediaAdapter();
}
const app = App.create(makeConfig(config, env));
app.emgr.onEvent(
+8 -4
View File
@@ -1,6 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { Api, type App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index";
import { nodeRequestToRequest } from "adapter/utils";
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api } from "bknd/client";
export type NextjsBkndConfig = FrameworkBkndConfig & {
cleanSearch?: string[];
@@ -29,8 +31,10 @@ export function createApi({ req }: GetServerSidePropsContext) {
}
export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) {
return (ctx: GetServerSidePropsContext & { api: Api }) => {
return handler({ ...ctx, api: createApi(ctx) });
return async (ctx: GetServerSidePropsContext & { api: Api }) => {
const api = createApi(ctx);
await api.verifyAuth();
return handler({ ...ctx, api });
};
}
+11 -5
View File
@@ -1,6 +1,12 @@
export * from "./node.adapter";
export {
StorageLocalAdapter,
type LocalAdapterConfig
import { registries } from "bknd";
import {
type LocalAdapterConfig,
StorageLocalAdapter
} from "../../media/storage/adapters/StorageLocalAdapter";
export { registerLocalMediaAdapter } from "../index";
export * from "./node.adapter";
export { StorageLocalAdapter, type LocalAdapterConfig };
export function registerLocalMediaAdapter() {
registries.media.register("local", StorageLocalAdapter);
}
+4 -3
View File
@@ -1,9 +1,10 @@
import path from "node:path";
import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/index";
import type { App } from "bknd";
import { config as $config } from "core";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { config as $config } from "bknd/core";
export type NodeBkndConfig = RuntimeBkndConfig & {
port?: number;
@@ -37,9 +38,9 @@ export function serve({
hostname,
fetch: async (req: Request) => {
if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({
...config,
registerLocalMedia: true,
serveStatic: serveStatic({ root })
});
}
+3 -1
View File
@@ -1,9 +1,11 @@
import { useAuth } from "bknd/client";
import type { BkndAdminProps } from "bknd/ui";
import { Suspense, lazy, useEffect, useState } from "react";
export function adminPage(props?: BkndAdminProps) {
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
return () => {
const auth = useAuth();
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
@@ -13,7 +15,7 @@ export function adminPage(props?: BkndAdminProps) {
return (
<Suspense>
<Admin {...props} />
<Admin withProvider={{ user: auth.user }} {...props} />
</Suspense>
);
};
+28 -5
View File
@@ -1,14 +1,37 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api } from "bknd/client";
export type RemixBkndConfig = FrameworkBkndConfig;
export type RemixBkndConfig<Args = RemixContext> = FrameworkBkndConfig<Args>;
type RemixContext = {
request: Request;
};
let app: App;
export function serve(config: RemixBkndConfig = {}) {
return async (args: { request: Request }) => {
export function serve<Args extends RemixContext = RemixContext>(
config: RemixBkndConfig<Args> = {}
) {
return async (args: Args) => {
if (!app) {
app = await createFrameworkApp(config);
app = await createFrameworkApp(config, args);
}
return app.fetch(args.request);
};
}
export function withApi<Args extends { request: Request; context: { api: Api } }, R>(
handler: (args: Args, api: Api) => Promise<R>
) {
return async (args: Args) => {
if (!args.context.api) {
args.context.api = new Api({
host: new URL(args.request.url).origin,
headers: args.request.headers
});
await args.context.api.verifyAuth();
}
return handler(args, args.context.api);
};
}
+25
View File
@@ -0,0 +1,25 @@
import type { IncomingMessage } from "node:http";
export function nodeRequestToRequest(req: IncomingMessage): Request {
let protocol = "http";
try {
protocol = req.headers["x-forwarded-proto"] as string;
} catch (e) {}
const host = req.headers.host;
const url = `${protocol}://${host}${req.url}`;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
headers.append(key, value.join(", "));
} else if (value) {
headers.append(key, value);
}
}
const method = req.method || "GET";
return new Request(url, {
method,
headers
});
}
+3 -2
View File
@@ -1,7 +1,8 @@
import { serveStatic } from "@hono/node-server/serve-static";
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config";
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
@@ -28,10 +29,10 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
}
async function createApp(config: ViteBkndConfig = {}, env?: any) {
registerLocalMediaAdapter();
return await createRuntimeApp(
{
...config,
registerLocalMedia: true,
adminOptions:
config.setAdminHtml === false
? undefined
+8 -2
View File
@@ -15,7 +15,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
}
async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input);
const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: "include"
});
if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token);
}
@@ -23,7 +26,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
}
async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input);
const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: "include"
});
if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token);
}
+13 -1
View File
@@ -21,6 +21,15 @@ export class AuthController extends Controller {
return this.auth.ctx.guard;
}
get em() {
return this.auth.ctx.em;
}
get userRepo() {
const entity_name = this.auth.config.entity_name;
return this.em.repo(entity_name as "users");
}
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
const actions = strategy.getActions?.();
if (!actions) {
@@ -96,7 +105,10 @@ export class AuthController extends Controller {
hono.get("/me", auth(), async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) {
return c.json({ user: this.auth.authenticator.getUser() });
const claims = this.auth.authenticator.getUser()!;
const { data: user } = await this.userRepo.findId(claims.id);
return c.json({ user });
}
return c.json({ user: null }, 403);
+13 -8
View File
@@ -259,7 +259,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
async requestCookieRefresh(c: Context) {
if (this.config.cookie.renew) {
if (this.config.cookie.renew && this.isUserLoggedIn()) {
const token = await this.getAuthCookie(c);
if (token) {
await this.setAuthCookie(c, token);
@@ -299,8 +299,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
}
private getSuccessPath(c: Context) {
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/");
private getSafeUrl(c: Context, path: string) {
const p = path.replace(/\/+$/, "/");
// nextjs doesn't support non-fq urls
// but env could be proxied (stackblitz), so we shouldn't fq every url
@@ -312,21 +312,26 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
if (this.isJsonRequest(c)) {
return c.json(data);
}
const successUrl = this.getSuccessPath(c);
const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/");
const referer = redirect ?? c.req.header("Referer") ?? successUrl;
//console.log("auth respond", { redirect, successUrl, successPath });
if ("token" in data) {
await this.setAuthCookie(c, data.token);
if (this.isJsonRequest(c)) {
return c.json(data);
}
// can't navigate to "/" doesn't work on nextjs
//console.log("auth success, redirecting to", successUrl);
return c.redirect(successUrl);
}
if (this.isJsonRequest(c)) {
return c.json(data, 400);
}
let message = "An error occured";
if (data instanceof Exception) {
message = data.message;
@@ -1,4 +1,5 @@
import type { Authenticator, Strategy } from "auth";
import { isDebug, tbValidator as tb } from "core";
import { type Static, StringEnum, Type, parse } from "core/utils";
import { hash } from "core/utils";
import { type Context, Hono } from "hono";
@@ -56,26 +57,56 @@ export class PasswordStrategy implements Strategy {
const hono = new Hono();
return hono
.post("/login", async (c) => {
const body = await authenticator.getBody(c);
.post(
"/login",
tb(
"query",
Type.Object({
redirect: Type.Optional(Type.String())
})
),
async (c) => {
const body = await authenticator.getBody(c);
const { redirect } = c.req.valid("query");
try {
const payload = await this.login(body);
const data = await authenticator.resolve("login", this, payload.password, payload);
try {
const payload = await this.login(body);
const data = await authenticator.resolve(
"login",
this,
payload.password,
payload
);
return await authenticator.respond(c, data);
} catch (e) {
return await authenticator.respond(c, e);
return await authenticator.respond(c, data, redirect);
} catch (e) {
return await authenticator.respond(c, e);
}
}
})
.post("/register", async (c) => {
const body = await authenticator.getBody(c);
)
.post(
"/register",
tb(
"query",
Type.Object({
redirect: Type.Optional(Type.String())
})
),
async (c) => {
const body = await authenticator.getBody(c);
const { redirect } = c.req.valid("query");
const payload = await this.register(body);
const data = await authenticator.resolve("register", this, payload.password, payload);
const payload = await this.register(body);
const data = await authenticator.resolve(
"register",
this,
payload.password,
payload
);
return await authenticator.respond(c, data);
});
return await authenticator.respond(c, data, redirect);
}
);
}
getActions(): StrategyActions {
+16 -10
View File
@@ -1,10 +1,10 @@
import { cloneDeep, get, has, mergeWith, omit, set } from "lodash-es";
import { get, has, omit, set } from "lodash-es";
import {
Default,
type Static,
type TObject,
getFullPathKeys,
mark,
mergeObjectWith,
parse,
stripMark
} from "../utils";
@@ -33,7 +33,7 @@ export class SchemaObject<Schema extends TObject> {
) {
this._default = Default(_schema, {} as any) as any;
this._value = initial
? parse(_schema, cloneDeep(initial as any), {
? parse(_schema, structuredClone(initial as any), {
forceParse: this.isForceParse(),
skipMark: this.isForceParse()
})
@@ -64,8 +64,12 @@ export class SchemaObject<Schema extends TObject> {
return this._config;
}
clone() {
return structuredClone(this._config);
}
async set(config: Static<Schema>, noEmit?: boolean): Promise<Static<Schema>> {
const valid = parse(this._schema, cloneDeep(config) as any, {
const valid = parse(this._schema, structuredClone(config) as any, {
forceParse: true,
skipMark: this.isForceParse()
});
@@ -114,7 +118,7 @@ export class SchemaObject<Schema extends TObject> {
}
async patch(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
const current = cloneDeep(this._config);
const current = this.clone();
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial);
@@ -122,11 +126,13 @@ export class SchemaObject<Schema extends TObject> {
// overwrite arrays and primitives, only deep merge objects
// @ts-ignore
const config = mergeWith(current, partial, (objValue, srcValue) => {
//console.log("---alt:new", _jsonp(mergeObject(current, partial)));
const config = mergeObjectWith(current, partial, (objValue, srcValue) => {
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return srcValue;
}
});
//console.log("---new", _jsonp(config));
//console.log("overwritePaths", this.options?.overwritePaths);
if (this.options?.overwritePaths) {
@@ -164,14 +170,14 @@ export class SchemaObject<Schema extends TObject> {
}
}
//console.log("patch", { path, value, partial, config, current });
//console.log("patch", _jsonp({ path, value, partial, config, current }));
const newConfig = await this.set(config);
return [partial, newConfig];
}
async overwrite(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
const current = cloneDeep(this._config);
const current = this.clone();
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial);
@@ -192,7 +198,7 @@ export class SchemaObject<Schema extends TObject> {
if (p.length > 1) {
const parent = p.slice(0, -1).join(".");
if (!has(this._config, parent)) {
console.log("parent", parent, JSON.stringify(this._config, null, 2));
//console.log("parent", parent, JSON.stringify(this._config, null, 2));
throw new Error(`Parent path "${parent}" does not exist`);
}
}
@@ -207,7 +213,7 @@ export class SchemaObject<Schema extends TObject> {
throw new Error(`Path "${path}" does not exist`);
}
const current = cloneDeep(this._config);
const current = this.clone();
const removed = get(current, path) as Partial<Static<Schema>>;
const config = omit(current, path);
const newConfig = await this.set(config);
+171 -9
View File
@@ -4,6 +4,28 @@ export function _jsonp(obj: any, space = 2): string {
return JSON.stringify(obj, null, space);
}
export function isPlainObject(value: unknown): value is Record<string, unknown> {
return Object.prototype.toString.call(value) === "[object Object]";
}
export function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
}
export function omitKeys<T extends object, K extends keyof T>(
obj: T,
keys_: readonly K[]
): Omit<T, Extract<K, keyof T>> {
const keys = new Set(keys_);
const result = {} as Omit<T, Extract<K, keyof T>>;
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
if (!keys.has(key as K)) {
(result as any)[key] = value;
}
}
return result;
}
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
return Object.entries(obj).reduce((acc, [key, value]) => {
try {
@@ -97,15 +119,6 @@ export function objectEach<T extends Record<string, any>, U>(
);
}
/**
* Simple object check.
* @param item
* @returns {boolean}
*/
export function isObject(item) {
return item && typeof item === "object" && !Array.isArray(item);
}
/**
* Deep merge two objects.
* @param target
@@ -197,3 +210,152 @@ export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj):
return acc;
}, {} as any);
}
/**
* Lodash's merge implementation caused issues in Next.js environments
* From: https://thescottyjam.github.io/snap.js/#!/nolodash/merge
* NOTE: This mutates `object`. It also may mutate anything that gets attached to `object` during the merge.
* @param object
* @param sources
*/
export function mergeObject(object, ...sources) {
for (const source of sources) {
for (const [key, value] of Object.entries(source)) {
if (value === undefined) {
continue;
}
// These checks are a week attempt at mimicking the various edge-case behaviors
// that Lodash's `_.merge()` exhibits. Feel free to simplify and
// remove checks that you don't need.
if (!isPlainObject(value) && !Array.isArray(value)) {
object[key] = value;
} else if (Array.isArray(value) && !Array.isArray(object[key])) {
object[key] = value;
} else if (!isObject(object[key])) {
object[key] = value;
} else {
mergeObject(object[key], value);
}
}
}
return object;
}
/**
* Lodash's mergeWith implementation caused issues in Next.js environments
* From: https://thescottyjam.github.io/snap.js/#!/nolodash/mergeWith
* NOTE: This mutates `object`. It also may mutate anything that gets attached to `object` during the merge.
* @param object
* @param sources
* @param customizer
*/
export function mergeObjectWith(object, source, customizer) {
for (const [key, value] of Object.entries(source)) {
const mergedValue = customizer(object[key], value, key, object, source);
if (mergedValue !== undefined) {
object[key] = mergedValue;
continue;
}
// Otherwise, fall back to default behavior
if (value === undefined) {
continue;
}
// These checks are a week attempt at mimicking the various edge-case behaviors
// that Lodash's `_.merge()` exhibits. Feel free to simplify and
// remove checks that you don't need.
if (!isPlainObject(value) && !Array.isArray(value)) {
object[key] = value;
} else if (Array.isArray(value) && !Array.isArray(object[key])) {
object[key] = value;
} else if (!isObject(object[key])) {
object[key] = value;
} else {
mergeObjectWith(object[key], value, customizer);
}
}
return object;
}
export function isEqual(value1: any, value2: any): boolean {
// Each type corresponds to a particular comparison algorithm
const getType = (value: any) => {
if (value !== Object(value)) return "primitive";
if (Array.isArray(value)) return "array";
if (value instanceof Map) return "map";
if (value != null && [null, Object.prototype].includes(Object.getPrototypeOf(value)))
return "plainObject";
if (value instanceof Function) return "function";
throw new Error(
`deeply comparing an instance of type ${value1.constructor?.name} is not supported.`
);
};
const type = getType(value1);
if (type !== getType(value2)) {
return false;
}
if (type === "primitive") {
return value1 === value2 || (Number.isNaN(value1) && Number.isNaN(value2));
} else if (type === "array") {
return (
value1.length === value2.length &&
value1.every((iterValue: any, i: number) => isEqual(iterValue, value2[i]))
);
} else if (type === "map") {
// In this particular implementation, map keys are not
// being deeply compared, only map values.
return (
value1.size === value2.size &&
[...value1].every(([iterKey, iterValue]) => {
return value2.has(iterKey) && isEqual(iterValue, value2.get(iterKey));
})
);
} else if (type === "plainObject") {
const value1AsMap = new Map(Object.entries(value1));
const value2AsMap = new Map(Object.entries(value2));
return (
value1AsMap.size === value2AsMap.size &&
[...value1AsMap].every(([iterKey, iterValue]) => {
return value2AsMap.has(iterKey) && isEqual(iterValue, value2AsMap.get(iterKey));
})
);
} else if (type === "function") {
// just check signature
return value1.toString() === value2.toString();
} else {
throw new Error("Unreachable");
}
}
export function getPath(
object: object,
_path: string | (string | number)[],
defaultValue = undefined
): any {
const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path;
if (path.length === 0) {
return object;
}
try {
const [head, ...tail] = path;
if (!head || !(head in object)) {
return defaultValue;
}
return getPath(object[head], tail, defaultValue);
} catch (error) {
if (typeof defaultValue !== "undefined") {
return defaultValue;
}
throw new Error(`Invalid path: ${path.join(".")}`);
}
}
+4 -1
View File
@@ -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(" ");
+1 -2
View File
@@ -21,7 +21,6 @@ import {
type ValueErrorIterator
} from "@sinclair/typebox/errors";
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
import { cloneDeep } from "lodash-es";
export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
@@ -73,7 +72,7 @@ export class TypeInvalidError extends Error {
}
export function stripMark<O = any>(obj: O) {
const newObj = cloneDeep(obj);
const newObj = structuredClone(obj);
mark(newObj, false);
return newObj as O;
}
+13 -4
View File
@@ -13,7 +13,15 @@ import { type AppDataConfig, dataConfigSchema } from "./data-schema";
export class AppData extends Module<typeof dataConfigSchema> {
override async build() {
const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => {
const {
entities: _entities = {},
relations: _relations = {},
indices: _indices = {}
} = this.config;
this.ctx.logger.context("AppData").log("building with entities", Object.keys(_entities));
const entities = transformObject(_entities, (entityConfig, name) => {
return constructEntity(name, entityConfig);
});
@@ -21,14 +29,14 @@ export class AppData extends Module<typeof dataConfigSchema> {
const name = typeof _e === "string" ? _e : _e.name;
const entity = entities[name];
if (entity) return entity;
throw new Error(`Entity "${name}" not found`);
throw new Error(`[AppData] Entity "${name}" not found`);
};
const relations = transformObject(this.config.relations ?? {}, (relation) =>
const relations = transformObject(_relations, (relation) =>
constructRelation(relation, _entity)
);
const indices = transformObject(this.config.indices ?? {}, (index, name) => {
const indices = transformObject(_indices, (index, name) => {
const entity = _entity(index.entity)!;
const fields = index.fields.map((f) => entity.field(f)!);
return new EntityIndex(entity, fields, index.unique, name);
@@ -52,6 +60,7 @@ export class AppData extends Module<typeof dataConfigSchema> {
);
this.ctx.guard.registerPermissions(Object.values(DataPermissions));
this.ctx.logger.clear();
this.setBuilt();
}
+44 -14
View File
@@ -5,7 +5,8 @@ import {
type StaticDecode,
StringEnum,
Type,
Value
Value,
isObject
} from "core/utils";
import { WhereBuilder, type WhereQuery } from "../entities";
@@ -71,22 +72,51 @@ export type RepoWithSchema = Record<
>;
export const withSchema = <TSelf extends TThis>(Self: TSelf) =>
Type.Transform(Type.Union([stringArray, Type.Record(Type.String(), Self)]))
Type.Transform(
Type.Union([Type.String(), Type.Array(Type.String()), Type.Record(Type.String(), Self)])
)
.Decode((value) => {
let _value = typeof value === "string" ? [value] : value;
// images
// images,comments
// ["images","comments"]
// { "images": {} }
if (Array.isArray(value)) {
if (!value.every((v) => typeof v === "string")) {
throw new Error("Invalid 'with' schema");
}
_value = value.reduce((acc, v) => {
acc[v] = {};
return acc;
}, {} as RepoWithSchema);
if (!Array.isArray(value) && isObject(value)) {
console.log("is object");
return value as RepoWithSchema;
}
return _value as RepoWithSchema;
let _value: any = null;
if (typeof value === "string") {
// if stringified object
if (value.match(/^\{/)) {
return JSON.parse(value) as RepoWithSchema;
}
// if stringified array
if (value.match(/^\[/)) {
_value = JSON.parse(value) as string[];
// if comma-separated string
} else if (value.includes(",")) {
_value = value.split(",");
// if single string
} else {
_value = [value];
}
} else if (Array.isArray(value)) {
_value = value;
}
if (!_value || !Array.isArray(_value) || !_value.every((v) => typeof v === "string")) {
throw new Error("Invalid 'with' schema");
}
return _value.reduce((acc, v) => {
acc[v] = {};
return acc;
}, {} as RepoWithSchema);
})
.Encode((value) => value);
@@ -117,7 +147,7 @@ export type RepoQueryIn = {
offset?: number;
sort?: string | { by: string; dir: "asc" | "desc" };
select?: string[];
with?: string[] | Record<string, RepoQueryIn>;
with?: string | string[] | Record<string, RepoQueryIn>;
join?: string[];
where?: WhereQuery;
};
+2 -2
View File
@@ -12,5 +12,5 @@ export {
export * as middlewares from "modules/middlewares";
export { registries } from "modules/registries";
export type * from "./adapter";
export { Api, type ApiOptions } from "./Api";
export type { MediaFieldSchema } from "media/AppMedia";
export type { UserFieldSchema } from "auth/AppAuth";
+2 -1
View File
@@ -16,7 +16,8 @@ export function buildMediaSchema() {
config: adapter.schema
},
{
title: name,
title: adapter.schema.title ?? name,
description: adapter.schema.description,
additionalProperties: false
}
);
@@ -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<typeof cloudinaryAdapterConfig>;
@@ -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<typeof localAdapterConfig>;
@@ -25,7 +25,8 @@ export const s3AdapterConfig = Type.Object(
})
},
{
title: "S3"
title: "AWS S3",
description: "AWS S3 or compatible storage"
}
);
+2 -1
View File
@@ -1,6 +1,6 @@
import type { App } from "App";
import type { Guard } from "auth";
import { SchemaObject } from "core";
import { type DebugLogger, SchemaObject } from "core";
import type { EventManager } from "core/events";
import type { Static, TSchema } from "core/utils";
import {
@@ -35,6 +35,7 @@ export type ModuleBuildContext = {
em: EntityManager;
emgr: EventManager<any>;
guard: Guard;
logger: DebugLogger;
flags: (typeof Module)["ctx_flags"];
};
+4 -3
View File
@@ -23,9 +23,10 @@ export type ApiResponse<Data = any> = {
export type TInput = string | (string | number | PrimaryFieldType)[];
export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModuleApiOptions> {
protected fetcher?: typeof fetch;
constructor(protected readonly _options: Partial<Options> = {}) {}
constructor(
protected readonly _options: Partial<Options> = {},
protected fetcher?: typeof fetch
) {}
protected getDefaultOptions(): Partial<Options> {
return {};
+35 -14
View File
@@ -68,6 +68,12 @@ export type InitialModuleConfigs =
} & ModuleConfigs)
| PartialRec<ModuleConfigs>;
enum Verbosity {
silent = 0,
error = 1,
log = 2
}
export type ModuleManagerOptions = {
initial?: InitialModuleConfigs;
eventManager?: EventManager<any>;
@@ -85,6 +91,8 @@ export type ModuleManagerOptions = {
trustFetched?: boolean;
// runs when initial config provided on a fresh database
seed?: (ctx: ModuleBuildContext) => Promise<void>;
// wether
verbosity?: Verbosity;
};
type ConfigTable<Json = ModuleConfigs> = {
@@ -135,7 +143,7 @@ export class ModuleManager {
private _built = false;
private readonly _booted_with?: "provided" | "partial";
private logger = new DebugLogger(false);
private logger: DebugLogger;
constructor(
private readonly connection: Connection,
@@ -144,6 +152,7 @@ export class ModuleManager {
this.__em = new EntityManager([__bknd], this.connection);
this.modules = {} as Modules;
this.emgr = new EventManager();
this.logger = new DebugLogger(this.verbosity === Verbosity.log);
const context = this.ctx(true);
let initial = {} as Partial<ModuleConfigs>;
@@ -171,6 +180,14 @@ export class ModuleManager {
}
}
private get verbosity() {
return this.options?.verbosity ?? Verbosity.silent;
}
isBuilt(): boolean {
return this._built;
}
/**
* This is set through module's setListener
* It's called everytime a module's config is updated in SchemaObject
@@ -231,7 +248,8 @@ export class ModuleManager {
em: this.em,
emgr: this.emgr,
guard: this.guard,
flags: Module.ctx_flags
flags: Module.ctx_flags,
logger: this.logger
};
}
@@ -240,20 +258,23 @@ export class ModuleManager {
const startTime = performance.now();
// disabling console log, because the table might not exist yet
const result = await withDisabledConsole(async () => {
const { data: result } = await this.repo().findOne(
{ type: "config" },
{
sort: { by: "version", dir: "desc" }
const result = await withDisabledConsole(
async () => {
const { data: result } = await this.repo().findOne(
{ type: "config" },
{
sort: { by: "version", dir: "desc" }
}
);
if (!result) {
throw BkndError.with("no config");
}
);
if (!result) {
throw BkndError.with("no config");
}
return result as unknown as ConfigTable;
}, ["log", "error", "warn"]);
return result as unknown as ConfigTable;
},
this.verbosity > Verbosity.silent ? [] : ["log", "error", "warn"]
);
this.logger
.log("took", performance.now() - startTime, "ms", {
+6 -1
View File
@@ -70,7 +70,8 @@ export class AdminController extends Controller {
hono.use("*", async (c, next) => {
const obj = {
user: auth.authenticator?.getUser(),
logout_route: this.withBasePath(authRoutes.logout)
logout_route: this.withBasePath(authRoutes.logout),
color_scheme: configs.server.admin.color_scheme
};
const html = await this.getHtml(obj);
if (!html) {
@@ -190,6 +191,10 @@ export class AdminController extends Controller {
/>
<link rel="icon" href={favicon} type="image/x-icon" />
<title>BKND</title>
{/*<script
crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js"
/>*/}
{isProd ? (
<Fragment>
<script
+2 -2
View File
@@ -3,6 +3,7 @@ import { Notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "modules";
import React from "react";
import { BkndProvider, useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
import { Logo } from "ui/components/display/Logo";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { FlashMessage } from "ui/modules/server/FlashMessage";
@@ -40,8 +41,7 @@ export default function Admin({
}
function AdminInternal() {
const b = useBknd();
const theme = b.app.getAdminConfig().color_scheme;
const { theme } = useTheme();
return (
<MantineProvider {...createMantineTheme(theme ?? "light")}>
+23 -10
View File
@@ -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";
@@ -18,11 +15,18 @@ type BkndContext = {
actions: ReturnType<typeof getSchemaActions>;
app: AppReduced;
adminOverride?: ModuleConfigs["server"]["admin"];
fallback: boolean;
};
const BkndContext = createContext<BkndContext>(undefined!);
export type { TSchemaActions };
enum Fetching {
None = 0,
Schema = 1,
Secrets = 2
}
export function BkndProvider({
includeSecrets = false,
adminOverride,
@@ -34,10 +38,11 @@ export function BkndProvider({
>) {
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
const [schema, setSchema] =
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions" | "fallback">>();
const [fetched, setFetched] = useState(false);
const [error, setError] = useState<boolean>();
const errorShown = useRef<boolean>();
const fetching = useRef<Fetching>(Fetching.None);
const [local_version, set_local_version] = useState(0);
const api = useApi();
@@ -46,7 +51,12 @@ export function BkndProvider({
}
async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) {
const requesting = withSecrets ? Fetching.Secrets : Fetching.Schema;
if (fetching.current === requesting) return;
if (withSecrets && !force) return;
fetching.current = requesting;
const res = await api.system.readSchema({
config: true,
secrets: _includeSecrets
@@ -57,32 +67,35 @@ export function BkndProvider({
errorShown.current = true;
setError(true);
//return;
// if already has schema, don't overwrite
if (fetched && schema?.schema) return;
} else if (error) {
setError(false);
}
const schema = res.ok
const newSchema = res.ok
? res.body
: ({
version: 0,
schema: getDefaultSchema(),
config: getDefaultConfig(),
permissions: []
permissions: [],
fallback: true
} as any);
if (adminOverride) {
schema.config.server.admin = {
...schema.config.server.admin,
newSchema.config.server.admin = {
...newSchema.config.server.admin,
...adminOverride
};
}
startTransition(() => {
setSchema(schema);
setSchema(newSchema);
setWithSecrets(_includeSecrets);
setFetched(true);
set_local_version((v) => v + 1);
fetching.current = Fetching.None;
});
}
+2 -1
View File
@@ -30,7 +30,7 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps)
console.error("error .....", e);
}
console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
//console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
return (
@@ -60,6 +60,7 @@ export const useBaseUrl = () => {
type BkndWindowContext = {
user?: TApiUser;
logout_route: string;
color_scheme?: "light" | "dark";
};
export function useBkndWindowContext(): BkndWindowContext {
if (typeof window !== "undefined" && window.__BKND__) {
+1 -1
View File
@@ -9,4 +9,4 @@ export {
export * from "./api/use-api";
export * from "./api/use-entity";
export { useAuth } from "./schema/auth/use-auth";
export { Api } from "../../Api";
export { Api, type TApiUser, type AuthState, type ApiOptions } from "../../Api";
@@ -43,7 +43,11 @@ export function useBkndData() {
return {
config: async (partial: Partial<TAppDataEntity["config"]>): Promise<boolean> => {
console.log("patch config", entityName, partial);
return await bkndActions.patch("data", `entities.${entityName}.config`, partial);
return await bkndActions.overwrite(
"data",
`entities.${entityName}.config`,
partial
);
},
fields: entityFieldActions(bkndActions, entityName)
};
@@ -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<TAppMediaConfig>) => {
return await bkndActions.set("media", data, true);
}
}
};
const $media = {};
return {
$media,
config: config.media,
schema: schema.media,
actions
};
}
@@ -1,8 +1,9 @@
import { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
export function useBkndSystem() {
const { config, schema, actions: bkndActions } = useBknd();
const theme = config.server.admin.color_scheme ?? "light";
const { theme } = useTheme();
const actions = {
theme: {
+14 -4
View File
@@ -1,8 +1,18 @@
import { useBkndWindowContext } from "ui/client/ClientProvider";
import { useBknd } from "ui/client/bknd";
export function useTheme(): { theme: "light" | "dark" } {
const b = useBknd();
const theme = b.app.getAdminConfig().color_scheme as any;
export type Theme = "light" | "dark";
return { theme };
export function useTheme(fallback: Theme = "light"): { theme: Theme } {
const b = useBknd();
const winCtx = useBkndWindowContext();
if (b) {
if (b?.adminOverride?.color_scheme) {
return { theme: b.adminOverride.color_scheme };
} else if (!b.fallback) {
return { theme: b.config.server.admin.color_scheme ?? fallback };
}
}
return { theme: winCtx.color_scheme ?? fallback };
}
+8 -3
View File
@@ -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 && <IconLeft size={iconSize} {...iconProps} />}
{children && <span className={twMerge("leading-none", labelClassName)}>{children}</span>}
{children && Children.count(children) === 1 ? (
<span className={twMerge("leading-none", labelClassName)}>{children}</span>
) : (
children
)}
{IconRight && <IconRight size={iconSize} {...iconProps} />}
</>
)
@@ -1,4 +1,3 @@
import { IconCopy } from "@tabler/icons-react";
import { TbCopy } from "react-icons/tb";
import { JsonView } from "react-json-view-lite";
import { twMerge } from "tailwind-merge";
+5 -5
View File
@@ -1,13 +1,13 @@
import { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
export function Logo({
scale = 0.2,
fill,
theme = "light"
...props
}: { scale?: number; fill?: string; theme?: string }) {
const $bknd = useBknd();
const _theme = theme ?? $bknd?.app?.getAdminConfig().color_scheme ?? "light";
const svgFill = fill ? fill : _theme === "light" ? "black" : "white";
const t = useTheme();
const theme = props.theme ?? t.theme;
const svgFill = fill ? fill : theme === "light" ? "black" : "white";
const dim = {
width: Math.round(578 * scale),
@@ -3,9 +3,10 @@ import { forwardRef, useEffect, useState } from "react";
export const BooleanInputMantine = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
(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]);
+120 -29
View File
@@ -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<React.ComponentProps<"div"> & { error?: boolean }> = ({
export const Group = <E extends ElementType = "div">({
error,
as,
...props
}) => (
<div
{...props}
className={twMerge(
"flex flex-col gap-1.5",
}: React.ComponentProps<E> & { error?: boolean; as?: E }) => {
const Tag = as || "div";
error && "text-red-500",
props.className
)}
/>
);
return (
<Tag
{...props}
className={twMerge(
"flex flex-col gap-1.5",
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
as === "fieldset" && error && "border-red-500",
error && "text-red-500",
props.className
)}
/>
);
};
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<React.ComponentProps<"label">> = (props) => <label {...props} />;
export const Label = <E extends ElementType = "label">({
as,
...props
}: React.ComponentProps<E> & { as?: E }) => {
const Tag = as || "label";
return <Tag {...props} />;
};
export const Help: React.FC<React.ComponentProps<"div">> = ({ className, ...props }) => (
<div {...props} className={twMerge("text-sm text-primary/50", className)} />
);
export const ErrorMessage: React.FC<React.ComponentProps<"div">> = ({ className, ...props }) => (
<div {...props} className={twMerge("text-sm text-red-500", className)} />
);
export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
field,
@@ -145,20 +175,81 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
}
);
export const Select = forwardRef<HTMLSelectElement, React.ComponentProps<"select">>(
(props, ref) => (
<div className="flex w-full relative">
<select
{...props}
ref={ref}
className={twMerge(
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
"appearance-none h-11 w-full",
"border-r-8 border-r-transparent",
props.className
)}
export type SwitchValue = boolean | 1 | 0 | "true" | "false" | "on" | "off";
export const Switch = forwardRef<
HTMLButtonElement,
Pick<
ComponentPropsWithoutRef<"input">,
"name" | "required" | "disabled" | "checked" | "defaultChecked" | "id" | "type"
> & {
value?: SwitchValue;
onChange?: (e: { target: { value: boolean } }) => void;
onCheckedChange?: (checked: boolean) => void;
}
>(({ type, required, ...props }, ref) => {
return (
<RadixSwitch.Root
className="relative h-7 w-12 p-[2px] cursor-pointer rounded-full bg-muted border border-primary/10 outline-none data-[state=checked]:bg-primary/75 appearance-none transition-colors hover:bg-muted/80"
onCheckedChange={(bool) => {
props.onChange?.({ target: { value: bool } });
}}
{...(props as any)}
checked={
typeof props.checked !== "undefined"
? props.checked
: typeof props.value !== "undefined"
? Boolean(props.value)
: undefined
}
ref={ref}
>
<RadixSwitch.Thumb className="block h-full aspect-square translate-x-0 rounded-full bg-background transition-transform duration-100 will-change-transform border border-muted data-[state=checked]:translate-x-[17px]" />
</RadixSwitch.Root>
);
});
export const Select = forwardRef<
HTMLSelectElement,
React.ComponentProps<"select"> & {
options?: { value: string; label: string }[] | (string | number)[];
}
>(({ children, options, ...props }, ref) => (
<div className="flex w-full relative">
<select
{...props}
ref={ref}
className={twMerge(
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
"appearance-none h-11 w-full",
!props.multiple && "border-r-8 border-r-transparent",
props.className
)}
>
{options ? (
<>
{!props.required && <option value="" />}
{options
.map((o) => {
if (typeof o !== "object") {
return { value: o, label: String(o) };
}
return o;
})
.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</>
) : (
children
)}
</select>
{!props.multiple && (
<TbChevronDown
className="absolute right-3 top-0 bottom-0 h-full opacity-70 pointer-events-none"
size={18}
/>
<TbChevronDown className="absolute right-3 top-0 bottom-0 h-full opacity-70" size={18} />
</div>
)
);
)}
</div>
));
@@ -0,0 +1,148 @@
import { atom, useAtom } from "jotai";
import type { JsonError, JsonSchema } from "json-schema-library";
import { type ChangeEvent, type ReactNode, createContext, useContext, useMemo } from "react";
import { twMerge } from "tailwind-merge";
import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field";
import { FormContextOverride, useDerivedFieldContext, useFormError } from "./Form";
import { getLabel, getMultiSchemaMatched } from "./utils";
export type AnyOfFieldRootProps = {
path?: string;
schema?: JsonSchema;
children: ReactNode;
};
export type AnyOfFieldContext = {
path: string;
schema: JsonSchema;
schemas?: JsonSchema[];
selectedSchema?: JsonSchema;
selected: number | null;
select: (index: number | null) => void;
options: string[];
errors: JsonError[];
selectSchema: JsonSchema;
};
const AnyOfContext = createContext<AnyOfFieldContext>(undefined!);
export const useAnyOfContext = () => {
return useContext(AnyOfContext);
};
const selectedAtom = atom<number | null>(null);
const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => {
const {
setValue,
lib,
pointer,
value: { matchedIndex, schemas },
schema
} = useDerivedFieldContext(path, _schema, (ctx) => {
const [matchedIndex, schemas = []] = getMultiSchemaMatched(ctx.schema, ctx.value);
return { matchedIndex, schemas };
});
const errors = useFormError(path, { strict: true });
if (!schema) return `AnyOfField(${path}): no schema ${pointer}`;
const [_selected, setSelected] = useAtom(selectedAtom);
const selected = _selected !== null ? _selected : matchedIndex > -1 ? matchedIndex : null;
const select = useEvent((index: number | null) => {
setValue(path, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined);
setSelected(index);
});
const context = useMemo(() => {
const options = schemas.map((s, i) => s.title ?? `Option ${i + 1}`);
const selectSchema = {
type: "string",
enum: options
} satisfies JsonSchema;
const selectedSchema = selected !== null ? (schemas[selected] as JsonSchema) : undefined;
return {
options,
selectSchema,
selectedSchema,
schema,
schemas,
selected
};
}, [selected]);
return (
<AnyOfContext.Provider
key={selected}
value={{
...context,
select,
path,
errors
}}
>
{children}
</AnyOfContext.Provider>
);
};
const Select = () => {
const { selected, select, path, schema, options, selectSchema } = useAnyOfContext();
const handleSelect = useEvent((e: ChangeEvent<HTMLInputElement>) => {
const i = e.target.value ? Number(e.target.value) : null;
select(i);
});
const _options = useMemo(() => options.map((label, value) => ({ label, value })), []);
return (
<>
<Formy.Label>{getLabel(path, schema)}</Formy.Label>
<FieldComponent
schema={selectSchema as any}
/* @ts-ignore */
options={_options}
onChange={handleSelect}
value={selected ?? undefined}
className="h-8 py-1"
/>
</>
);
};
// @todo: add local validation for AnyOf fields
const Field = ({ name, label, schema, ...props }: Partial<FormFieldProps>) => {
const { selected, selectedSchema, path, errors } = useAnyOfContext();
if (selected === null) return null;
return (
<FormContextOverride prefix={path} schema={selectedSchema}>
<div className={twMerge(errors.length > 0 && "bg-red-500/10")}>
<FormField key={`${path}_${selected}`} name={""} label={false} {...props} />
</div>
</FormContextOverride>
);
};
export const AnyOf = {
Root,
Select,
Field,
useContext: useAnyOfContext
};
export const AnyOfField = (props: Omit<AnyOfFieldRootProps, "children">) => {
return (
<fieldset>
<AnyOf.Root {...props}>
<legend className="flex flex-row gap-2 items-center py-2">
<AnyOf.Select />
</legend>
<AnyOf.Field />
</AnyOf.Root>
</fieldset>
);
};
@@ -0,0 +1,138 @@
import { IconLibraryPlus, IconTrash } from "@tabler/icons-react";
import type { JsonSchema } from "json-schema-library";
import { memo, useMemo } from "react";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { useEvent } from "ui/hooks/use-event";
import { FieldComponent } from "./Field";
import { FieldWrapper } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form";
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
export const ArrayField = ({
path = "",
schema: _schema
}: { path?: string; schema?: JsonSchema }) => {
const { setValue, pointer, required, ...ctx } = useDerivedFieldContext(path, _schema);
const schema = _schema ?? ctx.schema;
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
// if unique items with enum
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
return (
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
<FieldComponent
required
name={path}
schema={schema.items}
multiple
className="h-auto"
onChange={(e: any) => {
// @ts-ignore
const selected = Array.from(e.target.selectedOptions).map((o) => o.value);
setValue(ctx.path, selected);
}}
/>
</FieldWrapper>
);
}
return (
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
<ArrayIterator name={path}>
{({ value }) =>
value?.map((v, index: number) => (
<ArrayItem key={index} path={path} index={index} schema={schema} />
))
}
</ArrayIterator>
<div className="flex flex-row">
<ArrayAdd path={path} schema={schema} />
</div>
</FieldWrapper>
);
};
const ArrayItem = memo(({ path, index, schema }: any) => {
const { value, ...ctx } = useDerivedFieldContext(path, schema, (ctx) => {
return ctx.value?.[index];
});
const itemPath = suffixPath(path, index);
let subschema = schema.items;
const itemsMultiSchema = getMultiSchema(schema.items);
if (itemsMultiSchema) {
const [, , _subschema] = getMultiSchemaMatched(schema.items, value);
subschema = _subschema;
}
const handleUpdate = useEvent((pointer: string, value: any) => {
ctx.setValue(pointer, value);
});
const handleDelete = useEvent((pointer: string) => {
ctx.deleteValue(pointer);
});
const DeleteButton = useMemo(
() => <IconButton Icon={IconTrash} onClick={() => handleDelete(itemPath)} size="sm" />,
[itemPath]
);
return (
<div key={itemPath} className="flex flex-row gap-2">
<FieldComponent
name={itemPath}
schema={subschema!}
value={value}
onChange={(e) => {
handleUpdate(itemPath, coerce(e.target.value, subschema!));
}}
className="w-full"
/>
{DeleteButton}
</div>
);
}, isEqual);
const ArrayIterator = memo(
({ name, children }: any) => {
return children(useFormValue(name));
},
(prev, next) => prev.value?.length === next.value?.length
);
const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
const {
setValue,
value: { currentIndex },
...ctx
} = useDerivedFieldContext(path, schema, (ctx) => {
return { currentIndex: ctx.value?.length ?? 0 };
});
const itemsMultiSchema = getMultiSchema(schema.items);
function handleAdd(template?: any) {
const newPath = suffixPath(path, currentIndex);
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items));
}
if (itemsMultiSchema) {
return (
<Dropdown
dropdownWrapperProps={{
className: "min-w-0"
}}
items={itemsMultiSchema.map((s, i) => ({
label: s!.title ?? `Option ${i + 1}`,
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!))
}))}
onClickItem={console.log}
>
<Button IconLeft={IconLibraryPlus}>Add</Button>
</Dropdown>
);
}
return <Button onClick={() => handleAdd()}>Add</Button>;
};
@@ -0,0 +1,141 @@
import type { JsonSchema } from "json-schema-library";
import type { ChangeEvent, ComponentPropsWithoutRef } from "react";
import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
import { ArrayField } from "./ArrayField";
import { FieldWrapper } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form";
import { ObjectField } from "./ObjectField";
import { coerce, isType, isTypeSchema } from "./utils";
export type FieldProps = {
name: string;
schema?: JsonSchema;
onChange?: (e: ChangeEvent<any>) => void;
label?: string | false;
hidden?: boolean;
};
export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }: FieldProps) => {
const { path, setValue, required, ...ctx } = useDerivedFieldContext(name, _schema);
const schema = _schema ?? ctx.schema;
if (!isTypeSchema(schema))
return (
<Pre>
[Field] {path} has no schema ({JSON.stringify(schema)})
</Pre>
);
if (isType(schema.type, "object")) {
return <ObjectField path={name} schema={schema} />;
}
if (isType(schema.type, "array")) {
return <ArrayField path={name} schema={schema} />;
}
const disabled = schema.readOnly ?? "const" in schema ?? false;
const handleChange = useEvent((e: ChangeEvent<HTMLInputElement>) => {
const value = coerce(e.target.value, schema as any, { required });
if (typeof value === "undefined" && !required && ctx.options?.keepEmpty !== true) {
ctx.deleteValue(path);
} else {
setValue(path, value);
}
});
return (
<FieldWrapper name={name} label={_label} required={required} schema={schema} hidden={hidden}>
<FieldComponent
schema={schema}
name={name}
required={required}
disabled={disabled}
onChange={onChange ?? handleChange}
/>
</FieldWrapper>
);
};
export const Pre = ({ children }) => (
<pre className="dark:bg-red-950 bg-red-100 rounded-md px-3 py-1.5 text-wrap whitespace-break-spaces break-all">
{children}
</pre>
);
export const FieldComponent = ({
schema,
..._props
}: { schema: JsonSchema } & ComponentPropsWithoutRef<"input">) => {
const { value } = useFormValue(_props.name!, { strict: true });
if (!isTypeSchema(schema)) return null;
const props = {
..._props,
// allow override
value: typeof _props.value !== "undefined" ? _props.value : value
};
if (schema.enum) {
return <Formy.Select id={props.name} options={schema.enum} {...(props as any)} />;
}
if (isType(schema.type, ["number", "integer"])) {
const additional = {
min: schema.minimum,
max: schema.maximum,
step: schema.multipleOf
};
return (
<Formy.Input
type="number"
id={props.name}
{...props}
value={props.value ?? ""}
{...additional}
/>
);
}
if (isType(schema.type, "boolean")) {
return <Formy.Switch id={props.name} {...(props as any)} checked={value === true} />;
}
if (isType(schema.type, "string") && schema.format === "date-time") {
const value = props.value ? new Date(props.value as string).toISOString().slice(0, 16) : "";
return (
<Formy.DateInput
id={props.name}
{...props}
value={value}
type="datetime-local"
onChange={(e) => {
const date = new Date(e.target.value);
props.onChange?.({
// @ts-ignore
target: { value: date.toISOString() }
});
}}
/>
);
}
if (isType(schema.type, "string") && schema.format === "date") {
return <Formy.DateInput id={props.name} {...props} value={props.value ?? ""} />;
}
const additional = {
maxLength: schema.maxLength,
minLength: schema.minLength,
pattern: schema.pattern
} as any;
if (schema.format) {
if (["password", "hidden", "url", "email", "tel"].includes(schema.format)) {
additional.type = schema.format;
}
}
return <Formy.Input id={props.name} {...props} value={props.value ?? ""} {...additional} />;
};
@@ -0,0 +1,124 @@
import { IconBug } from "@tabler/icons-react";
import type { JsonSchema } from "json-schema-library";
import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react";
import { IconButton } from "ui/components/buttons/IconButton";
import { JsonViewer } from "ui/components/code/JsonViewer";
import * as Formy from "ui/components/form/Formy";
import {
useFormContext,
useFormError,
useFormValue
} from "ui/components/form/json-schema-form/Form";
import { Popover } from "ui/components/overlay/Popover";
import { getLabel } from "./utils";
export type FieldwrapperProps = {
name: string;
label?: string | false;
required?: boolean;
schema?: JsonSchema;
debug?: object | boolean;
wrapper?: "group" | "fieldset";
hidden?: boolean;
children: ReactElement | ReactNode;
errorPlacement?: "top" | "bottom";
};
export function FieldWrapper({
name,
label: _label,
required,
schema,
wrapper,
hidden,
errorPlacement = "bottom",
children
}: FieldwrapperProps) {
const errors = useFormError(name, { strict: true });
const examples = schema?.examples || [];
const examplesId = `${name}-examples`;
const description = schema?.description;
const label = typeof _label !== "undefined" ? _label : schema ? getLabel(name, schema) : name;
const Errors = errors.length > 0 && (
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
);
return (
<Formy.Group
error={errors.length > 0}
as={wrapper === "fieldset" ? "fieldset" : "div"}
className={hidden ? "hidden" : "relative"}
>
{errorPlacement === "top" && Errors}
<FieldDebug name={name} schema={schema} required={required} />
{label && (
<Formy.Label
as={wrapper === "fieldset" ? "legend" : "label"}
htmlFor={name}
className="self-start"
>
{label} {required && <span className="font-medium opacity-30">*</span>}
</Formy.Label>
)}
<div className="flex flex-row gap-2">
<div className="flex flex-1 flex-col gap-3">
{Children.count(children) === 1 && isValidElement(children)
? cloneElement(children, {
// @ts-ignore
list: examples.length > 0 ? examplesId : undefined
})
: children}
{examples.length > 0 && (
<datalist id={examplesId}>
{examples.map((e, i) => (
<option key={i} value={e as any} />
))}
</datalist>
)}
</div>
</div>
{description && <Formy.Help>{description}</Formy.Help>}
{errorPlacement === "bottom" && Errors}
</Formy.Group>
);
}
const FieldDebug = ({
name,
schema,
required
}: Pick<FieldwrapperProps, "name" | "schema" | "required">) => {
const { options } = useFormContext();
if (!options?.debug) return null;
const { value } = useFormValue(name);
const errors = useFormError(name, { strict: true });
return (
<div className="absolute top-0 right-0">
<Popover
overlayProps={{
className: "max-w-none"
}}
position="bottom-end"
target={({ toggle }) => (
<JsonViewer
className="bg-background pr-3 text-sm"
json={{
name,
value,
required,
schema,
errors
}}
expand={6}
/>
)}
>
<IconButton Icon={IconBug} size="xs" className="opacity-30" />
</Popover>
</div>
);
};
@@ -0,0 +1,378 @@
import {
type PrimitiveAtom,
atom,
getDefaultStore,
useAtom,
useAtomValue,
useSetAtom
} from "jotai";
import { selectAtom } from "jotai/utils";
import { Draft2019, type JsonError, type JsonSchema as LibJsonSchema } from "json-schema-library";
import type { TemplateOptions as LibTemplateOptions } from "json-schema-library/dist/lib/getTemplate";
import type { JSONSchema as $JSONSchema, FromSchema } from "json-schema-to-ts";
import * as immutable from "object-path-immutable";
import {
type ComponentPropsWithoutRef,
type FormEvent,
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef
} from "react";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { useEvent } from "ui/hooks/use-event";
import { Field } from "./Field";
import {
getPath,
isEqual,
isRequired,
omitSchema,
pathToPointer,
prefixPath,
prefixPointer
} from "./utils";
type JSONSchema = Exclude<$JSONSchema, boolean>;
type FormState<Data = any> = {
dirty: boolean;
submitting: boolean;
errors: JsonError[];
data: Data;
};
type FormOptions = {
debug?: boolean;
keepEmpty?: boolean;
};
export type FormContext<Data> = {
setData: (data: Data) => void;
setValue: (pointer: string, value: any) => void;
deleteValue: (pointer: string) => void;
errors: JsonError[];
dirty: boolean;
submitting: boolean;
schema: LibJsonSchema;
lib: Draft2019;
options: FormOptions;
root: string;
_formStateAtom: PrimitiveAtom<FormState<Data>>;
};
const FormContext = createContext<FormContext<any>>(undefined!);
FormContext.displayName = "FormContext";
export function Form<
const Schema extends JSONSchema,
const Data = Schema extends JSONSchema ? FromSchema<Schema> : any
>({
schema: _schema,
initialValues: _initialValues,
initialOpts,
children,
onChange,
onSubmit,
onInvalidSubmit,
validateOn = "submit",
hiddenSubmit = true,
ignoreKeys = [],
options = {},
...props
}: Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit"> & {
schema: Schema;
validateOn?: "change" | "submit";
initialOpts?: LibTemplateOptions;
ignoreKeys?: string[];
onChange?: (data: Partial<Data>, name: string, value: any) => void;
onSubmit?: (data: Data) => void | Promise<void>;
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
hiddenSubmit?: boolean;
options?: FormOptions;
initialValues?: Schema extends JSONSchema ? FromSchema<Schema> : never;
}) {
const [schema, initial] = omitSchema(_schema, ignoreKeys, _initialValues);
const lib = useMemo(() => new Draft2019(schema), [JSON.stringify(schema)]);
const initialValues = initial ?? lib.getTemplate(undefined, schema, initialOpts);
const _formStateAtom = useMemo(() => {
return atom<FormState<Data>>({
dirty: false,
submitting: false,
errors: [] as JsonError[],
data: initialValues
});
}, [initialValues]);
const setFormState = useSetAtom(_formStateAtom);
const formRef = useRef<HTMLFormElement | null>(null);
useEffect(() => {
if (initialValues) {
validate();
}
}, [initialValues]);
// @ts-ignore
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
const { data, errors } = validate();
if (onSubmit) {
e.preventDefault();
setFormState((prev) => ({ ...prev, submitting: true }));
try {
if (errors.length === 0) {
await onSubmit(data as Data);
} else {
console.log("invalid", errors);
onInvalidSubmit?.(errors, data);
}
} catch (e) {
console.warn(e);
}
setFormState((prev) => ({ ...prev, submitting: false }));
return false;
} else if (errors.length > 0) {
e.preventDefault();
onInvalidSubmit?.(errors, data);
return false;
}
}
const setValue = useEvent((path: string, value: any) => {
setFormState((state) => {
const prev = state.data;
const changed = immutable.set(prev, path, value);
onChange?.(changed, path, value);
return { ...state, data: changed };
});
check();
});
const deleteValue = useEvent((path: string) => {
setFormState((state) => {
const prev = state.data;
const changed = immutable.del(prev, path);
onChange?.(changed, path, undefined);
return { ...state, data: changed };
});
check();
});
const getCurrentState = useEvent(() => getDefaultStore().get(_formStateAtom));
const check = useEvent(() => {
const state = getCurrentState();
setFormState((prev) => ({ ...prev, dirty: !isEqual(initialValues, state.data) }));
if (validateOn === "change") {
validate();
} else if (state?.errors?.length > 0) {
validate();
}
});
const validate = useEvent((_data?: Partial<Data>) => {
const actual = _data ?? getCurrentState()?.data;
const errors = lib.validate(actual, schema);
setFormState((prev) => ({ ...prev, errors }));
return { data: actual, errors };
});
const context = useMemo(
() => ({
_formStateAtom,
setValue,
deleteValue,
schema,
lib,
options,
root: "",
path: ""
}),
[schema, initialValues]
) as any;
return (
<form {...props} ref={formRef} onSubmit={handleSubmit}>
<FormContext.Provider value={context}>
{children ? children : <Field name="" />}
{options?.debug && <FormDebug />}
</FormContext.Provider>
{hiddenSubmit && (
<button style={{ visibility: "hidden" }} type="submit">
Submit
</button>
)}
</form>
);
}
export function useFormContext() {
return useContext(FormContext);
}
export function FormContextOverride({
children,
overrideData,
prefix,
...overrides
}: Partial<FormContext<any>> & { children: ReactNode; prefix?: string; overrideData?: boolean }) {
const ctx = useFormContext();
const additional: Partial<FormContext<any>> = {};
// this makes a local schema down the three
// especially useful for AnyOf, since it doesn't need to fully validate (e.g. pattern)
if (prefix) {
additional.root = prefix;
additional.setValue = (pointer: string, value: any) => {
ctx.setValue(prefixPointer(pointer, prefix), value);
};
additional.deleteValue = (pointer: string) => {
ctx.deleteValue(prefixPointer(pointer, prefix));
};
}
const context = {
...ctx,
...overrides,
...additional
};
return <FormContext.Provider value={context}>{children}</FormContext.Provider>;
}
export function useFormValue(name: string, opts?: { strict?: boolean }) {
const { _formStateAtom, root } = useFormContext();
if ((typeof name !== "string" || name.length === 0) && opts?.strict === true)
return { value: undefined, errors: [] };
const selected = selectAtom(
_formStateAtom,
useCallback(
(state) => {
const prefixedName = prefixPath(name, root);
const pointer = pathToPointer(prefixedName);
return {
value: getPath(state.data, prefixedName),
errors: state.errors.filter((error) => error.data.pointer.startsWith(pointer))
};
},
[name]
),
isEqual
);
return useAtom(selected)[0];
}
export function useFormError(name: string, opt?: { strict?: boolean; debug?: boolean }) {
const { _formStateAtom, root } = useFormContext();
const selected = selectAtom(
_formStateAtom,
useCallback(
(state) => {
const prefixedName = prefixPath(name, root);
const pointer = pathToPointer(prefixedName);
return state.errors.filter((error) => {
return opt?.strict
? error.data.pointer === pointer
: error.data.pointer.startsWith(pointer);
});
},
[name]
),
isEqual
);
return useAtom(selected)[0];
}
export function useFormStateSelector<Data = any, Reduced = Data>(
selector: (state: FormState<Data>) => Reduced
): Reduced {
const { _formStateAtom } = useFormContext();
const selected = selectAtom(_formStateAtom, useCallback(selector, []), isEqual);
return useAtom(selected)[0];
}
type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
path,
_schema?: LibJsonSchema,
deriveFn?: SelectorFn<
FormContext<Data> & {
pointer: string;
required: boolean;
value: any;
path: string;
},
Reduced
>
): FormContext<Data> & {
value: Reduced;
pointer: string;
required: boolean;
path: string;
} {
const { _formStateAtom, root, lib, ...ctx } = useFormContext();
const schema = _schema ?? ctx.schema;
const selected = selectAtom(
_formStateAtom,
useCallback(
(state) => {
const pointer = pathToPointer(path);
const prefixedName = prefixPath(path, root);
const prefixedPointer = pathToPointer(prefixedName);
const value = getPath(state.data, prefixedName);
/*const errors = state.errors.filter((error) =>
error.data.pointer.startsWith(prefixedPointer)
);*/
const fieldSchema =
pointer === "#/"
? (schema as LibJsonSchema)
: lib.getSchema({ pointer, data: value, schema });
const required = isRequired(lib, prefixedPointer, schema, state.data);
const context = {
...ctx,
path: prefixedName,
root,
schema: fieldSchema as LibJsonSchema,
pointer,
required
};
const derived = deriveFn?.({ ...context, _formStateAtom, lib, value });
return {
...context,
value: derived
};
},
[path, schema ?? {}, root]
),
isEqual
);
return {
...useAtomValue(selected),
_formStateAtom,
lib
} as any;
}
export function Subscribe<Data = any, Refined = Data>({
children,
selector
}: {
children: (state: Refined) => ReactNode;
selector?: SelectorFn<FormState<Data>, Refined>;
}) {
return children(useFormStateSelector(selector ?? ((state) => state as unknown as Refined)));
}
export function FormDebug({ force = false }: { force?: boolean }) {
const { options } = useFormContext();
if (options?.debug !== true && force !== true) return null;
const ctx = useFormStateSelector((s) => s);
return <JsonViewer json={ctx} expand={99} />;
}
@@ -0,0 +1,47 @@
import type { JSONSchema } from "json-schema-to-ts";
import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
import { AnyOfField } from "./AnyOfField";
import { Field } from "./Field";
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { useDerivedFieldContext } from "./Form";
export type ObjectFieldProps = {
path?: string;
schema?: Exclude<JSONSchema, boolean>;
label?: string | false;
wrapperProps?: Partial<FieldwrapperProps>;
};
export const ObjectField = ({
path = "",
schema: _schema,
label: _label,
wrapperProps = {}
}: ObjectFieldProps) => {
const ctx = useDerivedFieldContext(path, _schema);
const schema = _schema ?? ctx.schema;
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
const properties = schema.properties ?? {};
return (
<FieldWrapper
name={path}
schema={{ ...schema, description: undefined }}
wrapper="fieldset"
errorPlacement="top"
{...wrapperProps}
>
{Object.keys(properties).map((prop) => {
const schema = properties[prop];
const name = [path, prop].filter(Boolean).join(".");
if (typeof schema === "undefined" || typeof schema === "boolean") return;
if (schema.anyOf || schema.oneOf) {
return <AnyOfField key={name} path={name} />;
}
return <Field key={name} name={name} />;
})}
</FieldWrapper>
);
};
@@ -0,0 +1,6 @@
export * from "./Field";
export * from "./Form";
export * from "./ObjectField";
export * from "./ArrayField";
export * from "./AnyOfField";
export * from "./FieldWrapper";
@@ -0,0 +1,140 @@
import { autoFormatString, omitKeys } from "core/utils";
import { type Draft, Draft2019, type JsonSchema } from "json-schema-library";
import type { JSONSchema } from "json-schema-to-ts";
import type { JSONSchemaType } from "json-schema-to-ts/lib/types/definitions/jsonSchema";
export { isEqual, getPath } from "core/utils/objects";
//export { isEqual } from "lodash-es";
export function coerce(value: any, schema: JsonSchema, opts?: { required?: boolean }) {
if (!value && typeof opts?.required === "boolean" && !opts.required) {
return undefined;
}
switch (schema.type) {
case "string":
return String(value);
case "integer":
case "number":
return Number(value);
case "boolean":
return ["true", "1", 1, "on", true].includes(value);
case "null":
return null;
}
return value;
}
const PathFilter = (value: any) => typeof value !== "undefined" && value !== null && value !== "";
export function pathToPointer(path: string) {
const p = path.includes(".") ? path.split(".") : [path];
return (
"#" +
p
.filter(PathFilter)
.map((part) => "/" + part)
.join("")
);
}
export function prefixPointer(pointer: string, prefix: string) {
const p = pointer.replace("#", "").split("/");
return "#" + p.map((part, i) => (i === 1 ? prefix : part)).join("/");
}
export function prefixPath(path: string = "", prefix: string | number = "") {
const p = path.includes(".") ? path.split(".") : [path];
return [prefix, ...p].filter(PathFilter).join(".");
}
export function suffixPath(path: string = "", suffix: string | number = "") {
const p = path.includes(".") ? path.split(".") : [path];
return [...p, suffix].filter(PathFilter).join(".");
}
export function getParentPointer(pointer: string) {
return pointer.substring(0, pointer.lastIndexOf("/"));
}
export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data?: any) {
if (pointer === "#/" || !schema) {
return false;
}
const childSchema = lib.getSchema({ pointer, data, schema });
if (typeof childSchema === "object" && "const" in childSchema) {
return true;
}
const parentPointer = getParentPointer(pointer);
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
return !!required;
}
export type IsTypeType =
| JSONSchemaType
| JSONSchemaType[]
| readonly JSONSchemaType[]
| string
| undefined;
export function isType(type: IsTypeType, compare: IsTypeType) {
if (!type || !compare) return false;
const _type = Array.isArray(type) ? type : [type];
const _compare = Array.isArray(compare) ? compare : [compare];
return _compare.some((t) => _type.includes(t));
}
export function getLabel(name: string, schema: JsonSchema) {
if (typeof schema === "object" && "title" in schema) return schema.title;
if (!name) return "";
const label = name.includes(".") ? (name.split(".").pop() ?? "") : name;
return autoFormatString(label);
}
export function getMultiSchema(schema: JsonSchema): JsonSchema[] | undefined {
if (!schema || typeof schema !== "object") return;
return (schema.anyOf ?? schema.oneOf) as any;
}
export function getMultiSchemaMatched(
schema: JsonSchema,
data: any
): [number, JsonSchema[], JsonSchema | undefined] {
const multiSchema = getMultiSchema(schema);
//console.log("getMultiSchemaMatched", schema, data, multiSchema);
if (!multiSchema) return [-1, [], undefined];
const index = multiSchema.findIndex((subschema) => {
const lib = new Draft2019(subschema as any);
return lib.validate(data, subschema).length === 0;
});
if (index === -1) return [-1, multiSchema, undefined];
return [index, multiSchema, multiSchema[index]];
}
export function omitSchema<Given extends JSONSchema>(_schema: Given, keys: string[], _data?: any) {
if (typeof _schema !== "object" || !("properties" in _schema) || keys.length === 0)
return [_schema, _data];
const schema = JSON.parse(JSON.stringify(_schema));
const data = _data ? JSON.parse(JSON.stringify(_data)) : undefined;
const updated = {
...schema,
properties: omitKeys(schema.properties, keys)
};
if (updated.required) {
updated.required = updated.required.filter((key) => !keys.includes(key as any));
}
const reducedConfig = omitKeys(data, keys) as any;
return [updated, reducedConfig];
}
export function isTypeSchema(schema?: JsonSchema): schema is JsonSchema {
return typeof schema === "object" && "type" in schema && !isType(schema.type, "error");
}
@@ -16,6 +16,7 @@ export type JsonSchemaFormProps = any & {
uiSchema?: any;
direction?: "horizontal" | "vertical";
onChange?: (value: any, isValid: () => boolean) => void;
cleanOnChange?: boolean;
};
export type JsonSchemaFormRef = {
@@ -36,6 +37,7 @@ export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>
templates,
fields,
widgets,
cleanOnChange,
...props
},
ref
@@ -51,8 +53,8 @@ export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>
return false;
};
const handleChange = ({ formData }: any, e) => {
const clean = JSON.parse(JSON.stringify(formData));
//console.log("Data changed: ", clean, JSON.stringify(formData, null, 2));
const clean = cleanOnChange !== false ? JSON.parse(JSON.stringify(formData)) : formData;
console.log("Data changed: ", clean, { cleanOnChange });
setValue(clean);
onChange?.(clean, () => isValid(clean));
};
@@ -0,0 +1,215 @@
import {
type ChangeEvent,
type ComponentPropsWithoutRef,
type FormEvent,
useEffect,
useRef,
useState
} from "react";
import { useEvent } from "ui/hooks/use-event";
import {
type CleanOptions,
type InputElement,
cleanObject,
coerce,
getFormTarget,
getTargetsByName,
setPath
} from "./utils";
export type NativeFormProps = {
hiddenSubmit?: boolean;
validateOn?: "change" | "submit";
errorFieldSelector?: <K extends keyof HTMLElementTagNameMap>(name: string) => any | null;
reportValidity?: boolean;
onSubmit?: (data: any, ctx: { event: FormEvent<HTMLFormElement> }) => Promise<void> | void;
onSubmitInvalid?: (
errors: InputError[],
ctx: { event: FormEvent<HTMLFormElement> }
) => Promise<void> | void;
onError?: (errors: InputError[]) => void;
onChange?: (
data: any,
ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] }
) => Promise<void> | void;
clean?: CleanOptions | true;
} & Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit">;
export type InputError = {
name: string;
message: string;
};
export function NativeForm({
children,
validateOn = "submit",
hiddenSubmit = false,
errorFieldSelector,
reportValidity,
onSubmit,
onSubmitInvalid,
onError,
clean,
...props
}: NativeFormProps) {
const formRef = useRef<HTMLFormElement>(null);
const [errors, setErrors] = useState<InputError[]>([]);
useEffect(() => {
if (!formRef.current || props.noValidate) return;
validate();
}, []);
useEffect(() => {
if (!formRef.current || props.noValidate) return;
// find submit buttons and disable them if there are errors
const invalid = errors.length > 0;
formRef.current.querySelectorAll("[type=submit]").forEach((submit) => {
if (!submit || !("type" in submit) || submit.type !== "submit") return;
// @ts-ignore
submit.disabled = invalid;
});
onError?.(errors);
}, [errors]);
const validateElement = useEvent((el: InputElement | null, opts?: { report?: boolean }) => {
if (props.noValidate || !el || !("name" in el)) return;
const errorElement = formRef.current?.querySelector(
errorFieldSelector?.(el.name) ?? `[data-role="input-error"][data-name="${el.name}"]`
);
if (!el.checkValidity()) {
const error = {
name: el.name,
message: el.validationMessage
};
setErrors((prev) => [...prev.filter((e) => e.name !== el.name), error]);
if (opts?.report) {
if (errorElement) {
errorElement.textContent = error.message;
} else if (reportValidity) {
el.reportValidity();
}
}
return error;
} else {
setErrors((prev) => prev.filter((e) => e.name !== el.name));
if (errorElement) {
errorElement.textContent = "";
}
}
return;
});
const validate = useEvent((opts?: { report?: boolean }) => {
if (!formRef.current || props.noValidate) return [];
const errors: InputError[] = [];
formRef.current.querySelectorAll("input, select, textarea").forEach((e) => {
const el = e as InputElement | null;
const error = validateElement(el, opts);
if (error) {
errors.push(error);
}
});
return errors;
});
const getFormValues = useEvent(() => {
if (!formRef.current) return {};
const formData = new FormData(formRef.current);
const obj: any = {};
formData.forEach((value, key) => {
const targets = getTargetsByName(formRef.current!, key);
if (targets.length === 0) {
console.warn(`No target found for key: ${key}`);
return;
}
const count = targets.length;
const multiple = count > 1;
targets.forEach((target, index) => {
let _key = key;
if (multiple) {
_key = `${key}[${index}]`;
}
setPath(obj, _key, coerce(target, target.value));
});
});
if (typeof clean === "undefined") return obj;
return cleanObject(obj, clean === true ? undefined : clean);
});
const handleChange = useEvent(async (e: ChangeEvent<HTMLFormElement>) => {
const form = formRef.current;
if (!form) return;
const target = getFormTarget(e);
if (!target) return;
if (validateOn === "change") {
validateElement(target, { report: true });
}
if (props.onChange) {
await props.onChange(getFormValues(), {
event: e,
key: target.name,
value: target.value,
errors
});
}
});
const handleSubmit = useEvent(async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = formRef.current;
if (!form) return;
const errors = validate({ report: true });
if (errors.length > 0) {
onSubmitInvalid?.(errors, { event: e });
return;
}
if (onSubmit) {
await onSubmit(getFormValues(), { event: e });
} else {
form.submit();
}
});
const handleKeyDown = useEvent((e: KeyboardEvent) => {
if (!formRef.current) return;
// if is enter key, submit is disabled, report errors
if (e.keyCode === 13) {
const invalid = errors.length > 0;
if (invalid && !props.noValidate && reportValidity) {
formRef.current.reportValidity();
}
}
});
return (
<form
{...props}
onChange={handleChange}
onSubmit={handleSubmit}
ref={formRef}
onKeyDown={handleKeyDown as any}
>
{children}
{hiddenSubmit && <input type="submit" style={{ visibility: "hidden" }} />}
</form>
);
}
@@ -0,0 +1,137 @@
import type { FormEvent } from "react";
export type InputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
export function ignoreTarget(target: InputElement | Element | null, form?: HTMLFormElement) {
const tagName = target?.tagName.toLowerCase() ?? "";
const tagNames = ["input", "select", "textarea"];
return (
!target ||
!form?.contains(target) ||
!tagNames.includes(tagName) ||
!("name" in target) ||
target.hasAttribute("data-ignore") ||
target.closest("[data-ignore]")
);
}
export function getFormTarget(e: FormEvent<HTMLFormElement>): InputElement | null {
const form = e.currentTarget;
const target = e.target as InputElement | null;
return ignoreTarget(target, form) ? null : target;
}
export function getTargetsByName(form: HTMLFormElement, name: string): InputElement[] {
const query = form.querySelectorAll(`[name="${name}"]`);
return Array.from(query).filter((e) => ignoreTarget(e)) as InputElement[];
}
export function coerce(target: InputElement | null, value?: any) {
if (!target) return value;
const required = target.required;
if (!value && !required) return undefined;
if (target.type === "number") {
const num = Number(value);
if (Number.isNaN(num) && !required) return undefined;
const min = "min" in target && target.min.length > 0 ? Number(target.min) : undefined;
const max = "max" in target && target.max.length > 0 ? Number(target.max) : undefined;
const step = "step" in target && target.step.length > 0 ? Number(target.step) : undefined;
if (min && num < min) return min;
if (max && num > max) return max;
if (step && step !== 1) return Math.round(num / step) * step;
return num;
} else if (target.type === "text") {
const maxLength =
"maxLength" in target && target.maxLength > -1 ? Number(target.maxLength) : undefined;
const pattern = "pattern" in target ? new RegExp(target.pattern) : undefined;
if (maxLength && value.length > maxLength) return value.slice(0, maxLength);
if (pattern && !pattern.test(value)) return "";
return value;
} else if (target.type === "checkbox") {
if ("checked" in target) return !!target.checked;
return ["on", "1", "true", 1, true].includes(value);
} else {
return value;
}
}
export type CleanOptions = {
empty?: any[];
emptyInArray?: any[];
keepEmptyArray?: boolean;
};
export function cleanObject<Obj extends { [key: string]: any }>(
obj: Obj,
_opts?: CleanOptions
): Obj {
if (!obj) return obj;
const _empty = [null, undefined, ""];
const opts = {
empty: _opts?.empty ?? _empty,
emptyInArray: _opts?.emptyInArray ?? _empty,
keepEmptyArray: _opts?.keepEmptyArray ?? false
};
return Object.entries(obj).reduce((acc, [key, value]) => {
if (value && Array.isArray(value) && value.some((v) => typeof v === "object")) {
const nested = value.map((o) => cleanObject(o, opts));
if (nested.length > 0 || opts?.keepEmptyArray) {
acc[key] = nested;
}
} else if (value && typeof value === "object" && !Array.isArray(value)) {
const nested = cleanObject(value, opts);
if (Object.keys(nested).length > 0) {
acc[key] = nested;
}
} else if (Array.isArray(value)) {
const nested = value.filter((v) => !opts.emptyInArray.includes(v));
if (nested.length > 0 || opts?.keepEmptyArray) {
acc[key] = nested;
}
} else if (!opts.empty.includes(value)) {
acc[key] = value;
}
return acc;
}, {} as any);
}
export function setPath(object, _path, value) {
let path = _path;
// Optional string-path support.
// You can remove this `if` block if you don't need it.
if (typeof path === "string") {
const isQuoted = (str) => str[0] === '"' && str.at(-1) === '"';
path = path
.split(/[.\[\]]+/)
.filter((x) => x)
.map((x) => (!Number.isNaN(Number(x)) ? Number(x) : x))
.map((x) => (typeof x === "string" && isQuoted(x) ? x.slice(1, -1) : x));
}
if (path.length === 0) {
throw new Error("The path must have at least one entry in it");
}
const [head, ...tail] = path;
if (tail.length === 0) {
object[head] = value;
return object;
}
if (!(head in object)) {
object[head] = typeof tail[0] === "number" ? [] : {};
}
setPath(object[head], tail, value);
return object;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { Modal, type ModalProps, Popover } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { IconBug } from "@tabler/icons-react";
import { ScrollArea } from "radix-ui";
import { Fragment, forwardRef, useImperativeHandle } from "react";
import { TbX } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
+10 -6
View File
@@ -1,7 +1,7 @@
import { useClickOutside } from "@mantine/hooks";
import { type ReactElement, cloneElement, useState } from "react";
import { type ComponentPropsWithoutRef, type ReactElement, cloneElement, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useEvent } from "../../hooks/use-event";
import { useEvent } from "ui/hooks/use-event";
export type PopoverProps = {
className?: string;
@@ -10,6 +10,7 @@ export type PopoverProps = {
backdrop?: boolean;
target: (props: { toggle: () => void }) => ReactElement;
children: ReactElement<{ onClick: () => void }>;
overlayProps?: ComponentPropsWithoutRef<"div">;
};
export function Popover({
@@ -18,20 +19,21 @@ export function Popover({
defaultOpen = false,
backdrop = false,
position = "bottom-start",
className,
overlayProps,
className
}: PopoverProps) {
const [open, setOpen] = useState(defaultOpen);
const clickoutsideRef = useClickOutside(() => setOpen(false));
const toggle = useEvent((delay: number = 50) =>
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0),
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0)
);
const pos = {
"bottom-start": "mt-1 top-[100%]",
"bottom-end": "right-0 top-[100%] mt-1",
"top-start": "bottom-[100%] mb-1",
"top-end": "bottom-[100%] right-0 mb-1",
"top-end": "bottom-[100%] right-0 mb-1"
}[position];
return (
@@ -43,9 +45,11 @@ export function Popover({
{cloneElement(children as any, { onClick: toggle })}
{open && (
<div
{...overlayProps}
className={twMerge(
"animate-fade-in absolute z-20 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg min-w-full max-w-20 backdrop-blur-sm",
"animate-fade-in absolute z-20 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg backdrop-blur-sm min-w-0 max-w-20",
pos,
overlayProps?.className
)}
>
{target({ toggle })}
@@ -1,22 +0,0 @@
import * as ReactScrollArea from "@radix-ui/react-scroll-area";
export const ScrollArea = ({ children, className }: any) => (
<ReactScrollArea.Root className={`${className} `}>
<ReactScrollArea.Viewport className="w-full h-full ">
{children}
</ReactScrollArea.Viewport>
<ReactScrollArea.Scrollbar
className="ScrollAreaScrollbar"
orientation="vertical"
>
<ReactScrollArea.Thumb className="ScrollAreaThumb" />
</ReactScrollArea.Scrollbar>
<ReactScrollArea.Scrollbar
className="ScrollAreaScrollbar"
orientation="horizontal"
>
<ReactScrollArea.Thumb className="ScrollAreaThumb" />
</ReactScrollArea.Scrollbar>
<ReactScrollArea.Corner className="ScrollAreaCorner" />
</ReactScrollArea.Root>
);
-86
View File
@@ -1,86 +0,0 @@
import {
type ComponentProps,
type ComponentPropsWithRef,
type ComponentPropsWithoutRef,
type ElementRef,
type ElementType,
type ForwardedRef,
type PropsWithChildren,
type ReactElement,
forwardRef
} from "react";
export function extend<ComponentType extends ElementType, AdditionalProps = {}>(
Component: ComponentType,
applyAdditionalProps?: (
props: PropsWithChildren<ComponentPropsWithoutRef<ComponentType> & AdditionalProps> & {
className?: string;
}
) => ComponentProps<ComponentType>
) {
return forwardRef<
ElementRef<ComponentType>,
ComponentPropsWithoutRef<ComponentType> & AdditionalProps
>((props, ref) => {
// Initialize newProps with a default empty object or the result of applyAdditionalProps
let newProps: ComponentProps<ComponentType> & AdditionalProps = applyAdditionalProps
? applyAdditionalProps(props as any)
: (props as any);
// Append className if it exists in both props and newProps
if (props.className && newProps.className) {
newProps = {
...newProps,
className: `${props.className} ${newProps.className}`
};
}
// @ts-expect-error haven't figured out the correct typing
return <Component {...newProps} ref={ref} />;
});
}
type RenderFunction<ComponentType extends React.ElementType, AdditionalProps = {}> = (
props: PropsWithChildren<ComponentPropsWithRef<ComponentType> & AdditionalProps> & {
className?: string;
},
ref: ForwardedRef<ElementRef<ComponentType>>
) => ReactElement;
export function extendComponent<ComponentType extends React.ElementType, AdditionalProps = {}>(
renderFunction: RenderFunction<ComponentType, AdditionalProps>
) {
// The extended component using forwardRef to forward the ref to the custom component
const ExtendedComponent = forwardRef<
ElementRef<ComponentType>,
ComponentPropsWithRef<ComponentType> & AdditionalProps
>((props, ref) => {
return renderFunction(props as any, ref);
});
return ExtendedComponent;
}
/*
export const Content = forwardRef<
ElementRef<typeof DropdownMenu.Content>,
ComponentPropsWithoutRef<typeof DropdownMenu.Content>
>(({ className, ...props }, forwardedRef) => (
<DropdownMenu.Content
className={`flex flex-col ${className}`}
{...props}
ref={forwardedRef}
/>
));
export const Item = forwardRef<
ElementRef<typeof DropdownMenu.Item>,
ComponentPropsWithoutRef<typeof DropdownMenu.Item>
>(({ className, ...props }, forwardedRef) => (
<DropdownMenu.Item
className={`flex flex-row flex-nowrap ${className}`}
{...props}
ref={forwardedRef}
/>
));
*/
+13 -11
View File
@@ -1,14 +1,23 @@
import type { ValueError } from "@sinclair/typebox/value";
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
import clsx from "clsx";
import { type TSchema, Type, Value } from "core/utils";
import { Form, type Validator } from "json-schema-form-react";
import { Type } from "core/utils";
import { Form } from "json-schema-form-react";
import { transform } from "lodash-es";
import type { ComponentPropsWithoutRef } from "react";
import { Button } from "ui/components/buttons/Button";
import { Group, Input, Label } from "ui/components/form/Formy/components";
import { SocialLink } from "./SocialLink";
import type { ValueError } from "@sinclair/typebox/value";
import { type TSchema, Value } from "core/utils";
import type { Validator } from "json-schema-form-react";
class TypeboxValidator implements Validator<ValueError> {
async validate(schema: TSchema, data: any) {
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
}
}
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
className?: string;
formData?: any;
@@ -18,14 +27,7 @@ export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" |
buttonLabel?: string;
};
class TypeboxValidator implements Validator<ValueError> {
async validate(schema: TSchema, data: any) {
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
}
}
const validator = new TypeboxValidator();
const schema = Type.Object({
email: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
@@ -82,7 +84,7 @@ export function AuthForm({
<Form
method={method}
action={password.action}
{...props}
{...(props as any)}
schema={schema}
validator={validator}
validationMode="change"
+15 -2
View File
@@ -7,10 +7,23 @@ export type AuthScreenProps = {
action?: "login" | "register";
logo?: ReactNode;
intro?: ReactNode;
formOnly?: boolean;
};
export function AuthScreen({ method = "POST", action = "login", logo, intro }: AuthScreenProps) {
export function AuthScreen({
method = "POST",
action = "login",
logo,
intro,
formOnly
}: AuthScreenProps) {
const { strategies, basepath, loading } = useAuthStrategies();
const Form = <AuthForm auth={{ basepath, strategies }} method={method} action={action} />;
if (formOnly) {
if (loading) return null;
return Form;
}
return (
<div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin">
@@ -25,7 +38,7 @@ export function AuthScreen({ method = "POST", action = "login", logo, intro }: A
<p className="text-primary/50">Enter your credentials below to get access.</p>
</div>
)}
<AuthForm auth={{ basepath, strategies }} method={method} action={action} />
{Form}
</div>
)}
</div>
+10 -2
View File
@@ -22,9 +22,17 @@ export function SocialLink({
basepath = "/api/auth",
children
}: SocialLinkProps) {
const url = [basepath, provider, action].join("/");
return (
<form method={method} action={[basepath, name, action].join("/")} className="w-full">
<Button type="submit" size="large" variant="outline" className="justify-center w-full">
<form method={method} action={url} className="w-full">
<Button
type="submit"
size="large"
variant="outline"
className="justify-center w-full"
IconLeft={icon}
>
Continue with {label ?? ucFirstAllSnakeToPascalWithSpaces(provider)}
</Button>
{children}
+3
View File
@@ -1,3 +1,4 @@
import { useAuthStrategies } from "../hooks/use-auth";
import { AuthForm } from "./AuthForm";
import { AuthScreen } from "./AuthScreen";
import { SocialLink } from "./SocialLink";
@@ -7,3 +8,5 @@ export const Auth = {
Form: AuthForm,
SocialLink: SocialLink
};
export { useAuthStrategies };
+2 -1
View File
@@ -1,2 +1,3 @@
export { Auth } from "./auth";
export * from "./auth";
export * from "./media";
export * from "../components/form/native-form/NativeForm";
+26 -12
View File
@@ -1,31 +1,32 @@
import type { RepoQuery, RepoQueryIn } from "data";
import type { RepoQueryIn } from "data";
import type { MediaFieldSchema } from "media/AppMedia";
import type { TAppMediaConfig } from "media/media-schema";
import { useId } from "react";
import { useApi, useBaseUrl, useEntityQuery, useInvalidate } from "ui/client";
import { type ReactNode, createContext, useContext, useId } from "react";
import { useApi, useEntityQuery, useInvalidate } from "ui/client";
import { useEvent } from "ui/hooks/use-event";
import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone";
import { mediaItemsToFileStates } from "./helper";
export type DropzoneContainerProps = {
children?: (props: DropzoneRenderProps) => JSX.Element;
children?: ReactNode;
initialItems?: MediaFieldSchema[];
entity?: {
name: string;
id: number;
field: string;
};
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
query?: RepoQueryIn;
} & Partial<Pick<TAppMediaConfig, "basepath" | "entity_name" | "storage">> &
Partial<DropzoneProps>;
} & Omit<Partial<DropzoneProps>, "children" | "initialItems">;
const DropzoneContainerContext = createContext<DropzoneRenderProps>(undefined!);
export function DropzoneContainer({
initialItems,
basepath = "/api/media",
storage = {},
entity_name = "media",
media,
entity,
query,
children,
...props
}: DropzoneContainerProps) {
const id = useId();
@@ -33,10 +34,11 @@ export function DropzoneContainer({
const baseUrl = api.baseUrl;
const invalidate = useInvalidate();
const limit = query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50;
console.log("dropzone:baseUrl", baseUrl);
const entity_name = (media?.entity_name ?? "media") as "media";
//console.log("dropzone:baseUrl", baseUrl);
const $q = useEntityQuery(
entity_name as "media",
entity_name,
undefined,
{
...query,
@@ -89,6 +91,18 @@ export function DropzoneContainer({
autoUpload
initialItems={_initialItems}
{...props}
/>
>
{children
? (props) => (
<DropzoneContainerContext.Provider value={props}>
{children}
</DropzoneContainerContext.Provider>
)
: undefined}
</Dropzone>
);
}
export function useDropzone() {
return useContext(DropzoneContainerContext);
}
+5 -2
View File
@@ -1,11 +1,14 @@
import { PreviewWrapperMemoized } from "./Dropzone";
import { DropzoneContainer } from "./DropzoneContainer";
import { DropzoneContainer, useDropzone } from "./DropzoneContainer";
export const Media = {
Dropzone: DropzoneContainer,
Preview: PreviewWrapperMemoized
Preview: PreviewWrapperMemoized,
useDropzone: useDropzone
};
export { useDropzone as useMediaDropzone };
export type {
PreviewComponentProps,
FileState,
+2
View File
@@ -1 +1,3 @@
export { default as Admin, type BkndAdminProps } from "./Admin";
export * from "./components/form/json-schema-form";
export { JsonViewer } from "./components/code/JsonViewer";
+22 -5
View File
@@ -1,13 +1,19 @@
import { useClickOutside, useHotkeys } from "@mantine/hooks";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { throttle } from "lodash-es";
import { type ComponentProps, useEffect, useRef, useState } from "react";
import { ScrollArea } from "radix-ui";
import {
type ComponentProps,
type ComponentPropsWithoutRef,
useEffect,
useRef,
useState
} from "react";
import type { IconType } from "react-icons";
import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton";
import { useEvent } from "ui/hooks/use-event";
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
import { useEvent } from "../../hooks/use-event";
export function Root({ children }) {
return (
@@ -68,8 +74,15 @@ export function Content({ children, center }: { children: React.ReactNode; cente
}
export function Main({ children }) {
const { sidebar } = useAppShell();
return (
<div data-shell="main" className="flex flex-col flex-grow w-1">
<div
data-shell="main"
className={twMerge(
"flex flex-col flex-grow w-1 flex-shrink-1",
sidebar.open && "max-w-[calc(100%-350px)]"
)}
>
{children}
</div>
);
@@ -292,7 +305,7 @@ export function Scrollable({
return (
<ScrollArea.Root style={{ height: `calc(100dvh - ${offset}px` }} ref={scrollRef}>
<ScrollArea.Viewport className="w-full h-full ">{children}</ScrollArea.Viewport>
<ScrollArea.Viewport className="w-full h-full">{children}</ScrollArea.Viewport>
<ScrollArea.Scrollbar
forceMount
className="flex select-none touch-none bg-transparent w-0.5"
@@ -357,4 +370,8 @@ export const SectionHeaderAccordionItem = ({
</div>
);
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
<hr {...props} className={twMerge("border-muted my-3", className)} />
);
export { Header } from "./Header";
+12 -14
View File
@@ -13,6 +13,7 @@ import {
import { useAuth, useBkndWindowContext } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
import { useTheme } from "ui/client/use-theme";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Logo } from "ui/components/display/Logo";
@@ -72,18 +73,15 @@ export function HeaderNavigation() {
<>
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
{items.map((item) => (
<Tooltip
key={item.label}
label={item.tooltip}
disabled={typeof item.tooltip === "undefined"}
position="bottom"
<NavLink
key={item.href}
as={Link}
href={item.href}
Icon={item.Icon}
disabled={item.disabled}
>
<div>
<NavLink as={Link} href={item.href} Icon={item.Icon} disabled={item.disabled}>
{item.label}
</NavLink>
</div>
</Tooltip>
{item.label}
</NavLink>
))}
</nav>
<nav className="flex md:hidden flex-row items-center">
@@ -114,9 +112,9 @@ function SidebarToggler() {
}
export function Header({ hasSidebar = true }) {
//const logoReturnPath = "";
const { app } = useBknd();
const { logo_return_path = "/", color_scheme = "light" } = app.getAdminConfig();
const { theme } = useTheme();
const { logo_return_path = "/" } = app.getAdminConfig();
return (
<header
@@ -128,7 +126,7 @@ export function Header({ hasSidebar = true }) {
native={logo_return_path !== "/"}
className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none"
>
<Logo theme={color_scheme} />
<Logo theme={theme} />
</Link>
<HeaderNavigation />
<div className="flex flex-grow" />
+29
View File
@@ -2,6 +2,35 @@
@tailwind components;
@tailwind utilities;
#bknd-admin.dark,
.dark .bknd-admin {
--color-primary: 250 250 250; /* zinc-50 */
--color-background: 30 31 34;
--color-muted: 47 47 52;
--color-darkest: 255 255 255; /* white */
--color-lightest: 24 24 27; /* black */
}
#bknd-admin,
.bknd-admin {
--color-primary: 9 9 11; /* zinc-950 */
--color-background: 250 250 250; /* zinc-50 */
--color-muted: 228 228 231; /* ? */
--color-darkest: 0 0 0; /* black */
--color-lightest: 255 255 255; /* white */
@mixin light {
--mantine-color-body: rgb(250 250 250);
}
@mixin dark {
--mantine-color-body: rgb(9 9 11);
}
table {
font-size: inherit;
}
}
#bknd-admin {
@apply bg-background text-primary overflow-hidden h-dvh w-dvw;
+1 -1
View File
@@ -201,7 +201,7 @@ const EntityContextMenu = ({
separator,
{
icon: IconSettings,
label: "Settings",
label: "Advanced",
onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true
@@ -40,8 +40,8 @@ export function DataEntityList({ params }) {
useBrowserTitle(["Data", entity?.label ?? params.entity]);
const [navigate] = useNavigate();
const search = useSearch(searchSchema, {
select: undefined,
sort: undefined
select: entity.getSelect(undefined, "table"),
sort: entity.getDefaultSort()
});
const $q = useApiQuery(
+3 -2
View File
@@ -1,5 +1,6 @@
import { Suspense, lazy } from "react";
import { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
import { Route, Router, Switch } from "wouter";
import AuthRoutes from "./auth";
import { AuthLogin } from "./auth/auth.login";
@@ -20,11 +21,11 @@ const TestRoutes = lazy(() => import("./test"));
export function Routes() {
const { app } = useBknd();
const { color_scheme: theme } = app.getAdminConfig();
const { theme } = useTheme();
const { basepath } = app.getAdminConfig();
return (
<div id="bknd-admin" className={(theme ?? "light") + " antialiased"}>
<div id="bknd-admin" className={theme + " antialiased"}>
<Router base={basepath}>
<Switch>
<Route path="/auth/login" component={AuthLogin} />
+12 -37
View File
@@ -1,33 +1,17 @@
import { IconPhoto } from "@tabler/icons-react";
import { IconAlertHexagon } from "@tabler/icons-react";
import { TbSettings } from "react-icons/tb";
import { useBknd } from "ui/client/BkndProvider";
import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty";
import { Link } from "ui/components/wouter/Link";
import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useLocation } from "wouter";
export function MediaRoot({ children }) {
const { app, config } = useBknd();
const [, navigate] = useLocation();
const mediaDisabled = !config.media.enabled;
useBrowserTitle(["Media"]);
if (!config.media.enabled) {
return (
<Empty
Icon={IconPhoto}
title="Media not enabled"
description="Please enable media in the settings to continue."
primary={{
children: "Manage Settings",
onClick: () => navigate(app.getSettingsPath(["media"]))
}}
/>
);
}
return (
<>
<AppShell.Sidebar>
@@ -42,31 +26,22 @@ export function MediaRoot({ children }) {
</AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow p-3 gap-3">
{/*<div>
<SearchInput placeholder="Search buckets" />
</div>*/}
<nav className="flex flex-col flex-1 gap-1">
<AppShell.SidebarLink as={Link} href="/media" className="active">
Main Bucket
<AppShell.SidebarLink
as={Link}
href={"/"}
className="flex flex-row justify-between"
>
Main Bucket {mediaDisabled && <IconAlertHexagon className="size-5" />}
</AppShell.SidebarLink>
<AppShell.SidebarLink as={Link} href={"/settings"}>
Settings
</AppShell.SidebarLink>
</nav>
</div>
</AppShell.Scrollable>
</AppShell.Sidebar>
<main className="flex flex-col flex-grow">{children}</main>
<AppShell.Main>{children}</AppShell.Main>
</>
);
}
// @todo: add infinite load
export function MediaEmpty() {
useBrowserTitle(["Media"]);
return (
<AppShell.Scrollable>
<div className="flex flex-1 p-3">
<Media.Dropzone />
</div>
</AppShell.Scrollable>
);
}
+5 -2
View File
@@ -1,10 +1,13 @@
import { Route } from "wouter";
import { MediaEmpty, MediaRoot } from "./_media.root";
import { MediaRoot } from "./_media.root";
import { MediaIndex } from "./media.index";
import { MediaSettings } from "./media.settings";
export default function MediaRoutes() {
return (
<MediaRoot>
<Route path="/" component={MediaEmpty} />
<Route path="/" component={MediaIndex} />
<Route path="/settings" component={MediaSettings} />
</MediaRoot>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { IconPhoto } from "@tabler/icons-react";
import { useBknd } from "ui/client/BkndProvider";
import { Empty } from "ui/components/display/Empty";
import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useLocation } from "wouter";
export function MediaIndex() {
const { app, config } = useBknd();
const [, navigate] = useLocation();
useBrowserTitle(["Media"]);
if (!config.media.enabled) {
return (
<Empty
Icon={IconPhoto}
title="Media not enabled"
description="Please enable media in the settings to continue."
primary={{
children: "Manage Settings",
onClick: () => navigate("/settings")
}}
/>
);
}
return (
<AppShell.Scrollable>
<div className="flex flex-1 p-3">
<Media.Dropzone />
</div>
</AppShell.Scrollable>
);
}
+179
View File
@@ -0,0 +1,179 @@
import { IconBrandAws, IconCloud, IconServer } from "@tabler/icons-react";
import { isDebug } from "core";
import { autoFormatString } from "core/utils";
import { twMerge } from "tailwind-merge";
import { useBknd } from "ui/client/BkndProvider";
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
import { Button } from "ui/components/buttons/Button";
import { Alert } from "ui/components/display/Alert";
import { Message } from "ui/components/display/Message";
import * as Formy from "ui/components/form/Formy";
import {
AnyOf,
Field,
Form,
FormContextOverride,
FormDebug,
ObjectField,
Subscribe,
useFormError
} from "ui/components/form/json-schema-form";
import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
export function MediaSettings(props) {
useBrowserTitle(["Media", "Settings"]);
const { hasSecrets } = useBknd({ withSecrets: true });
if (!hasSecrets) {
return <Message.MissingPermission what="Media Settings" />;
}
return <MediaSettingsInternal {...props} />;
}
const formConfig = {
ignoreKeys: ["entity_name", "basepath"],
options: { debug: isDebug(), keepEmpty: true }
};
function MediaSettingsInternal() {
const { config, schema: _schema, actions } = useBkndMedia();
const schema = JSON.parse(JSON.stringify(_schema));
schema.if = { properties: { enabled: { const: true } } };
// biome-ignore lint/suspicious/noThenProperty: <explanation>
schema.then = { required: ["adapter"] };
async function onSubmit(data: any) {
console.log("submit", data);
await actions.config.patch(data);
}
return (
<>
<Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}>
<Subscribe
selector={(state) => ({
dirty: state.dirty,
errors: state.errors.length > 0,
submitting: state.submitting
})}
>
{({ dirty, errors, submitting }) => (
<AppShell.SectionHeader
right={
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update
</Button>
}
>
Settings
</AppShell.SectionHeader>
)}
</Subscribe>
<AppShell.Scrollable>
<RootFormError />
<div className="flex flex-col gap-3 p-3">
<Field name="enabled" />
<div className="flex flex-col gap-3 relative">
<Overlay />
<Field name="storage.body_max_size" label="Storage Body Max Size" />
</div>
</div>
<AppShell.Separator />
<div className="flex flex-col gap-3 p-3">
<Overlay />
<AnyOf.Root path="adapter">
<Adapters />
</AnyOf.Root>
</div>
<FormDebug />
</AppShell.Scrollable>
</Form>
</>
);
}
const RootFormError = () => {
const errors = useFormError("", { strict: true });
if (errors.length === 0) return null;
return (
<Alert.Exception>
{errors.map((error, i) => (
<div key={i}>{error.message}</div>
))}
</Alert.Exception>
);
};
const Icons = [IconBrandAws, IconCloud, IconServer];
const AdapterIcon = ({ index }: { index: number }) => {
const Icon = Icons[index];
if (!Icon) return null;
return <Icon />;
};
function Adapters() {
const ctx = AnyOf.useContext();
return (
<Formy.Group>
<Formy.Label className="flex flex-row items-center gap-1">
<span className="font-bold">Media Adapter:</span>
{ctx.selected === null && <span className="opacity-70"> (Choose one)</span>}
</Formy.Label>
<div className="flex flex-row gap-1 mb-2">
{ctx.schemas?.map((schema: any, i) => (
<Button
key={i}
onClick={() => ctx.select(i)}
variant={ctx.selected === i ? "primary" : "outline"}
className={twMerge(
"flex flex-row items-center justify-center gap-3 border",
ctx.selected === i && "border-primary"
)}
>
<div>
<AdapterIcon index={i} />
</div>
<div className="flex flex-col items-start justify-center">
<span>{autoFormatString(schema.title)}</span>
{schema.description && (
<span className="text-xs opacity-70 text-left">{schema.description}</span>
)}
</div>
</Button>
))}
</div>
{ctx.selected !== null && (
<Formy.Group as="fieldset" error={ctx.errors.length > 0}>
<Formy.Label as="legend" className="font-mono px-2">
{autoFormatString(ctx.selectedSchema!.title!)}
</Formy.Label>
<FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
<Field name="type" hidden />
<ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} />
</FormContextOverride>
</Formy.Group>
)}
</Formy.Group>
);
}
const Overlay = () => (
<Subscribe selector={(state) => ({ enabled: state.data.enabled })}>
{({ enabled }) =>
!enabled && (
<div className="absolute w-full h-full z-50 bg-background opacity-70 pointer-events-none" />
)
}
</Subscribe>
);
+9 -2
View File
@@ -1,5 +1,8 @@
import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test";
import FormyTest from "ui/routes/test/tests/formy-test";
import HtmlFormTest from "ui/routes/test/tests/html-form-test";
import SwaggerTest from "ui/routes/test/tests/swagger-test";
import SWRAndAPI from "ui/routes/test/tests/swr-and-api";
import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api";
@@ -15,6 +18,7 @@ import DropdownTest from "./tests/dropdown-test";
import DropzoneElementTest from "./tests/dropzone-element-test";
import EntityFieldsForm from "./tests/entity-fields-form";
import FlowsTest from "./tests/flows-test";
import JsonSchemaForm3 from "./tests/json-schema-form3";
import JsonFormTest from "./tests/jsonform-test";
import { LiquidJsTest } from "./tests/liquid-js-test";
import MantineTest from "./tests/mantine-test";
@@ -45,7 +49,10 @@ const tests = {
SWRAndAPI,
SwrAndDataApi,
DropzoneElementTest,
JsonSchemaFormReactTest
JsonSchemaFormReactTest,
JsonSchemaForm3,
FormyTest,
HtmlFormTest
} as const;
export default function TestRoutes() {
@@ -83,7 +90,7 @@ function TestRoot({ children }) {
</div>
</AppShell.Scrollable>
</AppShell.Sidebar>
<main className="flex flex-col flex-grow">{children}</main>
<AppShell.Main>{children}</AppShell.Main>
</>
);
}
@@ -1,4 +1,4 @@
import { type DropzoneRenderProps, Media } from "ui/elements";
import { Media } from "ui/elements";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
export default function DropzoneElementTest() {
@@ -12,7 +12,7 @@ export default function DropzoneElementTest() {
maxItems={1}
overwrite
>
{(props) => <CustomUserAvatarDropzone {...props} />}
<CustomUserAvatarDropzone />
</Media.Dropzone>
</div>
@@ -49,12 +49,13 @@ export default function DropzoneElementTest() {
);
}
function CustomUserAvatarDropzone({
wrapperRef,
inputProps,
state: { files, isOver, isOverAccepted, showPlaceholder },
actions: { openFileInput }
}: DropzoneRenderProps) {
function CustomUserAvatarDropzone() {
const {
wrapperRef,
inputProps,
state: { files, isOver, isOverAccepted, showPlaceholder },
actions: { openFileInput }
} = Media.useDropzone();
const file = files[0];
return (
@@ -0,0 +1,17 @@
import * as Formy from "ui/components/form/Formy";
export default function FormyTest() {
return (
<div className="flex flex-col gap-3">
formy
<Formy.Group>
<Formy.Label>label</Formy.Label>
<Formy.Switch onCheckedChange={console.log} />
</Formy.Group>
<Formy.Group>
<Formy.Label>label</Formy.Label>
<Formy.Input />
</Formy.Group>
</div>
);
}
@@ -0,0 +1,39 @@
import { useState } from "react";
import { Button } from "ui/components/buttons/Button";
import { JsonViewer } from "ui/components/code/JsonViewer";
import * as Formy from "ui/components/form/Formy";
import { NativeForm } from "ui/components/form/native-form/NativeForm";
export default function HtmlFormTest() {
const [data, setData] = useState<any>();
const [errors, setErrors] = useState<any>();
return (
<div className="flex flex-col p-3">
<h1>html</h1>
<NativeForm
className="flex flex-col gap-3"
validateOn="change"
onChange={setData}
onSubmit={(data) => console.log("submit", data)}
onSubmitInvalid={(errors) => console.log("invalid", errors)}
onError={setErrors}
reportValidity
clean
>
<Formy.Input type="text" name="what" minLength={2} maxLength={5} required />
<div data-role="input-error" data-name="what" />
<Formy.Input type="number" name="age" step={5} required />
<Formy.Input type="checkbox" name="verified" />
<Formy.Input type="text" name="tag" minLength={1} required />
<Formy.Input type="number" name="tag" />
<Button type="submit">submit</Button>
</NativeForm>
<JsonViewer json={{ data, errors }} expand={9} />
</div>
);
}
@@ -23,8 +23,8 @@ export default function JsonSchemaFormReactTest() {
<>
<Form
schema={schema}
onChange={setData}
onSubmit={setData}
/*onChange={setData}
onSubmit={setData}*/
validator={validator}
validationMode="change"
>
@@ -0,0 +1,371 @@
import type { JSONSchema } from "json-schema-to-ts";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import {
AnyOf,
AnyOfField,
Field,
Form,
FormContextOverride,
FormDebug,
ObjectField,
useFormError
} from "ui/components/form/json-schema-form";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
const schema2 = {
type: "object",
properties: {
name: { type: "string", default: "Peter" },
age: { type: "number" },
gender: {
type: "string",
enum: ["male", "female", "uni"]
},
deep: {
type: "object",
properties: {
nested: { type: "string" }
}
}
},
required: ["age"]
} as const satisfies JSONSchema;
export default function JsonSchemaForm3() {
const { schema: _schema, config } = useBknd();
const schema = JSON.parse(JSON.stringify(_schema));
config.media.storage.body_max_size = 1;
schema.media.properties.storage.properties.body_max_size.minimum = 0;
schema.media.if = { properties: { enabled: { const: true } } };
// biome-ignore lint/suspicious/noThenProperty: <explanation>
schema.media.then = { required: ["adapter"] };
//schema.media.properties.adapter.anyOf[2].properties.config.properties.path.minLength = 1;
return (
<Scrollable>
<div className="flex flex-col p-3">
<Form
onChange={(data) => console.log("change", data)}
onSubmit={(data) => console.log("submit", data)}
schema={{
type: "object",
properties: {
name: { type: "string", default: "Peter", maxLength: 3 },
age: { type: "number" },
deep: {
type: "object",
properties: {
nested: { type: "string" }
}
}
},
required: ["age"],
additionalProperties: false
}}
initialValues={{ name: "Peter", age: 20, deep: { nested: "hello" } }}
className="flex flex-col gap-3"
validateOn="change"
options={{ debug: true }}
/>
{/*<Form
schema={{
type: "object",
properties: {
name: { type: "string", default: "Peter", minLength: 3 },
age: { type: "number" },
deep: {
anyOf: [
{
type: "object",
properties: {
nested: { type: "string" }
}
},
{
type: "object",
properties: {
nested2: { type: "string" }
}
}
]
}
},
required: ["age"]
}}
className="flex flex-col gap-3"
validateOn="change"
>
<Field name="" />
<Subscribe2>
{(state) => (
<pre className="text-wrap whitespace-break-spaces break-all">
{JSON.stringify(state, null, 2)}
</pre>
)}
</Subscribe2>
</Form>*/}
{/*<Form
schema={{
type: "object",
properties: {
name: { type: "string", default: "Peter", maxLength: 3 },
age: { type: "number" },
gender: {
type: "string",
enum: ["male", "female", "uni"]
},
deep: {
type: "object",
properties: {
nested: { type: "string" }
}
}
},
required: ["age"]
}}
className="flex flex-col gap-3"
validateOn="change"
>
<div>random thing</div>
<Field name="name" />
<Field name="age" />
<FormDebug />
<FormDebug2 name="name" />
<hr />
<Subscribe2
selector={(state) => ({ dirty: state.dirty, submitting: state.submitting })}
>
{(state) => (
<pre className="text-wrap whitespace-break-spaces break-all">
{JSON.stringify(state)}
</pre>
)}
</Subscribe2>
</Form>*/}
{/*<Form
schema={{
type: "object",
properties: {
bla: {
anyOf: [
{
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" }
}
},
{
type: "object",
properties: {
start: { type: "string", enum: ["a", "b", "c"] },
end: { type: "number" }
}
}
]
}
}
}}
>
<AutoForm />
</Form>*/}
{/*<Form
schema={{
type: "object",
properties: {
title: {
type: "string"
},
tags: {
type: "array",
items: {
type: "string"
}
}
}
}}
initialValues={{ tags: ["a", "b", "c"] }}
>
<AutoForm />
</Form>*/}
{/*<Form
schema={{
type: "object",
properties: {
title: {
type: "string"
},
tags: {
type: "array",
items: {
type: "number"
}
},
method: {
type: "array",
uniqueItems: true,
items: {
type: "string",
enum: ["GET", "POST", "PUT", "DELETE"]
}
}
}
}}
initialValues={{ tags: [0, 1], method: ["GET"] }}
options={{ debug: true }}
>
<Field name="" />
<FormDebug />
</Form>*/}
{/*<Form
schema={{
type: "object",
properties: {
title: {
type: "string"
},
tags: {
type: "array",
items: {
anyOf: [
{ type: "string", title: "String" },
{ type: "number", title: "Number" }
]
}
}
}
}}
initialValues={{ tags: [0, 1] }}
>
<Field name="" />
<FormDebug force />
</Form>*/}
{/*<CustomMediaForm />*/}
{/*<Form
schema={schema.media}
initialValues={config.media as any}
onSubmit={console.log}
validateOn="change"
/>*/}
{/*<Form
schema={removeKeyRecursively(schema.media, "pattern") as any}
initialValues={config.media}
>
<AutoForm />
</Form>*/}
{/*<Form
schema={removeKeyRecursively(schema.server, "pattern") as any}
initialValues={config.server}
>
<AutoForm />
</Form>*/}
{/*<Form schema={ss} validateOn="change" />*/}
</div>
</Scrollable>
);
}
const ss = {
type: "object",
properties: {
name: { type: "string" },
email: { type: "string", format: "email" },
interested: { type: "boolean" },
bla: {
type: "string",
enum: ["small", "medium", "large"]
},
password: { type: "string", format: "password" },
birthdate: { type: "string", format: "date" },
dinnerTime: { type: "string", format: "date-time" },
age: { type: "number", minimum: 0, multipleOf: 5 },
tags: {
type: "array",
items: {
type: "string"
}
},
config: {
type: "object",
properties: {
min: { type: "number" }
}
}
},
required: ["name"],
additionalProperties: false
} as const satisfies JSONSchema;
function CustomMediaForm() {
const { schema: _schema, config } = useBknd();
const schema = JSON.parse(JSON.stringify(_schema));
config.media.storage.body_max_size = 1;
schema.media.properties.storage.properties.body_max_size.minimum = 0;
schema.media.if = { properties: { enabled: { const: true } } };
// biome-ignore lint/suspicious/noThenProperty: <explanation>
schema.media.then = { required: ["adapter"] };
return (
<Form
schema={schema.media}
/*initialValues={config.media as any}*/
className="flex flex-col gap-3"
validateOn="change"
>
<Test />
<Field name="enabled" />
<Field name="basepath" />
<Field name="entity_name" />
<Field name="storage" />
<AnyOf.Root path="adapter">
<CustomMediaFormAdapter />
</AnyOf.Root>
{/*<FormDebug force />*/}
</Form>
);
}
const Test = () => {
const errors = useFormError("", { strict: true });
return <div>{errors.map((e) => e.message).join("\n")}</div>;
//return <pre>{JSON.stringify(errors, null, 2)}</pre>;
};
function CustomMediaFormAdapter() {
const ctx = AnyOf.useContext();
return (
<>
<div className="flex flex-row gap-1">
{ctx.schemas?.map((schema: any, i) => (
<Button
key={i}
onClick={() => ctx.select(i)}
variant={ctx.selected === i ? "primary" : "default"}
>
{schema.title ?? `Option ${i + 1}`}
</Button>
))}
</div>
{ctx.selected !== null && (
<FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
<Field name="type" hidden />
<ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} />
</FormContextOverride>
)}
</>
);
}
+1 -30
View File
@@ -16,34 +16,6 @@ html.fixed body {
touch-action: none;
}
#bknd-admin,
.bknd-admin {
--color-primary: 9 9 11; /* zinc-950 */
--color-background: 250 250 250; /* zinc-50 */
--color-muted: 228 228 231; /* ? */
--color-darkest: 0 0 0; /* black */
--color-lightest: 255 255 255; /* white */
&.dark {
--color-primary: 250 250 250; /* zinc-50 */
--color-background: 30 31 34;
--color-muted: 47 47 52;
--color-darkest: 255 255 255; /* white */
--color-lightest: 24 24 27; /* black */
}
@mixin light {
--mantine-color-body: rgb(250 250 250);
}
@mixin dark {
--mantine-color-body: rgb(9 9 11);
}
table {
font-size: inherit;
}
}
html,
body {
font-size: 14px;
@@ -63,9 +35,8 @@ body {
}
div[data-radix-scroll-area-viewport] > div:first-child {
min-width: auto !important;
display: block !important;
min-width: 100% !important;
max-width: 100%;
}
/* hide calendar icon on inputs */
+1 -1
View File
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"types": ["bun-types", "@cloudflare/workers-types"],
"composite": true,
"composite": false,
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
BIN
View File
Binary file not shown.
-106
View File
@@ -1,106 +0,0 @@
---
title: 'Development'
description: 'Preview changes locally to update your docs'
---
<Info>
**Prerequisite**: Please install Node.js (version 19 or higher) before proceeding.
</Info>
Follow these steps to install and run Mintlify on your operating system:
**Step 1**: Install Mintlify:
<CodeGroup>
```bash npm
npm i -g mintlify
```
```bash yarn
yarn global add mintlify
```
</CodeGroup>
**Step 2**: Navigate to the docs directory (where the `mint.json` file is located) and execute the following command:
```bash
mintlify dev
```
A local preview of your documentation will be available at `http://localhost:3000`.
### Custom Ports
By default, Mintlify uses port 3000. You can customize the port Mintlify runs on by using the `--port` flag. To run Mintlify on port 3333, for instance, use this command:
```bash
mintlify dev --port 3333
```
If you attempt to run Mintlify on a port that's already in use, it will use the next available port:
```md
Port 3000 is already in use. Trying 3001 instead.
```
## Mintlify Versions
Please note that each CLI release is associated with a specific version of Mintlify. If your local website doesn't align with the production version, please update the CLI:
<CodeGroup>
```bash npm
npm i -g mintlify@latest
```
```bash yarn
yarn global upgrade mintlify
```
</CodeGroup>
## Validating Links
The CLI can assist with validating reference links made in your documentation. To identify any broken links, use the following command:
```bash
mintlify broken-links
```
## Deployment
<Tip>
Unlimited editors available under the [Pro
Plan](https://mintlify.com/pricing) and above.
</Tip>
If the deployment is successful, you should see the following:
<Frame>
<img src="/images/checks-passed.png" style={{ borderRadius: '0.5rem' }} />
</Frame>
## Code Formatting
We suggest using extensions on your IDE to recognize and format MDX. If you're a VSCode user, consider the [MDX VSCode extension](https://marketplace.visualstudio.com/items?itemName=unifiedjs.vscode-mdx) for syntax highlighting, and [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) for code formatting.
## Troubleshooting
<AccordionGroup>
<Accordion title='Error: Could not load the "sharp" module using the darwin-arm64 runtime'>
This may be due to an outdated version of node. Try the following:
1. Remove the currently-installed version of mintlify: `npm remove -g mintlify`
2. Upgrade to Node v19 or higher.
3. Reinstall mintlify: `npm install -g mintlify`
</Accordion>
<Accordion title="Issue: Encountering an unknown error">
Solution: Go to the root of your device and delete the \~/.mintlify folder. Afterwards, run `mintlify dev` again.
</Accordion>
</AccordionGroup>
Curious about what changed in the CLI version? [Check out the CLI changelog.](https://www.npmjs.com/package/mintlify?activeTab=versions)

Some files were not shown because too many files have changed in this diff Show More