mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c4aac8843 | |||
| d5bb6ffa61 | |||
| fc513bb413 | |||
| 4162b9878a | |||
| c75f8d0937 | |||
| 88419548c7 |
+5
-1
@@ -142,6 +142,7 @@ const adapters = {
|
|||||||
},
|
},
|
||||||
nextjs: {
|
nextjs: {
|
||||||
dir: path.join(basePath, "examples/nextjs"),
|
dir: path.join(basePath, "examples/nextjs"),
|
||||||
|
env: "TEST_TIMEOUT=20000",
|
||||||
clean: async function () {
|
clean: async function () {
|
||||||
const cwd = path.relative(process.cwd(), this.dir);
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
await $`cd ${cwd} && rm -rf .nextjs data.db`;
|
await $`cd ${cwd} && rm -rf .nextjs data.db`;
|
||||||
@@ -195,7 +196,8 @@ async function testAdapter(name: keyof typeof adapters) {
|
|||||||
console.log("proc:", proc.pid, "data:", c.cyan(data));
|
console.log("proc:", proc.pid, "data:", c.cyan(data));
|
||||||
//proc.kill();process.exit(0);
|
//proc.kill();process.exit(0);
|
||||||
|
|
||||||
await $`TEST_URL=${data} TEST_ADAPTER=${name} bun run test:e2e`;
|
const add_env = "env" in config && config.env ? config.env : "";
|
||||||
|
await $`TEST_URL=${data} TEST_ADAPTER=${name} ${add_env} bun run test:e2e`;
|
||||||
console.log("DONE!");
|
console.log("DONE!");
|
||||||
|
|
||||||
while (!proc.killed) {
|
while (!proc.killed) {
|
||||||
@@ -205,6 +207,8 @@ async function testAdapter(name: keyof typeof adapters) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// run with: TEST_ADAPTER=astro bun run e2e/adapters.ts
|
||||||
|
// (modify `test:e2e` to `test:e2e:ui` to see the UI)
|
||||||
if (process.env.TEST_ADAPTER) {
|
if (process.env.TEST_ADAPTER) {
|
||||||
await testAdapter(process.env.TEST_ADAPTER as any);
|
await testAdapter(process.env.TEST_ADAPTER as any);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.14.0-rc.2",
|
"version": "0.14.0",
|
||||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||||
"homepage": "https://bknd.io",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -70,8 +70,7 @@
|
|||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
"object-path-immutable": "^4.1.2",
|
"object-path-immutable": "^4.1.2",
|
||||||
"radix-ui": "^1.1.3",
|
"radix-ui": "^1.1.3",
|
||||||
"swr": "^2.3.3",
|
"swr": "^2.3.3"
|
||||||
"uuid": "^11.1.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.758.0",
|
"@aws-sdk/client-s3": "^3.758.0",
|
||||||
@@ -121,6 +120,7 @@
|
|||||||
"tsc-alias": "^1.8.11",
|
"tsc-alias": "^1.8.11",
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.4.0",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^3.0.9",
|
"vitest": "^3.0.9",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
const baseUrl = process.env.TEST_URL || "http://localhost:28623";
|
const baseUrl = process.env.TEST_URL || "http://localhost:28623";
|
||||||
const startCommand = process.env.TEST_START_COMMAND || "bun run dev";
|
const startCommand = process.env.TEST_START_COMMAND || "bun run dev";
|
||||||
const autoStart = ["1", "true", undefined].includes(process.env.TEST_AUTO_START);
|
const autoStart = ["1", "true", undefined].includes(process.env.TEST_AUTO_START);
|
||||||
|
const timeout = process.env.TEST_TIMEOUT ? Number.parseInt(process.env.TEST_TIMEOUT) : 5000;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testMatch: "**/*.e2e-spec.ts",
|
testMatch: "**/*.e2e-spec.ts",
|
||||||
@@ -12,7 +13,7 @@ export default defineConfig({
|
|||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
timeout: 20000,
|
timeout,
|
||||||
use: {
|
use: {
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
args?: CfMakeConfigArgs<Env>,
|
args?: CfMakeConfigArgs<Env>,
|
||||||
) {
|
) {
|
||||||
if (!media_registered) {
|
if (!media_registered) {
|
||||||
registerMedia(args as any);
|
registerMedia(args?.env as any);
|
||||||
media_registered = true;
|
media_registered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export class D1Connection<
|
|||||||
> extends SqliteConnection {
|
> extends SqliteConnection {
|
||||||
protected override readonly supported = {
|
protected override readonly supported = {
|
||||||
batching: true,
|
batching: true,
|
||||||
counts: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(private config: D1ConnectionConfig<DB>) {
|
constructor(private config: D1ConnectionConfig<DB>) {
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import {
|
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
||||||
type DevServerOptions,
|
|
||||||
default as honoViteDevServer,
|
|
||||||
} from "@hono/vite-dev-server";
|
|
||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
import {
|
import { type RuntimeBkndConfig, createRuntimeApp, type FrameworkOptions } from "bknd/adapter";
|
||||||
type RuntimeBkndConfig,
|
|
||||||
createRuntimeApp,
|
|
||||||
type FrameworkOptions,
|
|
||||||
} from "bknd/adapter";
|
|
||||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||||
import { devServerConfig } from "./dev-server-config";
|
import { devServerConfig } from "./dev-server-config";
|
||||||
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
|
||||||
export type ViteEnv = NodeJS.ProcessEnv;
|
export type ViteEnv = NodeJS.ProcessEnv;
|
||||||
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {};
|
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {
|
||||||
|
serveStatic?: false | MiddlewareHandler;
|
||||||
|
};
|
||||||
|
|
||||||
export function addViteScript(
|
export function addViteScript(html: string, addBkndContext: boolean = true) {
|
||||||
html: string,
|
|
||||||
addBkndContext: boolean = true,
|
|
||||||
) {
|
|
||||||
return html.replace(
|
return html.replace(
|
||||||
"</head>",
|
"</head>",
|
||||||
`<script type="module">
|
`<script type="module">
|
||||||
@@ -48,7 +41,10 @@ async function createApp<ViteEnv>(
|
|||||||
mainPath: "/src/main.tsx",
|
mainPath: "/src/main.tsx",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
|
serveStatic: config.serveStatic || [
|
||||||
|
"/assets/*",
|
||||||
|
serveStatic({ root: config.distPath ?? "./" }),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
env,
|
env,
|
||||||
opts,
|
opts,
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export class DataController extends Controller {
|
|||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
const options = c.req.valid("query") as RepoQuery;
|
const options = c.req.valid("query") as RepoQuery;
|
||||||
const result = await this.em.repo(entity).findMany(options);
|
const result = await this.em.repository(entity).findMany(options);
|
||||||
|
|
||||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ export abstract class Connection<DB = any> {
|
|||||||
kysely: Kysely<DB>;
|
kysely: Kysely<DB>;
|
||||||
protected readonly supported = {
|
protected readonly supported = {
|
||||||
batching: false,
|
batching: false,
|
||||||
counts: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Connection, type FieldSpec, type SchemaResponse } from "./Connection";
|
|||||||
export class DummyConnection extends Connection {
|
export class DummyConnection extends Connection {
|
||||||
protected override readonly supported = {
|
protected override readonly supported = {
|
||||||
batching: true,
|
batching: true,
|
||||||
counts: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ export class LibsqlConnection extends SqliteConnection {
|
|||||||
private client: Client;
|
private client: Client;
|
||||||
protected override readonly supported = {
|
protected override readonly supported = {
|
||||||
batching: true,
|
batching: true,
|
||||||
counts: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(client: Client);
|
constructor(client: Client);
|
||||||
constructor(credentials: LibSqlCredentials);
|
constructor(credentials: LibSqlCredentials);
|
||||||
constructor(clientOrCredentials: Client | LibSqlCredentials) {
|
constructor(clientOrCredentials: Client | LibSqlCredentials) {
|
||||||
let client: Client;
|
let client: Client;
|
||||||
|
let batching_enabled = true;
|
||||||
if (clientOrCredentials && "url" in clientOrCredentials) {
|
if (clientOrCredentials && "url" in clientOrCredentials) {
|
||||||
let { url, authToken, protocol } = clientOrCredentials;
|
let { url, authToken, protocol } = clientOrCredentials;
|
||||||
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
|
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
|
||||||
@@ -56,6 +56,7 @@ export class LibsqlConnection extends SqliteConnection {
|
|||||||
|
|
||||||
super(kysely, {}, plugins);
|
super(kysely, {}, plugins);
|
||||||
this.client = client;
|
this.client = client;
|
||||||
|
this.supported.batching = batching_enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
getClient(): Client {
|
getClient(): Client {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export type RepositoryResponse<T = EntityData[]> = RepositoryRawResponse & {
|
|||||||
data: T;
|
data: T;
|
||||||
meta: {
|
meta: {
|
||||||
items: number;
|
items: number;
|
||||||
has_more?: boolean;
|
|
||||||
total?: number;
|
total?: number;
|
||||||
count?: number;
|
count?: number;
|
||||||
time?: number;
|
time?: number;
|
||||||
@@ -62,10 +61,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
public entity: Entity,
|
public entity: Entity,
|
||||||
protected options: RepositoryOptions = {},
|
protected options: RepositoryOptions = {},
|
||||||
) {
|
) {
|
||||||
this.options = {
|
|
||||||
...options,
|
|
||||||
includeCounts: options?.includeCounts ?? this.em.connection.supports("counts"),
|
|
||||||
};
|
|
||||||
this.emgr = options?.emgr ?? new EventManager(MutatorEvents);
|
this.emgr = options?.emgr ?? new EventManager(MutatorEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,11 +179,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
if (options.limit) validated.limit = options.limit;
|
if (options.limit) validated.limit = options.limit;
|
||||||
if (options.offset) validated.offset = options.offset;
|
if (options.offset) validated.offset = options.offset;
|
||||||
|
|
||||||
// if counts disabled, add +1 to limit
|
|
||||||
if (this.options?.includeCounts === false) {
|
|
||||||
validated.limit = (validated.limit ?? 10) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +210,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
|
|
||||||
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
|
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
|
||||||
const entity = this.entity;
|
const entity = this.entity;
|
||||||
|
|
||||||
const compiled = qb.compile();
|
const compiled = qb.compile();
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -455,16 +444,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
await this.triggerFindBefore(this.entity, options);
|
await this.triggerFindBefore(this.entity, options);
|
||||||
|
|
||||||
const res = await this.performQuery(qb);
|
const res = await this.performQuery(qb);
|
||||||
if (this.options?.includeCounts === false) {
|
|
||||||
const hasMore = res.data.length === (options.limit ?? 10);
|
|
||||||
res.meta.has_more = hasMore;
|
|
||||||
|
|
||||||
if (hasMore) {
|
|
||||||
res.data = res.data.slice(0, -1);
|
|
||||||
res.result = res.result.slice(0, -1);
|
|
||||||
res.meta.items = res.data.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.triggerFindAfter(this.entity, options, res.data);
|
await this.triggerFindAfter(this.entity, options, res.data);
|
||||||
return res as any;
|
return res as any;
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ export class SchemaManager {
|
|||||||
|
|
||||||
if (config.force) {
|
if (config.force) {
|
||||||
try {
|
try {
|
||||||
$console.log("[SchemaManager]", sql);
|
$console.debug("[SchemaManager]", sql);
|
||||||
await qb.execute();
|
await qb.execute();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
|
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export class Controller {
|
|||||||
|
|
||||||
protected getEntitiesEnum(em: EntityManager<any>) {
|
protected getEntitiesEnum(em: EntityManager<any>) {
|
||||||
const entities = em.entities.map((e) => e.name);
|
const entities = em.entities.map((e) => e.name);
|
||||||
return entities.length > 0 ? s.string({ enum: entities }) : s.string();
|
// @todo: current workaround to allow strings (sometimes building is not fast enough to get the entities)
|
||||||
|
return entities.length > 0 ? s.anyOf([s.string({ enum: entities }), s.string()]) : s.string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get basepath() {
|
get basepath() {
|
||||||
return this.options.basepath ?? "/";
|
return this.withAdminBasePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
private withBasePath(route: string = "") {
|
private withBasePath(route: string = "") {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export type DataTableProps<Data> = {
|
|||||||
checkable?: boolean;
|
checkable?: boolean;
|
||||||
onClickRow?: (row: Data) => void;
|
onClickRow?: (row: Data) => void;
|
||||||
onClickPage?: (page: number) => void;
|
onClickPage?: (page: number) => void;
|
||||||
hasMore?: boolean;
|
|
||||||
total?: number;
|
total?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
@@ -60,7 +59,6 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
onClickRow,
|
onClickRow,
|
||||||
onClickPage,
|
onClickPage,
|
||||||
onClickSort,
|
onClickSort,
|
||||||
hasMore,
|
|
||||||
total,
|
total,
|
||||||
sort,
|
sort,
|
||||||
page = 1,
|
page = 1,
|
||||||
@@ -77,7 +75,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
page = page || 1;
|
page = page || 1;
|
||||||
|
|
||||||
const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {});
|
const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {});
|
||||||
const pages = hasMore === undefined ? Math.max(Math.ceil(total / perPage), 1) : undefined;
|
const pages = Math.max(Math.ceil(total / perPage), 1);
|
||||||
const CellRender = renderValue || CellValue;
|
const CellRender = renderValue || CellValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,15 +104,17 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"link hover:bg-primary/5 py-1.5 rounded-md inline-flex flex-row justify-start items-center gap-1",
|
"py-1.5 rounded-md inline-flex flex-row justify-start items-center gap-1",
|
||||||
onClickSort ? "pl-2.5 pr-1" : "px-2.5",
|
onClickSort
|
||||||
|
? "link hover:bg-primary/5 pl-2.5 pr-1"
|
||||||
|
: "px-2.5",
|
||||||
)}
|
)}
|
||||||
onClick={() => onClickSort?.(property)}
|
onClick={() => onClickSort?.(property)}
|
||||||
>
|
>
|
||||||
<span className="text-left text-nowrap whitespace-nowrap">
|
<span className="text-left text-nowrap whitespace-nowrap">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{onClickSort && (
|
{(onClickSort || (sort && sort.by === property)) && (
|
||||||
<SortIndicator sort={sort} field={property} />
|
<SortIndicator sort={sort} field={property} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -198,14 +198,12 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center justify-between">
|
<div className="flex flex-row items-center justify-between">
|
||||||
<div className="hidden md:flex text-primary/40">
|
<div className="hidden md:flex text-primary/40">
|
||||||
{hasMore === undefined ? (
|
<TableDisplay
|
||||||
<TableDisplay
|
perPage={perPage}
|
||||||
perPage={perPage}
|
page={page}
|
||||||
page={page}
|
items={data?.length || 0}
|
||||||
items={data?.length || 0}
|
total={total}
|
||||||
total={total}
|
/>
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
||||||
{perPageOptions && (
|
{perPageOptions && (
|
||||||
@@ -224,16 +222,11 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-primary/40">
|
<div className="text-primary/40">
|
||||||
Page {page} {pages ? `of ${pages}` : ""}
|
Page {page} of {pages}
|
||||||
</div>
|
</div>
|
||||||
{onClickPage && (
|
{onClickPage && (
|
||||||
<div className="flex flex-row gap-1.5">
|
<div className="flex flex-row gap-1.5">
|
||||||
<TableNav
|
<TableNav current={page} total={pages} onClick={onClickPage} />
|
||||||
current={page}
|
|
||||||
total={pages}
|
|
||||||
hasMore={hasMore}
|
|
||||||
onClick={onClickPage}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -283,59 +276,41 @@ const TableDisplay = ({ perPage, page, items, total }) => {
|
|||||||
return <>Showing 1 row</>;
|
return <>Showing 1 row</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = `Showing ${perPage * (page - 1) + 1}-${perPage * (page - 1) + items}`;
|
return (
|
||||||
if (total) {
|
<>
|
||||||
return `${text} of ${total} rows`;
|
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
|
||||||
}
|
</>
|
||||||
|
);
|
||||||
return text;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type TableNavProps = {
|
type TableNavProps = {
|
||||||
current: number;
|
current: number;
|
||||||
total?: number;
|
total: number;
|
||||||
hasMore?: boolean;
|
|
||||||
onClick?: (page: number) => void;
|
onClick?: (page: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TableNav: React.FC<TableNavProps> = ({
|
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
|
||||||
current,
|
|
||||||
total: _total,
|
|
||||||
hasMore: _hasMore,
|
|
||||||
onClick,
|
|
||||||
}: TableNavProps) => {
|
|
||||||
const total = _total !== undefined ? _total : _hasMore !== undefined ? current + 1 : current;
|
|
||||||
const hasMore = _hasMore !== undefined ? _hasMore : current < (total ?? 0);
|
|
||||||
|
|
||||||
const navMap = [
|
const navMap = [
|
||||||
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||||
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||||
{ value: current + 1, Icon: TbChevronRight, disabled: !hasMore },
|
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
|
||||||
{
|
{ value: total, Icon: TbChevronsRight, disabled: current === total },
|
||||||
value: _hasMore === undefined ? total : undefined,
|
|
||||||
Icon: TbChevronsRight,
|
|
||||||
disabled: !hasMore,
|
|
||||||
},
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return navMap.map((nav, key) => {
|
return navMap.map((nav, key) => (
|
||||||
const page = nav.value;
|
<button
|
||||||
if (page === undefined) return null;
|
role="button"
|
||||||
|
type="button"
|
||||||
return (
|
key={key}
|
||||||
<button
|
disabled={nav.disabled}
|
||||||
role="button"
|
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
type="button"
|
onClick={() => {
|
||||||
key={key}
|
const page = nav.value;
|
||||||
disabled={nav.disabled}
|
const safePage = page < 1 ? 1 : page > total ? total : page;
|
||||||
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
onClick?.(safePage);
|
||||||
onClick={() => {
|
}}
|
||||||
const safePage = page < 1 ? 1 : page > total ? total : page;
|
>
|
||||||
onClick?.(safePage);
|
<nav.Icon />
|
||||||
}}
|
</button>
|
||||||
>
|
));
|
||||||
<nav.Icon />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +1,44 @@
|
|||||||
import { decodeSearch, encodeSearch, mergeObject, parseDecode } from "core/utils";
|
import { decodeSearch, encodeSearch, mergeObject } from "core/utils";
|
||||||
import { isEqual, transform } from "lodash-es";
|
import { isEqual, transform } from "lodash-es";
|
||||||
import { useLocation, useSearch as useWouterSearch } from "wouter";
|
import { useLocation, useSearch as useWouterSearch } from "wouter";
|
||||||
import { type s, parse, cloneSchema } from "core/object/schema";
|
import { type s, parse } from "core/object/schema";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export type UseSearchOptions<Schema extends s.TAnySchema = s.TAnySchema> = {
|
||||||
|
defaultValue?: Partial<s.StaticCoerced<Schema>>;
|
||||||
|
beforeEncode?: (search: Partial<s.StaticCoerced<Schema>>) => object;
|
||||||
|
};
|
||||||
|
|
||||||
// @todo: migrate to Typebox
|
|
||||||
export function useSearch<Schema extends s.TAnySchema = s.TAnySchema>(
|
export function useSearch<Schema extends s.TAnySchema = s.TAnySchema>(
|
||||||
_schema: Schema,
|
schema: Schema,
|
||||||
defaultValue?: Partial<s.StaticCoerced<Schema>>,
|
options?: UseSearchOptions<Schema>,
|
||||||
) {
|
) {
|
||||||
const schema = cloneSchema(_schema as any) as s.TSchema;
|
|
||||||
const searchString = useWouterSearch();
|
const searchString = useWouterSearch();
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
const initial = searchString.length > 0 ? decodeSearch(searchString) : (defaultValue ?? {});
|
const [value, setValue] = useState<s.StaticCoerced<Schema>>(
|
||||||
const value = parse(schema, initial, {
|
options?.defaultValue ?? ({} as any),
|
||||||
withDefaults: true,
|
);
|
||||||
clone: true,
|
|
||||||
}) as s.StaticCoerced<Schema>;
|
|
||||||
|
|
||||||
// @ts-ignore
|
const defaults = useMemo(() => {
|
||||||
const _defaults = mergeObject(schema.template({ withOptional: true }), defaultValue ?? {});
|
return mergeObject(
|
||||||
|
// @ts-ignore
|
||||||
|
schema.template({ withOptional: true }),
|
||||||
|
options?.defaultValue ?? {},
|
||||||
|
);
|
||||||
|
}, [JSON.stringify({ schema, dflt: options?.defaultValue })]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initial =
|
||||||
|
searchString.length > 0 ? decodeSearch(searchString) : (options?.defaultValue ?? {});
|
||||||
|
const v = parse(schema, Object.assign({}, defaults, initial)) as any;
|
||||||
|
setValue(v);
|
||||||
|
}, [searchString, JSON.stringify(options?.defaultValue), location]);
|
||||||
|
|
||||||
function set<Update extends Partial<s.StaticCoerced<Schema>>>(update: Update): void {
|
function set<Update extends Partial<s.StaticCoerced<Schema>>>(update: Update): void {
|
||||||
// @ts-ignore
|
const search = getWithoutDefaults(Object.assign({}, value, update), defaults);
|
||||||
if (schema.validate(update).valid) {
|
const prepared = options?.beforeEncode?.(search) ?? search;
|
||||||
const search = getWithoutDefaults(mergeObject(value, update), _defaults);
|
const encoded = encodeSearch(prepared, { encode: false });
|
||||||
const encoded = encodeSearch(search, { encode: false });
|
navigate(location + (encoded.length > 0 ? "?" + encoded : ""));
|
||||||
navigate(location + (encoded.length > 0 ? "?" + encoded : ""));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -257,15 +257,20 @@ function EntityDetailInner({
|
|||||||
}) {
|
}) {
|
||||||
const other = relation.other(entity);
|
const other = relation.other(entity);
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
|
const [search, setSearch] = useState({
|
||||||
const search = {
|
|
||||||
select: other.entity.getSelect(undefined, "table"),
|
select: other.entity.getSelect(undefined, "table"),
|
||||||
|
sort: other.entity.getDefaultSort(),
|
||||||
limit: 10,
|
limit: 10,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
};
|
});
|
||||||
|
|
||||||
// @todo: add custom key for invalidation
|
// @todo: add custom key for invalidation
|
||||||
const $q = useApiQuery((api) =>
|
const $q = useApiQuery(
|
||||||
api.data.readManyByReference(entity.name, id, other.reference, search),
|
(api) => api.data.readManyByReference(entity.name, id, other.reference, search),
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
revalidateOnFocus: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleClickRow(row: Record<string, any>) {
|
function handleClickRow(row: Record<string, any>) {
|
||||||
@@ -300,11 +305,17 @@ function EntityDetailInner({
|
|||||||
select={search.select}
|
select={search.select}
|
||||||
data={$q.data ?? null}
|
data={$q.data ?? null}
|
||||||
entity={other.entity}
|
entity={other.entity}
|
||||||
|
sort={search.sort}
|
||||||
onClickRow={handleClickRow}
|
onClickRow={handleClickRow}
|
||||||
onClickNew={handleClickNew}
|
onClickNew={handleClickNew}
|
||||||
page={1}
|
page={Math.floor(search.offset / search.limit) + 1}
|
||||||
total={$q.data?.body?.meta?.count ?? 1}
|
total={$q.data?.body?.meta?.count ?? 1}
|
||||||
/*onClickPage={handleClickPage}*/
|
onClickPage={(page) => {
|
||||||
|
setSearch((s) => ({
|
||||||
|
...s,
|
||||||
|
offset: (page - 1) * s.limit,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,8 +35,19 @@ 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: entity.getSelect(undefined, "table"),
|
defaultValue: {
|
||||||
sort: entity.getDefaultSort(),
|
select: entity.getSelect(undefined, "table"),
|
||||||
|
sort: entity.getDefaultSort(),
|
||||||
|
},
|
||||||
|
beforeEncode: (v) => {
|
||||||
|
if ("sort" in v && v.sort) {
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
sort: `${v.sort.dir === "asc" ? "" : "-"}${v.sort.by}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const $q = useApiQuery(
|
const $q = useApiQuery(
|
||||||
@@ -131,7 +142,6 @@ export function DataEntityList({ params }) {
|
|||||||
perPage={search.value.perPage}
|
perPage={search.value.perPage}
|
||||||
perPageOptions={PER_PAGE_OPTIONS}
|
perPageOptions={PER_PAGE_OPTIONS}
|
||||||
total={meta?.count}
|
total={meta?.count}
|
||||||
hasMore={meta?.has_more}
|
|
||||||
onClickPage={handleClickPage}
|
onClickPage={handleClickPage}
|
||||||
onClickPerPage={handleClickPerPage}
|
onClickPerPage={handleClickPerPage}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -114,12 +114,12 @@ Example using `@neondatabase/serverless`:
|
|||||||
import { createCustomPostgresConnection } from "@bknd/postgres";
|
import { createCustomPostgresConnection } from "@bknd/postgres";
|
||||||
import { NeonDialect } from "kysely-neon";
|
import { NeonDialect } from "kysely-neon";
|
||||||
|
|
||||||
const connection = createCustomPostgresConnection(NeonDialect)({
|
const neon = createCustomPostgresConnection(NeonDialect);
|
||||||
connectionString: process.env.NEON,
|
|
||||||
});
|
|
||||||
|
|
||||||
serve({
|
serve({
|
||||||
connection: connection,
|
connection: neon({
|
||||||
|
connectionString: process.env.NEON,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -137,14 +137,14 @@ const xata = new client({
|
|||||||
branch: process.env.XATA_BRANCH,
|
branch: process.env.XATA_BRANCH,
|
||||||
});
|
});
|
||||||
|
|
||||||
const connection = createCustomPostgresConnection(XataDialect, {
|
const xataConnection = createCustomPostgresConnection(XataDialect, {
|
||||||
supports: {
|
supports: {
|
||||||
batching: false,
|
batching: false,
|
||||||
},
|
},
|
||||||
})({ xata });
|
});
|
||||||
|
|
||||||
serve({
|
serve({
|
||||||
connection: connection,
|
connection: xataConnection({ xata }),
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ import react from "@astrojs/react";
|
|||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: "hybrid",
|
output: "server",
|
||||||
integrations: [react()]
|
integrations: [react()],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export const plugins = [new ParseJSONResultsPlugin()];
|
|||||||
export abstract class PostgresConnection<DB = any> extends Connection<DB> {
|
export abstract class PostgresConnection<DB = any> extends Connection<DB> {
|
||||||
protected override readonly supported = {
|
protected override readonly supported = {
|
||||||
batching: true,
|
batching: true,
|
||||||
counts: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(kysely: Kysely<DB>, fn?: Partial<DbFunctions>, _plugins?: KyselyPlugin[]) {
|
constructor(kysely: Kysely<DB>, fn?: Partial<DbFunctions>, _plugins?: KyselyPlugin[]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user