Compare commits

...

61 Commits

Author SHA1 Message Date
dswbx 1e5c0dbc22 Merge pull request #72 from bknd-io/fix/0.7.1
fix: popover regression & dropzone rerender lost state
2025-02-10 14:53:14 +01:00
dswbx d79da9d204 fix: popover regression & dropzone rerender lost state 2025-02-10 14:52:14 +01:00
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
dswbx 24542f30cb Merge pull request #53 from bknd-io/fix/release-0.6.1
fix auth screens' missing config permission, and media URL resolution
2025-01-21 17:13:57 +01:00
dswbx 51715158c2 fix auth screens' missing config permission, and media URL resolution 2025-01-21 17:10:45 +01:00
134 changed files with 4449 additions and 952 deletions
+115 -30
View File
@@ -1,13 +1,15 @@
[![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd [![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd)
"View this project on NPM")
[![npm downloads](https://img.shields.io/npm/dm/bknd)](https://www.npmjs.com/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](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, <p align="center" width="100%">
authentication, workflows and media. Since it's lightweight and built on Web Standards, it can <a href="https://stackblitz.com/github/bknd-io/bknd-examples?hideExplorer=1&embed=1&view=preview&startScript=example-admin-rich&initialPath=%2Fdata%2Fschema">
be deployed nearly anywhere, including running inside your framework of choice. No more <strong>⭐ Live Demo</strong>
deploying multiple separate services! </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.** **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 > Please keep in mind that **bknd** is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0. > and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## ✨ Features ## Size
**📊 Data**: Define, query, and control your data with ease. ![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@0.6.1/dist/index.js?compression=gzip&label=bknd)
- Define entities with fields and relationships, synced directly to your database. ![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)
- Supported field types: `primary`, `text`, `number`, `date`, `boolean`, `enum`, `json`, `jsonschema`. ![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)
- Relationship types: `one-to-one`, `many-to-one`, `many-to-many`, and `polymorphic`. ![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)
- 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.
**🔐 Auth**: Easily implement reliable authentication strategies. 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.
- 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.
**🖼️ Media**: Effortlessly manage and serve all your media files. ## Motivation
- Upload files with ease. 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.
- Adapter-based support for S3, S3-compatible storage (e.g., R2, Tigris), and Cloudinary.
**🔄 Flows** (UI integration coming soon!): Design and run workflows with seamless automation. **The solution:** A backend system that only assumes and implements primitive details, integrates into multiple environments, and adheres to industry standards.
- Create and run workflows with trigger-based automation:
- Manual triggers or events from data, auth, media, or server actions. ## Features
- HTTP triggers for external integrations. * ⚡ Instant backend with full REST API:
- Define tasks in sequence, parallel, or loops, with conditional execution. * **Data**: Define, query, and control your data with ease.
- Use reusable sub-workflows to organize complex processes. * **Auth**: Easily implement reliable authentication strategies.
- Leverage OpenAPI specifications for API-based tasks. * **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 ## 🚀 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 () => { it("fetches endpoint", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host }); const api = new Api({ host }, app.request as typeof fetch);
// @ts-expect-error it's protected
api.fetcher = app.request as typeof fetch;
const res = await api.get("/endpoint"); const res = await api.get("/endpoint");
expect(res.res.ok).toEqual(true); expect(res.res.ok).toEqual(true);
@@ -41,10 +38,7 @@ describe("ModuleApi", () => {
it("has accessible request", async () => { it("has accessible request", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host }); const api = new Api({ host }, app.request as typeof fetch);
// @ts-expect-error it's protected
api.fetcher = app.request as typeof fetch;
const promise = api.get("/endpoint"); const promise = api.get("/endpoint");
expect(promise.request).toBeDefined(); expect(promise.request).toBeDefined();
+98 -12
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Perf } from "../../src/core/utils"; import { Perf } from "../../src/core/utils";
import * as reqres from "../../src/core/utils/reqres"; import * as utils from "../../src/core/utils";
import * as strings from "../../src/core/utils/strings";
async function wait(ms: number) { async function wait(ms: number) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -13,7 +12,7 @@ describe("Core Utils", async () => {
describe("[core] strings", async () => { describe("[core] strings", async () => {
test("objectToKeyValueArray", async () => { test("objectToKeyValueArray", async () => {
const obj = { a: 1, b: 2, c: 3 }; const obj = { a: 1, b: 2, c: 3 };
const result = strings.objectToKeyValueArray(obj); const result = utils.objectToKeyValueArray(obj);
expect(result).toEqual([ expect(result).toEqual([
{ key: "a", value: 1 }, { key: "a", value: 1 },
{ key: "b", value: 2 }, { key: "b", value: 2 },
@@ -22,24 +21,24 @@ describe("Core Utils", async () => {
}); });
test("snakeToPascalWithSpaces", async () => { test("snakeToPascalWithSpaces", async () => {
const result = strings.snakeToPascalWithSpaces("snake_to_pascal"); const result = utils.snakeToPascalWithSpaces("snake_to_pascal");
expect(result).toBe("Snake To Pascal"); expect(result).toBe("Snake To Pascal");
}); });
test("randomString", async () => { test("randomString", async () => {
const result = strings.randomString(10); const result = utils.randomString(10);
expect(result).toHaveLength(10); expect(result).toHaveLength(10);
}); });
test("pascalToKebab", async () => { test("pascalToKebab", async () => {
const result = strings.pascalToKebab("PascalCase"); const result = utils.pascalToKebab("PascalCase");
expect(result).toBe("pascal-case"); expect(result).toBe("pascal-case");
}); });
test("replaceSimplePlaceholders", async () => { test("replaceSimplePlaceholders", async () => {
const str = "Hello, {$name}!"; const str = "Hello, {$name}!";
const vars = { name: "John" }; const vars = { name: "John" };
const result = strings.replaceSimplePlaceholders(str, vars); const result = utils.replaceSimplePlaceholders(str, vars);
expect(result).toBe("Hello, John!"); expect(result).toBe("Hello, John!");
}); });
}); });
@@ -49,7 +48,7 @@ describe("Core Utils", async () => {
const headers = new Headers(); const headers = new Headers();
headers.append("Content-Type", "application/json"); headers.append("Content-Type", "application/json");
headers.append("Authorization", "Bearer 123"); headers.append("Authorization", "Bearer 123");
const obj = reqres.headersToObject(headers); const obj = utils.headersToObject(headers);
expect(obj).toEqual({ expect(obj).toEqual({
"content-type": "application/json", "content-type": "application/json",
authorization: "Bearer 123" authorization: "Bearer 123"
@@ -59,21 +58,21 @@ describe("Core Utils", async () => {
test("replaceUrlParam", () => { test("replaceUrlParam", () => {
const url = "/api/:id/:name"; const url = "/api/:id/:name";
const params = { id: "123", name: "test" }; const params = { id: "123", name: "test" };
const result = reqres.replaceUrlParam(url, params); const result = utils.replaceUrlParam(url, params);
expect(result).toBe("/api/123/test"); expect(result).toBe("/api/123/test");
}); });
test("encode", () => { test("encode", () => {
const obj = { id: "123", name: "test" }; const obj = { id: "123", name: "test" };
const result = reqres.encodeSearch(obj); const result = utils.encodeSearch(obj);
expect(result).toBe("id=123&name=test"); expect(result).toBe("id=123&name=test");
const obj2 = { id: "123", name: ["test1", "test2"] }; 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"); expect(result2).toBe("id=123&name=test1&name=test2");
const obj3 = { id: "123", name: { test: "test" } }; 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"); 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); 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 { Hono } from "hono";
import { Guard } from "../../src/auth"; import { Guard } from "../../src/auth";
import { DebugLogger } from "../../src/core";
import { EventManager } from "../../src/core/events"; import { EventManager } from "../../src/core/events";
import { Default, stripMark } from "../../src/core/utils"; import { Default, stripMark } from "../../src/core/utils";
import { EntityManager } from "../../src/data"; import { EntityManager } from "../../src/data";
@@ -17,6 +18,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
emgr: new EventManager(), emgr: new EventManager(),
guard: new Guard(), guard: new Guard(),
flags: Module.ctx_flags, flags: Module.ctx_flags,
logger: new DebugLogger(false),
...overrides ...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, metafile: true,
splitting: false, splitting: false,
treeshake: true,
onSuccess: async () => { onSuccess: async () => {
delayTypes(); 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("remix"));
await tsup.build(baseConfig("bun")); await tsup.build(baseConfig("bun"));
await tsup.build(baseConfig("astro")); await tsup.build(baseConfig("astro"));
+18 -11
View File
@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.6.0", "version": "0.7.1",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
@@ -33,26 +33,30 @@
"license": "FSL-1.1-MIT", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^2.0.1", "@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", "@libsql/client": "^0.14.0",
"@mantine/core": "^7.13.4",
"@sinclair/typebox": "^0.32.34", "@sinclair/typebox": "^0.32.34",
"@tanstack/react-form": "0.19.2", "@tanstack/react-form": "0.19.2",
"@uiw/react-codemirror": "^4.23.6",
"@xyflow/react": "^12.3.2",
"aws4fetch": "^1.0.18", "aws4fetch": "^1.0.18",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0", "fast-xml-parser": "^4.4.0",
"hono": "^4.6.12", "hono": "^4.6.12",
"json-schema-form-react": "^0.0.2",
"json-schema-library": "^10.0.0-rc7",
"kysely": "^0.27.4", "kysely": "^0.27.4",
"liquidjs": "^10.15.0", "liquidjs": "^10.15.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"swr": "^2.2.5", "object-path-immutable": "^4.1.2",
"json-schema-form-react": "^0.0.2", "radix-ui": "^1.1.2",
"@uiw/react-codemirror": "^4.23.6", "json-schema-to-ts": "^3.1.1",
"@codemirror/lang-html": "^6.4.9", "swr": "^2.2.5"
"@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"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.613.0", "@aws-sdk/client-s3": "^3.613.0",
@@ -62,7 +66,6 @@
"@hono/zod-validator": "^0.4.1", "@hono/zod-validator": "^0.4.1",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@rjsf/core": "^5.22.2", "@rjsf/core": "^5.22.2",
"@tabler/icons-react": "3.18.0", "@tabler/icons-react": "3.18.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
@@ -148,6 +151,10 @@
"import": "./dist/adapter/cloudflare/index.js", "import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.cjs" "require": "./dist/adapter/cloudflare/index.cjs"
}, },
"./adapter": {
"types": "./dist/types/adapter/index.d.ts",
"import": "./dist/adapter/index.js"
},
"./adapter/vite": { "./adapter/vite": {
"types": "./dist/types/adapter/vite/index.d.ts", "types": "./dist/types/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js", "import": "./dist/adapter/vite/index.js",
+70 -27
View File
@@ -17,13 +17,21 @@ declare global {
} }
export type ApiOptions = { export type ApiOptions = {
host: string; host?: string;
user?: TApiUser;
token?: string;
headers?: Headers; headers?: Headers;
key?: string; key?: string;
localStorage?: boolean; localStorage?: boolean;
}; fetcher?: typeof fetch;
verified?: boolean;
} & (
| {
token?: string;
user?: TApiUser;
}
| {
request: Request;
}
);
export type AuthState = { export type AuthState = {
token?: string; token?: string;
@@ -42,14 +50,26 @@ export class Api {
public auth!: AuthApi; public auth!: AuthApi;
public media!: MediaApi; public media!: MediaApi;
constructor(private readonly options: ApiOptions) { constructor(private options: ApiOptions = {}) {
if (options.user) { // only mark verified if forced
this.user = options.user; this.verified = options.verified === true;
this.token_transport = "none";
this.verified = true; // prefer request if given
} else if (options.token) { 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.token_transport = "header";
this.updateToken(options.token); 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 { } else {
this.extractToken(); this.extractToken();
} }
@@ -58,7 +78,7 @@ export class Api {
} }
get baseUrl() { get baseUrl() {
return this.options.host; return this.options.host ?? "http://localhost";
} }
get tokenKey() { get tokenKey() {
@@ -66,13 +86,15 @@ export class Api {
} }
private extractToken() { private extractToken() {
// if token has to be extracted, it's never verified
this.verified = false;
if (this.options.headers) { if (this.options.headers) {
// try cookies // try cookies
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth"); const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
if (cookieToken) { if (cookieToken) {
this.updateToken(cookieToken);
this.token_transport = "cookie"; this.token_transport = "cookie";
this.verified = true; this.updateToken(cookieToken);
return; return;
} }
@@ -96,6 +118,8 @@ export class Api {
updateToken(token?: string, rebuild?: boolean) { updateToken(token?: string, rebuild?: boolean) {
this.token = token; this.token = token;
this.verified = false;
if (token) { if (token) {
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any; this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
} else { } else {
@@ -115,11 +139,15 @@ export class Api {
if (rebuild) this.buildApis(); if (rebuild) this.buildApis();
} }
markAuthVerified(verfied: boolean) { private markAuthVerified(verfied: boolean) {
this.verified = verfied; this.verified = verfied;
return this; return this;
} }
isAuthVerified(): boolean {
return this.verified;
}
getAuthState(): AuthState { getAuthState(): AuthState {
return { return {
token: this.token, token: this.token,
@@ -128,6 +156,11 @@ export class Api {
}; };
} }
isAuthenticated(): boolean {
const { token, user } = this.getAuthState();
return !!token && !!user;
}
async getVerifiedAuthState(): Promise<AuthState> { async getVerifiedAuthState(): Promise<AuthState> {
await this.verifyAuth(); await this.verifyAuth();
return this.getAuthState(); return this.getAuthState();
@@ -140,11 +173,13 @@ export class Api {
} }
try { try {
const res = await this.auth.me(); const { ok, data } = await this.auth.me();
if (!res.ok || !res.body.user) { const user = data?.user;
if (!ok || !user) {
throw new Error(); throw new Error();
} }
this.user = user;
this.markAuthVerified(true); this.markAuthVerified(true);
} catch (e) { } catch (e) {
this.markAuthVerified(false); this.markAuthVerified(false);
@@ -156,21 +191,29 @@ export class Api {
return this.user || null; return this.user || null;
} }
private buildApis() { getParams() {
const baseParams = { return Object.freeze({
host: this.options.host, host: this.baseUrl,
token: this.token, token: this.token,
headers: this.options.headers, headers: this.options.headers,
token_transport: this.token_transport 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 type { CreateUserPayload } from "auth/AppAuth";
import { Event } from "core/events"; import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono";
import { import {
type InitialModuleConfigs, type InitialModuleConfigs,
ModuleManager, ModuleManager,
@@ -132,7 +133,7 @@ export class App {
return this.modules.ctx().em; return this.modules.ctx().em;
} }
get fetch(): any { get fetch(): Hono["fetch"] {
return this.server.fetch; return this.server.fetch;
} }
@@ -155,6 +156,10 @@ export class App {
return this.modules.version(); return this.modules.version();
} }
isBuilt(): boolean {
return this.modules.isBuilt();
}
registerAdminController(config?: AdminControllerOptions) { registerAdminController(config?: AdminControllerOptions) {
// register admin // register admin
this.adminController = new AdminController(this, config); this.adminController = new AdminController(this, config);
+11 -8
View File
@@ -1,7 +1,8 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter"; import type { App } from "bknd";
import { Api, type ApiOptions, 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 = { type TAstro = {
request: Request; request: Request;
@@ -13,18 +14,20 @@ export type Options = {
host?: string; host?: string;
}; };
export function getApi(Astro: TAstro, options: Options = { mode: "static" }) { export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
return new Api({ const api = new Api({
host: new URL(Astro.request.url).origin, host: new URL(Astro.request.url).origin,
headers: options.mode === "dynamic" ? Astro.request.headers : undefined headers: options.mode === "dynamic" ? Astro.request.headers : undefined
}); });
await api.verifyAuth();
return api;
} }
let app: App; let app: App;
export function serve(config: AstroBkndConfig = {}) { export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) {
return async (args: TAstro) => { return async (args: Context) => {
if (!app) { if (!app) {
app = await createFrameworkApp(config); app = await createFrameworkApp(config, args);
} }
return app.fetch(args.request); return app.fetch(args.request);
}; };
+4 -3
View File
@@ -2,10 +2,11 @@
import path from "node:path"; import path from "node:path";
import type { App } from "bknd"; 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 type { ServeOptions } from "bun";
import { config } from "core";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
let app: App; let app: App;
@@ -15,9 +16,9 @@ export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {})
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
if (!app) { if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({ app = await createRuntimeApp({
...config, ...config,
registerLocalMedia: true,
serveStatic: serveStatic({ root }) serveStatic: serveStatic({ root })
}); });
} }
@@ -1,18 +1,17 @@
import type { CreateAppConfig } from "bknd"; import type { FrameworkBkndConfig } from "bknd/adapter";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers"; import { serveStatic } from "hono/cloudflare-workers";
import type { FrameworkBkndConfig } from "../index";
import { getCached } from "./modes/cached"; import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable"; import { getDurable } from "./modes/durable";
import { getFresh, getWarm } from "./modes/fresh"; import { getFresh, getWarm } from "./modes/fresh";
export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> & { export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
mode?: "warm" | "fresh" | "cache" | "durable"; mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (env: Env) => { bindings?: (args: Context<Env>) => {
kv?: KVNamespace; kv?: KVNamespace;
dobj?: DurableObjectNamespace; dobj?: DurableObjectNamespace;
}; };
static?: "kv" | "assets";
key?: string; key?: string;
keepAliveSeconds?: number; keepAliveSeconds?: number;
forceHttps?: boolean; forceHttps?: boolean;
@@ -21,28 +20,33 @@ export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> &
html?: string; html?: string;
}; };
export type Context = { export type Context<Env = any> = {
request: Request; request: Request;
env: any; env: Env;
ctx: ExecutionContext; ctx: ExecutionContext;
}; };
export function serve(config: CloudflareBkndConfig) { export function serve<Env = any>(config: CloudflareBkndConfig<Env>) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url); 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 pathname = url.pathname.slice(1);
const assetManifest = JSON.parse(manifest); const assetManifest = JSON.parse(config.manifest);
if (pathname && pathname in assetManifest) { if (pathname && pathname in assetManifest) {
const hono = new Hono(); const hono = new Hono();
hono.all("*", async (c, next) => { hono.all("*", async (c, next) => {
const res = await serveStatic({ const res = await serveStatic({
path: `./${pathname}`, path: `./${pathname}`,
manifest manifest: config.manifest!
})(c as any, next); })(c as any, next);
if (res instanceof Response) { if (res instanceof Response) {
const ttl = 60 * 60 * 24 * 365; const ttl = 60 * 60 * 24 * 365;
+3 -3
View File
@@ -1,8 +1,8 @@
import { createRuntimeApp } from "adapter";
import { App } from "bknd"; import { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index"; 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)!; const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app"; const key = config.key ?? "app";
@@ -37,7 +37,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Cont
}, },
adminOptions: { html: config.html } adminOptions: { html: config.html }
}, },
env { env, ctx, ...args }
); );
if (!cachedConfig) { if (!cachedConfig) {
+3 -3
View File
@@ -1,7 +1,7 @@
import { DurableObject } from "cloudflare:workers"; import { DurableObject } from "cloudflare:workers";
import { createRuntimeApp } from "adapter";
import type { CloudflareBkndConfig, Context } from "adapter/cloudflare";
import type { App, CreateAppConfig } from "bknd"; 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) { export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const { dobj } = config.bindings?.(ctx.env)!; const { dobj } = config.bindings?.(ctx.env)!;
@@ -17,7 +17,7 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const id = dobj.idFromName(key); const id = dobj.idFromName(key);
const stub = dobj.get(id) as unknown as DurableBkndApp; 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, { const res = await stub.fire(ctx.request, {
config: create_config, config: create_config,
+3 -3
View File
@@ -1,14 +1,14 @@
import { createRuntimeApp } from "adapter";
import type { App } from "bknd"; import type { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index"; 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( return await createRuntimeApp(
{ {
...config, ...config,
adminOptions: config.html ? { html: config.html } : undefined 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 } from "bknd";
import { App, type CreateAppConfig, registries } from "bknd"; import { config as $config } from "bknd/core";
import { config as $config } from "core";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
import type { AdminControllerOptions } from "modules/server/AdminController"; import type { AdminControllerOptions } from "modules/server/AdminController";
export type BkndConfig<Env = any> = CreateAppConfig & { export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((env: Env) => CreateAppConfig); app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
onBuilt?: (app: App) => Promise<void>; onBuilt?: (app: App) => Promise<void>;
beforeBuild?: (app: App) => Promise<void>; beforeBuild?: (app: App) => Promise<void>;
buildConfig?: Parameters<App["build"]>[0]; 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; distPath?: string;
}; };
export function nodeRequestToRequest(req: IncomingMessage): Request { export function makeConfig<Args = any>(config: BkndConfig<Args>, args?: Args): CreateAppConfig {
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 {
let additionalConfig: CreateAppConfig = {}; let additionalConfig: CreateAppConfig = {};
if ("app" in config && config.app) { if ("app" in config && config.app) {
if (typeof config.app === "function") { if (typeof config.app === "function") {
if (!env) { if (!args) {
throw new Error("env is required when config.app is a function"); throw new Error("args is required when config.app is a function");
} }
additionalConfig = config.app(env); additionalConfig = config.app(args);
} else { } else {
additionalConfig = config.app; additionalConfig = config.app;
} }
@@ -62,11 +32,11 @@ export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): Creat
return { ...config, ...additionalConfig }; return { ...config, ...additionalConfig };
} }
export async function createFrameworkApp<Env = any>( export async function createFrameworkApp<Args = any>(
config: FrameworkBkndConfig, config: FrameworkBkndConfig,
env?: Env args?: Args
): Promise<App> { ): Promise<App> {
const app = App.create(makeConfig(config, env)); const app = App.create(makeConfig(config, args));
if (config.onBuilt) { if (config.onBuilt) {
app.emgr.onEvent( app.emgr.onEvent(
@@ -87,20 +57,14 @@ export async function createFrameworkApp<Env = any>(
export async function createRuntimeApp<Env = any>( export async function createRuntimeApp<Env = any>(
{ {
serveStatic, serveStatic,
registerLocalMedia,
adminOptions, adminOptions,
...config ...config
}: RuntimeBkndConfig & { }: RuntimeBkndConfig & {
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler]; serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
registerLocalMedia?: boolean;
adminOptions?: AdminControllerOptions | false; adminOptions?: AdminControllerOptions | false;
}, },
env?: Env env?: Env
): Promise<App> { ): Promise<App> {
if (registerLocalMedia) {
registerLocalMediaAdapter();
}
const app = App.create(makeConfig(config, env)); const app = App.create(makeConfig(config, env));
app.emgr.onEvent( app.emgr.onEvent(
+8 -4
View File
@@ -1,6 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Api, type App } from "bknd"; import { nodeRequestToRequest } from "adapter/utils";
import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index"; import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api } from "bknd/client";
export type NextjsBkndConfig = FrameworkBkndConfig & { export type NextjsBkndConfig = FrameworkBkndConfig & {
cleanSearch?: string[]; cleanSearch?: string[];
@@ -29,8 +31,10 @@ export function createApi({ req }: GetServerSidePropsContext) {
} }
export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) { export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) {
return (ctx: GetServerSidePropsContext & { api: Api }) => { return async (ctx: GetServerSidePropsContext & { api: Api }) => {
return handler({ ...ctx, api: createApi(ctx) }); const api = createApi(ctx);
await api.verifyAuth();
return handler({ ...ctx, api });
}; };
} }
+11 -5
View File
@@ -1,6 +1,12 @@
export * from "./node.adapter"; import { registries } from "bknd";
export { import {
StorageLocalAdapter, type LocalAdapterConfig,
type LocalAdapterConfig StorageLocalAdapter
} from "../../media/storage/adapters/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 path from "node:path";
import { serve as honoServe } from "@hono/node-server"; import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/index";
import type { App } from "bknd"; import type { App } from "bknd";
import { config as $config } from "core"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index"; import { config as $config } from "bknd/core";
export type NodeBkndConfig = RuntimeBkndConfig & { export type NodeBkndConfig = RuntimeBkndConfig & {
port?: number; port?: number;
@@ -37,9 +38,9 @@ export function serve({
hostname, hostname,
fetch: async (req: Request) => { fetch: async (req: Request) => {
if (!app) { if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({ app = await createRuntimeApp({
...config, ...config,
registerLocalMedia: true,
serveStatic: serveStatic({ root }) serveStatic: serveStatic({ root })
}); });
} }
+3 -1
View File
@@ -1,9 +1,11 @@
import { useAuth } from "bknd/client";
import type { BkndAdminProps } from "bknd/ui"; import type { BkndAdminProps } from "bknd/ui";
import { Suspense, lazy, useEffect, useState } from "react"; import { Suspense, lazy, useEffect, useState } from "react";
export function adminPage(props?: BkndAdminProps) { export function adminPage(props?: BkndAdminProps) {
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin }))); const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
return () => { return () => {
const auth = useAuth();
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -13,7 +15,7 @@ export function adminPage(props?: BkndAdminProps) {
return ( return (
<Suspense> <Suspense>
<Admin {...props} /> <Admin withProvider={{ user: auth.user }} {...props} />
</Suspense> </Suspense>
); );
}; };
+28 -5
View File
@@ -1,14 +1,37 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
import type { App } from "bknd"; 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; let app: App;
export function serve(config: RemixBkndConfig = {}) { export function serve<Args extends RemixContext = RemixContext>(
return async (args: { request: Request }) => { config: RemixBkndConfig<Args> = {}
) {
return async (args: Args) => {
if (!app) { if (!app) {
app = await createFrameworkApp(config); app = await createFrameworkApp(config, args);
} }
return app.fetch(args.request); 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 { serveStatic } from "@hono/node-server/serve-static";
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server"; import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
import type { App } from "bknd"; import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config"; import { devServerConfig } from "./dev-server-config";
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & { export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
@@ -28,10 +29,10 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
} }
async function createApp(config: ViteBkndConfig = {}, env?: any) { async function createApp(config: ViteBkndConfig = {}, env?: any) {
registerLocalMediaAdapter();
return await createRuntimeApp( return await createRuntimeApp(
{ {
...config, ...config,
registerLocalMedia: true,
adminOptions: adminOptions:
config.setAdminHtml === false config.setAdminHtml === false
? undefined ? undefined
+8 -2
View File
@@ -15,7 +15,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
} }
async login(strategy: string, input: any) { 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) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(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) { 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) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(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; 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>) { private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
const actions = strategy.getActions?.(); const actions = strategy.getActions?.();
if (!actions) { if (!actions) {
@@ -96,7 +105,10 @@ export class AuthController extends Controller {
hono.get("/me", auth(), async (c) => { hono.get("/me", auth(), async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) { 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); 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) { async requestCookieRefresh(c: Context) {
if (this.config.cookie.renew) { if (this.config.cookie.renew && this.isUserLoggedIn()) {
const token = await this.getAuthCookie(c); const token = await this.getAuthCookie(c);
if (token) { if (token) {
await this.setAuthCookie(c, token); await this.setAuthCookie(c, token);
@@ -299,8 +299,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
} }
private getSuccessPath(c: Context) { private getSafeUrl(c: Context, path: string) {
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/"); const p = path.replace(/\/+$/, "/");
// nextjs doesn't support non-fq urls // nextjs doesn't support non-fq urls
// but env could be proxied (stackblitz), so we shouldn't fq every url // 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) { async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
if (this.isJsonRequest(c)) { const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/");
return c.json(data);
}
const successUrl = this.getSuccessPath(c);
const referer = redirect ?? c.req.header("Referer") ?? successUrl; const referer = redirect ?? c.req.header("Referer") ?? successUrl;
//console.log("auth respond", { redirect, successUrl, successPath }); //console.log("auth respond", { redirect, successUrl, successPath });
if ("token" in data) { if ("token" in data) {
await this.setAuthCookie(c, data.token); await this.setAuthCookie(c, data.token);
if (this.isJsonRequest(c)) {
return c.json(data);
}
// can't navigate to "/" doesn't work on nextjs // can't navigate to "/" doesn't work on nextjs
//console.log("auth success, redirecting to", successUrl); //console.log("auth success, redirecting to", successUrl);
return c.redirect(successUrl); return c.redirect(successUrl);
} }
if (this.isJsonRequest(c)) {
return c.json(data, 400);
}
let message = "An error occured"; let message = "An error occured";
if (data instanceof Exception) { if (data instanceof Exception) {
message = data.message; message = data.message;
@@ -1,4 +1,5 @@
import type { Authenticator, Strategy } from "auth"; import type { Authenticator, Strategy } from "auth";
import { isDebug, tbValidator as tb } from "core";
import { type Static, StringEnum, Type, parse } from "core/utils"; import { type Static, StringEnum, Type, parse } from "core/utils";
import { hash } from "core/utils"; import { hash } from "core/utils";
import { type Context, Hono } from "hono"; import { type Context, Hono } from "hono";
@@ -56,26 +57,56 @@ export class PasswordStrategy implements Strategy {
const hono = new Hono(); const hono = new Hono();
return hono return hono
.post("/login", async (c) => { .post(
const body = await authenticator.getBody(c); "/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 { try {
const payload = await this.login(body); const payload = await this.login(body);
const data = await authenticator.resolve("login", this, payload.password, payload); const data = await authenticator.resolve(
"login",
this,
payload.password,
payload
);
return await authenticator.respond(c, data); return await authenticator.respond(c, data, redirect);
} catch (e) { } catch (e) {
return await authenticator.respond(c, e); return await authenticator.respond(c, e);
}
} }
}) )
.post("/register", async (c) => { .post(
const body = await authenticator.getBody(c); "/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 payload = await this.register(body);
const data = await authenticator.resolve("register", this, payload.password, payload); 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 { 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 { import {
Default, Default,
type Static, type Static,
type TObject, type TObject,
getFullPathKeys, getFullPathKeys,
mark, mergeObjectWith,
parse, parse,
stripMark stripMark
} from "../utils"; } from "../utils";
@@ -33,7 +33,7 @@ export class SchemaObject<Schema extends TObject> {
) { ) {
this._default = Default(_schema, {} as any) as any; this._default = Default(_schema, {} as any) as any;
this._value = initial this._value = initial
? parse(_schema, cloneDeep(initial as any), { ? parse(_schema, structuredClone(initial as any), {
forceParse: this.isForceParse(), forceParse: this.isForceParse(),
skipMark: this.isForceParse() skipMark: this.isForceParse()
}) })
@@ -64,8 +64,12 @@ export class SchemaObject<Schema extends TObject> {
return this._config; return this._config;
} }
clone() {
return structuredClone(this._config);
}
async set(config: Static<Schema>, noEmit?: boolean): Promise<Static<Schema>> { 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, forceParse: true,
skipMark: this.isForceParse() 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>]> { 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; const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial); this.throwIfRestricted(partial);
@@ -122,11 +126,13 @@ export class SchemaObject<Schema extends TObject> {
// overwrite arrays and primitives, only deep merge objects // overwrite arrays and primitives, only deep merge objects
// @ts-ignore // @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)) { if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return srcValue; return srcValue;
} }
}); });
//console.log("---new", _jsonp(config));
//console.log("overwritePaths", this.options?.overwritePaths); //console.log("overwritePaths", this.options?.overwritePaths);
if (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); const newConfig = await this.set(config);
return [partial, newConfig]; return [partial, newConfig];
} }
async overwrite(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> { 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; const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial); this.throwIfRestricted(partial);
@@ -192,7 +198,7 @@ export class SchemaObject<Schema extends TObject> {
if (p.length > 1) { if (p.length > 1) {
const parent = p.slice(0, -1).join("."); const parent = p.slice(0, -1).join(".");
if (!has(this._config, parent)) { 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`); 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`); 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 removed = get(current, path) as Partial<Static<Schema>>;
const config = omit(current, path); const config = omit(current, path);
const newConfig = await this.set(config); 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); 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 { export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
return Object.entries(obj).reduce((acc, [key, value]) => { return Object.entries(obj).reduce((acc, [key, value]) => {
try { 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. * Deep merge two objects.
* @param target * @param target
@@ -197,3 +210,152 @@ export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj):
return acc; return acc;
}, {} as any); }, {} 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": case "SCREAMING_SNAKE_CASE":
return snakeToPascalWithSpaces(str.toLowerCase()); return snakeToPascalWithSpaces(str.toLowerCase());
case "unknown": case "unknown":
return str; return ucFirst(str);
} }
} }
export function autoFormatString(str: string) {
return identifierToHumanReadable(str);
}
export function kebabToPascalWithSpaces(str: string): string { export function kebabToPascalWithSpaces(str: string): string {
return str.split("-").map(ucFirst).join(" "); return str.split("-").map(ucFirst).join(" ");
+1 -2
View File
@@ -21,7 +21,6 @@ import {
type ValueErrorIterator type ValueErrorIterator
} from "@sinclair/typebox/errors"; } from "@sinclair/typebox/errors";
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value"; import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
import { cloneDeep } from "lodash-es";
export type RecursivePartial<T> = { export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[] [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) { export function stripMark<O = any>(obj: O) {
const newObj = cloneDeep(obj); const newObj = structuredClone(obj);
mark(newObj, false); mark(newObj, false);
return newObj as O; 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> { export class AppData extends Module<typeof dataConfigSchema> {
override async build() { 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); return constructEntity(name, entityConfig);
}); });
@@ -21,14 +29,14 @@ export class AppData extends Module<typeof dataConfigSchema> {
const name = typeof _e === "string" ? _e : _e.name; const name = typeof _e === "string" ? _e : _e.name;
const entity = entities[name]; const entity = entities[name];
if (entity) return entity; 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) constructRelation(relation, _entity)
); );
const indices = transformObject(this.config.indices ?? {}, (index, name) => { const indices = transformObject(_indices, (index, name) => {
const entity = _entity(index.entity)!; const entity = _entity(index.entity)!;
const fields = index.fields.map((f) => entity.field(f)!); const fields = index.fields.map((f) => entity.field(f)!);
return new EntityIndex(entity, fields, index.unique, name); 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.guard.registerPermissions(Object.values(DataPermissions));
this.ctx.logger.clear();
this.setBuilt(); this.setBuilt();
} }
+2
View File
@@ -28,3 +28,5 @@ export const DatabaseEvents = {
export { MutatorEvents, RepositoryEvents }; export { MutatorEvents, RepositoryEvents };
export * as DataPermissions from "./permissions"; export * as DataPermissions from "./permissions";
export { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
+44 -14
View File
@@ -5,7 +5,8 @@ import {
type StaticDecode, type StaticDecode,
StringEnum, StringEnum,
Type, Type,
Value Value,
isObject
} from "core/utils"; } from "core/utils";
import { WhereBuilder, type WhereQuery } from "../entities"; import { WhereBuilder, type WhereQuery } from "../entities";
@@ -71,22 +72,51 @@ export type RepoWithSchema = Record<
>; >;
export const withSchema = <TSelf extends TThis>(Self: TSelf) => 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) => { .Decode((value) => {
let _value = typeof value === "string" ? [value] : value; // images
// images,comments
// ["images","comments"]
// { "images": {} }
if (Array.isArray(value)) { if (!Array.isArray(value) && isObject(value)) {
if (!value.every((v) => typeof v === "string")) { console.log("is object");
throw new Error("Invalid 'with' schema"); return value as RepoWithSchema;
}
_value = value.reduce((acc, v) => {
acc[v] = {};
return acc;
}, {} 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); .Encode((value) => value);
@@ -117,7 +147,7 @@ export type RepoQueryIn = {
offset?: number; offset?: number;
sort?: string | { by: string; dir: "asc" | "desc" }; sort?: string | { by: string; dir: "asc" | "desc" };
select?: string[]; select?: string[];
with?: string[] | Record<string, RepoQueryIn>; with?: string | string[] | Record<string, RepoQueryIn>;
join?: string[]; join?: string[];
where?: WhereQuery; where?: WhereQuery;
}; };
+2 -2
View File
@@ -12,5 +12,5 @@ export {
export * as middlewares from "modules/middlewares"; export * as middlewares from "modules/middlewares";
export { registries } from "modules/registries"; export { registries } from "modules/registries";
export type * from "./adapter"; export type { MediaFieldSchema } from "media/AppMedia";
export { Api, type ApiOptions } from "./Api"; export type { UserFieldSchema } from "auth/AppAuth";
+2 -1
View File
@@ -16,7 +16,8 @@ export function buildMediaSchema() {
config: adapter.schema config: adapter.schema
}, },
{ {
title: name, title: adapter.schema.title ?? name,
description: adapter.schema.description,
additionalProperties: false additionalProperties: false
} }
); );
@@ -9,7 +9,7 @@ export const cloudinaryAdapterConfig = Type.Object(
api_secret: Type.String(), api_secret: Type.String(),
upload_preset: Type.Optional(Type.String()) upload_preset: Type.Optional(Type.String())
}, },
{ title: "Cloudinary" } { title: "Cloudinary", description: "Cloudinary media storage" }
); );
export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>; export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>;
@@ -7,7 +7,7 @@ export const localAdapterConfig = Type.Object(
{ {
path: Type.String({ default: "./" }) path: Type.String({ default: "./" })
}, },
{ title: "Local" } { title: "Local", description: "Local file system storage" }
); );
export type LocalAdapterConfig = Static<typeof localAdapterConfig>; 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 { App } from "App";
import type { Guard } from "auth"; import type { Guard } from "auth";
import { SchemaObject } from "core"; import { type DebugLogger, SchemaObject } from "core";
import type { EventManager } from "core/events"; import type { EventManager } from "core/events";
import type { Static, TSchema } from "core/utils"; import type { Static, TSchema } from "core/utils";
import { import {
@@ -35,6 +35,7 @@ export type ModuleBuildContext = {
em: EntityManager; em: EntityManager;
emgr: EventManager<any>; emgr: EventManager<any>;
guard: Guard; guard: Guard;
logger: DebugLogger;
flags: (typeof Module)["ctx_flags"]; 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 type TInput = string | (string | number | PrimaryFieldType)[];
export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModuleApiOptions> { 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> { protected getDefaultOptions(): Partial<Options> {
return {}; return {};
+35 -14
View File
@@ -68,6 +68,12 @@ export type InitialModuleConfigs =
} & ModuleConfigs) } & ModuleConfigs)
| PartialRec<ModuleConfigs>; | PartialRec<ModuleConfigs>;
enum Verbosity {
silent = 0,
error = 1,
log = 2
}
export type ModuleManagerOptions = { export type ModuleManagerOptions = {
initial?: InitialModuleConfigs; initial?: InitialModuleConfigs;
eventManager?: EventManager<any>; eventManager?: EventManager<any>;
@@ -85,6 +91,8 @@ export type ModuleManagerOptions = {
trustFetched?: boolean; trustFetched?: boolean;
// runs when initial config provided on a fresh database // runs when initial config provided on a fresh database
seed?: (ctx: ModuleBuildContext) => Promise<void>; seed?: (ctx: ModuleBuildContext) => Promise<void>;
// wether
verbosity?: Verbosity;
}; };
type ConfigTable<Json = ModuleConfigs> = { type ConfigTable<Json = ModuleConfigs> = {
@@ -135,7 +143,7 @@ export class ModuleManager {
private _built = false; private _built = false;
private readonly _booted_with?: "provided" | "partial"; private readonly _booted_with?: "provided" | "partial";
private logger = new DebugLogger(false); private logger: DebugLogger;
constructor( constructor(
private readonly connection: Connection, private readonly connection: Connection,
@@ -144,6 +152,7 @@ export class ModuleManager {
this.__em = new EntityManager([__bknd], this.connection); this.__em = new EntityManager([__bknd], this.connection);
this.modules = {} as Modules; this.modules = {} as Modules;
this.emgr = new EventManager(); this.emgr = new EventManager();
this.logger = new DebugLogger(this.verbosity === Verbosity.log);
const context = this.ctx(true); const context = this.ctx(true);
let initial = {} as Partial<ModuleConfigs>; 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 * This is set through module's setListener
* It's called everytime a module's config is updated in SchemaObject * It's called everytime a module's config is updated in SchemaObject
@@ -231,7 +248,8 @@ export class ModuleManager {
em: this.em, em: this.em,
emgr: this.emgr, emgr: this.emgr,
guard: this.guard, 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(); const startTime = performance.now();
// disabling console log, because the table might not exist yet // disabling console log, because the table might not exist yet
const result = await withDisabledConsole(async () => { const result = await withDisabledConsole(
const { data: result } = await this.repo().findOne( async () => {
{ type: "config" }, const { data: result } = await this.repo().findOne(
{ { type: "config" },
sort: { by: "version", dir: "desc" } {
sort: { by: "version", dir: "desc" }
}
);
if (!result) {
throw BkndError.with("no config");
} }
);
if (!result) { return result as unknown as ConfigTable;
throw BkndError.with("no config"); },
} this.verbosity > Verbosity.silent ? [] : ["log", "error", "warn"]
);
return result as unknown as ConfigTable;
}, ["log", "error", "warn"]);
this.logger this.logger
.log("took", performance.now() - startTime, "ms", { .log("took", performance.now() - startTime, "ms", {
+6 -1
View File
@@ -70,7 +70,8 @@ export class AdminController extends Controller {
hono.use("*", async (c, next) => { hono.use("*", async (c, next) => {
const obj = { const obj = {
user: auth.authenticator?.getUser(), 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); const html = await this.getHtml(obj);
if (!html) { if (!html) {
@@ -190,6 +191,10 @@ export class AdminController extends Controller {
/> />
<link rel="icon" href={favicon} type="image/x-icon" /> <link rel="icon" href={favicon} type="image/x-icon" />
<title>BKND</title> <title>BKND</title>
{/*<script
crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js"
/>*/}
{isProd ? ( {isProd ? (
<Fragment> <Fragment>
<script <script
+2 -2
View File
@@ -3,6 +3,7 @@ import { Notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "modules"; import type { ModuleConfigs } from "modules";
import React from "react"; import React from "react";
import { BkndProvider, useBknd } from "ui/client/bknd"; import { BkndProvider, useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
import { Logo } from "ui/components/display/Logo"; import { Logo } from "ui/components/display/Logo";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { FlashMessage } from "ui/modules/server/FlashMessage"; import { FlashMessage } from "ui/modules/server/FlashMessage";
@@ -40,8 +41,7 @@ export default function Admin({
} }
function AdminInternal() { function AdminInternal() {
const b = useBknd(); const { theme } = useTheme();
const theme = b.app.getAdminConfig().color_scheme;
return ( return (
<MantineProvider {...createMantineTheme(theme ?? "light")}> <MantineProvider {...createMantineTheme(theme ?? "light")}>
+25 -12
View File
@@ -1,10 +1,7 @@
import { IconAlertHexagon } from "@tabler/icons-react";
import type { ModuleConfigs, ModuleSchemas } from "modules"; import type { ModuleConfigs, ModuleSchemas } from "modules";
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager"; import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react"; import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
import { useApi } from "ui/client"; 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 { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced"; import { AppReduced } from "./utils/AppReduced";
@@ -18,11 +15,18 @@ type BkndContext = {
actions: ReturnType<typeof getSchemaActions>; actions: ReturnType<typeof getSchemaActions>;
app: AppReduced; app: AppReduced;
adminOverride?: ModuleConfigs["server"]["admin"]; adminOverride?: ModuleConfigs["server"]["admin"];
fallback: boolean;
}; };
const BkndContext = createContext<BkndContext>(undefined!); const BkndContext = createContext<BkndContext>(undefined!);
export type { TSchemaActions }; export type { TSchemaActions };
enum Fetching {
None = 0,
Schema = 1,
Secrets = 2
}
export function BkndProvider({ export function BkndProvider({
includeSecrets = false, includeSecrets = false,
adminOverride, adminOverride,
@@ -34,10 +38,11 @@ export function BkndProvider({
>) { >) {
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets); const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
const [schema, setSchema] = const [schema, setSchema] =
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>(); useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions" | "fallback">>();
const [fetched, setFetched] = useState(false); const [fetched, setFetched] = useState(false);
const [error, setError] = useState<boolean>(); const [error, setError] = useState<boolean>();
const errorShown = useRef<boolean>(); const errorShown = useRef<boolean>();
const fetching = useRef<Fetching>(Fetching.None);
const [local_version, set_local_version] = useState(0); const [local_version, set_local_version] = useState(0);
const api = useApi(); const api = useApi();
@@ -46,7 +51,12 @@ export function BkndProvider({
} }
async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) { async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) {
const requesting = withSecrets ? Fetching.Secrets : Fetching.Schema;
if (fetching.current === requesting) return;
if (withSecrets && !force) return; if (withSecrets && !force) return;
fetching.current = requesting;
const res = await api.system.readSchema({ const res = await api.system.readSchema({
config: true, config: true,
secrets: _includeSecrets secrets: _includeSecrets
@@ -57,32 +67,35 @@ export function BkndProvider({
errorShown.current = true; errorShown.current = true;
setError(true); setError(true);
return; // if already has schema, don't overwrite
if (fetched && schema?.schema) return;
} else if (error) { } else if (error) {
setError(false); setError(false);
} }
const schema = res.ok const newSchema = res.ok
? res.body ? res.body
: ({ : ({
version: 0, version: 0,
schema: getDefaultSchema(), schema: getDefaultSchema(),
config: getDefaultConfig(), config: getDefaultConfig(),
permissions: [] permissions: [],
fallback: true
} as any); } as any);
if (adminOverride) { if (adminOverride) {
schema.config.server.admin = { newSchema.config.server.admin = {
...schema.config.server.admin, ...newSchema.config.server.admin,
...adminOverride ...adminOverride
}; };
} }
startTransition(() => { startTransition(() => {
setSchema(schema); setSchema(newSchema);
setWithSecrets(_includeSecrets); setWithSecrets(_includeSecrets);
setFetched(true); setFetched(true);
set_local_version((v) => v + 1); set_local_version((v) => v + 1);
fetching.current = Fetching.None;
}); });
} }
@@ -106,7 +119,7 @@ export function BkndProvider({
value={{ ...schema, actions, requireSecrets, app, adminOverride, hasSecrets }} value={{ ...schema, actions, requireSecrets, app, adminOverride, hasSecrets }}
key={local_version} key={local_version}
> >
{error && ( {/*{error && (
<Alert.Exception className="gap-2"> <Alert.Exception className="gap-2">
<IconAlertHexagon /> <IconAlertHexagon />
You attempted to load system configuration with secrets without having proper You attempted to load system configuration with secrets without having proper
@@ -115,7 +128,7 @@ export function BkndProvider({
<Button variant="red">Reload</Button> <Button variant="red">Reload</Button>
</a> </a>
</Alert.Exception> </Alert.Exception>
)} )}*/}
{children} {children}
</BkndContext.Provider> </BkndContext.Provider>
+2 -1
View File
@@ -30,7 +30,7 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps)
console.error("error .....", e); 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 }); const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
return ( return (
@@ -60,6 +60,7 @@ export const useBaseUrl = () => {
type BkndWindowContext = { type BkndWindowContext = {
user?: TApiUser; user?: TApiUser;
logout_route: string; logout_route: string;
color_scheme?: "light" | "dark";
}; };
export function useBkndWindowContext(): BkndWindowContext { export function useBkndWindowContext(): BkndWindowContext {
if (typeof window !== "undefined" && window.__BKND__) { if (typeof window !== "undefined" && window.__BKND__) {
+1 -1
View File
@@ -9,4 +9,4 @@ export {
export * from "./api/use-api"; export * from "./api/use-api";
export * from "./api/use-entity"; export * from "./api/use-entity";
export { useAuth } from "./schema/auth/use-auth"; 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 { return {
config: async (partial: Partial<TAppDataEntity["config"]>): Promise<boolean> => { config: async (partial: Partial<TAppDataEntity["config"]>): Promise<boolean> => {
console.log("patch config", entityName, partial); 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) 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 { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
export function useBkndSystem() { export function useBkndSystem() {
const { config, schema, actions: bkndActions } = useBknd(); const { config, schema, actions: bkndActions } = useBknd();
const theme = config.server.admin.color_scheme ?? "light"; const { theme } = useTheme();
const actions = { const actions = {
theme: { theme: {
+14 -4
View File
@@ -1,8 +1,18 @@
import { useBkndWindowContext } from "ui/client/ClientProvider";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
export function useTheme(): { theme: "light" | "dark" } { export type Theme = "light" | "dark";
const b = useBknd();
const theme = b.app.getAdminConfig().color_scheme as any;
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 type React from "react";
import { Children } from "react";
import { forwardRef } from "react"; import { forwardRef } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Link } from "ui/components/wouter/Link"; 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", default: "bg-primary/5 hover:bg-primary/10 link text-primary/70",
primary: "bg-primary hover:bg-primary/80 link text-background", primary: "bg-primary hover:bg-primary/80 link text-background",
ghost: "bg-transparent hover:bg-primary/5 link text-primary/70", 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", red: "dark:bg-red-950 dark:hover:bg-red-900 bg-red-100 hover:bg-red-200 link text-primary/70",
subtlered: subtlered:
"dark:text-red-950 text-red-700 dark:hover:bg-red-900 bg-transparent hover:bg-red-50 link" "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) => ({ }: BaseProps) => ({
...props, ...props,
className: twMerge( 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"], sizes[size ?? "default"],
styles[variant ?? "default"], styles[variant ?? "default"],
props.className props.className
@@ -58,7 +59,11 @@ const Base = ({
children: ( children: (
<> <>
{IconLeft && <IconLeft size={iconSize} {...iconProps} />} {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} />} {IconRight && <IconRight size={iconSize} {...iconProps} />}
</> </>
) )
@@ -1,4 +1,3 @@
import { IconCopy } from "@tabler/icons-react";
import { TbCopy } from "react-icons/tb"; import { TbCopy } from "react-icons/tb";
import { JsonView } from "react-json-view-lite"; import { JsonView } from "react-json-view-lite";
import { twMerge } from "tailwind-merge"; 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({ export function Logo({
scale = 0.2, scale = 0.2,
fill, fill,
theme = "light" ...props
}: { scale?: number; fill?: string; theme?: string }) { }: { scale?: number; fill?: string; theme?: string }) {
const $bknd = useBknd(); const t = useTheme();
const _theme = theme ?? $bknd?.app?.getAdminConfig().color_scheme ?? "light"; const theme = props.theme ?? t.theme;
const svgFill = fill ? fill : _theme === "light" ? "black" : "white"; const svgFill = fill ? fill : theme === "light" ? "black" : "white";
const dim = { const dim = {
width: Math.round(578 * scale), width: Math.round(578 * scale),
@@ -3,9 +3,10 @@ import { forwardRef, useEffect, useState } from "react";
export const BooleanInputMantine = forwardRef<HTMLInputElement, React.ComponentProps<"input">>( export const BooleanInputMantine = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
(props, ref) => { (props, ref) => {
const [checked, setChecked] = useState(Boolean(props.value)); const [checked, setChecked] = useState(Boolean(props.value ?? props.defaultValue));
useEffect(() => { useEffect(() => {
console.log("value change", props.value);
setChecked(Boolean(props.value)); setChecked(Boolean(props.value));
}, [props.value]); }, [props.value]);
+120 -29
View File
@@ -1,25 +1,41 @@
import { getBrowser } from "core/utils"; import { getBrowser } from "core/utils";
import type { Field } from "data"; 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 { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { useEvent } from "ui/hooks/use-event"; 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, error,
as,
...props ...props
}) => ( }: React.ComponentProps<E> & { error?: boolean; as?: E }) => {
<div const Tag = as || "div";
{...props}
className={twMerge(
"flex flex-col gap-1.5",
error && "text-red-500", return (
props.className <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) => { export const formElementFactory = (element: string, props: any) => {
switch (element) { 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 }> = ({ export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
field, field,
@@ -145,20 +175,81 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
} }
); );
export const Select = forwardRef<HTMLSelectElement, React.ComponentProps<"select">>( export type SwitchValue = boolean | 1 | 0 | "true" | "false" | "on" | "off";
(props, ref) => ( export const Switch = forwardRef<
<div className="flex w-full relative"> HTMLButtonElement,
<select Pick<
{...props} ComponentPropsWithoutRef<"input">,
ref={ref} "name" | "required" | "disabled" | "checked" | "defaultChecked" | "id" | "type"
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", value?: SwitchValue;
"appearance-none h-11 w-full", onChange?: (e: { target: { value: boolean } }) => void;
"border-r-8 border-r-transparent", onCheckedChange?: (checked: boolean) => void;
props.className }
)} >(({ 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; uiSchema?: any;
direction?: "horizontal" | "vertical"; direction?: "horizontal" | "vertical";
onChange?: (value: any, isValid: () => boolean) => void; onChange?: (value: any, isValid: () => boolean) => void;
cleanOnChange?: boolean;
}; };
export type JsonSchemaFormRef = { export type JsonSchemaFormRef = {
@@ -36,6 +37,7 @@ export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>
templates, templates,
fields, fields,
widgets, widgets,
cleanOnChange,
...props ...props
}, },
ref ref
@@ -51,8 +53,8 @@ export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>
return false; return false;
}; };
const handleChange = ({ formData }: any, e) => { const handleChange = ({ formData }: any, e) => {
const clean = JSON.parse(JSON.stringify(formData)); const clean = cleanOnChange !== false ? JSON.parse(JSON.stringify(formData)) : formData;
//console.log("Data changed: ", clean, JSON.stringify(formData, null, 2)); console.log("Data changed: ", clean, { cleanOnChange });
setValue(clean); setValue(clean);
onChange?.(clean, () => isValid(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 { Modal, type ModalProps, Popover } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { IconBug } from "@tabler/icons-react"; import { IconBug } from "@tabler/icons-react";
import { ScrollArea } from "radix-ui";
import { Fragment, forwardRef, useImperativeHandle } from "react"; import { Fragment, forwardRef, useImperativeHandle } from "react";
import { TbX } from "react-icons/tb"; import { TbX } from "react-icons/tb";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
+10 -6
View File
@@ -1,7 +1,7 @@
import { useClickOutside } from "@mantine/hooks"; 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 { twMerge } from "tailwind-merge";
import { useEvent } from "../../hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
export type PopoverProps = { export type PopoverProps = {
className?: string; className?: string;
@@ -10,6 +10,7 @@ export type PopoverProps = {
backdrop?: boolean; backdrop?: boolean;
target: (props: { toggle: () => void }) => ReactElement; target: (props: { toggle: () => void }) => ReactElement;
children: ReactElement<{ onClick: () => void }>; children: ReactElement<{ onClick: () => void }>;
overlayProps?: ComponentPropsWithoutRef<"div">;
}; };
export function Popover({ export function Popover({
@@ -18,20 +19,21 @@ export function Popover({
defaultOpen = false, defaultOpen = false,
backdrop = false, backdrop = false,
position = "bottom-start", position = "bottom-start",
className, overlayProps,
className
}: PopoverProps) { }: PopoverProps) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const clickoutsideRef = useClickOutside(() => setOpen(false)); const clickoutsideRef = useClickOutside(() => setOpen(false));
const toggle = useEvent((delay: number = 50) => 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 = { const pos = {
"bottom-start": "mt-1 top-[100%]", "bottom-start": "mt-1 top-[100%]",
"bottom-end": "right-0 top-[100%] mt-1", "bottom-end": "right-0 top-[100%] mt-1",
"top-start": "bottom-[100%] mb-1", "top-start": "bottom-[100%] mb-1",
"top-end": "bottom-[100%] right-0 mb-1", "top-end": "bottom-[100%] right-0 mb-1"
}[position]; }[position];
return ( return (
@@ -43,9 +45,11 @@ export function Popover({
{cloneElement(children as any, { onClick: toggle })} {cloneElement(children as any, { onClick: toggle })}
{open && ( {open && (
<div <div
{...overlayProps}
className={twMerge( 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-full max-w-20",
pos, pos,
overlayProps?.className
)} )}
> >
{target({ toggle })} {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 type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
import clsx from "clsx"; import clsx from "clsx";
import { type TSchema, Type, Value } from "core/utils"; import { Type } from "core/utils";
import { Form, type Validator } from "json-schema-form-react"; import { Form } from "json-schema-form-react";
import { transform } from "lodash-es"; import { transform } from "lodash-es";
import type { ComponentPropsWithoutRef } from "react"; import type { ComponentPropsWithoutRef } from "react";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Group, Input, Label } from "ui/components/form/Formy/components"; import { Group, Input, Label } from "ui/components/form/Formy/components";
import { SocialLink } from "./SocialLink"; 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"> & { export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
className?: string; className?: string;
formData?: any; formData?: any;
@@ -18,14 +27,7 @@ export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" |
buttonLabel?: string; 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 validator = new TypeboxValidator();
const schema = Type.Object({ const schema = Type.Object({
email: Type.String({ email: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$" pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
@@ -82,7 +84,7 @@ export function AuthForm({
<Form <Form
method={method} method={method}
action={password.action} action={password.action}
{...props} {...(props as any)}
schema={schema} schema={schema}
validator={validator} validator={validator}
validationMode="change" validationMode="change"
+15 -2
View File
@@ -7,10 +7,23 @@ export type AuthScreenProps = {
action?: "login" | "register"; action?: "login" | "register";
logo?: ReactNode; logo?: ReactNode;
intro?: 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 { strategies, basepath, loading } = useAuthStrategies();
const Form = <AuthForm auth={{ basepath, strategies }} method={method} action={action} />;
if (formOnly) {
if (loading) return null;
return Form;
}
return ( return (
<div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin"> <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> <p className="text-primary/50">Enter your credentials below to get access.</p>
</div> </div>
)} )}
<AuthForm auth={{ basepath, strategies }} method={method} action={action} /> {Form}
</div> </div>
)} )}
</div> </div>
+10 -2
View File
@@ -22,9 +22,17 @@ export function SocialLink({
basepath = "/api/auth", basepath = "/api/auth",
children children
}: SocialLinkProps) { }: SocialLinkProps) {
const url = [basepath, provider, action].join("/");
return ( return (
<form method={method} action={[basepath, name, action].join("/")} className="w-full"> <form method={method} action={url} className="w-full">
<Button type="submit" size="large" variant="outline" className="justify-center w-full"> <Button
type="submit"
size="large"
variant="outline"
className="justify-center w-full"
IconLeft={icon}
>
Continue with {label ?? ucFirstAllSnakeToPascalWithSpaces(provider)} Continue with {label ?? ucFirstAllSnakeToPascalWithSpaces(provider)}
</Button> </Button>
{children} {children}
+3
View File
@@ -1,3 +1,4 @@
import { useAuthStrategies } from "../hooks/use-auth";
import { AuthForm } from "./AuthForm"; import { AuthForm } from "./AuthForm";
import { AuthScreen } from "./AuthScreen"; import { AuthScreen } from "./AuthScreen";
import { SocialLink } from "./SocialLink"; import { SocialLink } from "./SocialLink";
@@ -7,3 +8,5 @@ export const Auth = {
Form: AuthForm, Form: AuthForm,
SocialLink: SocialLink SocialLink: SocialLink
}; };
export { useAuthStrategies };
+2 -1
View File
@@ -1,2 +1,3 @@
export { Auth } from "./auth"; export * from "./auth";
export * from "./media"; export * from "./media";
export * from "../components/form/native-form/NativeForm";
+29 -16
View File
@@ -37,19 +37,20 @@ export type DropzoneRenderProps = {
deleteFile: (file: FileState) => Promise<void>; deleteFile: (file: FileState) => Promise<void>;
openFileInput: () => void; openFileInput: () => void;
}; };
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload">; dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
}; };
export type DropzoneProps = { export type DropzoneProps = {
getUploadInfo: (file: FileWithPath) => { url: string; headers?: Headers; method?: string }; getUploadInfo: (file: FileWithPath) => { url: string; headers?: Headers; method?: string };
handleDelete: (file: FileState) => Promise<boolean>; handleDelete: (file: FileState) => Promise<boolean>;
initialItems?: FileState[]; initialItems?: FileState[];
flow?: "start" | "end";
maxItems?: number; maxItems?: number;
overwrite?: boolean; overwrite?: boolean;
autoUpload?: boolean; autoUpload?: boolean;
onRejected?: (files: FileWithPath[]) => void; onRejected?: (files: FileWithPath[]) => void;
onDeleted?: (file: FileState) => void; onDeleted?: (file: FileState) => void;
onUploaded?: (file: FileState) => void; onUploaded?: (files: FileState[]) => void;
placeholder?: { placeholder?: {
show?: boolean; show?: boolean;
text?: string; text?: string;
@@ -61,6 +62,7 @@ export function Dropzone({
getUploadInfo, getUploadInfo,
handleDelete, handleDelete,
initialItems = [], initialItems = [],
flow = "start",
maxItems, maxItems,
overwrite, overwrite,
autoUpload, autoUpload,
@@ -133,7 +135,7 @@ export function Dropzone({
progress: 0 progress: 0
})); }));
return [..._prev, ...filteredFiles]; return flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles];
}); });
if (autoUpload) { if (autoUpload) {
@@ -164,6 +166,8 @@ export function Dropzone({
for (const file of pendingFiles) { for (const file of pendingFiles) {
await uploadFileProgress(file); await uploadFileProgress(file);
} }
setUploading(false);
onUploaded?.(files);
} }
})(); })();
} }
@@ -219,19 +223,20 @@ export function Dropzone({
return; return;
} }
const { url, headers, method = "POST" } = getUploadInfo(file.body); const uploadInfo = getUploadInfo(file.body);
console.log("dropzone:uploadInfo", uploadInfo);
const { url, headers, method = "POST" } = uploadInfo;
const formData = new FormData(); const formData = new FormData();
formData.append("file", file.body); formData.append("file", file.body);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const urlWithParams = new URL(url); console.log("xhr:url", url);
const searchParams = new URLSearchParams();
if (overwrite) { if (overwrite) {
urlWithParams.searchParams.append("overwrite", "1"); searchParams.append("overwrite", "1");
} }
console.log("url", urlWithParams.toString());
//return;
xhr.open(method, urlWithParams.toString(), true); xhr.open(method, String(url) + "?" + String(searchParams), true);
if (headers) { if (headers) {
headers.forEach((value, key) => { headers.forEach((value, key) => {
@@ -258,7 +263,6 @@ export function Dropzone({
if (xhr.status === 200) { if (xhr.status === 200) {
//setFileState(file.path, "uploaded", 1); //setFileState(file.path, "uploaded", 1);
console.log("Upload complete"); console.log("Upload complete");
onUploaded?.(file);
try { try {
const response = JSON.parse(xhr.responseText); const response = JSON.parse(xhr.responseText);
@@ -311,6 +315,11 @@ export function Dropzone({
} }
} }
async function uploadFile(file: FileState) {
await uploadFileProgress(file);
onUploaded?.([file]);
}
const openFileInput = () => inputRef.current?.click(); const openFileInput = () => inputRef.current?.click();
const showPlaceholder = Boolean( const showPlaceholder = Boolean(
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems) placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)
@@ -331,14 +340,15 @@ export function Dropzone({
showPlaceholder showPlaceholder
}, },
actions: { actions: {
uploadFile: uploadFileProgress, uploadFile,
deleteFile, deleteFile,
openFileInput openFileInput
}, },
dropzoneProps: { dropzoneProps: {
maxItems, maxItems,
placeholder, placeholder,
autoUpload autoUpload,
flow
} }
}; };
@@ -350,8 +360,12 @@ const DropzoneInner = ({
inputProps, inputProps,
state: { files, isOver, isOverAccepted, showPlaceholder }, state: { files, isOver, isOverAccepted, showPlaceholder },
actions: { uploadFile, deleteFile, openFileInput }, actions: { uploadFile, deleteFile, openFileInput },
dropzoneProps: { placeholder } dropzoneProps: { placeholder, flow }
}: DropzoneRenderProps) => { }: DropzoneRenderProps) => {
const Placeholder = showPlaceholder && (
<UploadPlaceholder onClick={openFileInput} text={placeholder?.text} />
);
return ( return (
<div <div
ref={wrapperRef} ref={wrapperRef}
@@ -366,6 +380,7 @@ const DropzoneInner = ({
</div> </div>
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<div className="flex flex-row flex-wrap gap-2 md:gap-3"> <div className="flex flex-row flex-wrap gap-2 md:gap-3">
{flow === "start" && Placeholder}
{files.map((file) => ( {files.map((file) => (
<Preview <Preview
key={file.path} key={file.path}
@@ -374,9 +389,7 @@ const DropzoneInner = ({
handleDelete={deleteFile} handleDelete={deleteFile}
/> />
))} ))}
{showPlaceholder && ( {flow === "end" && Placeholder}
<UploadPlaceholder onClick={openFileInput} text={placeholder?.text} />
)}
</div> </div>
</div> </div>
</div> </div>
+45 -28
View File
@@ -1,55 +1,63 @@
import type { RepoQuery, RepoQueryIn } from "data"; import type { Api } from "bknd/client";
import type { RepoQueryIn } from "data";
import type { MediaFieldSchema } from "media/AppMedia"; import type { MediaFieldSchema } from "media/AppMedia";
import type { TAppMediaConfig } from "media/media-schema"; import type { TAppMediaConfig } from "media/media-schema";
import { useId } from "react"; import { type ReactNode, createContext, useContext, useId } from "react";
import { useApi, useBaseUrl, useEntityQuery, useInvalidate } from "ui/client"; import { useApi, useApiQuery, useInvalidate } from "ui/client";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone"; import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone";
import { mediaItemsToFileStates } from "./helper"; import { mediaItemsToFileStates } from "./helper";
export type DropzoneContainerProps = { export type DropzoneContainerProps = {
children?: (props: DropzoneRenderProps) => JSX.Element; children?: ReactNode;
initialItems?: MediaFieldSchema[]; initialItems?: MediaFieldSchema[];
entity?: { entity?: {
name: string; name: string;
id: number; id: number;
field: string; field: string;
}; };
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
query?: RepoQueryIn; query?: RepoQueryIn;
} & Partial<Pick<TAppMediaConfig, "basepath" | "entity_name" | "storage">> & } & Omit<Partial<DropzoneProps>, "children" | "initialItems">;
Partial<DropzoneProps>;
const DropzoneContainerContext = createContext<DropzoneRenderProps>(undefined!);
export function DropzoneContainer({ export function DropzoneContainer({
initialItems, initialItems,
basepath = "/api/media", media,
storage = {},
entity_name = "media",
entity, entity,
query, query,
children,
...props ...props
}: DropzoneContainerProps) { }: DropzoneContainerProps) {
const id = useId(); const id = useId();
const baseUrl = useBaseUrl();
const api = useApi(); const api = useApi();
const invalidate = useInvalidate(); const invalidate = useInvalidate();
const limit = query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50; const baseUrl = api.baseUrl;
const defaultQuery = {
limit: query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50,
sort: "-id"
};
const entity_name = (media?.entity_name ?? "media") as "media";
//console.log("dropzone:baseUrl", baseUrl);
const $q = useEntityQuery( const selectApi = (api: Api) =>
entity_name as "media", entity
undefined, ? api.data.readManyByReference(entity.name, entity.id, entity.field, {
{ ...defaultQuery,
...query, ...query,
limit, where: {
where: entity
? {
reference: `${entity.name}.${entity.field}`, reference: `${entity.name}.${entity.field}`,
entity_id: entity.id, entity_id: entity.id,
...query?.where ...query?.where
} }
: query?.where })
}, : api.data.readMany(entity_name, {
{ enabled: !initialItems } ...defaultQuery,
); ...query
});
const $q = useApiQuery(selectApi, { enabled: !initialItems });
const getUploadInfo = useEvent((file) => { const getUploadInfo = useEvent((file) => {
const url = entity const url = entity
@@ -64,10 +72,7 @@ export function DropzoneContainer({
}); });
const refresh = useEvent(async () => { const refresh = useEvent(async () => {
if (entity) { await invalidate($q.promise.key({ search: false }));
invalidate((api) => api.data.readOne(entity.name, entity.id));
}
await $q.mutate();
}); });
const handleDelete = useEvent(async (file: FileState) => { const handleDelete = useEvent(async (file: FileState) => {
@@ -88,6 +93,18 @@ export function DropzoneContainer({
autoUpload autoUpload
initialItems={_initialItems} initialItems={_initialItems}
{...props} {...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 { PreviewWrapperMemoized } from "./Dropzone";
import { DropzoneContainer } from "./DropzoneContainer"; import { DropzoneContainer, useDropzone } from "./DropzoneContainer";
export const Media = { export const Media = {
Dropzone: DropzoneContainer, Dropzone: DropzoneContainer,
Preview: PreviewWrapperMemoized Preview: PreviewWrapperMemoized,
useDropzone: useDropzone
}; };
export { useDropzone as useMediaDropzone };
export type { export type {
PreviewComponentProps, PreviewComponentProps,
FileState, FileState,
+2
View File
@@ -1 +1,3 @@
export { default as Admin, type BkndAdminProps } from "./Admin"; 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 { useClickOutside, useHotkeys } from "@mantine/hooks";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { throttle } from "lodash-es"; 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 type { IconType } from "react-icons";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { useEvent } from "ui/hooks/use-event";
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell"; import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
import { useEvent } from "../../hooks/use-event";
export function Root({ children }) { export function Root({ children }) {
return ( return (
@@ -68,8 +74,15 @@ export function Content({ children, center }: { children: React.ReactNode; cente
} }
export function Main({ children }) { export function Main({ children }) {
const { sidebar } = useAppShell();
return ( 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} {children}
</div> </div>
); );
@@ -292,7 +305,7 @@ export function Scrollable({
return ( return (
<ScrollArea.Root style={{ height: `calc(100dvh - ${offset}px` }} ref={scrollRef}> <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 <ScrollArea.Scrollbar
forceMount forceMount
className="flex select-none touch-none bg-transparent w-0.5" className="flex select-none touch-none bg-transparent w-0.5"
@@ -357,4 +370,8 @@ export const SectionHeaderAccordionItem = ({
</div> </div>
); );
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
<hr {...props} className={twMerge("border-muted my-3", className)} />
);
export { Header } from "./Header"; export { Header } from "./Header";
+12 -14
View File
@@ -13,6 +13,7 @@ import {
import { useAuth, useBkndWindowContext } from "ui/client"; import { useAuth, useBkndWindowContext } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system"; 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 { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { Logo } from "ui/components/display/Logo"; 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"> <nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
{items.map((item) => ( {items.map((item) => (
<Tooltip <NavLink
key={item.label} key={item.href}
label={item.tooltip} as={Link}
disabled={typeof item.tooltip === "undefined"} href={item.href}
position="bottom" Icon={item.Icon}
disabled={item.disabled}
> >
<div> {item.label}
<NavLink as={Link} href={item.href} Icon={item.Icon} disabled={item.disabled}> </NavLink>
{item.label}
</NavLink>
</div>
</Tooltip>
))} ))}
</nav> </nav>
<nav className="flex md:hidden flex-row items-center"> <nav className="flex md:hidden flex-row items-center">
@@ -114,9 +112,9 @@ function SidebarToggler() {
} }
export function Header({ hasSidebar = true }) { export function Header({ hasSidebar = true }) {
//const logoReturnPath = "";
const { app } = useBknd(); const { app } = useBknd();
const { logo_return_path = "/", color_scheme = "light" } = app.getAdminConfig(); const { theme } = useTheme();
const { logo_return_path = "/" } = app.getAdminConfig();
return ( return (
<header <header
@@ -128,7 +126,7 @@ export function Header({ hasSidebar = true }) {
native={logo_return_path !== "/"} native={logo_return_path !== "/"}
className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none" 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> </Link>
<HeaderNavigation /> <HeaderNavigation />
<div className="flex flex-grow" /> <div className="flex flex-grow" />
+29
View File
@@ -2,6 +2,35 @@
@tailwind components; @tailwind components;
@tailwind utilities; @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 { #bknd-admin {
@apply bg-background text-primary overflow-hidden h-dvh w-dvw; @apply bg-background text-primary overflow-hidden h-dvh w-dvw;
+1 -1
View File
@@ -201,7 +201,7 @@ const EntityContextMenu = ({
separator, separator,
{ {
icon: IconSettings, icon: IconSettings,
label: "Settings", label: "Advanced",
onClick: () => onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), { navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true absolute: true
@@ -40,8 +40,8 @@ export function DataEntityList({ params }) {
useBrowserTitle(["Data", entity?.label ?? params.entity]); useBrowserTitle(["Data", entity?.label ?? params.entity]);
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const search = useSearch(searchSchema, { const search = useSearch(searchSchema, {
select: undefined, select: entity.getSelect(undefined, "table"),
sort: undefined sort: entity.getDefaultSort()
}); });
const $q = useApiQuery( const $q = useApiQuery(
+3 -2
View File
@@ -1,5 +1,6 @@
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
import { Route, Router, Switch } from "wouter"; import { Route, Router, Switch } from "wouter";
import AuthRoutes from "./auth"; import AuthRoutes from "./auth";
import { AuthLogin } from "./auth/auth.login"; import { AuthLogin } from "./auth/auth.login";
@@ -20,11 +21,11 @@ const TestRoutes = lazy(() => import("./test"));
export function Routes() { export function Routes() {
const { app } = useBknd(); const { app } = useBknd();
const { color_scheme: theme } = app.getAdminConfig(); const { theme } = useTheme();
const { basepath } = app.getAdminConfig(); const { basepath } = app.getAdminConfig();
return ( return (
<div id="bknd-admin" className={(theme ?? "light") + " antialiased"}> <div id="bknd-admin" className={theme + " antialiased"}>
<Router base={basepath}> <Router base={basepath}>
<Switch> <Switch>
<Route path="/auth/login" component={AuthLogin} /> <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 { TbSettings } from "react-icons/tb";
import { useBknd } from "ui/client/BkndProvider"; import { useBknd } from "ui/client/BkndProvider";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
import { Media } from "ui/elements"; import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useLocation } from "wouter";
export function MediaRoot({ children }) { export function MediaRoot({ children }) {
const { app, config } = useBknd(); const { app, config } = useBknd();
const [, navigate] = useLocation(); const mediaDisabled = !config.media.enabled;
useBrowserTitle(["Media"]); 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 ( return (
<> <>
<AppShell.Sidebar> <AppShell.Sidebar>
@@ -42,31 +26,22 @@ export function MediaRoot({ children }) {
</AppShell.SectionHeader> </AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}> <AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow p-3 gap-3"> <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"> <nav className="flex flex-col flex-1 gap-1">
<AppShell.SidebarLink as={Link} href="/media" className="active"> <AppShell.SidebarLink
Main Bucket 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> </AppShell.SidebarLink>
</nav> </nav>
</div> </div>
</AppShell.Scrollable> </AppShell.Scrollable>
</AppShell.Sidebar> </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 { 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() { export default function MediaRoutes() {
return ( return (
<MediaRoot> <MediaRoot>
<Route path="/" component={MediaEmpty} /> <Route path="/" component={MediaIndex} />
<Route path="/settings" component={MediaSettings} />
</MediaRoot> </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 AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-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 SwaggerTest from "ui/routes/test/tests/swagger-test";
import SWRAndAPI from "ui/routes/test/tests/swr-and-api"; import SWRAndAPI from "ui/routes/test/tests/swr-and-api";
import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-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 DropzoneElementTest from "./tests/dropzone-element-test";
import EntityFieldsForm from "./tests/entity-fields-form"; import EntityFieldsForm from "./tests/entity-fields-form";
import FlowsTest from "./tests/flows-test"; import FlowsTest from "./tests/flows-test";
import JsonSchemaForm3 from "./tests/json-schema-form3";
import JsonFormTest from "./tests/jsonform-test"; import JsonFormTest from "./tests/jsonform-test";
import { LiquidJsTest } from "./tests/liquid-js-test"; import { LiquidJsTest } from "./tests/liquid-js-test";
import MantineTest from "./tests/mantine-test"; import MantineTest from "./tests/mantine-test";
@@ -45,7 +49,10 @@ const tests = {
SWRAndAPI, SWRAndAPI,
SwrAndDataApi, SwrAndDataApi,
DropzoneElementTest, DropzoneElementTest,
JsonSchemaFormReactTest JsonSchemaFormReactTest,
JsonSchemaForm3,
FormyTest,
HtmlFormTest
} as const; } as const;
export default function TestRoutes() { export default function TestRoutes() {
@@ -83,7 +90,7 @@ function TestRoot({ children }) {
</div> </div>
</AppShell.Scrollable> </AppShell.Scrollable>
</AppShell.Sidebar> </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"; import { Scrollable } from "ui/layouts/AppShell/AppShell";
export default function DropzoneElementTest() { export default function DropzoneElementTest() {
@@ -12,7 +12,7 @@ export default function DropzoneElementTest() {
maxItems={1} maxItems={1}
overwrite overwrite
> >
{(props) => <CustomUserAvatarDropzone {...props} />} <CustomUserAvatarDropzone />
</Media.Dropzone> </Media.Dropzone>
</div> </div>
@@ -49,12 +49,13 @@ export default function DropzoneElementTest() {
); );
} }
function CustomUserAvatarDropzone({ function CustomUserAvatarDropzone() {
wrapperRef, const {
inputProps, wrapperRef,
state: { files, isOver, isOverAccepted, showPlaceholder }, inputProps,
actions: { openFileInput } state: { files, isOver, isOverAccepted, showPlaceholder },
}: DropzoneRenderProps) { actions: { openFileInput }
} = Media.useDropzone();
const file = files[0]; const file = files[0];
return ( 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 <Form
schema={schema} schema={schema}
onChange={setData} /*onChange={setData}
onSubmit={setData} onSubmit={setData}*/
validator={validator} validator={validator}
validationMode="change" 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; 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, html,
body { body {
font-size: 14px; font-size: 14px;
@@ -63,9 +35,8 @@ body {
} }
div[data-radix-scroll-area-viewport] > div:first-child { div[data-radix-scroll-area-viewport] > div:first-child {
min-width: auto !important;
display: block !important; display: block !important;
min-width: 100% !important;
max-width: 100%;
} }
/* hide calendar icon on inputs */ /* hide calendar icon on inputs */
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"types": ["bun-types", "@cloudflare/workers-types"], "types": ["bun-types", "@cloudflare/workers-types"],
"composite": true, "composite": false,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"jsx": "react-jsx", "jsx": "react-jsx",

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