From 21325356ffd256427255725af53f996523041afd Mon Sep 17 00:00:00 2001 From: Khemmathiti Wangsaptawee <39180519+t0ngk@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:05:33 -0400 Subject: [PATCH 1/9] feat: add action select --- examples/erp/genseki/collections/tags.ts | 1 + packages/react/src/core/collection.ts | 1 + packages/react/src/react/views/collections/list/list.client.tsx | 2 +- packages/react/src/react/views/collections/types.ts | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/erp/genseki/collections/tags.ts b/examples/erp/genseki/collections/tags.ts index 11136128..08877668 100644 --- a/examples/erp/genseki/collections/tags.ts +++ b/examples/erp/genseki/collections/tags.ts @@ -14,6 +14,7 @@ const list = builder.list(fields, { search: ['name'], sortBy: ['name'], }, + actions: { create: true, update: true }, }) const update = builder.update(fields, {}) diff --git a/packages/react/src/core/collection.ts b/packages/react/src/core/collection.ts index 6162e915..f9f373be 100644 --- a/packages/react/src/core/collection.ts +++ b/packages/react/src/core/collection.ts @@ -404,6 +404,7 @@ export type CollectionListConfig< update?: boolean delete?: boolean one?: boolean + select?: boolean } } & CollectionFieldsOptions diff --git a/packages/react/src/react/views/collections/list/list.client.tsx b/packages/react/src/react/views/collections/list/list.client.tsx index d8e0db7b..270687dd 100644 --- a/packages/react/src/react/views/collections/list/list.client.tsx +++ b/packages/react/src/react/views/collections/list/list.client.tsx @@ -69,7 +69,7 @@ export function ClientCollectionListView() { const columnHelper = createColumnHelper() return [ - ...(listViewProps.actions?.delete + ...(listViewProps.actions?.select ? [ columnHelper.display({ id: 'select', diff --git a/packages/react/src/react/views/collections/types.ts b/packages/react/src/react/views/collections/types.ts index 555a64e9..6af19f5a 100644 --- a/packages/react/src/react/views/collections/types.ts +++ b/packages/react/src/react/views/collections/types.ts @@ -19,4 +19,5 @@ export interface ListActions { update?: boolean delete?: boolean one?: boolean + select?: boolean } From e88f0b7a2fd523a5e769579db9e86c9524385187 Mon Sep 17 00:00:00 2001 From: Khemmathiti Wangsaptawee <39180519+t0ngk@users.noreply.github.com> Date: Wed, 27 Aug 2025 00:09:43 +0700 Subject: [PATCH 2/9] Add changeset for action select feature --- .changeset/spotty-chairs-tie.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/spotty-chairs-tie.md diff --git a/.changeset/spotty-chairs-tie.md b/.changeset/spotty-chairs-tie.md new file mode 100644 index 00000000..c9982d6c --- /dev/null +++ b/.changeset/spotty-chairs-tie.md @@ -0,0 +1,6 @@ +--- +"@example/erp": patch +"@genseki/react": patch +--- + +feat: add action select From e9e2a40faa654483855eb16662ad7c11abb15b5a Mon Sep 17 00:00:00 2001 From: Khemmathiti Wangsaptawee <39180519+t0ngk@users.noreply.github.com> Date: Tue, 26 Aug 2025 14:13:30 -0400 Subject: [PATCH 3/9] fix: click row still select --- packages/react/src/react/views/collections/list/list.client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/react/views/collections/list/list.client.tsx b/packages/react/src/react/views/collections/list/list.client.tsx index 270687dd..8a05946d 100644 --- a/packages/react/src/react/views/collections/list/list.client.tsx +++ b/packages/react/src/react/views/collections/list/list.client.tsx @@ -178,7 +178,7 @@ export function ClientCollectionListView() { table={table} loadingItems={pagination.pageSize} className="static" - onRowClick="toggleSelect" + onRowClick={listViewProps.actions?.select ? 'toggleSelect' : undefined} isLoading={isLoading} isError={isError} configuration={listViewProps.listConfiguration} From d6c0d69795dd97015eded2a37a5467ace026f4a5 Mon Sep 17 00:00:00 2001 From: Khemmathiti Wangsaptawee <39180519+t0ngk@users.noreply.github.com> Date: Wed, 27 Aug 2025 10:24:40 -0400 Subject: [PATCH 4/9] doc: add example for action select --- examples/erp/genseki/collections/tags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/erp/genseki/collections/tags.ts b/examples/erp/genseki/collections/tags.ts index 08877668..926d8ced 100644 --- a/examples/erp/genseki/collections/tags.ts +++ b/examples/erp/genseki/collections/tags.ts @@ -14,7 +14,7 @@ const list = builder.list(fields, { search: ['name'], sortBy: ['name'], }, - actions: { create: true, update: true }, + actions: { create: true, update: true, select: true }, }) const update = builder.update(fields, {}) From abd58bc8be8c943d8ae00e67331be6c71cb1a123 Mon Sep 17 00:00:00 2001 From: Khemmathiti Wangsaptawee <39180519+t0ngk@users.noreply.github.com> Date: Wed, 27 Aug 2025 10:46:21 -0400 Subject: [PATCH 5/9] Merge commit '615d51ecc39ab29c88390aa823948fcae83c0f2b' into tong/feat/action-select --- .changeset/mighty-ducks-run.md | 9 + .../erp/genseki/collections/posts.client.tsx | 126 ++- examples/erp/genseki/collections/posts.tsx | 176 ++-- examples/erp/genseki/collections/tags.ts | 43 +- examples/erp/genseki/collections/users.ts | 75 +- examples/erp/genseki/config.tsx | 37 +- packages/next/src/layouts/root.tsx | 6 +- packages/next/src/resource.ts | 4 +- packages/next/src/server-function.ts | 28 +- packages/next/src/with.ts | 32 +- packages/plugins/src/admin/index.ts | 180 ++-- packages/react-query/src/index.ts | 29 +- packages/react/package.json | 1 + .../api/validate-reset-password-token.ts | 2 +- .../auth/plugins/email-and-password/index.tsx | 187 ++--- .../forgot-password.client.tsx | 19 +- .../views/login/login.client.tsx | 2 +- .../reset-password/reset-password.client.tsx | 2 +- packages/react/src/auth/plugins/me/index.tsx | 19 +- packages/react/src/core/builder.tsx | 368 +------- packages/react/src/core/builder.utils.ts | 84 +- packages/react/src/core/collection.ts | 481 ----------- .../index.spec.ts} | 9 +- packages/react/src/core/collection/index.tsx | 791 ++++++++++++++++++ packages/react/src/core/config.tsx | 202 ++--- .../custom-collection/custom-list-page.tsx | 128 --- packages/react/src/core/endpoint.ts | 56 +- packages/react/src/core/index.ts | 36 +- packages/react/src/core/plugin.ts | 127 +++ .../compound/collection-sidebar/index.tsx | 6 +- .../components/primitives/tanstack-table.tsx | 3 +- packages/react/src/react/hooks/use-search.ts | 22 +- packages/react/src/react/providers/index.ts | 2 +- packages/react/src/react/providers/root.tsx | 92 +- packages/react/src/react/server-function.ts | 45 +- .../src/react/views/collections/context.tsx | 35 + .../collections/layouts/collection-layout.tsx | 9 +- .../views/collections/list/container.tsx | 20 + .../react/views/collections/list/context.tsx | 162 ++++ .../react/views/collections/list/default.tsx | 125 +++ .../list/hooks/use-collection-list.ts | 6 +- .../src/react/views/collections/list/index.ts | 10 + .../react/views/collections/list/index.tsx | 15 - .../views/collections/list/list.client.tsx | 189 ----- .../list/providers/collection-list-view.tsx | 55 -- .../views/collections/list/providers/index.ts | 2 - .../list/providers/list-view-props.tsx | 30 - .../views/collections/list/table/index.tsx | 59 +- .../collections/list/table/pagination.tsx | 8 +- .../views/collections/list/toolbar/index.tsx | 70 +- .../views/collections/list/toolbar/search.tsx | 31 +- .../src/react/views/collections/types.ts | 11 +- packages/react/src/react/views/index.ts | 12 +- packages/rest/src/index.ts | 17 +- pnpm-lock.yaml | 57 +- 55 files changed, 2231 insertions(+), 2121 deletions(-) create mode 100644 .changeset/mighty-ducks-run.md delete mode 100644 packages/react/src/core/collection.ts rename packages/react/src/core/{collection.spec.ts => collection/index.spec.ts} (95%) create mode 100644 packages/react/src/core/collection/index.tsx delete mode 100644 packages/react/src/core/custom-collection/custom-list-page.tsx create mode 100644 packages/react/src/core/plugin.ts create mode 100644 packages/react/src/react/views/collections/context.tsx create mode 100644 packages/react/src/react/views/collections/list/container.tsx create mode 100644 packages/react/src/react/views/collections/list/context.tsx create mode 100644 packages/react/src/react/views/collections/list/default.tsx create mode 100644 packages/react/src/react/views/collections/list/index.ts delete mode 100644 packages/react/src/react/views/collections/list/index.tsx delete mode 100644 packages/react/src/react/views/collections/list/list.client.tsx delete mode 100644 packages/react/src/react/views/collections/list/providers/collection-list-view.tsx delete mode 100644 packages/react/src/react/views/collections/list/providers/index.ts delete mode 100644 packages/react/src/react/views/collections/list/providers/list-view-props.tsx diff --git a/.changeset/mighty-ducks-run.md b/.changeset/mighty-ducks-run.md new file mode 100644 index 00000000..3dec256f --- /dev/null +++ b/.changeset/mighty-ducks-run.md @@ -0,0 +1,9 @@ +--- +"@example/erp": patch +"@genseki/next": patch +"@genseki/plugins": patch +"@genseki/react-query": patch +"@genseki/react": patch +--- + +Refactor Collection Model diff --git a/examples/erp/genseki/collections/posts.client.tsx b/examples/erp/genseki/collections/posts.client.tsx index c79e2256..141f7106 100644 --- a/examples/erp/genseki/collections/posts.client.tsx +++ b/examples/erp/genseki/collections/posts.client.tsx @@ -6,11 +6,10 @@ import { DotsThreeVerticalIcon } from '@phosphor-icons/react' import { useQueryClient } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import type { BaseData } from '@genseki/react' +import type { BaseData, CollectionLayoutProps } from '@genseki/react' import { BaseIcon, Checkbox, - CollectionListPagination, CollectionListToolbar, type InferFields, Menu, @@ -21,9 +20,10 @@ import { TanstackTable, toast, useCollectionDeleteMutation, + useCollectionList, useCollectionListQuery, - useCollectionListTable, - useListViewPropsContext, + useGenseki, + useListTable, useNavigation, useTableStatesContext, } from '@genseki/react' @@ -66,47 +66,12 @@ export const columns = [ * @description This is an example how you can use the given `CollectionListToolbar` from Genseki, * you may use custom toolbar here whehter you want. */ -export const PostClientToolbar = (props: { children?: React.ReactNode }) => { - const listViewProps = useListViewPropsContext() - const { rowSelection, setRowSelection } = useTableStatesContext() - - const selectedRowIds = Object.keys(rowSelection).filter((key) => rowSelection[key]) - - const isShowDeleteButton = selectedRowIds.length > 0 - - const queryClient = useQueryClient() - - const deleteMutation = useCollectionDeleteMutation({ - slug: listViewProps.slug, - onSuccess: async () => { - setRowSelection({}) - await queryClient.invalidateQueries({ - queryKey: ['GET', `/posts`], - }) - toast.success('Deletion successfully') - }, - onError: () => { - toast.error('Failed to delete items') - }, - }) - - const handleBulkDelete = async () => { - // Return immediately if delete is not enabled - if (!listViewProps.actions?.delete) return - - if (selectedRowIds.length === 0) return - - deleteMutation.mutate(selectedRowIds) - } +export function PostClientToolbar() { + const context = useCollectionList() return (
- +
) } @@ -115,7 +80,7 @@ export const PostClientToolbar = (props: { children?: React.ReactNode }) => { * @description This is an example how you can use the given `TanstackTable` and `CollectionListPagination` from Genseki to compose your view. */ export const PostClientTable = (props: { children?: React.ReactNode }) => { - const listViewProps = useListViewPropsContext() + const context = useCollectionList() const { setRowSelection } = useTableStatesContext() const queryClient = useQueryClient() @@ -123,14 +88,14 @@ export const PostClientTable = (props: { children?: React.ReactNode }) => { const navigation = useNavigation() // Example of fethcing list data - const query = useCollectionListQuery({ slug: listViewProps.slug }) + const query = useCollectionListQuery({ slug: context.slug }) const deleteMutation = useCollectionDeleteMutation({ - slug: listViewProps.slug, + slug: context.slug, onSuccess: async () => { setRowSelection({}) await queryClient.invalidateQueries({ - queryKey: ['GET', `/${listViewProps.slug}`], + queryKey: ['GET', `/${context.slug}`], }) toast.success('Deletion successfully') }, @@ -142,7 +107,7 @@ export const PostClientTable = (props: { children?: React.ReactNode }) => { const columnHelper = createColumnHelper() // You can setup your own custom columns const enhancedColumns = [ - ...(listViewProps.actions?.delete + ...(context.actions?.delete ? [ columnHelper.display({ id: 'select', @@ -168,15 +133,11 @@ export const PostClientTable = (props: { children?: React.ReactNode }) => { }), ] : []), - ...listViewProps.columns, + ...context.columns, columnHelper.display({ id: 'actions', cell: ({ row }) => { - if ( - !listViewProps.actions?.one && - !listViewProps.actions?.update && - !listViewProps.actions?.delete - ) { + if (!context.actions?.one && !context.actions?.update && !context.actions?.delete) { return null } @@ -187,30 +148,29 @@ export const PostClientTable = (props: { children?: React.ReactNode }) => { - {listViewProps.actions?.one && ( + {context.actions?.one && ( { - navigation.navigate(`./${listViewProps.slug}/${row.original.__id}`) + navigation.navigate(`./${context.slug}/${row.original.__id}`) }} > View )} - {listViewProps.actions?.update && ( + {context.actions?.update && ( { - navigation.navigate(`./${listViewProps.slug}/update/${row.original.__id}`) + navigation.navigate(`./${context.slug}/update/${row.original.__id}`) }} > Edit )} - {listViewProps.actions?.delete && ( + {context.actions?.delete && ( <> - {listViewProps.actions?.one || - (listViewProps.actions?.update && )} + {context.actions?.one || (context.actions?.update && )} { }), ] - const table = useCollectionListTable({ + const table = useListTable({ total: query.data?.total, data: query.data?.data || [], columns: enhancedColumns, - listConfiguration: listViewProps.listConfiguration, }) return ( @@ -254,16 +213,49 @@ export const PostClientTable = (props: { children?: React.ReactNode }) => { onRowClick="toggleSelect" isLoading={query.isLoading} isError={query.isError} - configuration={listViewProps.listConfiguration} + configuration={{ + sortBy: context.sortBy, + }} /> ) } -export function PostClientPagination() { - const { slug } = useListViewPropsContext() +export function Layout(props: CollectionLayoutProps) { + const { + components: { AppTopbar, AppSidebar, AppSidebarInset, AppSidebarProvider }, + } = useGenseki() + + return ( + + + + + {props.children} + + + ) +} - const query = useCollectionListQuery({ slug }) +export function Page() { + const { + components: { + ListBanner, + ListTableContainer, + ListTablePagination, + ListTable, + ListTableToolbar, + }, + } = useCollectionList() - return + return ( + <> + + + + + + + + ) } diff --git a/examples/erp/genseki/collections/posts.tsx b/examples/erp/genseki/collections/posts.tsx index 48e8a5be..fbd5b9f9 100644 --- a/examples/erp/genseki/collections/posts.tsx +++ b/examples/erp/genseki/collections/posts.tsx @@ -1,3 +1,5 @@ +import type React from 'react' + import Color from '@tiptap/extension-color' import TextAlign from '@tiptap/extension-text-align' import TextStyle from '@tiptap/extension-text-style' @@ -6,15 +8,18 @@ import StarterKit from '@tiptap/starter-kit' import { BackColorExtension, + CollectionBuilder, + createPlugin, CustomImageExtension, ImageUploadNodeExtension, SelectionExtension, } from '@genseki/react' -import { columns, PostClientPagination, PostClientTable, PostClientToolbar } from './posts.client' +import { columns, Layout, Page } from './posts.client' +import { FullModelSchemas } from '../../generated/genseki/unsanitized' import { EditorSlotBefore } from '../editor/slot-before' -import { builder, prisma } from '../helper' +import { builder, context, prisma } from '../helper' export const postEditorProviderProps = { immediatelyRender: false, @@ -175,98 +180,93 @@ export const options = builder.options(fields, { }, }) -const list = builder.list(fields, { - columns: columns, - configuration: { - search: ['title'], - sortBy: ['updatedAt', 'title'], - }, - options: options, - actions: { create: true, delete: true, one: true, update: true }, - uis: { - layout(args) { - const CollectionLayout = args.CollectionLayout - const CollectionSidebar = args.CollectionSidebar - const SidebarProvider = args.SidebarProvider - const SidebarInset = args.SidebarInset - const TopbarNav = args.TopbarNav - - /** - * @description You can use following template for simple scaffolding - * return ( - * - * - * {args.children} - * - * ) - */ +export const postsCollection = createPlugin('posts', (app) => { + const collection = new CollectionBuilder('posts', context, FullModelSchemas) - return ( - <> - - - - + return app + .overridePages(collection.overrideHomePage()) + .addPageAndApiRouter( + collection.list(fields, { + columns: columns, + configuration: { + search: ['title'], + sortBy: ['updatedAt', 'title'], + }, + actions: { delete: true, update: true, create: true }, + layout: Layout, + page: Page, + }) + ) + .addPageAndApiRouter(collection.create(fields, { options: options })) + .addPageAndApiRouter(collection.update(fields, { options: options })) + .addApiRouter(collection.deleteApiRouter(fields)) +}) -
{args.children}
-
-
- - ) - }, - pages(args) { - const ListViewContainer = args.ListViewContainer - const ListView = args.ListView - const Banner = args.Banner +// TODO: THE NEXT SPECIFICATION, INCLUDE RESUABLE LAYOUT +// const x = [ +// app.route('/collections', { +// middlewares: [], +// Component: (serverProps: { children }) =>
Collections
, +// routes: [ +// app.route('', { +// Component: (serverProps) =>
List Posts
, +// }), +// app.route('/posts/create', { +// Component: (serverProps) =>
Create Post
, +// }), +// ], +// }), - /** - * @description You can also use the following template for simple scaffolding - * - * ```return ( - *
- * - * - * - * - *
- * )``` - */ +// app.route('/api', { +// middlewares: [], +// routes: [ +// app.GET( +// '/gay', +// { +// query: Z.object(), +// middlewares: [], +// }, +// ({ query }) => {} +// ), - return ( -
- - - - - - -
- ) - }, - }, -}) +// builder.endpoint(), -const create = builder.create(fields, { - options: options, -}) +// app.POST('', { +// middlewares: [], +// }), +// ], +// }), -const update = builder.update(fields, { - options: options, -}) +// app.group('A', [ +// app.route('/api', { +// middlewares: [A], +// routes: [ +// app.GET('', { +// middlewares: [], +// }), +// ], +// }), +// ]), -const _delete = builder.delete(fields, { - options: options, -}) +// app.group('B', [ +// app.route('/api', { +// middlewares: [B], +// routes: [ +// app.POST('', { +// middlewares: [], +// }), +// ], +// }), +// ]), -const one = builder.one(fields, { - options: options, -}) +// collection.list(fields, {}), +// ] -export const postsCollection = builder.collection({ - slug: 'posts', - list: list, - create: create, - update: update, - delete: _delete, - one: one, -}) +// type RouteSpec = +// | { +// Componenet: React.FC +// } +// | { +// GET: () => void +// POST: () => void +// } diff --git a/examples/erp/genseki/collections/tags.ts b/examples/erp/genseki/collections/tags.ts index 926d8ced..15f1469f 100644 --- a/examples/erp/genseki/collections/tags.ts +++ b/examples/erp/genseki/collections/tags.ts @@ -1,6 +1,9 @@ +import { CollectionBuilder, createPlugin } from '@genseki/react' + import { columns } from './tags.client' -import { builder } from '../helper' +import { FullModelSchemas } from '../../generated/genseki/unsanitized' +import { builder, context } from '../helper' export const fields = builder.fields('tag', (fb) => ({ name: fb.columns('name', { @@ -8,21 +11,27 @@ export const fields = builder.fields('tag', (fb) => ({ }), })) -const list = builder.list(fields, { - columns: columns, - configuration: { - search: ['name'], - sortBy: ['name'], - }, - actions: { create: true, update: true, select: true }, -}) - -const update = builder.update(fields, {}) -const create = builder.create(fields, {}) +export const tagsCollection = createPlugin('tags', (app) => { + const collection = new CollectionBuilder('tags', context, FullModelSchemas) -export const tagsCollection = builder.collection({ - slug: 'tags', - list: list, - create: create, - update: update, + return app + .overridePages(collection.overrideHomePage()) + .addPageAndApiRouter( + collection.list(fields, { + columns: columns, + configuration: { + search: ['name'], + sortBy: ['name'], + }, + actions: { + create: true, + select: true, + delete: true, + }, + }) + ) + .addPageAndApiRouter(collection.create(fields, {})) + .addPageAndApiRouter(collection.update(fields, {})) + .addPageAndApiRouter(collection.one(fields)) + .addApiRouter(collection.deleteApiRouter(fields)) }) diff --git a/examples/erp/genseki/collections/users.ts b/examples/erp/genseki/collections/users.ts index 8fc5306b..cd5542a0 100644 --- a/examples/erp/genseki/collections/users.ts +++ b/examples/erp/genseki/collections/users.ts @@ -1,8 +1,11 @@ import z from 'zod' +import { CollectionBuilder, createPlugin } from '@genseki/react' + import { columns } from './users.client' -import { builder } from '../helper' +import { FullModelSchemas } from '../../generated/genseki/unsanitized' +import { builder, context } from '../helper' export const fields = builder.fields('user', (fb) => ({ name: fb.columns('name', { @@ -25,41 +28,41 @@ export const fields = builder.fields('user', (fb) => ({ }), })) -const list = builder.list(fields, { - columns: columns, - configuration: { - search: ['name'], - sortBy: ['name'], - }, -}) - -const update = builder.update(fields, {}) +export const usersCollection = createPlugin('users', (app) => { + const collection = new CollectionBuilder('users', context, FullModelSchemas) -const api = { - example: builder.endpoint( - { - method: 'GET', - path: '/', - responses: { - 200: z.object({ - data: z.any(), - }), - }, - }, - async () => { - return { - status: 200, - body: { - data: 'Hello from users collection', + return app + .overridePages(collection.overrideHomePage()) + .addApiRouter(collection.listApiRouter(fields)) + .addApiRouter(collection.updateApiRouter(fields)) + .addPageAndApiRouter( + collection.list(fields, { + columns: columns, + configuration: { + search: ['name'], + sortBy: ['name'], }, - } - } - ), -} - -export const usersCollection = builder.collection({ - slug: 'users', - list: list, - api: api, - update: update, + }) + ) + .addApiRouter({ + example: builder.endpoint( + { + method: 'GET', + path: '/', + responses: { + 200: z.object({ + data: z.any(), + }), + }, + }, + async () => { + return { + status: 200, + body: { + data: 'Hello from users collection', + }, + } + } + ), + }) }) diff --git a/examples/erp/genseki/config.tsx b/examples/erp/genseki/config.tsx index 1b4d5d0c..7f253b90 100644 --- a/examples/erp/genseki/config.tsx +++ b/examples/erp/genseki/config.tsx @@ -1,6 +1,12 @@ import { withNextJs } from '@genseki/next' import { admin } from '@genseki/plugins' -import { emailAndPasswordPlugin, GensekiApp, mePlugin, StorageAdapterS3 } from '@genseki/react' +import { + createPlugin, + emailAndPasswordPlugin, + GensekiApp, + mePlugin, + StorageAdapterS3, +} from '@genseki/react' import { accessControl } from './access-control' import { SetupPage } from './auth/setup/setup' @@ -15,6 +21,10 @@ import { FullModelSchemas } from '../generated/genseki/unsanitized' const app = new GensekiApp({ title: 'Genseki ERP Example', version: '0.0.0', + appBaseUrl: process.env.NEXT_PUBLIC_APP_BASE_URL || 'http://localhost:3000', + apiBaseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', + appPathPrefix: '/admin', + apiPathPrefix: '/admin/api', storageAdapter: StorageAdapterS3.initialize(context, { bucket: process.env.AWS_BUCKET_NAME!, imageBaseUrl: process.env.NEXT_PUBLIC_AWS_IMAGE_URL!, @@ -74,26 +84,17 @@ const app = new GensekiApp({ } ) ) - .apply( - admin( - context, - { user: FullModelSchemas.user }, - { - accessControl: accessControl, - } - ) - ) + .apply(admin(context, { user: FullModelSchemas.user }, { accessControl: accessControl })) .apply(usersCollection) .apply(postsCollection) .apply(tagsCollection) - .apply({ - api: { - auth: { - setup: setupApi, - }, - }, - }) - .build() + .apply( + createPlugin('common', (app) => { + return app.addApiRouter({ + auth: { setup: setupApi }, + }) + }) + ) const nextjsApp = withNextJs(app) diff --git a/packages/next/src/layouts/root.tsx b/packages/next/src/layouts/root.tsx index 5b33016e..d6c725dc 100644 --- a/packages/next/src/layouts/root.tsx +++ b/packages/next/src/layouts/root.tsx @@ -2,7 +2,7 @@ import { type ReactNode } from 'react' import { NuqsAdapter } from 'nuqs/adapters/next/app' -import { RootProvider, type ServerFunction } from '@genseki/react' +import { GensekiProvider, type ServerFunction } from '@genseki/react' import { NextNavigationProvider } from './navigation' @@ -20,9 +20,9 @@ export function RootLayout(props: RootLayoutProps) { return ( - + {props.children} - + ) diff --git a/packages/next/src/resource.ts b/packages/next/src/resource.ts index 0a1026b7..baae6b56 100644 --- a/packages/next/src/resource.ts +++ b/packages/next/src/resource.ts @@ -4,9 +4,9 @@ import { createRouter } from 'radix3' import { type ApiRoute, type ApiRouter, - flattenApiRouter, type GensekiCore, isApiRoute, + recordifyApiRouter, } from '@genseki/react' interface RouteData { @@ -108,7 +108,7 @@ interface ApiResourceRouterOptions { export function createApiResourceRouter(core: GensekiCore, options: ApiResourceRouterOptions = {}) { const pathPrefix = options.pathPrefix ?? '' - const flatApiRouter = flattenApiRouter(core.api) + const flatApiRouter = recordifyApiRouter(core.api) const radixGetRouter = createRouter({ routes: createRadixRoutesFromApiRouter(flatApiRouter, 'GET', { ...options, pathPrefix }), diff --git a/packages/next/src/server-function.ts b/packages/next/src/server-function.ts index bb65ecc0..903947cd 100644 --- a/packages/next/src/server-function.ts +++ b/packages/next/src/server-function.ts @@ -5,32 +5,13 @@ import { type AnyApiRouteSchema, type ApiRoute, type ApiRouteHandlerBasePayload, - type ApiRouter, type ApiRouteSchema, type GensekiCore, - type GetApiRouterFromGensekiCore, type GetGensekiApiRouterMethod, type GetServerFunctionApiArgs, type GetServerFunctionResponse, - isApiRoute, } from '@genseki/react' -function findApiRoute(apiRouter: ApiRouter, methodName: string): ApiRoute | undefined { - const [head, ...tails] = methodName.split('.') - const router = apiRouter[head] - if (!router) return undefined - if (isApiRoute(router) && tails.length === 0) { - return router - } - - // TODO: Improve split and join logic performance - if (!isApiRoute(router)) { - return findApiRoute(router, tails.join('.')) - } - - return undefined -} - function createRequest( schema: ApiRouteSchema, data: ApiRouteHandlerBasePayload @@ -63,15 +44,14 @@ function createResponse() { export async function handleServerFunction< TCore extends GensekiCore, TMethod extends GetGensekiApiRouterMethod, - TApiArgs extends GetServerFunctionApiArgs, TMethod>, + TApiArgs extends GetServerFunctionApiArgs, >( core: TCore, methodName: TMethod, args: TApiArgs -): Promise, TMethod>> { +): Promise> { try { - // TODO: Recusively find the method in the core.api object - const apiRoute = findApiRoute(core.api, methodName) + const apiRoute = core.api[methodName as keyof typeof core.api] as ApiRoute | undefined if (!apiRoute) { throw new Error(`No API route found for method: ${methodName as string}`) } @@ -87,7 +67,7 @@ export async function handleServerFunction< c.set(setCookieData.name, setCookieData.value, setCookieData) } - return result as GetServerFunctionResponse, TMethod> + return result as GetServerFunctionResponse } catch (error) { console.error('Error handling server function:', error) return { diff --git a/packages/next/src/with.ts b/packages/next/src/with.ts index 251ed6b8..fb1a095d 100644 --- a/packages/next/src/with.ts +++ b/packages/next/src/with.ts @@ -1,25 +1,33 @@ import { createRouter } from 'radix3' -import type { GensekiAppCompiled, GensekiUiRouter } from '@genseki/react' +import type { + FlatApiRouter, + GensekiApp, + GensekiAppClient, + GensekiAppCompiled, + GensekiUiRouter, +} from '@genseki/react' -export interface NextJsGensekiApp extends GensekiAppCompiled { +export interface NextJsGensekiApp + extends GensekiAppCompiled { radixRouter: ReturnType> toClient: () => NextJsGensekiAppClient } -export interface NextJsGensekiAppClient extends Pick {} +export interface NextJsGensekiAppClient extends GensekiAppClient {} -export function withNextJs(app: TGensekiApp) { +export function withNextJs( + app: GensekiApp +): NextJsGensekiApp { const radixRouter = createRouter() - app.uis.forEach((ui) => radixRouter.insert(ui.path, ui)) + const compliedApp = app.build() + const appClient = compliedApp.toClient() + + compliedApp.uis.forEach((ui) => radixRouter.insert(ui.path, ui)) return { - ...app, - radixRouter: radixRouter, - toClient(): NextJsGensekiAppClient { - return { - storageAdapter: app.storageAdapter, - } - }, + ...compliedApp, + radixRouter, + toClient: () => appClient, } } diff --git a/packages/plugins/src/admin/index.ts b/packages/plugins/src/admin/index.ts index a98eb6f5..2b696eb7 100644 --- a/packages/plugins/src/admin/index.ts +++ b/packages/plugins/src/admin/index.ts @@ -151,110 +151,104 @@ export function admin { - const prisma = context.getPrismaClient() - const builder = new Builder({ schema: schema, context }) - - const hasPermissionEndpoint = builder.endpoint( - { - method: 'POST', - path: '/auth/admin/has-permission', - body: z.object({ - role: z.string(), - permission: z.string(), + return createPlugin('admin', (app) => { + const prisma = context.getPrismaClient() + const builder = new Builder({ schema: schema, context }) + + const hasPermissionEndpoint = builder.endpoint( + { + method: 'POST', + path: '/auth/admin/has-permission', + body: z.object({ + role: z.string(), + permission: z.string(), + }), + responses: { + 200: z.object({ + ok: z.boolean(), }), - responses: { - 200: z.object({ - ok: z.boolean(), - }), - }, }, - ({ body }) => { - const { role, permission } = body - const ok = options.accessControl.hasPermission(role, permission as never) - return { - status: 200 as const, - body: { ok: ok }, - } + }, + ({ body }) => { + const { role, permission } = body + const ok = options.accessControl.hasPermission(role, permission as never) + return { + status: 200 as const, + body: { ok: ok }, } - ) - - const hasPermissionsEndpoint = builder.endpoint( - { - method: 'POST', - path: '/auth/admin/has-permissions', - body: z.object({ - role: z.string(), - permissions: z.string().array(), + } + ) + + const hasPermissionsEndpoint = builder.endpoint( + { + method: 'POST', + path: '/auth/admin/has-permissions', + body: z.object({ + role: z.string(), + permissions: z.string().array(), + }), + responses: { + 200: z.object({ + ok: z.boolean(), }), - responses: { - 200: z.object({ - ok: z.boolean(), - }), - }, }, - ({ body }) => { - const { role, permissions } = body - const ok = options.accessControl.hasPermissions(role, permissions as never[]) - return { - status: 200 as const, - body: { ok: ok }, - } + }, + ({ body }) => { + const { role, permissions } = body + const ok = options.accessControl.hasPermissions(role, permissions as never[]) + return { + status: 200 as const, + body: { ok: ok }, } - ) - - const banUserEndpoint = builder.endpoint( - { - method: 'POST', - path: '/auth/admin/ban-user', - body: z.object({ - userId: z.string(), - reason: z.string().optional(), - expiresAt: z.date().optional(), + } + ) + + const banUserEndpoint = builder.endpoint( + { + method: 'POST', + path: '/auth/admin/ban-user', + body: z.object({ + userId: z.string(), + reason: z.string().optional(), + expiresAt: z.date().optional(), + }), + responses: { + 200: z.object({ + ok: z.boolean(), }), - responses: { - 200: z.object({ - ok: z.boolean(), - }), - }, }, - async ({ body }) => { - const { userId } = body - - const banField = schema.user.shape.columns.banned - const bannedReasonField = schema.user.shape.columns.bannedReason - const bannedExpiredAtField = schema.user.shape.columns.bannedExpiredAt - - await prisma[schema.user.config.prismaModelName].update({ - where: { id: userId }, - data: { - [banField.name]: true, - [bannedReasonField.name]: body.reason ?? 'No reason provided', - [bannedExpiredAtField.name]: body.expiresAt ?? null, // null means no expiration - }, - }) + }, + async ({ body }) => { + const { userId } = body + + const banField = schema.user.shape.columns.banned + const bannedReasonField = schema.user.shape.columns.bannedReason + const bannedExpiredAtField = schema.user.shape.columns.bannedExpiredAt + + await prisma[schema.user.config.prismaModelName].update({ + where: { id: userId }, + data: { + [banField.name]: true, + [bannedReasonField.name]: body.reason ?? 'No reason provided', + [bannedExpiredAtField.name]: body.expiresAt ?? null, // null means no expiration + }, + }) - return { - status: 200 as const, - body: { ok: true }, - } + return { + status: 200 as const, + body: { ok: true }, } - ) + } + ) - const api = { - hasPermission: hasPermissionEndpoint, - hasPermissions: hasPermissionsEndpoint, - banUser: banUserEndpoint, - } as const + const api = { + hasPermission: hasPermissionEndpoint, + hasPermissions: hasPermissionsEndpoint, + banUser: banUserEndpoint, + } as const - return { - api: { - admin: api, - }, - uis: [], - } - }, + return app.addApiRouter({ + admin: api, + }) }) } diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index 4f2c5a82..a75e1c31 100644 --- a/packages/react-query/src/index.ts +++ b/packages/react-query/src/index.ts @@ -17,11 +17,10 @@ import type { PartialDeep } from 'type-fest' import type { ApiRoute, ApiRouteHandlerPayload, - ApiRouter, ApiRouteResponse, FilterByMethod, - FlattenApiRouter, - GensekiCore, + FlatApiRouter, + GensekiAppCompiled, } from '@genseki/react' import { createRestClient, type CreateRestClientConfig } from '@genseki/rest' @@ -46,12 +45,12 @@ type ApiRouteSchemaFromMethodAndPath< type UseQuery = { < const TMethod extends QueryMethod, - const TPath extends FilterByMethod['schema']['path'], + const TPath extends FilterByMethod['schema']['path'], const TPayload extends ApiRouteHandlerPayload< - ApiRouteSchemaFromMethodAndPath + ApiRouteSchemaFromMethodAndPath >, const TResponse extends ApiRouteResponse< - ApiRouteSchemaFromMethodAndPath['responses'] + ApiRouteSchemaFromMethodAndPath['responses'] >, const TError extends DefaultError = DefaultError, >( @@ -65,12 +64,12 @@ type UseQuery = { type QueryOptions = { < const TMethod extends QueryMethod, - const TPath extends FilterByMethod['schema']['path'], + const TPath extends FilterByMethod['schema']['path'], const TPayload extends ApiRouteHandlerPayload< - ApiRouteSchemaFromMethodAndPath + ApiRouteSchemaFromMethodAndPath >, const TResponse extends ApiRouteResponse< - ApiRouteSchemaFromMethodAndPath['responses'] + ApiRouteSchemaFromMethodAndPath['responses'] >, const TError extends DefaultError = DefaultError, >( @@ -194,8 +193,10 @@ type UseOptimisticUpdateQuery = { | undefined } -export type QueryClient = - FlattenApiRouter extends infer TApiRoute extends ApiRoute +type ValueOf = T[keyof T] + +export type QueryClient = + ValueOf extends infer TApiRoute extends ApiRoute ? { useQuery: UseQuery queryOptions: QueryOptions @@ -217,9 +218,9 @@ export function queryKey(method: string, path: string | number | symbol, payload return [method, path, payloadKey] as const } -export function createQueryClient( +export function createQueryClient( config: CreateRestClientConfig -): QueryClient { +): QueryClient { const restClient = createRestClient(config) const useQuery = function (method: string, path: string, payload: any, options?: any) { @@ -337,5 +338,5 @@ export function createQueryClient( useGetQueryData, useSetQueryData, useOptimisticUpdateQuery, - } as QueryClient + } as QueryClient } diff --git a/packages/react/package.json b/packages/react/package.json index a840b6c5..39eea1a6 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -44,6 +44,7 @@ "@internationalized/date": "^3.8.2", "@phosphor-icons/react": "^2.1.8", "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-use-controllable-state": "^1.2.2", "@react-aria/i18n": "^3.12.9", "@react-aria/visually-hidden": "^3.8.23", "@react-stately/calendar": "^3.8.2", diff --git a/packages/react/src/auth/plugins/email-and-password/api/validate-reset-password-token.ts b/packages/react/src/auth/plugins/email-and-password/api/validate-reset-password-token.ts index 46f99496..98525885 100644 --- a/packages/react/src/auth/plugins/email-and-password/api/validate-reset-password-token.ts +++ b/packages/react/src/auth/plugins/email-and-password/api/validate-reset-password-token.ts @@ -13,7 +13,7 @@ export function validateResetPasswordToken< service.context, { method: 'POST', - path: '/api/email-and-password/validate-reset-password-token', + path: '/auth/email-and-password/validate-reset-password-token', body: z.object({ token: z.string(), }), diff --git a/packages/react/src/auth/plugins/email-and-password/index.tsx b/packages/react/src/auth/plugins/email-and-password/index.tsx index dd591418..b1ec621f 100644 --- a/packages/react/src/auth/plugins/email-and-password/index.tsx +++ b/packages/react/src/auth/plugins/email-and-password/index.tsx @@ -16,8 +16,9 @@ import { AuthLayout } from './views/layout' import { LoginView } from './views/login/login' import { ResetPasswordView } from './views/reset-password/reset-password' -import { createGensekiUiRoute, createPlugin, type GensekiMiddleware } from '../../../core/config' +import { createGensekiUiRoute, type GensekiMiddleware } from '../../../core/config' import type { AnyContextable } from '../../../core/context' +import { createPlugin } from '../../../core/plugin' import type { IsValidTable } from '../../../core/table' import { GensekiUiCommonId } from '../../../core/ui' import type { @@ -142,117 +143,109 @@ export function emailAndPasswordPlugin< return { redirect: `/admin/auth/setup` } } - return createPlugin({ - name: 'auth', - plugin: (input) => { - if (_options.setup.enabled) { - input.middlewares?.push(setupMiddleware) - } + return createPlugin('emailAndPassword', (app) => { + if (_options.setup.enabled) { + app.addMiddleware(setupMiddleware) + } - const api = { - // No authentication required - loginEmail: loginEmail(service), - signOut: signOut(service), - resetPasswordEmail: resetPasswordEmail(service), - validateResetPasswordToken: validateResetPasswordToken(service), - sendEmailResetPassword: requestResetPassword(service), - } as const + const api = { + // No authentication required + loginEmail: loginEmail(service), + signOut: signOut(service), + resetPasswordEmail: resetPasswordEmail(service), + validateResetPasswordToken: validateResetPasswordToken(service), + sendEmailResetPassword: requestResetPassword(service), + } as const + + const uis = [ + createGensekiUiRoute({ + id: GensekiUiCommonId.AUTH_LOGIN, + context: context, + path: '/auth/login', + requiredAuthenticated: false, + render: (args) => { + return ( + + + + ) + }, + }), + createGensekiUiRoute({ + id: GensekiUiCommonId.AUTH_FORGOT_PASSWORD, + context: context, + path: '/auth/forgot-password', + requiredAuthenticated: false, + render: (args) => { + if (!options.resetPassword.enabled) { + throw new Error('Reset password is not enabled') + } + + return ( + + + + ) + }, + }), + createGensekiUiRoute({ + id: GensekiUiCommonId.AUTH_RESET_PASSWORD, + context: context, + path: '/auth/reset-password', + requiredAuthenticated: false, + render: (args) => { + if (!options.resetPassword.enabled) { + throw new Error('Reset password is not enabled') + } + + return ( + + { + try { + const verification = await service.validateResetPasswordToken(token) + return !!verification + } catch (error) { + console.error('Error validating reset password token:', error) + return false + } + }} + /> + + ) + }, + }), + ] - const uis = [ + if (options.setup?.enabled ?? true) { + const View = options.setup.ui + uis.push( createGensekiUiRoute({ - id: GensekiUiCommonId.AUTH_LOGIN, context: context, - path: '/auth/login', + path: '/auth/setup', requiredAuthenticated: false, - render: (args) => { - return ( - - - - ) - }, - }), - createGensekiUiRoute({ - id: GensekiUiCommonId.AUTH_FORGOT_PASSWORD, - context: context, - path: '/auth/forgot-password', - requiredAuthenticated: false, - render: (args) => { - if (!options.resetPassword.enabled) { - throw new Error('Reset password is not enabled') + render: async () => { + if (!options.setup?.enabled) { + throw new Error('Set up is not enabled') } - return ( - - - - ) - }, - }), - createGensekiUiRoute({ - id: GensekiUiCommonId.AUTH_RESET_PASSWORD, - context: context, - path: '/auth/reset-password', - requiredAuthenticated: false, - render: (args) => { - if (!options.resetPassword.enabled) { - throw new Error('Reset password is not enabled') + const count = await service.userCounts() + if (count > 0) { + return { redirect: '/admin/auth/login', type: 'replace' } } return ( - { - try { - const verification = await service.validateResetPasswordToken(token) - return !!verification - } catch (error) { - console.error('Error validating reset password token:', error) - return false - } - }} - /> + ) }, - }), - ] - - if (options.setup?.enabled ?? true) { - const View = options.setup.ui - uis.push( - createGensekiUiRoute({ - context: context, - path: '/auth/setup', - requiredAuthenticated: false, - render: async () => { - if (!options.setup?.enabled) { - throw new Error('Set up is not enabled') - } - - const count = await service.userCounts() - if (count > 0) { - return { redirect: '/admin/auth/login', type: 'replace' } - } - - return ( - - - - ) - }, - }) - ) - } + }) + ) + } - return { - api: { - emailAndPassword: api, - }, - uis: uis, - } - }, + return app.addApiRouter(api).addPages(uis) }) } diff --git a/packages/react/src/auth/plugins/email-and-password/views/forgot-password/forgot-password.client.tsx b/packages/react/src/auth/plugins/email-and-password/views/forgot-password/forgot-password.client.tsx index ca645413..c7921546 100644 --- a/packages/react/src/auth/plugins/email-and-password/views/forgot-password/forgot-password.client.tsx +++ b/packages/react/src/auth/plugins/email-and-password/views/forgot-password/forgot-password.client.tsx @@ -21,14 +21,17 @@ export function ForgotPasswordClientForm() { return ( { - const response = await serverFunction('emailAndPassword.sendEmailResetPassword', { - body: { - email, - }, - headers: {}, - query: {}, - pathParams: {}, - }) + const response = await serverFunction( + 'POST /auth/email-and-password/request-reset-password', + { + body: { + email, + }, + headers: {}, + query: {}, + pathParams: {}, + } + ) if (response.status !== 200) { toast.error('Failed to send OTP', { diff --git a/packages/react/src/auth/plugins/email-and-password/views/login/login.client.tsx b/packages/react/src/auth/plugins/email-and-password/views/login/login.client.tsx index 5d26feaf..37c6e709 100644 --- a/packages/react/src/auth/plugins/email-and-password/views/login/login.client.tsx +++ b/packages/react/src/auth/plugins/email-and-password/views/login/login.client.tsx @@ -48,7 +48,7 @@ export function LoginClientForm() { form.clearErrors('password') setIsLoading(true) - const response = await serverFunction('emailAndPassword.loginEmail', { + const response = await serverFunction('POST /auth/email-and-password/login', { body: { email: data.email, password: data.password, diff --git a/packages/react/src/auth/plugins/email-and-password/views/reset-password/reset-password.client.tsx b/packages/react/src/auth/plugins/email-and-password/views/reset-password/reset-password.client.tsx index fbee3036..b191300a 100644 --- a/packages/react/src/auth/plugins/email-and-password/views/reset-password/reset-password.client.tsx +++ b/packages/react/src/auth/plugins/email-and-password/views/reset-password/reset-password.client.tsx @@ -54,7 +54,7 @@ export function ResetPasswordClientForm({ token }: ResetPasswordClientFormProps) const { handleSubmit, control } = form const handleResetPassword = async (data: z.infer) => { - const response = await serverFunction('emailAndPassword.resetPasswordEmail', { + const response = await serverFunction('POST /auth/email-and-password/reset-password', { body: { password: data.password, }, diff --git a/packages/react/src/auth/plugins/me/index.tsx b/packages/react/src/auth/plugins/me/index.tsx index 683c2c74..3bb8e1ec 100644 --- a/packages/react/src/auth/plugins/me/index.tsx +++ b/packages/react/src/auth/plugins/me/index.tsx @@ -1,5 +1,4 @@ -import { createEndpoint } from '../../../core' -import { createPlugin } from '../../../core/config' +import { createEndpoint, createPlugin } from '../../../core' import type { Contextable } from '../../../core/context' export function mePlugin>(context: TContext) { @@ -21,15 +20,11 @@ export function mePlugin>(context: TContext) { } ) - return createPlugin({ - name: 'auth', - plugin: () => { - return { - api: { - me: meApi, - }, - uis: [], - } - }, + return createPlugin('auth', (app) => { + return app.addApiRouter({ + api: { + me: meApi, + }, + }) }) } diff --git a/packages/react/src/core/builder.tsx b/packages/react/src/core/builder.tsx index 8c791dd9..cb31c2a7 100644 --- a/packages/react/src/core/builder.tsx +++ b/packages/react/src/core/builder.tsx @@ -1,44 +1,14 @@ -import { - type CollectionDefaultAdminApiRouter, - getCollectionDefaultCreateApiRoute, - getCollectionDefaultDeleteApiRoute, - getCollectionDefaultFindManyApiRoute, - getCollectionDefaultFindOneApiRoute, - getCollectionDefaultUpdateApiRoute, - getCollectionDefaultUpdateDefaultApiRoute, - getOptionsRoute, -} from './builder.utils' -import type { - CollectionConfig, - CollectionCreateConfig, - CollectionDeleteConfig, - CollectionListConfig, - CollectionOneConfig, - CollectionUpdateConfig, - InferFields, -} from './collection' -import { createGensekiUiRoute, type GensekiPlugin, type GensekiUiRouter } from './config' import type { AnyContextable, ContextToRequestContext } from './context' -import { generateCustomCollectionListUI } from './custom-collection/custom-list-page' import { type ApiRoute, type ApiRouteHandlerInitial, type ApiRouteSchema, - type AppendApiPathPrefix, - appendApiPathPrefix, createEndpoint, } from './endpoint' import { FieldBuilder, type Fields, type FieldsOptions, type FieldsShape } from './field' import type { ModelSchemas } from './model' -import { GensekiUiCommonId, type GensekiUiCommonProps } from './ui' - -import { AppTopbarNav, CollectionLayout, HomeView } from '../react' -import { CreateView } from '../react/views/collections/create' -import { OneView } from '../react/views/collections/one' -import type { BaseViewProps } from '../react/views/collections/types' -import { UpdateView } from '../react/views/collections/update' -export class Builder { +export class Builder { constructor( private readonly config: { schema: TModelSchemas @@ -46,304 +16,6 @@ export class Builder, - >( - config: TConfig - ): GensekiPlugin< - TConfig['slug'], - { - [K in TConfig['slug']]: AppendApiPathPrefix<`/${TConfig['slug']}`, TConfig['api']> & - CollectionDefaultAdminApiRouter< - TConfig['slug'], - { - create: TConfig['create'] - update: TConfig['update'] - list: TConfig['list'] - one: TConfig['one'] - delete: TConfig['delete'] - } - > - } - > { - const slug = config.slug - - const api = appendApiPathPrefix(`/${slug}`, config.api ?? {}) - - const plugin: GensekiPlugin< - TConfig['slug'], - { - [K in TConfig['slug']]: AppendApiPathPrefix<`/${TConfig['slug']}`, TConfig['api']> & - CollectionDefaultAdminApiRouter< - TConfig['slug'], - { - create: TConfig['create'] - update: TConfig['update'] - list: TConfig['list'] - one: TConfig['one'] - delete: TConfig['delete'] - } - > - } - > = { - name: slug, - plugin: (gensekiOptions) => { - const previousCollectionHomeRouteIndex = gensekiOptions.uis.findIndex( - (ui) => ui.id === GensekiUiCommonId.COLLECTIONS_HOME - ) - const previousCollectionHomeRoute = - previousCollectionHomeRouteIndex >= 0 - ? (gensekiOptions.uis[previousCollectionHomeRouteIndex] as GensekiUiRouter< - GensekiUiCommonProps['COLLECTIONS_HOME'] - >) - : undefined - - // `collectionHomeRoute` is a page which group every collections - // If this is the first plugged collection, It will create the `collection home` page case (else) - const collectionHomeRoute = previousCollectionHomeRoute - ? { - ...previousCollectionHomeRoute, - props: { - cards: [ - ...(previousCollectionHomeRoute.props?.cards ?? []), - { name: slug, path: `/admin/collections/${slug}` }, - ], - }, - } - : createGensekiUiRoute({ - id: GensekiUiCommonId.COLLECTIONS_HOME, - context: this.config.context, - path: `/collections`, - requiredAuthenticated: true, - render: (args) => ( - - - - - ), - props: { - cards: [{ name: slug, path: `/admin/collections/${slug}` }], - } satisfies GensekiUiCommonProps[typeof GensekiUiCommonId.COLLECTIONS_HOME], - }) - - const uis = [] - - if (previousCollectionHomeRouteIndex >= 0) { - gensekiOptions.uis[previousCollectionHomeRouteIndex] = collectionHomeRoute - } else { - uis.push(collectionHomeRoute) - } - - if (config.list) { - const { route } = getCollectionDefaultFindManyApiRoute({ - slug: slug, - context: this.config.context, - schema: this.config.schema, - fields: config.list.fields, - customHandler: config.list.api as any, - listConfiguration: config.list.configuration, - }) - - Object.assign(api, { findMany: route }) - - const listUI = generateCustomCollectionListUI({ - slug: slug, - context: this.config.context, - listConfig: config.list, - gensekiOptions: gensekiOptions, - identifierColumn: config.list.fields.config.identifierColumn, - fields: config.list.fields, - route: route, - features: { - create: !!config.create, - update: !!config.update, - delete: !!config.delete, - one: !!config.one, - }, - }) - - uis.push(listUI) - } - - if (config.create) { - const defaultArgs = { - slug: slug, - context: this.config.context, - identifierColumn: config.create.fields.config.identifierColumn, - fields: config.create.fields, - } satisfies BaseViewProps - - const { route } = getCollectionDefaultCreateApiRoute({ - slug: slug, - context: this.config.context, - schema: this.config.schema, - fields: config.create.fields, - customHandler: config.create.api as any, - }) - - Object.assign(api, { create: route }) - - if ('options' in config.create) { - const { route } = getOptionsRoute( - this.config.context, - `/${config.slug}/create/options`, - config.create.options as FieldsOptions - ) - Object.assign(api, { createOptions: route }) - } - - uis.push( - createGensekiUiRoute({ - path: `/collections/${slug}/create`, - requiredAuthenticated: true, - context: this.config.context, - render: (args) => { - return ( - - - - - ) - }, - }) - ) - } - - if (config.update) { - const defaultArgs = { - slug: slug, - context: this.config.context, - identifierColumn: config.update.fields.config.identifierColumn, - fields: config.update.fields, - } satisfies BaseViewProps - - const { route: updateRoute } = getCollectionDefaultUpdateApiRoute({ - slug: slug, - context: this.config.context, - schema: this.config.schema, - fields: config.update.fields, - customHandler: config.update.updateApi as any, - }) - - Object.assign(api, { update: updateRoute }) - - if ('options' in config.update) { - const { route } = getOptionsRoute( - this.config.context, - `/${config.slug}/update/options`, - config.update.options as FieldsOptions - ) - Object.assign(api, { updateOptions: route }) - } - - const { route: updateDefaultRoute } = getCollectionDefaultUpdateDefaultApiRoute({ - slug: slug, - context: this.config.context, - schema: this.config.schema, - fields: config.update.fields, - customHandler: config.update.updateDefaultApi as any, - }) - - Object.assign(api, { updateDefault: updateDefaultRoute }) - - uis.push( - createGensekiUiRoute({ - path: `/collections/${slug}/update/:identifier`, - requiredAuthenticated: true, - context: this.config.context, - render: (args) => { - return ( - - - - - ) - }, - }) - ) - } - - if (config.one) { - const defaultArgs = { - slug: slug, - context: this.config.context, - identifierColumn: config.one.fields.config.identifierColumn, - fields: config.one.fields, - } satisfies BaseViewProps - - const { route } = getCollectionDefaultFindOneApiRoute({ - slug: slug, - context: this.config.context, - schema: this.config.schema, - fields: config.one.fields, - customHandler: config.one.api as any, - }) - Object.assign(api, { findOne: route }) - - uis.push( - createGensekiUiRoute({ - path: `/collections/${slug}/:identifier`, - requiredAuthenticated: true, - context: this.config.context, - render: (args) => { - return ( - - - - - ) - }, - }) - ) - } - - if (config.delete) { - const { route } = getCollectionDefaultDeleteApiRoute({ - slug: slug, - context: this.config.context, - schema: this.config.schema, - fields: config.delete.fields, - customHandler: config.delete.api as any, - }) - Object.assign(api, { delete: route }) - } - - return { - api: { - [slug]: api, - } as { - [K in TConfig['slug']]: AppendApiPathPrefix<`/${TConfig['slug']}`, TConfig['api']> & - CollectionDefaultAdminApiRouter< - TConfig['slug'], - { - create: TConfig['create'] - update: TConfig['update'] - list: TConfig['list'] - one: TConfig['one'] - delete: TConfig['delete'] - } - > - }, - uis: uis, - } - }, - } - return plugin - } - fields( modelName: TModelName, configFn: (fb: FieldBuilder) => TFieldsShape, @@ -370,42 +42,4 @@ export class Builder>, - >( - fields: TFields, - config: Omit - ): CollectionListConfig> { - return { fields, ...config } as unknown as CollectionListConfig< - TContext, - TFields, - InferFields - > - } - one>( - fields: TFields, - config: Omit - ): CollectionOneConfig { - return { fields, ...config } as unknown as CollectionOneConfig - } - create>( - fields: TFields, - config: Omit - ): CollectionCreateConfig { - return { fields, ...config } as unknown as CollectionCreateConfig - } - update>( - fields: TFields, - config: Omit - ): CollectionUpdateConfig { - return { fields, ...config } as unknown as CollectionUpdateConfig - } - delete>( - fields: TFields, - config: Omit - ): CollectionDeleteConfig { - return { fields, ...config } as unknown as CollectionDeleteConfig - } } diff --git a/packages/react/src/core/builder.utils.ts b/packages/react/src/core/builder.utils.ts index 0c659f59..61ec8dd1 100644 --- a/packages/react/src/core/builder.utils.ts +++ b/packages/react/src/core/builder.utils.ts @@ -1,9 +1,21 @@ import z from 'zod' -import type { ApiDefaultMethod } from './collection' +import type { + CollectionCreateApiHandler, + CollectionCreateApiReturn, + CollectionDeleteApiHandler, + CollectionDeleteApiReturn, + CollectionFindOneApiHandler, + CollectionFindOneApiReturn, + CollectionListApiHandler, + CollectionListApiReturn, + CollectionUpdateApiHandler, + CollectionUpdateApiReturn, + CollectionUpdateDefaultApiHandler, + CollectionUpdateDefaultApiReturn, +} from './collection' import type { ListConfiguration } from './collection' -import { type ApiReturnType, type InferCreateFields, type InferUpdateFields } from './collection' -import { type ApiConfigHandlerFn } from './collection' +import { type InferCreateFields, type InferUpdateFields } from './collection' import type { AnyContextable, Contextable } from './context' import { type ApiRoute, createEndpoint } from './endpoint' import { HttpInternalServerError } from './error' @@ -31,7 +43,7 @@ const zStringPositiveNumberOptional = z function createCollectionDefaultCreateHandler( config: { schema: ModelSchemas; context: TContext }, fields: Fields -): ApiConfigHandlerFn { +): CollectionCreateApiHandler { const prisma = config.context.getPrismaClient() const model = config.schema[fields.config.prismaModelName] @@ -52,7 +64,7 @@ function createCollectionDefaultCreateHandler( config: { schema: ModelSchemas; context: TContext }, fields: Fields -): ApiConfigHandlerFn { +): CollectionUpdateApiHandler { const prisma = config.context.getPrismaClient() const model = config.schema[fields.config.prismaModelName] @@ -74,7 +86,7 @@ function createCollectionDefaultUpdateHandler( config: { schema: ModelSchemas; context: TContext }, fields: Fields -): ApiConfigHandlerFn { +): CollectionDeleteApiHandler { const prisma = config.context.getPrismaClient() const model = config.schema[fields.config.prismaModelName] @@ -97,7 +109,7 @@ function createCollectionDefaultFindOneHandler< >( config: { schema: ModelSchemas; context: TContext }, fields: Fields -): ApiConfigHandlerFn { +): CollectionFindOneApiHandler { const prisma = config.context.getPrismaClient() return async (args) => { @@ -110,14 +122,11 @@ function createCollectionDefaultFindOneHandler< } } -function createCollectionDefaultFindManyHandler< - TContext extends Contextable, - TFields extends Fields, ->( +function createCollectionDefaultListHandler( config: { schema: ModelSchemas; context: TContext }, fields: Fields, listConfiguration?: ListConfiguration -): ApiConfigHandlerFn { +): CollectionListApiHandler { const prisma = config.context.getPrismaClient() const model = config.schema[fields.config.prismaModelName] @@ -204,7 +213,7 @@ export type CollectionCreateApiRoute> responses: { - 200: ToZodObject> + 200: ToZodObject } }> @@ -213,7 +222,7 @@ export function getCollectionDefaultCreateApiRoute(args: { context: AnyContextable schema: ModelSchemas fields: Fields - customHandler?: ApiConfigHandlerFn + customHandler?: CollectionCreateApiHandler }) { const defaultHandler = createCollectionDefaultCreateHandler( { schema: args.schema, context: args.context }, @@ -268,7 +277,7 @@ export type CollectionUpdateApiRoute body: ToZodObject> responses: { - 200: ToZodObject> + 200: ToZodObject } }> @@ -277,7 +286,7 @@ export function getCollectionDefaultUpdateApiRoute(args: { context: AnyContextable schema: ModelSchemas fields: Fields - customHandler?: ApiConfigHandlerFn + customHandler?: CollectionUpdateApiHandler }) { const defaultHandler = createCollectionDefaultUpdateHandler( { schema: args.schema, context: args.context }, @@ -334,7 +343,7 @@ export type CollectionUpdateDefaultApiRoute< method: 'GET' pathParams: ToZodObject<{ id: string }> responses: { - 200: ToZodObject> + 200: ToZodObject> } }> @@ -343,8 +352,7 @@ export function getCollectionDefaultUpdateDefaultApiRoute(args: { context: AnyContextable schema: ModelSchemas fields: Fields - // TODO: This should return default values for the form, not just findOne response - customHandler?: ApiConfigHandlerFn + customHandler?: CollectionUpdateDefaultApiHandler }) { const defaultHandler = createCollectionDefaultFindOneHandler( { schema: args.schema, context: args.context }, @@ -395,7 +403,7 @@ export type CollectionDeleteApiRoute = ApiRoute<{ method: 'DELETE' body: ToZodObject<{ ids: string[] | number[] }> responses: { - 200: ToZodObject> + 200: ToZodObject } }> @@ -404,7 +412,7 @@ export function getCollectionDefaultDeleteApiRoute(args: { context: AnyContextable schema: ModelSchemas fields: Fields - customHandler?: ApiConfigHandlerFn + customHandler?: CollectionDeleteApiHandler }) { const defaultHandler = createCollectionDefaultDeleteHandler( { schema: args.schema, context: args.context }, @@ -457,7 +465,7 @@ export type CollectionFindOneApiRoute responses: { - 200: ToZodObject> + 200: ToZodObject> } }> @@ -466,7 +474,7 @@ export function getCollectionDefaultFindOneApiRoute(args: { context: AnyContextable schema: ModelSchemas fields: Fields - customHandler?: ApiConfigHandlerFn + customHandler?: CollectionFindOneApiHandler }) { const defaultHandler = createCollectionDefaultFindOneHandler( { schema: args.schema, context: args.context }, @@ -510,7 +518,7 @@ export function getCollectionDefaultFindOneApiRoute(args: { } } -export type CollectionFindManyApiRoute = ApiRoute<{ +export type CollectionListApiRoute = ApiRoute<{ path: `/${TSlug}` method: 'GET' query: ToZodObject<{ @@ -520,19 +528,19 @@ export type CollectionFindManyApiRoute responses: { - 200: ToZodObject> + 200: ToZodObject> } }> -export function getCollectionDefaultFindManyApiRoute(args: { +export function getCollectionDefaultListApiRoute(args: { slug: string context: AnyContextable schema: ModelSchemas fields: Fields - customHandler?: ApiConfigHandlerFn + customHandler?: CollectionListApiHandler listConfiguration?: ListConfiguration }) { - const defaultHandler = createCollectionDefaultFindManyHandler( + const defaultHandler = createCollectionDefaultListHandler( { schema: args.schema, context: args.context }, args.fields, args.listConfiguration @@ -586,7 +594,7 @@ export function getCollectionDefaultFindManyApiRoute(args: { ) return { - route: route as unknown as CollectionFindManyApiRoute, + route: route as unknown as CollectionListApiRoute, handler, defaultHandler, } @@ -594,7 +602,7 @@ export function getCollectionDefaultFindManyApiRoute(args: { export type CollectionFieldsOptionsApiRoute = ApiRoute<{ path: TPath - method: 'GET' + method: 'POST' query: ToZodObject<{ name: string }> body: z.ZodOptional responses: { @@ -605,9 +613,9 @@ export type CollectionFieldsOptionsApiRoute = ApiRoute<{ } }> -export function getOptionsRoute( +export function getOptionsRoute( context: AnyContextable, - path: string, + path: TPath, fieldsOptions: FieldsOptions ) { const route = createEndpoint( @@ -632,15 +640,15 @@ export function getOptionsRoute( }, }, async (payload) => { - const optionsFn = fieldsOptions[ - payload.query.name as keyof FieldsOptions - ] as FieldOptionsCallback + const name = ((payload as any).query as { name: string }).name + const body = (payload as any).body as { [key: string]: any } + const optionsFn = fieldsOptions[name as keyof FieldsOptions] as FieldOptionsCallback if (!optionsFn) { - throw new HttpInternalServerError(`Field options "${payload.query.name}" not found`) + throw new HttpInternalServerError(`Field options "${name}" not found`) } - const result = await optionsFn({ context: payload.context, body: payload.body }) + const result = await optionsFn({ context: payload.context, body: body }) return { status: 200, @@ -684,7 +692,7 @@ export type CollectionDefaultAdminApiRouter< : {}) & (TOptions['list'] extends { fields: Fields } ? { - findMany: CollectionFindManyApiRoute + findMany: CollectionListApiRoute } : {}) & (TOptions['one'] extends { fields: Fields } diff --git a/packages/react/src/core/collection.ts b/packages/react/src/core/collection.ts deleted file mode 100644 index f9f373be..00000000 --- a/packages/react/src/core/collection.ts +++ /dev/null @@ -1,481 +0,0 @@ -import type React from 'react' - -import type { ColumnDef } from '@tanstack/react-table' -import type { ConditionalExcept, IsEmptyObject, Promisable, Simplify } from 'type-fest' -import type { UndefinedToOptional } from 'type-fest/source/internal' -import type { ZodObject, ZodOptional, ZodType } from 'zod' - -import type { CollectionFindManyApiRoute } from './builder.utils' -import type { RenderArgs } from './config' -import type { AnyContextable, ContextToRequestContext } from './context' -import { type AnyApiRouter } from './endpoint' -import { - type FieldColumnShape, - type FieldRelationConnectOrCreateShape, - type FieldRelationConnectShape, - type FieldRelationCreateShape, - type FieldRelationShape, - type FieldRelationShapeBase, - type Fields, - type FieldShape, - type FieldShapeBase, - type FieldsOptions, -} from './field' -import type { DataType, InferDataType } from './model' - -import type { ListViewContainerProps, SidebarProviderProps } from '../react' -import type { - BaseViewProps, - ClientBaseViewProps, - ListActions, -} from '../react/views/collections/types' - -export type ToZodObject> = ZodObject<{ - [Key in keyof T]-?: T[Key] extends undefined - ? ZodOptional>> - : ZodType -}> - -type SimplifyConditionalExcept = Simplify> - -export const ApiDefaultMethod = { - CREATE: 'create', - FIND_ONE: 'findOne', - FIND_MANY: 'findMany', - UPDATE: 'update', - DELETE: 'delete', -} as const -export type ApiDefaultMethod = (typeof ApiDefaultMethod)[keyof typeof ApiDefaultMethod] - -export type ApplyFieldProperty = TField['hidden'] extends true - ? never - : TField['required'] extends false - ? TType | undefined - : TType - -export type InferUpdateOneRelationFieldShape< - TFieldShape extends FieldRelationShapeBase, - TKeys extends 'create' | 'connect' | 'disconnect', -> = Simplify< - ('create' extends TKeys - ? { - create: TFieldShape extends FieldRelationCreateShape | FieldRelationConnectOrCreateShape - ? InferUpdateFields - : never - } - : {}) & - ('connect' extends TKeys - ? { - connect: InferDataType - } - : {}) & - ('disconnect' extends TKeys - ? { - disconnect: InferDataType - } - : {}) -> - -export type InferUpdateManyRelationFieldShape< - TField extends FieldRelationShapeBase, - TKeys extends 'create' | 'connect' | 'disconnect', -> = Partial>[] - -export type InferUpdateRelationField< - TField extends FieldRelationShapeBase, - TKeys extends 'create' | 'connect' | 'disconnect', -> = TField['$server']['relation']['isList'] extends true - ? InferUpdateManyRelationFieldShape - : Partial> - -export type InferUpdateFieldShape = ApplyFieldProperty< - TFieldShape extends FieldRelationShapeBase - ? TFieldShape extends FieldRelationCreateShape - ? Simplify> - : TFieldShape extends FieldRelationConnectShape - ? Simplify> - : TFieldShape extends FieldRelationConnectOrCreateShape - ? Simplify> - : never - : TFieldShape extends FieldColumnShape - ? TFieldShape['$server']['column']['isList'] extends true - ? TFieldShape['$server']['column']['isRequired'] extends true - ? InferDataType[] - : InferDataType[] | undefined | null - : TFieldShape['$server']['column']['isRequired'] extends true - ? InferDataType - : InferDataType | undefined | null - : never, - TFieldShape -> - -export type InferUpdateFields = UndefinedToOptional<{ - -readonly [TKey in keyof TFields['shape']]: InferUpdateFieldShape -}> - -export type InferCreateOneRelationFieldShape< - TFieldShape extends FieldRelationShapeBase, - TKeys extends 'create' | 'connect' | 'disconnect', -> = Simplify< - ('create' extends TKeys - ? { - create: TFieldShape extends FieldRelationCreateShape | FieldRelationConnectOrCreateShape - ? InferCreateFields - : never - } - : {}) & - ('connect' extends TKeys - ? { - connect: InferDataType - } - : {}) & - ('disconnect' extends TKeys - ? { - disconnect: InferDataType - } - : {}) -> - -export type InferCreateManyRelationFieldShape< - TField extends FieldRelationShapeBase, - TKeys extends 'create' | 'connect' | 'disconnect', -> = Partial>[] - -export type InferCreateRelationField< - TField extends FieldRelationShapeBase, - TKeys extends 'create' | 'connect' | 'disconnect', -> = TField['$server']['relation']['isList'] extends true - ? InferCreateManyRelationFieldShape - : Partial> - -export type InferCreateFieldShape = ApplyFieldProperty< - TFieldShape extends FieldRelationShapeBase - ? TFieldShape extends FieldRelationCreateShape - ? Simplify> - : TFieldShape extends FieldRelationConnectShape - ? Simplify> - : TFieldShape extends FieldRelationConnectOrCreateShape - ? Simplify> - : never - : TFieldShape extends FieldColumnShape - ? TFieldShape['$server']['column']['isList'] extends true - ? TFieldShape['$server']['column']['isRequired'] extends true - ? InferDataType[] - : InferDataType[] | undefined | null - : TFieldShape['$server']['column']['isRequired'] extends true - ? InferDataType - : InferDataType | undefined | null - : never, - TFieldShape -> - -export type InferCreateFields = UndefinedToOptional<{ - -readonly [TShapeKey in keyof TFields['shape']]: InferCreateFieldShape< - TFields['shape'][TShapeKey] - > -}> - -export type InferRelationField< - TFieldShape extends FieldRelationShape, - TKeys extends 'create' | 'connect' | 'disconnect', -> = TFieldShape['$server']['relation']['isList'] extends true - ? InferCreateManyRelationFieldShape - : InferCreateOneRelationFieldShape - -export type InferField = TField extends FieldRelationShape - ? TField['$server']['relation']['isList'] extends true - ? // TODO: Order field - TField['$server']['relation']['isRequired'] extends true - ? _InferFields[] - : _InferFields[] | undefined | null - : TField['$server']['relation']['isRequired'] extends true - ? _InferFields - : _InferFields | undefined | null - : TField extends FieldColumnShape - ? TField['$server']['column']['isList'] extends true - ? TField['$server']['column']['isRequired'] extends true - ? InferDataType[] - : InferDataType[] | undefined | null - : TField['$server']['column']['isRequired'] extends true - ? InferDataType - : InferDataType | undefined | null - : never - -type _InferFields = SimplifyConditionalExcept< - { - -readonly [TKey in keyof TFields['shape']]: TFields['shape'][TKey] extends FieldShapeBase - ? InferField - : never - } & BaseData, - never -> - -export type InferFields = UndefinedToOptional<_InferFields> - -export interface ServerApiHandlerArgs { - slug: string - fields: TFields - context: ContextToRequestContext -} - -export type ApiArgs< - TContext extends AnyContextable, - TMethod extends ApiDefaultMethod, - TFields extends Fields, -> = TMethod extends typeof ApiDefaultMethod.CREATE - ? ServerApiHandlerArgs & ApiCreateArgs - : TMethod extends typeof ApiDefaultMethod.FIND_ONE - ? ServerApiHandlerArgs & ApiFindOneArgs - : TMethod extends typeof ApiDefaultMethod.FIND_MANY - ? ServerApiHandlerArgs & ApiFindManyArgs - : TMethod extends typeof ApiDefaultMethod.UPDATE - ? ServerApiHandlerArgs & ApiUpdateArgs - : TMethod extends typeof ApiDefaultMethod.DELETE - ? ServerApiHandlerArgs & ApiDeleteArgs - : never - -export type ApiReturnType< - TMethod extends ApiDefaultMethod, - TFields extends Fields, -> = TMethod extends typeof ApiDefaultMethod.CREATE - ? { __pk: string | number; __id: string | number } - : TMethod extends typeof ApiDefaultMethod.FIND_ONE - ? InferFields - : TMethod extends typeof ApiDefaultMethod.FIND_MANY - ? { data: InferFields[]; total: number; totalPage: number; currentPage: number } - : TMethod extends typeof ApiDefaultMethod.UPDATE - ? { __pk: string | number; __id: string | number } - : TMethod extends typeof ApiDefaultMethod.DELETE - ? { success: boolean } - : never - -export type ApiFindOneArgs = { - // This should be the primary field of the collection e.g. __pk or username - id: string | number -} - -export type ApiFindManyArgs = { - page?: number - pageSize?: number - search?: string - sortBy?: string - sortOrder?: 'asc' | 'desc' -} - -export type ApiCreateArgs = { - data: InferCreateFields -} - -export type ApiHandlerFn< - TContext extends AnyContextable, - TFields extends Fields, - TMethod extends ApiDefaultMethod, -> = (args: ApiArgs) => Promisable> - -export type ApiUpdateArgs = { - // This should be the primary field of the collection e.g. __pk or username - id: string | number - data: InferUpdateFields -} - -export type ApiDeleteArgs = { - ids: string[] | number[] -} - -export type ApiConfigHandlerFn< - TContext extends AnyContextable, - TFields extends Fields, - TMethod extends ApiDefaultMethod, -> = ( - args: ApiArgs & { - defaultApi: ApiHandlerFn - } -) => Promisable> - -export type CollectionFieldsOptions = - FieldsOptions extends infer TOptions - ? IsEmptyObject extends true - ? {} - : { options: Simplify } - : {} - -export type CollectionCreateConfig< - TContext extends AnyContextable = AnyContextable, - TFields extends Fields = Fields, -> = { - fields: TFields - api?: ApiConfigHandlerFn -} & CollectionFieldsOptions - -export type CollectionUpdateConfig< - TContext extends AnyContextable = AnyContextable, - TFields extends Fields = Fields, -> = { - fields: TFields - updateApi?: ApiConfigHandlerFn - // TODO: This is not correct, it should return default value of form instead of just simple findOne response - updateDefaultApi?: ApiConfigHandlerFn -} & CollectionFieldsOptions - -// Extract searchable columns from fields -export type ExtractSearchableColumns = { - [K in keyof TFields['shape']]: TFields['shape'][K] extends FieldColumnShape - ? TFields['shape'][K]['$client']['column']['dataType'] extends typeof DataType.STRING - ? K - : never - : never -}[keyof TFields['shape']] - -// Extract sortable columns from fields -export type ExtractSortableColumns = { - [K in keyof TFields['shape']]: TFields['shape'][K] extends FieldColumnShape - ? TFields['shape'][K]['$client']['column']['dataType'] extends - | typeof DataType.STRING - | typeof DataType.INT - | typeof DataType.FLOAT - | typeof DataType.DATETIME - | typeof DataType.BIGINT - | typeof DataType.DECIMAL - ? K - : never - : never -}[keyof TFields['shape']] - -export interface ListConfiguration { - search?: ExtractSearchableColumns[] - sortBy?: ExtractSortableColumns[] -} - -export interface BaseData { - __id: string | number - __pk: string | number -} - -export type CollectionListResponse = { - data: ({} & BaseData)[] - total: number - totalPage: number - currentPage: number -} - -interface CustomCollectionLayout { - (args: { - CollectionLayout: React.FC<{ children: React.ReactNode }> - CollectionSidebar: React.FC - SidebarProvider: React.FC - SidebarInset: React.FC> - TopbarNav: React.FC - listViewProps: CollectionListViewProps - children: React.ReactNode - }): React.ReactElement -} - -interface CustomCollectionPage { - (args: { - listViewProps: CollectionListViewProps - ListViewContainer: React.FC - ListView: React.FC - Banner: React.FC - }): React.ReactElement -} -interface CustomCollectionUI< - TContext extends AnyContextable = AnyContextable, - TFields extends Fields = Fields, -> { - layout?: CustomCollectionLayout - pages?: CustomCollectionPage -} - -export type CollectionListConfig< - TContext extends AnyContextable = AnyContextable, - TFields extends Fields = Fields, - TFieldsData = any, -> = { - fields: TFields - columns: ColumnDef[] - api?: ApiConfigHandlerFn - uis?: CustomCollectionUI - configuration?: ListConfiguration - /** - * @param actions will decide whether or not to show actios in `list` view screen, This is not related to available features of collection, but rather only visible UI part of the `list` page - */ - actions?: { - create?: boolean - update?: boolean - delete?: boolean - one?: boolean - select?: boolean - } -} & CollectionFieldsOptions - -export type CollectionOneConfig< - TContext extends AnyContextable = AnyContextable, - TFields extends Fields = Fields, -> = { - fields: TFields - api?: ApiConfigHandlerFn -} & CollectionFieldsOptions - -export type CollectionDeleteConfig< - TContext extends AnyContextable = AnyContextable, - TFields extends Fields = Fields, -> = { - fields: TFields - api?: ApiConfigHandlerFn -} & CollectionFieldsOptions - -export interface CollectionConfig< - TContext extends AnyContextable, - TSlug extends string, - TCreateFields extends Fields, - TUpdateFields extends Fields, - TListFields extends Fields, - TOneFields extends Fields, - TDeleteFields extends Fields, - TApiRouter extends AnyApiRouter, -> { - slug: TSlug - create?: CollectionCreateConfig - update?: CollectionUpdateConfig - list?: CollectionListConfig - one?: CollectionOneConfig - delete?: CollectionDeleteConfig - api?: TApiRouter -} - -export interface CollectionConfigClient { - slug: string - create?: { - fields: Fields - } - update?: { - fields: Fields - } - list?: { - fields: Fields - } - one?: { - fields: Fields - } -} - -export interface CollectionListViewProps - extends BaseViewProps, - RenderArgs { - headers: Headers - searchParams: Record - columns: ColumnDef[] - findMany: CollectionFindManyApiRoute - listConfiguration?: ListConfiguration - actions?: ListActions -} - -export interface ClientCollectionListViewProps - extends ClientBaseViewProps, - RenderArgs { - headers: Headers - searchParams: Record - columns: ColumnDef[] - listConfiguration?: ListConfiguration - actions?: ListActions -} diff --git a/packages/react/src/core/collection.spec.ts b/packages/react/src/core/collection/index.spec.ts similarity index 95% rename from packages/react/src/core/collection.spec.ts rename to packages/react/src/core/collection/index.spec.ts index 25564460..2cef0525 100644 --- a/packages/react/src/core/collection.spec.ts +++ b/packages/react/src/core/collection/index.spec.ts @@ -1,10 +1,11 @@ import type { JsonValue } from 'type-fest' import { describe, expectTypeOf, it } from 'vitest' -import { mockContext } from './__mocks__/context' -import { FullModelSchemas } from './__mocks__/unsanitized' -import { Builder } from './builder' -import type { InferCreateFields, InferFields, InferUpdateFields } from './collection' +import type { InferCreateFields, InferFields, InferUpdateFields } from '.' + +import { mockContext } from '../__mocks__/context' +import { FullModelSchemas } from '../__mocks__/unsanitized' +import { Builder } from '../builder' type Expect = A extends B ? (B extends A ? true : never) : never diff --git a/packages/react/src/core/collection/index.tsx b/packages/react/src/core/collection/index.tsx new file mode 100644 index 00000000..79f6985a --- /dev/null +++ b/packages/react/src/core/collection/index.tsx @@ -0,0 +1,791 @@ +import type React from 'react' +import type { PropsWithChildren } from 'react' + +import type { ColumnDef } from '@tanstack/react-table' +import type { ConditionalExcept, Promisable, Simplify } from 'type-fest' +import type { UndefinedToOptional } from 'type-fest/source/internal' +import type { ZodObject, ZodOptional, ZodType } from 'zod' + +import { + type CollectionLayoutProps, + CreateView, + DefaultCollectionLayout, + HomeView, + OneView, + UpdateView, +} from '../../react' +import { getHeadersObject } from '../../react/utils/headers' +import { CollectionProvider } from '../../react/views/collections/context' +import { CollectionListProvider } from '../../react/views/collections/list/context' +import { DefaultCollectionListPage } from '../../react/views/collections/list/default' +import type { BaseViewProps } from '../../react/views/collections/types' +import { + type CollectionCreateApiRoute, + type CollectionDeleteApiRoute, + type CollectionFindOneApiRoute, + type CollectionListApiRoute, + type CollectionUpdateApiRoute, + type CollectionUpdateDefaultApiRoute, + getCollectionDefaultCreateApiRoute, + getCollectionDefaultDeleteApiRoute, + getCollectionDefaultFindOneApiRoute, + getCollectionDefaultListApiRoute, + getCollectionDefaultUpdateApiRoute, + getCollectionDefaultUpdateDefaultApiRoute, + getOptionsRoute, +} from '../builder.utils' +import { + createGensekiUiRoute, + type GensekiAppOptions, + type GensekiUiRouter, + getFieldsClient, +} from '../config' +import type { AnyContextable, ContextToRequestContext } from '../context' +import { + type FieldColumnShape, + type FieldRelationConnectOrCreateShape, + type FieldRelationConnectShape, + type FieldRelationCreateShape, + type FieldRelationShape, + type FieldRelationShapeBase, + type Fields, + type FieldShape, + type FieldShapeBase, + type FieldsOptions, +} from '../field' +import type { DataType, InferDataType, ModelSchemas } from '../model' +import type { GensekiPluginBuilderOptions } from '../plugin' +import { GensekiUiCommonId, type GensekiUiCommonProps } from '../ui' + +export type ToZodObject> = ZodObject<{ + [Key in keyof T]-?: T[Key] extends undefined + ? ZodOptional>> + : ZodType +}> + +type SimplifyConditionalExcept = Simplify> + +export const ApiDefaultMethod = { + CREATE: 'create', + FIND_ONE: 'findOne', + FIND_MANY: 'findMany', + UPDATE: 'update', + DELETE: 'delete', +} as const +export type ApiDefaultMethod = (typeof ApiDefaultMethod)[keyof typeof ApiDefaultMethod] + +export type ApplyFieldProperty = TField['hidden'] extends true + ? never + : TField['required'] extends false + ? TType | undefined + : TType + +export type InferUpdateOneRelationFieldShape< + TFieldShape extends FieldRelationShapeBase, + TKeys extends 'create' | 'connect' | 'disconnect', +> = Simplify< + ('create' extends TKeys + ? { + create: TFieldShape extends FieldRelationCreateShape | FieldRelationConnectOrCreateShape + ? InferUpdateFields + : never + } + : {}) & + ('connect' extends TKeys + ? { + connect: InferDataType + } + : {}) & + ('disconnect' extends TKeys + ? { + disconnect: InferDataType + } + : {}) +> + +export type InferUpdateManyRelationFieldShape< + TField extends FieldRelationShapeBase, + TKeys extends 'create' | 'connect' | 'disconnect', +> = Partial>[] + +export type InferUpdateRelationField< + TField extends FieldRelationShapeBase, + TKeys extends 'create' | 'connect' | 'disconnect', +> = TField['$server']['relation']['isList'] extends true + ? InferUpdateManyRelationFieldShape + : Partial> + +export type InferUpdateFieldShape = ApplyFieldProperty< + TFieldShape extends FieldRelationShapeBase + ? TFieldShape extends FieldRelationCreateShape + ? Simplify> + : TFieldShape extends FieldRelationConnectShape + ? Simplify> + : TFieldShape extends FieldRelationConnectOrCreateShape + ? Simplify> + : never + : TFieldShape extends FieldColumnShape + ? TFieldShape['$server']['column']['isList'] extends true + ? TFieldShape['$server']['column']['isRequired'] extends true + ? InferDataType[] + : InferDataType[] | undefined | null + : TFieldShape['$server']['column']['isRequired'] extends true + ? InferDataType + : InferDataType | undefined | null + : never, + TFieldShape +> + +export type InferUpdateFields = UndefinedToOptional<{ + -readonly [TKey in keyof TFields['shape']]: InferUpdateFieldShape +}> + +export type InferCreateOneRelationFieldShape< + TFieldShape extends FieldRelationShapeBase, + TKeys extends 'create' | 'connect' | 'disconnect', +> = Simplify< + ('create' extends TKeys + ? { + create: TFieldShape extends FieldRelationCreateShape | FieldRelationConnectOrCreateShape + ? InferCreateFields + : never + } + : {}) & + ('connect' extends TKeys + ? { + connect: InferDataType + } + : {}) & + ('disconnect' extends TKeys + ? { + disconnect: InferDataType + } + : {}) +> + +export type InferCreateManyRelationFieldShape< + TField extends FieldRelationShapeBase, + TKeys extends 'create' | 'connect' | 'disconnect', +> = Partial>[] + +export type InferCreateRelationField< + TField extends FieldRelationShapeBase, + TKeys extends 'create' | 'connect' | 'disconnect', +> = TField['$server']['relation']['isList'] extends true + ? InferCreateManyRelationFieldShape + : Partial> + +export type InferCreateFieldShape = ApplyFieldProperty< + TFieldShape extends FieldRelationShapeBase + ? TFieldShape extends FieldRelationCreateShape + ? Simplify> + : TFieldShape extends FieldRelationConnectShape + ? Simplify> + : TFieldShape extends FieldRelationConnectOrCreateShape + ? Simplify> + : never + : TFieldShape extends FieldColumnShape + ? TFieldShape['$server']['column']['isList'] extends true + ? TFieldShape['$server']['column']['isRequired'] extends true + ? InferDataType[] + : InferDataType[] | undefined | null + : TFieldShape['$server']['column']['isRequired'] extends true + ? InferDataType + : InferDataType | undefined | null + : never, + TFieldShape +> + +export type InferCreateFields = UndefinedToOptional<{ + -readonly [TShapeKey in keyof TFields['shape']]: InferCreateFieldShape< + TFields['shape'][TShapeKey] + > +}> + +export type InferRelationField< + TFieldShape extends FieldRelationShape, + TKeys extends 'create' | 'connect' | 'disconnect', +> = TFieldShape['$server']['relation']['isList'] extends true + ? InferCreateManyRelationFieldShape + : InferCreateOneRelationFieldShape + +export type InferField = TField extends FieldRelationShape + ? TField['$server']['relation']['isList'] extends true + ? // TODO: Order field + TField['$server']['relation']['isRequired'] extends true + ? _InferFields[] + : _InferFields[] | undefined | null + : TField['$server']['relation']['isRequired'] extends true + ? _InferFields + : _InferFields | undefined | null + : TField extends FieldColumnShape + ? TField['$server']['column']['isList'] extends true + ? TField['$server']['column']['isRequired'] extends true + ? InferDataType[] + : InferDataType[] | undefined | null + : TField['$server']['column']['isRequired'] extends true + ? InferDataType + : InferDataType | undefined | null + : never + +type _InferFields = SimplifyConditionalExcept< + { + -readonly [TKey in keyof TFields['shape']]: TFields['shape'][TKey] extends FieldShapeBase + ? InferField + : never + } & { __id: string | number; __pk: string | number }, + never +> + +export type InferFields = UndefinedToOptional<_InferFields> + +export interface ServerApiHandlerArgs { + slug: string + fields: TFields + context: ContextToRequestContext +} + +export type CollectionCreateApiArgs< + TContext extends AnyContextable, + TFields extends Fields, +> = ServerApiHandlerArgs & { + data: InferCreateFields +} + +export type CollectionCreateApiReturn = { + __pk: string | number + __id: string | number +} + +export type CollectionCreateApiHandler = ( + args: CollectionCreateApiArgs & { + defaultApi: CollectionCreateApiHandler + } +) => Promisable + +export type CollectionCreateConfig< + TContext extends AnyContextable = AnyContextable, + TFields extends Fields = Fields, +> = { + api?: CollectionCreateApiHandler + options?: Simplify> +} + +export type CollectionFindOneApiArgs< + TContext extends AnyContextable, + TFields extends Fields, +> = ServerApiHandlerArgs & { + // This should be the primary field of the collection e.g. __pk or username + id: string | number +} + +export type CollectionFindOneApiReturn = InferFields + +export type CollectionFindOneApiHandler = ( + args: CollectionFindOneApiArgs & { + defaultApi: CollectionFindOneApiHandler + } +) => Promisable> + +export type CollectionListApiArgs< + TContext extends AnyContextable, + TFields extends Fields, +> = ServerApiHandlerArgs & { + page?: number + pageSize?: number + search?: string + sortBy?: string + sortOrder?: 'asc' | 'desc' +} + +export type CollectionListApiReturn = { + data: InferFields[] + total: number + totalPage: number + currentPage: number +} + +export type CollectionListApiHandler = ( + args: CollectionListApiArgs & { + defaultApi: CollectionListApiHandler + } +) => Promisable> + +export type CollectionListConfig< + TContext extends AnyContextable = AnyContextable, + TFields extends Fields = Fields, + TFieldsData = any, +> = { + columns: ColumnDef[] + configuration?: ListConfiguration + api?: CollectionListApiHandler + page?: React.FC + layout?: React.FC + /** + * @param actions will decide whether or not to show actios in `list` view screen, This is not related to available features of collection, but rather only visible UI part of the `list` page + */ + actions?: { + create?: boolean + update?: boolean + delete?: boolean + one?: boolean + select?: boolean + } +} + +export type CollectionUpdateApiArgs< + TContext extends AnyContextable, + TFields extends Fields, +> = ServerApiHandlerArgs & { + // This should be the primary field of the collection e.g. __pk or username + id: string | number + data: InferUpdateFields +} + +export type CollectionUpdateApiReturn = { + __pk: string | number + __id: string | number +} + +export type CollectionUpdateApiHandler = ( + args: CollectionUpdateApiArgs & { + defaultApi: CollectionUpdateApiHandler + } +) => Promisable + +export type CollectionUpdateDefaultApiArgs< + TContext extends AnyContextable, + TFields extends Fields, +> = ServerApiHandlerArgs & { + // This should be the primary field of the collection e.g. __pk or username + id: string | number +} + +// TODO: This is not correct, it should return default value of form instead of just simple findOne response +export type CollectionUpdateDefaultApiReturn = any + +export type CollectionUpdateDefaultApiHandler< + TContext extends AnyContextable, + TFields extends Fields, +> = ( + args: CollectionUpdateDefaultApiArgs & { + defaultApi: CollectionUpdateDefaultApiHandler + } +) => Promisable> + +export type CollectionUpdateConfig< + TContext extends AnyContextable = AnyContextable, + TFields extends Fields = Fields, +> = { + updateApi?: CollectionUpdateApiHandler + updateDefaultApi?: CollectionUpdateDefaultApiHandler + options?: Simplify> +} + +export interface CollectionDeleteApiArgs + extends ServerApiHandlerArgs { + ids: string[] | number[] +} + +export type CollectionDeleteApiReturn = { + success: boolean +} + +export type CollectionDeleteApiHandler = ( + args: CollectionDeleteApiArgs & { + defaultApi: CollectionDeleteApiHandler + } +) => Promisable + +// Extract searchable columns from fields +export type ExtractSearchableColumns = { + [K in keyof TFields['shape']]: TFields['shape'][K] extends FieldColumnShape + ? TFields['shape'][K]['$client']['column']['dataType'] extends typeof DataType.STRING + ? Extract + : never + : never +}[keyof TFields['shape']] + +// Extract sortable columns from fields +export type ExtractSortableColumns = { + [K in keyof TFields['shape']]: TFields['shape'][K] extends FieldColumnShape + ? TFields['shape'][K]['$client']['column']['dataType'] extends + | typeof DataType.STRING + | typeof DataType.INT + | typeof DataType.FLOAT + | typeof DataType.DATETIME + | typeof DataType.BIGINT + | typeof DataType.DECIMAL + ? Extract + : never + : never +}[keyof TFields['shape']] + +// ListConfiguration +export interface ListConfiguration { + search?: ExtractSearchableColumns[] + sortBy?: ExtractSortableColumns[] +} + +export interface CollectionListResponse { + data: ({ __id: string | number; __pk: string | number } & Record)[] + total: number + totalPage: number + currentPage: number +} + +export interface CollectionConfig { + apiPathPrefix: string + uiPathPrefix: string +} + +export class CollectionBuilder< + TSlug extends string, + TContext extends AnyContextable, + TModelSchemas extends ModelSchemas, + TConfig extends CollectionConfig = { apiPathPrefix: ''; uiPathPrefix: '/collections' }, +> { + constructor( + private readonly slug: TSlug, + private readonly context: TContext, + private readonly schema: TModelSchemas, + private readonly config: TConfig = { + uiPathPrefix: '/collections', + apiPathPrefix: '', + } as TConfig + ) {} + + overrideHomePage() { + return (pages: GensekiUiRouter[], options: GensekiPluginBuilderOptions) => { + const homePageIndex = pages.findIndex( + (page) => page.id === GensekiUiCommonId.COLLECTIONS_HOME + ) + + if (homePageIndex === -1) { + return [ + ...pages, + createGensekiUiRoute({ + id: GensekiUiCommonId.COLLECTIONS_HOME, + requiredAuthenticated: true, + path: `${this.config.uiPathPrefix}`, + context: this.context, + render: (args) => { + return ( + + + + ) + }, + props: { + cards: [{ name: this.slug, path: `/admin${this.config.uiPathPrefix}/${this.slug}` }], + } satisfies GensekiUiCommonProps[typeof GensekiUiCommonId.COLLECTIONS_HOME], + }), + ] + } + + const homePage: GensekiUiRouter< + GensekiUiCommonProps[typeof GensekiUiCommonId.COLLECTIONS_HOME] + > = pages[homePageIndex] + + homePage.props = { + ...homePage.props, + cards: [ + ...(homePage.props?.cards ?? []), + { name: this.slug, path: `/admin${this.config.uiPathPrefix}/${this.slug}` }, + ], + } + + pages[homePageIndex] = homePage + return pages + } + } + + // TODO: Config + list( + fields: TFields, + config: CollectionListConfig = { columns: [] } + ) { + return (appOptions: GensekiAppOptions) => { + const route = this.listApiRouter(fields) + + const ui = createGensekiUiRoute({ + path: `${this.config.uiPathPrefix}/${this.slug}`, + context: this.context, + requiredAuthenticated: true, + render: (args) => { + // Layout + const CustomLayout = config.layout + const Layout: React.FC = (props) => { + const layoutProps: CollectionLayoutProps = { + pathname: args.pathname, + title: appOptions.title, + version: appOptions.version, + sidebar: appOptions.sidebar, + children: props.children, + } + if (CustomLayout) return + return + } + + // Page + const CustomPage = config.page + const page = CustomPage ? : + + return ( + + + {page} + + + ) + }, + }) + + return { ui: ui, api: route } + } + } + + listApiRouter(fields: TFields) { + const { route } = getCollectionDefaultListApiRoute({ + slug: this.slug, + context: this.context, + schema: this.schema, + fields: fields, + }) + + return { + list: route as Simplify>, + } + } + + one(fields: TFields) { + return (appOptions: GensekiAppOptions) => { + const route = this.oneApiRouter(fields) + + const defaultArgs = { + slug: this.slug, + context: this.context, + fields: fields, + } satisfies BaseViewProps + + const ui = createGensekiUiRoute({ + path: `${this.config.uiPathPrefix}/${this.slug}/:identifier`, + requiredAuthenticated: true, + context: this.context, + render: (args) => { + return ( + + + + + + ) + }, + }) + + return { ui: ui, api: route } + } + } + + oneApiRouter(fields: TFields) { + const { route } = getCollectionDefaultFindOneApiRoute({ + slug: this.slug, + context: this.context, + schema: this.schema, + fields: fields, + }) + + return { + one: route as Simplify>, + } + } + + create>( + fields: TFields, + config: TConfig + ) { + return (appOptions: GensekiAppOptions) => { + const route = this.createApiRouter(fields, config) + + const defaultArgs = { + slug: this.slug, + context: this.context, + fields: fields, + } satisfies BaseViewProps + + const ui = createGensekiUiRoute({ + path: `${this.config.uiPathPrefix}/${this.slug}/create`, + requiredAuthenticated: true, + context: this.context, + render: (args) => { + return ( + + + + + + ) + }, + }) + + return { ui: ui, api: route } + } + } + + createApiRouter< + TFields extends Fields, + TConfig extends CollectionCreateConfig, + >(fields: TFields, config: TConfig = {} as TConfig) { + const { route } = getCollectionDefaultCreateApiRoute({ + slug: this.slug, + context: this.context, + schema: this.schema, + fields: fields, + customHandler: config.api as any, + }) + + const { route: createOptionsRoute } = getOptionsRoute( + this.context, + `/${this.slug}/create/options`, + config.options ?? {} + ) + + return { + create: route as Simplify>, + createOptions: createOptionsRoute, + } + } + + update>( + fields: TFields, + config: TConfig = {} as TConfig + ) { + return (appOptions: GensekiAppOptions) => { + const route = this.updateApiRouter(fields, config) + + const defaultArgs = { + slug: this.slug, + context: this.context, + fields: fields, + } satisfies BaseViewProps + + const ui = createGensekiUiRoute({ + path: `${this.config.uiPathPrefix}/${this.slug}/update/:identifier`, + requiredAuthenticated: true, + context: this.context, + render: (args) => { + return ( + + + + + + ) + }, + }) + + return { ui: ui, api: route } + } + } + + updateApiRouter< + TFields extends Fields, + TConfig extends CollectionUpdateConfig, + >(fields: TFields, config: TConfig = {} as TConfig) { + const { route: updateRoute } = getCollectionDefaultUpdateApiRoute({ + slug: this.slug, + context: this.context, + schema: this.schema, + fields: fields, + customHandler: config.updateApi as any, + }) + + const { route: updateDefaultRoute } = getCollectionDefaultUpdateDefaultApiRoute({ + slug: this.slug, + context: this.context, + schema: this.schema, + fields: fields, + customHandler: config.updateApi as any, + }) + + const { route: updateOptionsRoute } = getOptionsRoute( + this.context, + `/${this.slug}/update/options`, + config.options ?? {} + ) + + return { + update: updateRoute as Simplify>, + updateDefault: updateDefaultRoute as Simplify< + CollectionUpdateDefaultApiRoute + >, + updateOptions: updateOptionsRoute, + } + } + + deleteApiRouter(fields: TFields) { + const { route } = getCollectionDefaultDeleteApiRoute({ + slug: this.slug, + context: this.context, + schema: this.schema, + fields: fields, + }) + + return { + delete: route as Simplify>, + } + } +} + +export interface CollectionListActions { + create?: boolean + update?: boolean + delete?: boolean + one?: boolean + select?: boolean +} diff --git a/packages/react/src/core/config.tsx b/packages/react/src/core/config.tsx index ef9f19b6..d40d1f7d 100644 --- a/packages/react/src/core/config.tsx +++ b/packages/react/src/core/config.tsx @@ -1,24 +1,22 @@ import { type ReactNode } from 'react' -import { deepmerge } from 'deepmerge-ts' import * as R from 'remeda' -import type { Promisable } from 'type-fest' +import type { Promisable, Simplify } from 'type-fest' -import { - type AnyContextable, - type AnyRequestContextable, - type ApiRouter, - createFileUploadHandlers, - type ListViewProps, -} from '.' -import type { ClientCollectionListViewProps } from './collection' -import { type AnyApiRouter, type ApiRoutePath, isApiRoute } from './endpoint' +import type { AnyContextable, AnyRequestContextable } from './context' +import { type ApiRoutePath, type FlatApiRouter } from './endpoint' import type { Fields, FieldsClient, FieldShape, FieldShapeClient } from './field' import { getStorageAdapterClient, type StorageAdapter, type StorageAdapterClient, } from './file-storage-adapters/generic-adapter' +import { createFileUploadHandlers } from './file-storage-adapters/handlers' +import { + type AnyGensekiPlugin, + GensekiPluginBuilder, + type InferApiRouterFromGensekiPlugin, +} from './plugin' import { getEditorProviderClientProps } from './richtext' import { isMediaFieldShape, isRelationFieldShape, isRichTextFieldShape } from './utils' @@ -76,50 +74,44 @@ export type GensekiMiddleware = ( export interface GensekiAppOptions { title: string version: string - apiPrefix?: string - uiPathPrefix?: string - components?: { - NotFound?: () => ReactNode - } + appBaseUrl: string + appPathPrefix?: string // default to /admin + apiBaseUrl?: string // default to "appBaseUrl" + apiPathPrefix?: string // default to /admin/api sidebar?: AppSideBarBuilderProps storageAdapter?: StorageAdapter middlewares?: GensekiMiddleware[] } -export interface GensekiPluginOptions extends GensekiAppOptions, GensekiCore {} +export interface GensekiAppOptionsWithDefaults extends GensekiAppOptions { + appPathPrefix: string // default to /admin + apiBaseUrl: string // default to "appBaseUrl" + apiPathPrefix: string // default to /admin/api +} -export interface GensekiCore { +export interface GensekiCore { api: TApiRouter uis: GensekiUiRouter[] } -export interface GensekiAppCompiled - extends GensekiCore { - middlewares?: GensekiMiddleware[] - storageAdapter?: StorageAdapterClient -} - -export interface GensekiAppCompiledClient { +export interface GensekiAppClient { + title: string + version: string + appBaseUrl: string + appPathPrefix?: string // default to /admin + apiBaseUrl: string // default to "appBaseUrl" + apiPathPrefix?: string // default to /admin/api + sidebar?: AppSideBarBuilderProps storageAdapter?: StorageAdapterClient } - -export interface GensekiPlugin { - name: TName - plugin: (options: GensekiPluginOptions) => GensekiCore -} - -export function createPlugin(args: { - name: TName - plugin: (options: GensekiPluginOptions) => GensekiCore -}): GensekiPlugin { - return args +export interface GensekiAppCompiled + extends GensekiAppClient { + api: TApiRouter + uis: GensekiUiRouter[] + middlewares?: GensekiMiddleware[] + toClient: () => GensekiAppClient } -export type AnyGensekiPlugin = GensekiPlugin -export type InferApiRouterFromGensekiPlugin = ReturnType< - TPlugin['plugin'] ->['api'] - const unauthorizedMiddleware: GensekiMiddleware = async (args: GensekiMiddlewareArgs) => { if (args.ui.requiredAuthenticated) { try { @@ -130,10 +122,10 @@ const unauthorizedMiddleware: GensekiMiddleware = async (args: GensekiMiddleware } } -export class GensekiApp { - // private readonly apiPathPrefix: string - private readonly plugins: AnyGensekiPlugin[] = [] - private core: GensekiCore = {} as GensekiCore +export class GensekiApp { + private readonly pluginBuilder: GensekiPluginBuilder + + private readonly options: GensekiAppOptionsWithDefaults private storageRoutesForClient?: { putObjSignedUrl: ApiRoutePath @@ -141,16 +133,23 @@ export class GensekiApp( plugin: TPlugin - ): GensekiApp> - - apply( - core: Partial> - ): GensekiApp - - apply(input: any) { - if (isGensekiPlugin(input)) { - this.plugins.push(input) - } else { - this.core = { - api: { ...(this.core.api ?? {}), ...(input.api ?? {}) }, - uis: [...(this.core.uis ?? []), ...(input.uis ?? [])], - } - } - return this + ): GensekiApp>> { + plugin.plugin(this.pluginBuilder) + return this as unknown as GensekiApp> } - private _logApiRouter(router: ApiRouter): string[] { + private _logApiRouter(router: FlatApiRouter): string[] { const logs: string[] = [] - if (isApiRoute(router)) { - logs.push(`API Route: ${router.schema.method} ${router.schema.path}`) - return logs - } - Object.entries(router).forEach(([key, value]) => { - if (typeof value === 'object' && value !== null) { - logs.push(...this._logApiRouter(value as ApiRouter)) - } + Object.values(router).forEach((route) => { + logs.push(`API Route: ${route.schema.method} ${route.schema.path}`) }) return logs } @@ -215,44 +195,33 @@ export class GensekiApp { - const core = this.plugins.reduce( - (acc, plugin) => { - const pluginCore = plugin.plugin({ - ...this.options, - ...acc, - }) - return { - api: deepmerge(acc.api, pluginCore.api) as TMainApiRouter, - uis: [...acc.uis, ...(pluginCore.uis ?? [])], - } - }, - { - uis: this.core.uis ?? [], - api: this.core.api ?? {}, - } as unknown as GensekiCore - ) - if (this.options.storageAdapter) { const { handlers } = createFileUploadHandlers( this.options.storageAdapter.context, this.options.storageAdapter ) - core.api = deepmerge(core.api, { + this.pluginBuilder.addApiRouter({ storage: { putObjSignedUrl: handlers['file.generatePutObjSignedUrl'], getObjSignedUrl: handlers['file.generateGetObjSignedUrl'], deleteObjSignedUrl: handlers['file.generateDeleteObjSignedUrl'], }, - }) as TMainApiRouter + }) } - const logs = this._logApiRouter(core.api).sort((a, b) => a.localeCompare(b)) - const uiLogs = this._logUis(core.uis).sort((a, b) => a.localeCompare(b)) + const logs = this._logApiRouter(this.pluginBuilder.getApi()).sort((a, b) => a.localeCompare(b)) + const uiLogs = this._logUis(this.pluginBuilder.getUis()).sort((a, b) => a.localeCompare(b)) logs.forEach((log) => console.log(`${log}`)) uiLogs.forEach((log) => console.log(`${log}`)) - return { - middlewares: this.options.middlewares, + const appClient = { + title: this.options.title, + version: this.options.version, + appBaseUrl: this.options.appBaseUrl, + appPathPrefix: this.options.appPathPrefix, + apiBaseUrl: this.options.apiBaseUrl ?? this.options.appBaseUrl, + apiPathPrefix: this.options.apiPathPrefix, + sidebar: this.options.sidebar, storageAdapter: this.storageRoutesForClient ? getStorageAdapterClient({ storageAdapter: this.options.storageAdapter, @@ -261,8 +230,14 @@ export class GensekiApp appClient, } } } @@ -334,26 +309,3 @@ export function getFieldsClient(fields: Fields): FieldsClient { config: fields.config, } } -export function getClientListViewProps( - args: ListViewProps -): ClientCollectionListViewProps { - const fieldsClient = getFieldsClient(args.fields) - - const clientListViewProps = R.pick(args, [ - 'actions', - 'columns', - 'headers', - 'identifierColumn', - 'listConfiguration', - 'params', - 'pathname', - 'searchParams', - 'slug', - ]) - - return { ...clientListViewProps, fieldsClient } -} - -function isGensekiPlugin(plugin: any): plugin is TPlugin { - return 'name' in plugin && 'plugin' in plugin -} diff --git a/packages/react/src/core/custom-collection/custom-list-page.tsx b/packages/react/src/core/custom-collection/custom-list-page.tsx deleted file mode 100644 index f1fc8989..00000000 --- a/packages/react/src/core/custom-collection/custom-list-page.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React from 'react' - -import { - AppSidebar, - AppTopbarNav as _AppTopbarNav, - Banner, - CollectionLayout as _CollectionLayout, - CollectionListView as _ListView, - CollectionListViewProvider, - ListViewContainer, - type ListViewContainerProps, - SidebarInset, - SidebarProvider, -} from '../../react' -import type { BaseViewProps, ListActions } from '../../react/views/collections/types' -import type { CollectionFindManyApiRoute } from '../builder.utils' -import type { CollectionListConfig, CollectionListViewProps } from '../collection' -import { createGensekiUiRoute, type GensekiPluginOptions, getClientListViewProps } from '../config' - -export function generateCustomCollectionListUI< - TCollectionListConfig extends CollectionListConfig, ->( - customCollectionArgs: { - listConfig: TCollectionListConfig - gensekiOptions: GensekiPluginOptions - route: CollectionFindManyApiRoute - features: ListActions - } & BaseViewProps -) { - return createGensekiUiRoute({ - path: `/collections/${customCollectionArgs.slug}`, - requiredAuthenticated: true, - context: customCollectionArgs.context, - render: (args) => { - const listViewProps = { - slug: customCollectionArgs.slug, - identifierColumn: customCollectionArgs.identifierColumn, - fields: customCollectionArgs.fields, - context: customCollectionArgs.context, - pathname: args.pathname, - headers: args.headers, - params: args.params, - searchParams: args.searchParams, - findMany: customCollectionArgs.route, - columns: customCollectionArgs.listConfig.columns ?? [], - listConfiguration: customCollectionArgs.listConfig.configuration, - actions: customCollectionArgs.listConfig.actions, - } satisfies CollectionListViewProps - const AppTopbarNav = () => <_AppTopbarNav /> - const _Banner = () => - const _ListViewContainer = (props: ListViewContainerProps) => - const CollectionLayout = (props: { children?: React.ReactNode }) => { - const NewCollectionLayout = customCollectionArgs.listConfig.uis?.layout - - if (NewCollectionLayout) { - return ( - } - CollectionSidebar={() => ( - - )} - SidebarInset={(props) => } - TopbarNav={() => } - CollectionLayout={(_props) => ( - <_CollectionLayout - pathname={args.pathname} - {...customCollectionArgs.gensekiOptions} - children={_props.children} - /> - )} - listViewProps={listViewProps} - > - {props.children} - - ) - } - - return ( - <_CollectionLayout - pathname={args.pathname} - {...customCollectionArgs.gensekiOptions} - children={props.children} - /> - ) - } - const ListView = () => { - const NewListView = customCollectionArgs.listConfig.uis?.pages - const clientListViewProps = getClientListViewProps(listViewProps) - - if (NewListView) { - return ( - - <_ListView {...listViewProps} />} - ListViewContainer={_ListViewContainer} - listViewProps={listViewProps} - /> - - ) - } - - return <_ListView {...listViewProps} /> - } - - if ( - !customCollectionArgs.listConfig.uis?.layout && - !customCollectionArgs.listConfig.uis?.pages - ) { - return ( - - - <_Banner /> - <_ListViewContainer> - - - - ) - } - - return ( - - - - ) - }, - }) -} diff --git a/packages/react/src/core/endpoint.ts b/packages/react/src/core/endpoint.ts index 8acabef0..1d69f2d5 100644 --- a/packages/react/src/core/endpoint.ts +++ b/packages/react/src/core/endpoint.ts @@ -34,28 +34,58 @@ export type InferPathParams = Simplify< : never > -export type FlattenApiRouter = ValueOf<{ +type _FlattenApiRouter = ValueOf<{ [TKey in keyof TApiRouter]: TApiRouter[TKey] extends infer TApiRoute extends ApiRoute - ? Simplify + ? TApiRoute : TApiRouter[TKey] extends infer TApiRouter extends AnyApiRouter - ? Simplify> + ? _FlattenApiRouter : never }> -export function flattenApiRouter( - apiRouter: TApiRouter, - prefix: string = '' -): Record { - const flattened: Record = {} +export type FlattenApiRoutes = _FlattenApiRouter[] + +export function flattenApiRoutes( + apiRouter: TApiRouter +): FlattenApiRoutes { + const flattened: ApiRoute[] = [] for (const key in apiRouter) { const route = apiRouter[key] if (isApiRoute(route)) { - flattened[`${prefix}${key}`] = route + flattened.push(route) continue } - Object.assign(flattened, flattenApiRouter(route, `${prefix}${key}.`)) + flattened.push(...flattenApiRoutes(route)) } - return flattened + return flattened as FlattenApiRoutes +} + +export type RecordifyFlattenApiRouter = + TApiRoutes[number] extends infer TApiRoute extends ApiRoute + ? { + [K in `${TApiRoute['schema']['method']} ${TApiRoute['schema']['path']}`]: TApiRoute + } + : never + +export function recordifyFlattenApiRoutes( + routes: TApiRoutes +): RecordifyFlattenApiRouter { + return routes.reduce( + (acc, route) => { + acc[`${route.schema.method} ${route.schema.path}`] = route + return acc + }, + {} as Record + ) as RecordifyFlattenApiRouter +} + +export type RecordifyApiRoutes = RecordifyFlattenApiRouter< + FlattenApiRoutes +> + +export function recordifyApiRouter( + apiRouter: TApiRouter +): RecordifyApiRoutes { + return recordifyFlattenApiRoutes(flattenApiRoutes(apiRouter)) } export type FilterByMethod = Extract< @@ -177,6 +207,10 @@ export interface AnyApiRouter { [key: string]: AnyApiRouter | ApiRoute } +export interface FlatApiRouter { + [key: string]: ApiRoute +} + export function isApiRoute( apiRoute: TApiRoute | ApiRouter ): apiRoute is TApiRoute { diff --git a/packages/react/src/core/index.ts b/packages/react/src/core/index.ts index 2f5a8f87..2ba55d41 100644 --- a/packages/react/src/core/index.ts +++ b/packages/react/src/core/index.ts @@ -8,29 +8,17 @@ export type { } from '../core/context' export { RequestContextable } from '../core/context' export { Builder } from './builder' -export type { - ApiReturnType, - BaseData, - CollectionConfig as CollectionOptions, - InferField, - InferFields, - ListConfiguration, - CollectionListViewProps as ListViewProps, -} from './collection' -export { ApiDefaultMethod } from './collection' +export type { InferField, InferFields, ListConfiguration } from './collection' +export { ApiDefaultMethod, CollectionBuilder } from './collection' export { - type AnyGensekiPlugin, createGensekiUiRoute, - createPlugin, GensekiApp, + type GensekiAppClient, type GensekiAppCompiled, - type GensekiAppCompiledClient, type GensekiAppOptions, type GensekiCore, - type GensekiPlugin, type GensekiUiRouter, getFieldsClient, - type InferApiRouterFromGensekiPlugin, } from './config' export type { AnyApiRouter, @@ -44,10 +32,17 @@ export type { ApiRouteSchema, ApiRouteSchemaClient, FilterByMethod, - FlattenApiRouter, + FlatApiRouter, + FlattenApiRoutes, InferApiRouteResponses, } from './endpoint' -export { createEndpoint, flattenApiRouter, isApiRoute } from './endpoint' +export { + createEndpoint, + flattenApiRoutes, + isApiRoute, + recordifyApiRouter, + recordifyFlattenApiRoutes, +} from './endpoint' export * from './error' export type { FieldClientBase, @@ -85,6 +80,13 @@ export { SchemaType, unsanitizedModelSchemas, } from './model' +export type { + AnyGensekiPlugin, + GensekiPlugin, + GensekiPluginBuilder, + InferApiRouterFromGensekiPlugin, +} from './plugin' +export { createPlugin } from './plugin' export type { AnyTable, AnyTypedFieldColumn, diff --git a/packages/react/src/core/plugin.ts b/packages/react/src/core/plugin.ts new file mode 100644 index 00000000..8ba22c21 --- /dev/null +++ b/packages/react/src/core/plugin.ts @@ -0,0 +1,127 @@ +import { deepmerge } from 'deepmerge-ts' +import type { Simplify } from 'type-fest' + +import type { + GensekiAppOptions, + GensekiAppOptionsWithDefaults, + GensekiMiddleware, + GensekiUiRouter, +} from './config' +import { + type AnyApiRouter, + type ApiRoute, + type FlatApiRouter, + recordifyApiRouter, + type RecordifyApiRoutes, +} from './endpoint' + +export interface GensekiPluginBuilderOptions extends GensekiAppOptionsWithDefaults {} + +export class GensekiPluginBuilder { + private api: TMainApiRouter = {} as TMainApiRouter + private uis: GensekiUiRouter[] = [] + private readonly middlewares: GensekiMiddleware[] = [] + + constructor(private readonly options: GensekiPluginBuilderOptions) {} + + getApi(): TMainApiRouter { + return this.api + } + + getUis(): GensekiUiRouter[] { + return this.uis + } + + getOptions(): GensekiPluginBuilderOptions { + return this.options + } + + getMiddlewares(): GensekiMiddleware[] { + return this.middlewares + } + + addMiddleware(middleware: GensekiMiddleware) { + this.middlewares.push(middleware) + return this + } + + addMiddlewares(middlewares: GensekiMiddleware[]) { + for (const m of middlewares) this.addMiddleware(m) + return this + } + + addPage(page: GensekiUiRouter) { + this.uis.push(page) + return this + } + + addPages(pages: GensekiUiRouter[]) { + for (const page of pages) this.addPage(page) + return this + } + + overridePages( + cb: (pages: GensekiUiRouter[], options: GensekiPluginBuilderOptions) => GensekiUiRouter[] + ) { + const newPages = cb(this.uis, this.options) + this.uis = newPages + return this + } + + addPageFn( + page: GensekiUiRouter | ((options: GensekiAppOptions) => GensekiUiRouter) + ): GensekiPluginBuilder { + if (typeof page === 'function') this.addPage(page(this.options)) + else this.addPage(page) + return this + } + + addPageAndApiRouter( + args: + | { + ui: GensekiUiRouter + api: TApiRouter + } + | ((options: GensekiAppOptions) => { + ui: GensekiUiRouter + api: TApiRouter + }) + ): GensekiPluginBuilder>> { + if (typeof args === 'function') return this.addPageAndApiRouter(args(this.options)) + this.addPage(args.ui) + this.addApiRouter(args.api) + return this as unknown as GensekiPluginBuilder> + } + + addApiRouter( + router: TApiRouter + ): GensekiPluginBuilder>> { + const flatApiRouter = recordifyApiRouter(router) + this.api = deepmerge(this.api ?? {}, flatApiRouter ?? {}) as TMainApiRouter & + RecordifyApiRoutes + return this as unknown as GensekiPluginBuilder> + } +} + +export interface GensekiPlugin< + TName extends string, + TMainApiRouter extends Record = {}, +> { + name: TName + plugin: (options: GensekiPluginBuilder) => GensekiPluginBuilder +} + +export function createPlugin< + TName extends string, + TMainApiRouter extends Record = {}, +>( + name: TName, + plugin: (options: GensekiPluginBuilder) => GensekiPluginBuilder +): GensekiPlugin { + return { name: name, plugin: plugin } +} + +export type AnyGensekiPlugin = GensekiPlugin> + +export type InferApiRouterFromGensekiPlugin = + TPlugin extends GensekiPlugin ? TApiRouter : never diff --git a/packages/react/src/react/components/compound/collection-sidebar/index.tsx b/packages/react/src/react/components/compound/collection-sidebar/index.tsx index 513e3339..34b9d541 100644 --- a/packages/react/src/react/components/compound/collection-sidebar/index.tsx +++ b/packages/react/src/react/components/compound/collection-sidebar/index.tsx @@ -41,7 +41,7 @@ export interface AppSidebarProps { } // TODO: Revise this component -export async function AppSidebar(props: AppSidebarProps) { +export function AppSidebar(props: AppSidebarProps) { return ( props.pathname.includes(path) + const isCurrentPage = (path: string) => { + return props.pathname.includes(path) + } return props.items.map((item, index) => item.type === 'section' ? ( diff --git a/packages/react/src/react/components/primitives/tanstack-table.tsx b/packages/react/src/react/components/primitives/tanstack-table.tsx index 86f46f22..fe3ee61b 100644 --- a/packages/react/src/react/components/primitives/tanstack-table.tsx +++ b/packages/react/src/react/components/primitives/tanstack-table.tsx @@ -16,7 +16,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '. type RowClickHandler = (row: Row, e: React.MouseEvent) => void -interface TanstackTableProps { +export interface TanstackTableProps { className?: string classNames?: { tableContainer?: string @@ -37,7 +37,6 @@ interface TanstackTableProps { emptyFallback?: React.ReactNode emptyMessage?: React.ReactNode configuration?: { - search?: (string | number | symbol)[] sortBy?: (string | number | symbol)[] } } diff --git a/packages/react/src/react/hooks/use-search.ts b/packages/react/src/react/hooks/use-search.ts index f9584b06..80ca66bd 100644 --- a/packages/react/src/react/hooks/use-search.ts +++ b/packages/react/src/react/hooks/use-search.ts @@ -1,30 +1,18 @@ 'use client' -import { type Dispatch, type SetStateAction, useState } from 'react' +import { type Dispatch, type SetStateAction } from 'react' import { type Options, parseAsString, useQueryState } from 'nuqs' -import { useDebounce } from './use-debounce' - export interface UseSearchReturn { - debouncedSearch: string setSearch: Dispatch> search: string } -export function useSearch(options?: { debounce?: number } & Options): UseSearchReturn { - const { debounce, ...nuqsOptions } = options || {} - - const [search, setSearch] = useState('') - const [debouncedSearch, setDebouncedSearch] = useQueryState( +export function useSearch(options?: Options): UseSearchReturn { + const [search, setSearch] = useQueryState( 'search', - parseAsString.withOptions({ clearOnDefault: true, ...nuqsOptions }).withDefault('') + parseAsString.withOptions({ clearOnDefault: true, ...(options ?? {}) }).withDefault('') ) - const onSearchChange = async (value: string) => { - setDebouncedSearch(value) - } - - useDebounce(search, onSearchChange, debounce || 500) - - return { debouncedSearch, search, setSearch } + return { search, setSearch } } diff --git a/packages/react/src/react/providers/index.ts b/packages/react/src/react/providers/index.ts index 07beeb73..1f105f95 100644 --- a/packages/react/src/react/providers/index.ts +++ b/packages/react/src/react/providers/index.ts @@ -1,3 +1,3 @@ export { NavigationContext, type NavigationContextValue, useNavigation } from './navigation' -export { RootProvider, useRootContext, useServerFunction } from './root' +export { GensekiProvider, useGenseki, useServerFunction } from './root' export * from './table' diff --git a/packages/react/src/react/providers/root.tsx b/packages/react/src/react/providers/root.tsx index 5541cb59..6bbf14ed 100644 --- a/packages/react/src/react/providers/root.tsx +++ b/packages/react/src/react/providers/root.tsx @@ -1,29 +1,48 @@ 'use client' -import { createContext, type ReactNode, useContext } from 'react' +import React, { + createContext, + type PropsWithChildren, + type ReactNode, + useContext, + useMemo, +} from 'react' +import { useNavigation } from './navigation' import { UiProviders } from './ui' -import type { GensekiAppCompiled, GensekiAppCompiledClient, GensekiCore } from '../../core/config' +import type { GensekiAppClient, GensekiAppCompiled } from '../../core/config' +import type { FlatApiRouter } from '../../core/endpoint' +import { AppSidebar } from '../components/compound/collection-sidebar' +import { AppTopbarNav } from '../components/compound/collection-sidebar/nav/app-topbar-nav' +import { SidebarInset, SidebarProvider } from '../components/primitives/sidebar' import { Toast } from '../components/primitives/toast' import type { ServerFunction } from '../server-function' -type RootContextValue = { - app: GensekiAppCompiledClient - serverFunction: ServerFunction +interface RootGensekiComponents { + AppSidebar: React.FC + AppSidebarProvider: React.FC + AppSidebarInset: React.FC + AppTopbar: React.FC } -const RootContext = createContext(null!) +type RootContextValue = { + app: GensekiAppClient + components: RootGensekiComponents + serverFunction: ServerFunction +} + +const GensekiContext = createContext(null!) -export const useRootContext = () => { - const context = useContext(RootContext) - if (!context) throw new Error('useRootContext must be used within a RootProvider') - return context as unknown as RootContextValue +export function useGenseki() { + const context = useContext(GensekiContext) + if (!context) throw new Error('"useGenseki" must be used within a "GensekiProvider"') + return context as unknown as RootContextValue } -export const useStorageAdapter = () => { - const context = useContext(RootContext) - if (!context) throw new Error('useStorageAdapter must be used within a RootProvider') +export function useStorageAdapter() { + const context = useContext(GensekiContext) + if (!context) throw new Error('"useStorageAdapter" must be used within a "GensekiProvider"') const storageAdapter = context.app?.storageAdapter if (!storageAdapter) { throw new Error('Storage adapter is not configured in the GensekiCore') @@ -31,21 +50,50 @@ export const useStorageAdapter = () => { return storageAdapter } -export const useServerFunction = () => { - const context = useContext(RootContext) - if (!context) throw new Error('useCollectionServerFunctions must be used within a RootProvider') - return context.serverFunction as unknown as ServerFunction +export function useServerFunction< + TApp extends GensekiAppCompiled = GensekiAppCompiled, +>() { + const context = useContext(GensekiContext) + if (!context) throw new Error('"useServerFunction" must be used within a "GensekiProvider"') + return context.serverFunction as unknown as ServerFunction } -export const RootProvider = (props: { - app: GensekiAppCompiledClient +export function GensekiProvider(props: { + app: GensekiAppClient serverFunction: ServerFunction children: ReactNode -}) => { +}) { + const navigation = useNavigation() + + const pathname = navigation.getPathname() + + const components: RootGensekiComponents = useMemo( + () => ({ + AppTopbar: () => , + AppSidebar: () => ( + + ), + AppSidebarInset: (props) => , + AppSidebarProvider: (props) => , + }), + [props.app, pathname] + ) + return ( - + {props.children} - + ) } diff --git a/packages/react/src/react/server-function.ts b/packages/react/src/react/server-function.ts index 52d9ad3c..6b65a613 100644 --- a/packages/react/src/react/server-function.ts +++ b/packages/react/src/react/server-function.ts @@ -3,51 +3,34 @@ import type { ValueOf } from 'type-fest' import { type ApiRoute, type ApiRouteHandlerPayload, - type ApiRouter, type ApiRouteResponse, type ApiRouteSchema, + type FlatApiRouter, + type GensekiAppCompiled, } from '../core' -import type { GensekiCore } from '../core/config' -export type ServerFunction = < - TMethod extends GetGensekiApiRouterMethod, - TArgs extends GetServerFunctionApiArgs, TMethod>, +export type ServerFunction = < + TMethod extends GetGensekiApiRouterMethod, + TArgs extends GetServerFunctionApiArgs, >( method: TMethod, args: TArgs -) => Promise, TMethod>> +) => Promise> -export type GetApiRouterFromGensekiCore = - TCore['api'] extends ApiRouter ? TCore['api'] : never - -export type GetGensekiApiRouterMethod = ValueOf<{ - [TKey in keyof TApiRouter]: TKey extends string - ? TApiRouter[TKey] extends ApiRouter - ? TKey extends string - ? `${TKey}.${GetGensekiApiRouterMethod}` - : never - : TKey - : never +export type GetGensekiApiRouterMethod = ValueOf<{ + [TKey in keyof TApiRouter]: Extract }> export type GetServerFunctionResponse< - TApiRouter extends ApiRouter, + TApiRouter extends FlatApiRouter, TMethod extends string, -> = TMethod extends `${infer TPrefix}.${infer TMethodName}` - ? TApiRouter[TPrefix] extends ApiRouter - ? GetServerFunctionResponse - : TApiRouter[TMethodName] extends ApiRoute - ? ApiRouteResponse - : ApiRouteResponse +> = TApiRouter[TMethod] extends ApiRoute + ? ApiRouteResponse : ApiRouteResponse export type GetServerFunctionApiArgs< - TApiRouter extends ApiRouter, + TApiRouter extends FlatApiRouter, TMethod extends string, -> = TMethod extends `${infer TPrefix}.${infer TMethodName}` - ? TApiRouter[TPrefix] extends ApiRouter - ? GetServerFunctionApiArgs - : TApiRouter[TMethodName] extends ApiRoute - ? ApiRouteHandlerPayload - : ApiRouteHandlerPayload +> = TApiRouter[TMethod] extends ApiRoute + ? ApiRouteHandlerPayload : ApiRouteHandlerPayload diff --git a/packages/react/src/react/views/collections/context.tsx b/packages/react/src/react/views/collections/context.tsx new file mode 100644 index 00000000..258d4d0f --- /dev/null +++ b/packages/react/src/react/views/collections/context.tsx @@ -0,0 +1,35 @@ +'use client' +import React, { createContext, useContext } from 'react' + +import type { FieldsClient } from '../../../core/field' + +export interface CollectionContextValue { + slug: string + fields: FieldsClient + params: Record + headers: Record + searchParams: Record + pathname: string +} + +const ColectionContext = createContext(null!) + +export interface CollectionProviderProps extends CollectionContextValue { + children?: React.ReactNode +} + +/** + * @description A provider to provide `listViewProps` for client + */ +export function CollectionProvider(props: CollectionProviderProps) { + const { children, ...rest } = props + return {children} +} + +export function useCollection() { + const value = useContext(ColectionContext) + if (!value) { + throw new Error('"useCollectionContext" must be used within a "CollectionProvider"') + } + return value +} diff --git a/packages/react/src/react/views/collections/layouts/collection-layout.tsx b/packages/react/src/react/views/collections/layouts/collection-layout.tsx index 55324883..e6dfb6c8 100644 --- a/packages/react/src/react/views/collections/layouts/collection-layout.tsx +++ b/packages/react/src/react/views/collections/layouts/collection-layout.tsx @@ -1,6 +1,7 @@ import { AppSidebar, type AppSideBarBuilderProps, + AppTopbarNav, SidebarInset, SidebarProvider, } from '../../../components' @@ -13,7 +14,7 @@ export interface CollectionLayoutProps { sidebar?: AppSideBarBuilderProps } -export function CollectionLayout(props: CollectionLayoutProps) { +export function DefaultCollectionLayout(props: CollectionLayoutProps) { return ( <> @@ -23,7 +24,11 @@ export function CollectionLayout(props: CollectionLayoutProps) { sidebar={props.sidebar} pathname={props.pathname} /> - {props.children} + + + + {props.children} + ) diff --git a/packages/react/src/react/views/collections/list/container.tsx b/packages/react/src/react/views/collections/list/container.tsx new file mode 100644 index 00000000..fc432d60 --- /dev/null +++ b/packages/react/src/react/views/collections/list/container.tsx @@ -0,0 +1,20 @@ +'use client' + +import React from 'react' + +import { cn } from '../../../utils/cn' + +export interface CollectionListTableContainerProps { + children?: React.ReactNode + className?: string +} + +export function CollectionListTableContainer(props: CollectionListTableContainerProps) { + return ( +
+ {props.children} +
+ ) +} diff --git a/packages/react/src/react/views/collections/list/context.tsx b/packages/react/src/react/views/collections/list/context.tsx new file mode 100644 index 00000000..e4dda471 --- /dev/null +++ b/packages/react/src/react/views/collections/list/context.tsx @@ -0,0 +1,162 @@ +'use client' +import React, { createContext, type ReactNode, useContext, useMemo } from 'react' + +import { useQueryClient } from '@tanstack/react-query' +import type { ColumnDef } from '@tanstack/react-table' + +import { Banner } from './banner' +import { CollectionListTableContainer, type CollectionListTableContainerProps } from './container' +import { useCollectionDeleteMutation } from './hooks/use-collection-delete' +import { useCollectionListQuery } from './hooks/use-collection-list' +import { CollectionListTable, type CollectionListTableProps } from './table' +import { CollectionListPagination } from './table/pagination' +import { CollectionListToolbar, type CollectionListToolbarProps } from './toolbar' + +import { toast } from '../../../..' +import type { CollectionListActions } from '../../../../core/collection' +import type { FieldsClient } from '../../../../core/field' +import { TableStatesProvider, useTableStatesContext } from '../../../providers/table' +import { useCollection } from '../context' +import type { BaseData } from '../types' + +interface CollectionListComponents { + ListBanner: React.FC + ListTableContainer: React.FC + ListTableToolbar: React.FC + ListTable: (props: CollectionListTableProps) => ReactNode + ListTablePagination: React.FC +} + +export interface CollectionListContextValue { + // Should split into another context + slug: string + fields: FieldsClient + + // Should split into another context + params: Record + headers: Record + searchParams: Record + + // Only for list + + components: CollectionListComponents + + isError?: boolean + isMutating?: boolean + isQuerying?: boolean + + data: T[] + total: number + + columns: ColumnDef[] + search?: string[] + sortBy?: string[] + actions?: CollectionListActions + + // Helper functions + deleteRows: () => void + invalidateList: (page?: number) => Promise +} + +const CollectionListContext = createContext(null!) + +export interface CollectionListProviderProps { + children?: React.ReactNode + + columns: ColumnDef[] + search?: string[] + sortBy?: string[] + actions?: CollectionListActions +} + +/** + * @description A provider to provide `listViewProps` for client + */ +function _CollectionListProvider(props: CollectionListProviderProps) { + const context = useCollection() + + const { children, ...rest } = props + + const { rowSelectionIds, setRowSelection } = useTableStatesContext() + const queryClient = useQueryClient() + const query = useCollectionListQuery({ slug: context.slug }) + + const invalidateList = async (page?: number) => { + const additionalKeys = page ? [{ query: { page } }] : [] + await queryClient.invalidateQueries({ + queryKey: ['GET', `/${context.slug}`, ...additionalKeys], + exact: false, + }) + } + + const deleteMutation = useCollectionDeleteMutation({ + slug: context.slug, + onSuccess: async () => { + setRowSelection({}) + invalidateList() + toast.success('Deletion successfully') + }, + onError: () => { + toast.error('Failed to delete items') + }, + }) + + const deleteRows = () => { + if (rowSelectionIds.length === 0) return + deleteMutation.mutate(rowSelectionIds) + } + + const isError = deleteMutation.isError || query.isError + const isQuerying = query.isLoading + const isMutating = deleteMutation.isPending + + const data = query.data?.data ?? [] + const total = query.data?.total ?? 0 + + const components: CollectionListComponents = useMemo( + () => ({ + ListBanner: () => , + ListTable: (props) => , + ListTableToolbar: (props) => , + ListTableContainer: (props) => , + ListTablePagination: (props) => , + }), + [context.slug] + ) + + return ( + + {children} + + ) +} + +export function CollectionListProvider(props: CollectionListProviderProps) { + return ( + + <_CollectionListProvider {...props} /> + + ) +} + +export function useCollectionList() { + const value = useContext(CollectionListContext) + if (!value) { + throw new Error('"useCollectionList" must be used within a "CollectionListProvider"') + } + return value as unknown as CollectionListContextValue +} diff --git a/packages/react/src/react/views/collections/list/default.tsx b/packages/react/src/react/views/collections/list/default.tsx new file mode 100644 index 00000000..269c01eb --- /dev/null +++ b/packages/react/src/react/views/collections/list/default.tsx @@ -0,0 +1,125 @@ +'use client' + +import React, { useMemo } from 'react' + +import { DotsThreeVerticalIcon } from '@phosphor-icons/react' +import { type ColumnDef, createColumnHelper } from '@tanstack/react-table' + +import { Banner } from './banner' +import { CollectionListTableContainer } from './container' +import { useCollectionList } from './context' +import { CollectionListTable } from './table' +import { CollectionListPagination } from './table/pagination' +import { CollectionListToolbar } from './toolbar' + +import { + BaseIcon, + Checkbox, + Menu, + MenuContent, + MenuItem, + MenuSeparator, + MenuTrigger, +} from '../../../components' +import { useNavigation } from '../../../providers' +import type { BaseData } from '../types' + +export function DefaultCollectionListPage() { + const navigation = useNavigation() + + const context = useCollectionList() + + const columns = useMemo(() => { + if (context.isQuerying) return context.columns + + const columnHelper = createColumnHelper() + return [ + ...(context.actions?.select + ? [ + columnHelper.display({ + id: 'select', + header: ({ table }) => ( + + table.getToggleAllRowsSelectedHandler()({ target: { checked } }) + } + /> + ), + cell: ({ row }) => ( + row.getToggleSelectedHandler()({ target: { checked } })} + /> + ), + }), + ] + : []), + ...context.columns, + columnHelper.display({ + id: 'actions', + cell: ({ row }) => { + if (!context.actions?.one && !context.actions?.update && !context.actions?.delete) { + return null + } + + return ( +
+ + + + + + {context.actions.one && ( + { + navigation.navigate(`./${context.slug}/${row.original.__id}`) + }} + > + View + + )} + {context.actions.update && ( + { + navigation.navigate(`./${context.slug}/update/${row.original.__id}`) + }} + > + Edit + + )} + {context.actions?.delete && ( + <> + {context.actions.one || (context.actions.update && )} + + Delete + + + )} + + +
+ ) + }, + }), + ] as ColumnDef<{ __pk: string; __id: string }>[] + }, [context.columns, context.actions, context.isQuerying]) + + return ( + <> + + + + + columns={columns} + onRowClick={context.actions?.select ? 'toggleSelect' : undefined} + /> + + + + ) +} diff --git a/packages/react/src/react/views/collections/list/hooks/use-collection-list.ts b/packages/react/src/react/views/collections/list/hooks/use-collection-list.ts index a446867d..7bb30756 100644 --- a/packages/react/src/react/views/collections/list/hooks/use-collection-list.ts +++ b/packages/react/src/react/views/collections/list/hooks/use-collection-list.ts @@ -15,7 +15,11 @@ export function useCollectionListQuery( const { pagination } = usePagination() const { search } = useSearch() - const queryKey = { ...(args.pagination || pagination), search: args.search ?? search, sort: sort } + const queryKey = { + ...(args.pagination || pagination), + search: args.search ?? search, + sort: sort, + } const query: UseQueryResult = useQuery({ queryKey: ['GET', `/${args.slug}`, { query: queryKey }] as const, diff --git a/packages/react/src/react/views/collections/list/index.ts b/packages/react/src/react/views/collections/list/index.ts new file mode 100644 index 00000000..c00b9419 --- /dev/null +++ b/packages/react/src/react/views/collections/list/index.ts @@ -0,0 +1,10 @@ +export * from './banner' +export * from './context' +export * from './hooks' +export * from './table' +export * from './table/pagination' +export * from './toolbar' +export * from './toolbar/create' +export * from './toolbar/delete' +export * from './toolbar/filter' +export * from './toolbar/search' diff --git a/packages/react/src/react/views/collections/list/index.tsx b/packages/react/src/react/views/collections/list/index.tsx deleted file mode 100644 index 95a8547e..00000000 --- a/packages/react/src/react/views/collections/list/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ClientCollectionListView } from './list.client' -import { CollectionListViewProvider } from './providers' - -import type { CollectionListViewProps } from '../../../../core/collection' -import { getClientListViewProps } from '../../../../core/config' - -export async function CollectionListView(props: CollectionListViewProps) { - const clientListViewProps = getClientListViewProps(props) - - return ( - - - - ) -} diff --git a/packages/react/src/react/views/collections/list/list.client.tsx b/packages/react/src/react/views/collections/list/list.client.tsx deleted file mode 100644 index 8a05946d..00000000 --- a/packages/react/src/react/views/collections/list/list.client.tsx +++ /dev/null @@ -1,189 +0,0 @@ -'use client' - -import React, { useMemo } from 'react' - -import { DotsThreeVerticalIcon } from '@phosphor-icons/react' -import { useQueryClient } from '@tanstack/react-query' -import { type ColumnDef, createColumnHelper } from '@tanstack/react-table' -import { toast } from 'sonner' - -import { useCollectionDeleteMutation, useCollectionListQuery } from './hooks' -import { useListViewPropsContext } from './providers' -import { useCollectionListTable } from './table' -import { CollectionListPagination } from './table/pagination' -import { CollectionListToolbar } from './toolbar' - -import type { BaseData } from '../../../../core' -import { - BaseIcon, - Checkbox, - Menu, - MenuContent, - MenuItem, - MenuSeparator, - MenuTrigger, - TanstackTable, -} from '../../../components' -import { useNavigation, useTableStatesContext } from '../../../providers' -import { cn } from '../../../utils/cn' - -export interface ListViewContainerProps { - children?: React.ReactNode - className?: string -} - -export const ListViewContainer = (props: ListViewContainerProps) => { - return ( -
- {props.children} -
- ) -} - -export function ClientCollectionListView() { - const navigation = useNavigation() - const queryClient = useQueryClient() - const listViewProps = useListViewPropsContext() - const { pagination, isRowsSelected, rowSelectionIds, setRowSelection } = useTableStatesContext() - - const query = useCollectionListQuery({ slug: listViewProps.slug }) - - const deleteMutation = useCollectionDeleteMutation({ - slug: listViewProps.slug, - onSuccess: async () => { - setRowSelection({}) - await queryClient.invalidateQueries({ - queryKey: ['GET', `/${listViewProps.slug}`], - }) - toast.success('Deletion successfully') - }, - onError: () => { - toast.error('Failed to delete items') - }, - }) - - const columns = useMemo(() => { - if (query.isLoading) return listViewProps.columns - - const columnHelper = createColumnHelper() - return [ - ...(listViewProps.actions?.select - ? [ - columnHelper.display({ - id: 'select', - header: ({ table }) => ( - - table.getToggleAllRowsSelectedHandler()({ target: { checked } }) - } - /> - ), - cell: ({ row }) => ( - row.getToggleSelectedHandler()({ target: { checked } })} - /> - ), - }), - ] - : []), - ...listViewProps.columns, - columnHelper.display({ - id: 'actions', - cell: ({ row }) => { - if ( - !listViewProps.actions?.one && - !listViewProps.actions?.update && - !listViewProps.actions?.delete - ) { - return null - } - - return ( -
- - - - - - {listViewProps.actions.one && ( - { - navigation.navigate(`./${listViewProps.slug}/${row.original.__id}`) - }} - > - View - - )} - {listViewProps.actions.update && ( - { - navigation.navigate(`./${listViewProps.slug}/update/${row.original.__id}`) - }} - > - Edit - - )} - {listViewProps.actions?.delete && ( - <> - {listViewProps.actions.one || - (listViewProps.actions.update && )} - { - deleteMutation.mutate([row.original.__id.toString()]) - }} - > - Delete - - - )} - - -
- ) - }, - }), - ] as ColumnDef<{ __pk: string; __id: string }>[] - }, [listViewProps.columns, listViewProps.actions, query.isLoading]) - - const table = useCollectionListTable({ - total: query.data?.total, - data: query.data?.data || [], - columns: columns, - listConfiguration: listViewProps.listConfiguration, - }) - - const isError = deleteMutation.isError || query.isError - const isLoading = deleteMutation.isPending || query.isPending || query.isFetching - - return ( - <> - deleteMutation.mutate(rowSelectionIds)} - isShowDeleteButton={isRowsSelected} - isLoading={isLoading} - /> - - - - ) -} diff --git a/packages/react/src/react/views/collections/list/providers/collection-list-view.tsx b/packages/react/src/react/views/collections/list/providers/collection-list-view.tsx deleted file mode 100644 index 222160f7..00000000 --- a/packages/react/src/react/views/collections/list/providers/collection-list-view.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type React from 'react' - -import { - type ListViewPropsContextValue, - ListViewPropsProvider, - type ListViewPropsProviderProps, - useListViewPropsContext, -} from './list-view-props' - -import type { Fields } from '../../../../../core' -import { - TableStatesProvider, - type TanstackTableContextValue, - useTableStatesContext, -} from '../../../../providers' - -interface CollectionListViewContextValue { - tanstackTableContextValue: TanstackTableContextValue - listViewPropsContextValue: ListViewPropsContextValue -} - -interface CollectionListViewProviderProps extends ListViewPropsProviderProps { - children?: React.ReactNode -} -/** - * @description This provider is a higher levle provider composed 2 providers - * 1. `TableStatesProvider` - * 2. `ListViewPropsProvider` - */ -export function CollectionListViewProvider(props: CollectionListViewProviderProps) { - return ( - - {props.children} - - ) -} - -/** - * @description This custom hook will let you access 2 providers context - * 1. `TableStatesContext` - * 2. `ListViewPropsContext` - * - * This is optionally hook, you may use `useTableStatesContext` or `useListViewPropsContext` individually - */ -export function useCollectionListViewContext< - TFields extends Fields, ->(): CollectionListViewContextValue { - const tanstackTableContextValue = useTableStatesContext() - const listViewPropsContextValue = useListViewPropsContext() - - return { - listViewPropsContextValue, - tanstackTableContextValue, - } -} diff --git a/packages/react/src/react/views/collections/list/providers/index.ts b/packages/react/src/react/views/collections/list/providers/index.ts deleted file mode 100644 index 57666520..00000000 --- a/packages/react/src/react/views/collections/list/providers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './collection-list-view' -export * from './list-view-props' diff --git a/packages/react/src/react/views/collections/list/providers/list-view-props.tsx b/packages/react/src/react/views/collections/list/providers/list-view-props.tsx deleted file mode 100644 index 3d7c20cc..00000000 --- a/packages/react/src/react/views/collections/list/providers/list-view-props.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' -import React, { createContext, use } from 'react' - -import type { Fields } from '../../../../../core' -import type { ClientCollectionListViewProps } from '../../../../../core/collection' - -export type ListViewPropsContextValue = - ClientCollectionListViewProps - -const ListViewPropsContext = createContext>(null!) - -export interface ListViewPropsProviderProps { - children?: React.ReactNode - clientListViewProps: ClientCollectionListViewProps -} - -/** - * @description A provider to provide `listViewProps` for client - */ -export function ListViewPropsProvider(props: ListViewPropsProviderProps) { - return ( - {props.children} - ) -} - -export function useListViewPropsContext() { - const value = use(ListViewPropsContext) as ListViewPropsContextValue - if (!value) throw new Error('useListActions must be used within a ListActionsProvider') - return value -} diff --git a/packages/react/src/react/views/collections/list/table/index.tsx b/packages/react/src/react/views/collections/list/table/index.tsx index 5b77e63d..03585b79 100644 --- a/packages/react/src/react/views/collections/list/table/index.tsx +++ b/packages/react/src/react/views/collections/list/table/index.tsx @@ -8,14 +8,16 @@ import { useReactTable, } from '@tanstack/react-table' -import type { BaseData } from '../../../../../core/collection' +import { + TanstackTable, + type TanstackTableProps, +} from '../../../../components/primitives/tanstack-table' import { useTableStatesContext } from '../../../../providers/table' +import type { BaseData } from '../../types' +import { useCollectionList } from '../context' -interface CollectionListTableArgs { - listConfiguration?: { - search?: (string | number | symbol)[] - sortBy?: (string | number | symbol)[] - } +interface UseListTableArgs { + sortBy?: (string | number | symbol)[] total?: number data: TFieldsData[] columns: ColumnDef[] @@ -25,15 +27,13 @@ interface CollectionListTableArgs { * @description A flexible hook for creating collection tables with sorting, pagination and row selection. * Provides unopinionated table functionality that can be customized for different collection data structures. */ -export const useCollectionListTable = ( - args: CollectionListTableArgs -) => { +export function useListTable(args: UseListTableArgs) { const { pagination, setPagination, rowSelection, setRowSelection, sort, setSort } = useTableStatesContext() // Get default sort field from configuration const defaultSortField = (() => { - if (args.listConfiguration?.sortBy && args.listConfiguration.sortBy.length > 0) { - return args.listConfiguration.sortBy[0].toString() + if (args.sortBy && args.sortBy.length > 0) { + return args.sortBy[0].toString() } return undefined })() @@ -80,3 +80,40 @@ export const useCollectionListTable = ( return table } + +export interface CollectionListTableProps + extends Omit, 'table' | 'configuration'> { + total?: number + data?: T[] + columns?: ColumnDef[] + sortBy?: string[] + + isLoading?: boolean + isError?: boolean +} + +export function CollectionListTable(props: CollectionListTableProps) { + const context = useCollectionList() + + const table = useListTable({ + total: props.total ?? context.total, + data: props.data ?? context.data ?? [], + columns: props.columns ?? context.columns, + sortBy: props.sortBy ?? context.sortBy, + }) + + return ( + + ) +} diff --git a/packages/react/src/react/views/collections/list/table/pagination.tsx b/packages/react/src/react/views/collections/list/table/pagination.tsx index 46950b04..3390badf 100644 --- a/packages/react/src/react/views/collections/list/table/pagination.tsx +++ b/packages/react/src/react/views/collections/list/table/pagination.tsx @@ -2,15 +2,21 @@ import { PageSizeSelect, Pagination } from '../../../../components' import { usePagination } from '../../../../hooks/use-pagination' +import { useCollection } from '../../context' +import { useCollectionListQuery } from '../hooks/use-collection-list' export interface CollectionListPaginationProps { totalPage?: number } export function CollectionListPagination(props: CollectionListPaginationProps) { + const context = useCollection() + const { pagination, setPagination } = usePagination() - const totalPage = props.totalPage ?? 1 + const query = useCollectionListQuery({ slug: context.slug }) + + const totalPage = props.totalPage ?? query.data?.totalPage ?? 1 const onPageChange = (page: number) => setPagination((pagination) => ({ page: page, pageSize: pagination.pageSize })) diff --git a/packages/react/src/react/views/collections/list/toolbar/index.tsx b/packages/react/src/react/views/collections/list/toolbar/index.tsx index b69ffb25..018b6c0d 100644 --- a/packages/react/src/react/views/collections/list/toolbar/index.tsx +++ b/packages/react/src/react/views/collections/list/toolbar/index.tsx @@ -1,33 +1,48 @@ 'use client' import { CaretLeftIcon } from '@phosphor-icons/react/dist/ssr' +import { useQueryClient } from '@tanstack/react-query' -import { CollectionListCreate, type MinimalCollectionListCreateProps } from './create' -import { CollectionListDelete, type MinimalCollectionListDeleteProps } from './delete' -import { CollectionListFilter, type MinimalCollectionListFilterProps } from './filter' -import { CollectionListSearch, type CollectionListSearchProps } from './search' +import { CollectionListCreate } from './create' +import { CollectionListDelete } from './delete' +import { CollectionListFilter } from './filter' +import { CollectionListSearch } from './search' +import type { CollectionListActions } from '../../../../../core/collection' +import { toast } from '../../../..' import { BaseIcon, ButtonLink } from '../../../../components' -import type { ListActions } from '../../types' +import { useTableStatesContext } from '../../../../providers/table' +import { useCollectionList } from '../context' +import { useCollectionDeleteMutation } from '../hooks/use-collection-delete' -export interface CollectionListToolbarProps - extends CollectionListSearchProps, - MinimalCollectionListDeleteProps, - MinimalCollectionListFilterProps, - MinimalCollectionListCreateProps { - isShowDeleteButton?: boolean - actions?: ListActions +export interface CollectionListToolbarProps { + actions?: Partial } -/** - * @param props.slug A slug for page - * @param props.isLoading A loading state for the toolbar - * @param props.isShowDeleteButton A boolean to show/hide delete button - * @param props.onDelete A callback function when delete button is clicked - * @param props.features Features configuration for the list view - */ export function CollectionListToolbar(props: CollectionListToolbarProps) { - const { isShowDeleteButton = false, actions } = props + const context = useCollectionList() + const queryClient = useQueryClient() + const { rowSelectionIds, setRowSelection, isRowsSelected } = useTableStatesContext() + + const actions: CollectionListActions = { + create: props.actions?.create ?? context.actions?.create, + update: props.actions?.update ?? context.actions?.update, + delete: props.actions?.delete ?? context.actions?.delete, + one: props.actions?.one ?? context.actions?.one, + } + const deleteMutation = useCollectionDeleteMutation({ + slug: context.slug, + onSuccess: async () => { + setRowSelection({}) + await queryClient.invalidateQueries({ + queryKey: ['GET', `/${context.slug}`], + }) + toast.success('Deletion successfully') + }, + onError: () => { + toast.error('Failed to delete items') + }, + }) return (
@@ -41,16 +56,13 @@ export function CollectionListToolbar(props: CollectionListToolbarProps) { Back
- {actions?.delete && isShowDeleteButton && ( - + {actions?.delete && isRowsSelected && ( + deleteMutation.mutate(rowSelectionIds)} /> )} - - {/* TODO: Filter */} - {actions?.create && } + + {/* TODO: Filter */} + + {actions?.create && }
) diff --git a/packages/react/src/react/views/collections/list/toolbar/search.tsx b/packages/react/src/react/views/collections/list/toolbar/search.tsx index e09a2c0e..ef0e71fd 100644 --- a/packages/react/src/react/views/collections/list/toolbar/search.tsx +++ b/packages/react/src/react/views/collections/list/toolbar/search.tsx @@ -1,9 +1,11 @@ 'use client' -import React, { useMemo } from 'react' +import React from 'react' import { MagnifyingGlassIcon } from '@phosphor-icons/react' +import { useControllableState } from '@radix-ui/react-use-controllable-state' import { BaseIcon, TextField } from '../../../../components' +import { useDebounce } from '../../../../hooks/use-debounce' import { useSearch } from '../../../../hooks/use-search' export interface CollectionListSearchProps { @@ -18,24 +20,21 @@ export interface CollectionListSearchProps { * @param props.isLoading A loading state */ export function CollectionListSearch(props: CollectionListSearchProps) { - const { search, setSearch } = useSearch({ - debounce: 500, + const { search: paramSearch, setSearch: setParamSearch } = useSearch() + const [search, onSearch] = useControllableState({ + prop: props.search, + onChange: props.onSearchChange, + defaultProp: paramSearch, }) - const isIncorrectControlledInput = useMemo( - () => - [typeof props.onSearchChange === 'undefined', typeof props.search === 'undefined'].filter( - (option) => !!option - ).length === 1, - [] + useDebounce( + search, + (value: string) => { + setParamSearch(value) + }, + 500 ) - if (isIncorrectControlledInput) { - throw new Error( - 'The controlled input is not properly configured. You need to provide either `onSearchChange` and `search` for controlled input' - ) - } - return ( ) } diff --git a/packages/react/src/react/views/collections/types.ts b/packages/react/src/react/views/collections/types.ts index 6af19f5a..f72e5350 100644 --- a/packages/react/src/react/views/collections/types.ts +++ b/packages/react/src/react/views/collections/types.ts @@ -4,20 +4,15 @@ import type { AnyContextable } from '../../../core/context' export interface BaseViewProps { slug: string context: AnyContextable - identifierColumn: string fields: Fields } export interface ClientBaseViewProps { slug: string - identifierColumn: string fieldsClient: FieldsClient } -export interface ListActions { - create?: boolean - update?: boolean - delete?: boolean - one?: boolean - select?: boolean +export interface BaseData { + __pk: string | number + __id: string | number } diff --git a/packages/react/src/react/views/index.ts b/packages/react/src/react/views/index.ts index 5fbcfdcd..90bcc237 100644 --- a/packages/react/src/react/views/index.ts +++ b/packages/react/src/react/views/index.ts @@ -1,20 +1,10 @@ +export * from './collections/context' export * from './collections/create' export * from './collections/create.client' export * from './collections/home' export * from './collections/layouts/collection-form-layout' export * from './collections/layouts/collection-layout' export * from './collections/list' -export * from './collections/list/banner' -export * from './collections/list/hooks' -export * from './collections/list/list.client' -export * from './collections/list/providers' -export * from './collections/list/table' -export * from './collections/list/table/pagination' -export * from './collections/list/toolbar' -export * from './collections/list/toolbar/create' -export * from './collections/list/toolbar/delete' -export * from './collections/list/toolbar/filter' -export * from './collections/list/toolbar/search' export * from './collections/one' export * from './collections/types' export * from './collections/update' diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 8b7ffe9b..65c6842c 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -1,11 +1,10 @@ import type { ApiRoute, ApiRouteHandlerPayload, - ApiRouter, ApiRouteResponse, FilterByMethod, - FlattenApiRouter, - GensekiCore, + FlatApiRouter, + GensekiAppCompiled, } from '@genseki/react' import { withPathParams, withQueryParams } from './utils' @@ -48,9 +47,9 @@ async function makeFetch( return response.json() } -export function createRestClient( +export function createRestClient( config: CreateRestClientConfig -): RestClient { +): RestClient { return { GET: async (path: string, payload: AnyPayload) => { return makeFetch('GET', path, payload, config) @@ -67,7 +66,7 @@ export function createRestClient( PATCH: async (path: string, payload: AnyPayload) => { return makeFetch('PATCH', path, payload, config) }, - } as RestClient + } as RestClient } export type ExtractApiRouterPath = TApiRoute['schema']['path'] @@ -87,8 +86,10 @@ export type RestMethod = < payload: RestPayload ) => Promise> -export type RestClient = - FlattenApiRouter extends infer TApiRoute extends ApiRoute +type ValueOf = T[keyof T] + +export type RestClient = + ValueOf extends infer TApiRoute extends ApiRoute ? { GET: RestMethod> POST: RestMethod> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12e94ae8..7520a04a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,7 +288,7 @@ importers: version: 15.2.2(@babel/core@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nuqs: specifier: ^2.4.3 - version: 2.4.3(next@15.2.2(@babel/core@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 2.4.3(next@15.2.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) radix3: specifier: ^1.1.2 version: 1.1.2 @@ -421,6 +421,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.0 version: 1.2.2(@types/react@19.1.6)(react@19.1.0) + '@radix-ui/react-use-controllable-state': + specifier: ^1.2.2 + version: 1.2.2(@types/react@19.1.6)(react@19.1.0) '@react-aria/i18n': specifier: ^3.12.9 version: 3.12.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -498,7 +501,7 @@ importers: version: 12.11.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nuqs: specifier: ^2.4.3 - version: 2.4.3(next@15.2.2(@babel/core@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 2.4.3(next@15.2.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) radix3: specifier: ^1.1.2 version: 1.1.2 @@ -1484,6 +1487,33 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@react-aria/autocomplete@3.0.0-beta.5': resolution: {integrity: sha512-zYiVeKGYHStpBXS0mf51k14xkVunU/dFqxumfYXDiiyknxIDE4L1kN7XKo16nus3TkTmJtqBHJrWmzCfNkRd9g==} peerDependencies: @@ -6403,6 +6433,27 @@ snapshots: optionalDependencies: '@types/react': 19.1.6 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.6)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.6)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.6 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.6)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.6 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.6)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.6 + '@react-aria/autocomplete@3.0.0-beta.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@react-aria/combobox': 3.12.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -9764,7 +9815,7 @@ snapshots: node-releases@2.0.19: {} - nuqs@2.4.3(next@15.2.2(@babel/core@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): + nuqs@2.4.3(next@15.2.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): dependencies: mitt: 3.0.1 react: 19.1.0 From 20b800f8222efcb4196717ae3ddf46dd37e3af17 Mon Sep 17 00:00:00 2001 From: Khemmathiti Wangsaptawee <39180519+t0ngk@users.noreply.github.com> Date: Wed, 3 Sep 2025 01:32:21 -0400 Subject: [PATCH 6/9] wip: action item helper --- .../{tags.client.ts => tags.client.tsx} | 17 ++- examples/erp/genseki/collections/tags.ts | 1 + .../react/views/collections/list/default.tsx | 95 +------------- .../collections/list/helper/actionsColumn.tsx | 124 ++++++++++++++++++ .../views/collections/list/helper/index.ts | 2 + .../collections/list/helper/selectColumn.tsx | 26 ++++ .../src/react/views/collections/list/index.ts | 1 + 7 files changed, 172 insertions(+), 94 deletions(-) rename examples/erp/genseki/collections/{tags.client.ts => tags.client.tsx} (52%) create mode 100644 packages/react/src/react/views/collections/list/helper/actionsColumn.tsx create mode 100644 packages/react/src/react/views/collections/list/helper/index.ts create mode 100644 packages/react/src/react/views/collections/list/helper/selectColumn.tsx diff --git a/examples/erp/genseki/collections/tags.client.ts b/examples/erp/genseki/collections/tags.client.tsx similarity index 52% rename from examples/erp/genseki/collections/tags.client.ts rename to examples/erp/genseki/collections/tags.client.tsx index 3fcdf121..ab8fd8bf 100644 --- a/examples/erp/genseki/collections/tags.client.ts +++ b/examples/erp/genseki/collections/tags.client.tsx @@ -2,7 +2,15 @@ import { createColumnHelper } from '@tanstack/react-table' -import type { InferFields } from '@genseki/react' +import { + actionsColumn, + createDeleteActionItem, + createEditActionItem, + createSeparatorItem, + createViewActionItem, + type InferFields, + selectColumn, +} from '@genseki/react' import type { fields } from './tags' @@ -10,10 +18,17 @@ type Tag = InferFields const columnHelper = createColumnHelper() export const columns = [ + selectColumn(), columnHelper.accessor('__id', { cell: (info) => info.getValue(), }), columnHelper.accessor('name', { cell: (info) => info.getValue(), }), + actionsColumn([ + createViewActionItem(), + createEditActionItem(), + createSeparatorItem(), + createDeleteActionItem(), + ]), ] diff --git a/examples/erp/genseki/collections/tags.ts b/examples/erp/genseki/collections/tags.ts index 15f1469f..e7969e0d 100644 --- a/examples/erp/genseki/collections/tags.ts +++ b/examples/erp/genseki/collections/tags.ts @@ -25,6 +25,7 @@ export const tagsCollection = createPlugin('tags', (app) => { }, actions: { create: true, + update: true, select: true, delete: true, }, diff --git a/packages/react/src/react/views/collections/list/default.tsx b/packages/react/src/react/views/collections/list/default.tsx index 269c01eb..2cf0edf0 100644 --- a/packages/react/src/react/views/collections/list/default.tsx +++ b/packages/react/src/react/views/collections/list/default.tsx @@ -1,9 +1,6 @@ 'use client' -import React, { useMemo } from 'react' - -import { DotsThreeVerticalIcon } from '@phosphor-icons/react' -import { type ColumnDef, createColumnHelper } from '@tanstack/react-table' +import { useMemo } from 'react' import { Banner } from './banner' import { CollectionListTableContainer } from './container' @@ -12,101 +9,13 @@ import { CollectionListTable } from './table' import { CollectionListPagination } from './table/pagination' import { CollectionListToolbar } from './toolbar' -import { - BaseIcon, - Checkbox, - Menu, - MenuContent, - MenuItem, - MenuSeparator, - MenuTrigger, -} from '../../../components' -import { useNavigation } from '../../../providers' -import type { BaseData } from '../types' - export function DefaultCollectionListPage() { - const navigation = useNavigation() - const context = useCollectionList() const columns = useMemo(() => { if (context.isQuerying) return context.columns - const columnHelper = createColumnHelper() - return [ - ...(context.actions?.select - ? [ - columnHelper.display({ - id: 'select', - header: ({ table }) => ( - - table.getToggleAllRowsSelectedHandler()({ target: { checked } }) - } - /> - ), - cell: ({ row }) => ( - row.getToggleSelectedHandler()({ target: { checked } })} - /> - ), - }), - ] - : []), - ...context.columns, - columnHelper.display({ - id: 'actions', - cell: ({ row }) => { - if (!context.actions?.one && !context.actions?.update && !context.actions?.delete) { - return null - } - - return ( -
- - - - - - {context.actions.one && ( - { - navigation.navigate(`./${context.slug}/${row.original.__id}`) - }} - > - View - - )} - {context.actions.update && ( - { - navigation.navigate(`./${context.slug}/update/${row.original.__id}`) - }} - > - Edit - - )} - {context.actions?.delete && ( - <> - {context.actions.one || (context.actions.update && )} - - Delete - - - )} - - -
- ) - }, - }), - ] as ColumnDef<{ __pk: string; __id: string }>[] + return context.columns }, [context.columns, context.actions, context.isQuerying]) return ( diff --git a/packages/react/src/react/views/collections/list/helper/actionsColumn.tsx b/packages/react/src/react/views/collections/list/helper/actionsColumn.tsx new file mode 100644 index 00000000..699dcae7 --- /dev/null +++ b/packages/react/src/react/views/collections/list/helper/actionsColumn.tsx @@ -0,0 +1,124 @@ +import type { Icon } from '@phosphor-icons/react' +import { DotsThreeVerticalIcon } from '@phosphor-icons/react/dist/ssr' +import { createColumnHelper, type Row } from '@tanstack/react-table' +import * as R from 'remeda' + +import { + BaseIcon, + Menu, + MenuContent, + MenuItem, + MenuSeparator, + MenuTrigger, +} from '../../../../components' +import { type NavigationContextValue, useNavigation } from '../../../../providers' +import type { BaseData } from '../../types' +import { useCollectionList } from '../context' + +type ActionItem = ( + context: ReturnType, + row: Row, + key: string +) => React.ReactNode + +export function createActionItem( + render: ( + context: ReturnType, + row: Row, + key: string + ) => React.ReactNode +) { + return render +} + +function createDefaultActionItem( + title: string, + icon?: Icon, + onAction?: ( + context: ReturnType, + row: Row, + navigation: NavigationContextValue + ) => string | void, + isDanger?: boolean +) { + return createActionItem((context, row, key) => { + const navigation = useNavigation() + return ( + { + if (onAction) { + onAction(context, row, navigation) + } + }} + > +
+ {icon && } + {title} +
+
+ ) + }) +} + +export function createViewActionItem(title: string = 'View', icon: Icon | undefined = undefined) { + return createDefaultActionItem(title, icon, (context, row, navigation) => + navigation.navigate(`./${context.slug}/${row.original.__id}`) + ) +} + +export function createEditActionItem(title: string = 'Edit', icon: Icon | undefined = undefined) { + return createDefaultActionItem(title, icon, (context, row, navigation) => + navigation.navigate(`./${context.slug}/update/${row.original.__id}`) + ) +} + +export function createDeleteActionItem( + title: string = 'Delete', + icon: Icon | undefined = undefined +) { + return createDefaultActionItem( + title, + icon, + (context) => { + if (context.deleteRows) { + context.deleteRows() + } + }, + true + ) +} + +export function createSeparatorItem() { + return createActionItem((context, row, key) => ) +} + +export function actionsColumn(actionItems: ActionItem[] = []) { + const columnHelper = createColumnHelper() + + return columnHelper.display({ + id: 'actions', + cell: ({ row }) => { + const context = useCollectionList() + + return ( +
+ + + + + {actionItems.length !== 0 ? ( + + {(actionItems ?? []).map((createActionItem) => { + return createActionItem(context, row, R.randomString(6)) + })} + + ) : null} + +
+ ) + }, + }) +} diff --git a/packages/react/src/react/views/collections/list/helper/index.ts b/packages/react/src/react/views/collections/list/helper/index.ts new file mode 100644 index 00000000..c7ae9417 --- /dev/null +++ b/packages/react/src/react/views/collections/list/helper/index.ts @@ -0,0 +1,2 @@ +export * from './actionsColumn' +export * from './selectColumn' diff --git a/packages/react/src/react/views/collections/list/helper/selectColumn.tsx b/packages/react/src/react/views/collections/list/helper/selectColumn.tsx new file mode 100644 index 00000000..71cec0d1 --- /dev/null +++ b/packages/react/src/react/views/collections/list/helper/selectColumn.tsx @@ -0,0 +1,26 @@ +import { createColumnHelper } from '@tanstack/react-table' + +import { Checkbox } from '../../../../components' +import type { BaseData } from '../../types' + +export function selectColumn() { + const columnHelper = createColumnHelper() + + return columnHelper.display({ + id: 'select', + header: ({ table }) => ( + table.getToggleAllRowsSelectedHandler()({ target: { checked } })} + /> + ), + cell: ({ row }) => ( + row.getToggleSelectedHandler()({ target: { checked } })} + /> + ), + }) +} diff --git a/packages/react/src/react/views/collections/list/index.ts b/packages/react/src/react/views/collections/list/index.ts index c00b9419..9d5540bb 100644 --- a/packages/react/src/react/views/collections/list/index.ts +++ b/packages/react/src/react/views/collections/list/index.ts @@ -1,5 +1,6 @@ export * from './banner' export * from './context' +export * from './helper' export * from './hooks' export * from './table' export * from './table/pagination' From 076e9ba1b2cf8e8066a24fe2ab744b35f3a9fd99 Mon Sep 17 00:00:00 2001 From: Khemmathiti Wangsaptawee <39180519+t0ngk@users.noreply.github.com> Date: Wed, 3 Sep 2025 03:35:09 -0400 Subject: [PATCH 7/9] feat: customize action items --- .../erp/genseki/collections/posts.client.tsx | 114 ++---------------- examples/erp/genseki/collections/posts.tsx | 5 +- examples/erp/genseki/collections/tags.ts | 4 +- packages/react/src/core/collection/index.tsx | 15 +-- .../react/views/collections/list/context.tsx | 6 +- .../react/views/collections/list/default.tsx | 8 +- .../collections/list/helper/actionsColumn.tsx | 6 +- .../views/collections/list/toolbar/index.tsx | 16 ++- 8 files changed, 30 insertions(+), 144 deletions(-) diff --git a/examples/erp/genseki/collections/posts.client.tsx b/examples/erp/genseki/collections/posts.client.tsx index 5842eebb..a5c2d476 100644 --- a/examples/erp/genseki/collections/posts.client.tsx +++ b/examples/erp/genseki/collections/posts.client.tsx @@ -5,24 +5,20 @@ import { useState } from 'react' import { type SubmitErrorHandler, type SubmitHandler, useFormContext } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { DotsThreeVerticalIcon } from '@phosphor-icons/react' import { useQueryClient } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import z from 'zod' -import type { BaseData, CollectionLayoutProps, InferCreateFields } from '@genseki/react' +import type { CollectionLayoutProps, InferCreateFields } from '@genseki/react' import { - BaseIcon, + actionsColumn, Button, - Checkbox, CollectionListToolbar, + createDeleteActionItem, + createEditActionItem, + createSeparatorItem, Form, type InferFields, - Menu, - MenuContent, - MenuItem, - MenuSeparator, - MenuTrigger, SubmitButton, TanstackTable, toast, @@ -71,6 +67,7 @@ export const columns = [ header: 'Updated At', cell: (info) =>
{new Date(info.getValue()).toLocaleDateString('en-GB')}
, }), + actionsColumn([createEditActionItem(), createSeparatorItem(), createDeleteActionItem()]), ] /** @@ -82,7 +79,7 @@ export function PostClientToolbar() { return (
- +
) } @@ -115,105 +112,10 @@ export const PostClientTable = (props: { children?: React.ReactNode }) => { }, }) - const columnHelper = createColumnHelper() - // You can setup your own custom columns - const enhancedColumns = [ - ...(context.actions?.delete - ? [ - columnHelper.display({ - id: 'select', - header: ({ table }) => ( - - table.getToggleAllRowsSelectedHandler()({ target: { checked } }) - } - /> - ), - cell: ({ row }) => ( - { - const handler = row.getToggleSelectedHandler() - handler(event) - }} - /> - ), - }), - ] - : []), - ...context.columns, - columnHelper.display({ - id: 'actions', - cell: ({ row }) => { - if (!context.actions?.one && !context.actions?.update && !context.actions?.delete) { - return null - } - - return ( -
- - - - - - {context.actions?.one && ( - { - navigation.navigate(`./${context.slug}/${row.original.__id}`) - }} - > - View - - )} - {context.actions?.update && ( - { - navigation.navigate(`./${context.slug}/update/${row.original.__id}`) - }} - > - Edit - - )} - {context.actions?.delete && ( - <> - {context.actions?.one || (context.actions?.update && )} - { - deleteMutation.mutate([row.original.__id.toString()]) - }} - > - Delete - - - )} - - { - confirm('Confirm to revert the action?') - }} - > - Revert - - - -
- ) - }, - }), - ] - const table = useListTable({ total: query.data?.total, data: query.data?.data || [], - columns: enhancedColumns, + columns: context.columns, }) return ( diff --git a/examples/erp/genseki/collections/posts.tsx b/examples/erp/genseki/collections/posts.tsx index 2fd00994..7e8edd8c 100644 --- a/examples/erp/genseki/collections/posts.tsx +++ b/examples/erp/genseki/collections/posts.tsx @@ -192,7 +192,10 @@ export const postsCollection = createPlugin('posts', (app) => { search: ['title'], sortBy: ['updatedAt', 'title'], }, - actions: { delete: true, update: true, create: true }, + toolbar: { + create: true, + delete: true, + }, layout: Layout, page: CustomListPage, }) diff --git a/examples/erp/genseki/collections/tags.ts b/examples/erp/genseki/collections/tags.ts index e7969e0d..7658c3a4 100644 --- a/examples/erp/genseki/collections/tags.ts +++ b/examples/erp/genseki/collections/tags.ts @@ -23,10 +23,8 @@ export const tagsCollection = createPlugin('tags', (app) => { search: ['name'], sortBy: ['name'], }, - actions: { + toolbar: { create: true, - update: true, - select: true, delete: true, }, }) diff --git a/packages/react/src/core/collection/index.tsx b/packages/react/src/core/collection/index.tsx index ed59c2e4..163871d6 100644 --- a/packages/react/src/core/collection/index.tsx +++ b/packages/react/src/core/collection/index.tsx @@ -319,13 +319,7 @@ export type CollectionListConfig< /** * @param actions will decide whether or not to show actios in `list` view screen, This is not related to available features of collection, but rather only visible UI part of the `list` page */ - actions?: { - create?: boolean - update?: boolean - delete?: boolean - one?: boolean - select?: boolean - } + toolbar?: CollectionToolbarActions } export type CollectionUpdateApiArgs< @@ -539,7 +533,7 @@ export class CollectionBuilder< columns={config.columns} search={config.configuration?.search} sortBy={config.configuration?.sortBy} - actions={config.actions} + toolbar={config.toolbar} > {page} @@ -775,10 +769,7 @@ export class CollectionBuilder< } } -export interface CollectionListActions { +export interface CollectionToolbarActions { create?: boolean - update?: boolean delete?: boolean - one?: boolean - select?: boolean } diff --git a/packages/react/src/react/views/collections/list/context.tsx b/packages/react/src/react/views/collections/list/context.tsx index 879c7aa3..affea1ea 100644 --- a/packages/react/src/react/views/collections/list/context.tsx +++ b/packages/react/src/react/views/collections/list/context.tsx @@ -13,7 +13,7 @@ import { CollectionListPagination } from './table/pagination' import { CollectionListToolbar, type CollectionListToolbarProps } from './toolbar' import { toast } from '../../../..' -import type { CollectionListActions } from '../../../../core/collection' +import type { CollectionToolbarActions } from '../../../../core/collection' import type { FieldsClient } from '../../../../core/field' import { TableStatesProvider, useTableStatesContext } from '../../../providers/table' import { useCollection } from '../context' @@ -51,7 +51,7 @@ export interface CollectionListContextValue { columns: ColumnDef[] search?: string[] sortBy?: string[] - actions?: CollectionListActions + toolbar?: CollectionToolbarActions // Helper functions deleteRows: (rows?: string[]) => void @@ -66,7 +66,7 @@ export interface CollectionListProviderProps { columns: ColumnDef[] search?: string[] sortBy?: string[] - actions?: CollectionListActions + toolbar?: CollectionToolbarActions } /** diff --git a/packages/react/src/react/views/collections/list/default.tsx b/packages/react/src/react/views/collections/list/default.tsx index 2cf0edf0..1d3fae70 100644 --- a/packages/react/src/react/views/collections/list/default.tsx +++ b/packages/react/src/react/views/collections/list/default.tsx @@ -16,17 +16,13 @@ export function DefaultCollectionListPage() { if (context.isQuerying) return context.columns return context.columns - }, [context.columns, context.actions, context.isQuerying]) - + }, [context.columns, context.isQuerying]) return ( <> - - columns={columns} - onRowClick={context.actions?.select ? 'toggleSelect' : undefined} - /> + columns={columns} onRowClick={undefined} /> diff --git a/packages/react/src/react/views/collections/list/helper/actionsColumn.tsx b/packages/react/src/react/views/collections/list/helper/actionsColumn.tsx index 699dcae7..74f25e56 100644 --- a/packages/react/src/react/views/collections/list/helper/actionsColumn.tsx +++ b/packages/react/src/react/views/collections/list/helper/actionsColumn.tsx @@ -82,10 +82,8 @@ export function createDeleteActionItem( return createDefaultActionItem( title, icon, - (context) => { - if (context.deleteRows) { - context.deleteRows() - } + (context, row) => { + context.deleteRows([row.original.__id as string]) }, true ) diff --git a/packages/react/src/react/views/collections/list/toolbar/index.tsx b/packages/react/src/react/views/collections/list/toolbar/index.tsx index 018b6c0d..01e9ec50 100644 --- a/packages/react/src/react/views/collections/list/toolbar/index.tsx +++ b/packages/react/src/react/views/collections/list/toolbar/index.tsx @@ -8,7 +8,7 @@ import { CollectionListDelete } from './delete' import { CollectionListFilter } from './filter' import { CollectionListSearch } from './search' -import type { CollectionListActions } from '../../../../../core/collection' +import type { CollectionToolbarActions } from '../../../../../core/collection' import { toast } from '../../../..' import { BaseIcon, ButtonLink } from '../../../../components' import { useTableStatesContext } from '../../../../providers/table' @@ -16,7 +16,7 @@ import { useCollectionList } from '../context' import { useCollectionDeleteMutation } from '../hooks/use-collection-delete' export interface CollectionListToolbarProps { - actions?: Partial + toolbar?: Partial } export function CollectionListToolbar(props: CollectionListToolbarProps) { @@ -24,11 +24,9 @@ export function CollectionListToolbar(props: CollectionListToolbarProps) { const queryClient = useQueryClient() const { rowSelectionIds, setRowSelection, isRowsSelected } = useTableStatesContext() - const actions: CollectionListActions = { - create: props.actions?.create ?? context.actions?.create, - update: props.actions?.update ?? context.actions?.update, - delete: props.actions?.delete ?? context.actions?.delete, - one: props.actions?.one ?? context.actions?.one, + const toolbar: CollectionToolbarActions = { + create: props.toolbar?.create ?? context.toolbar?.create, + delete: props.toolbar?.delete ?? context.toolbar?.delete, } const deleteMutation = useCollectionDeleteMutation({ slug: context.slug, @@ -56,13 +54,13 @@ export function CollectionListToolbar(props: CollectionListToolbarProps) { Back
- {actions?.delete && isRowsSelected && ( + {toolbar?.delete && isRowsSelected && ( deleteMutation.mutate(rowSelectionIds)} /> )} {/* TODO: Filter */} - {actions?.create && } + {toolbar?.create && }
) From 706a717bbdbe7faaa3885e035168966330c6ecc2 Mon Sep 17 00:00:00 2001 From: jettapat Date: Wed, 10 Sep 2025 18:19:28 +0700 Subject: [PATCH 8/9] fix: remove unused hook and --- .../react/src/react/views/collections/list/default.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/react/src/react/views/collections/list/default.tsx b/packages/react/src/react/views/collections/list/default.tsx index 1d3fae70..aabc1645 100644 --- a/packages/react/src/react/views/collections/list/default.tsx +++ b/packages/react/src/react/views/collections/list/default.tsx @@ -1,7 +1,5 @@ 'use client' -import { useMemo } from 'react' - import { Banner } from './banner' import { CollectionListTableContainer } from './container' import { useCollectionList } from './context' @@ -12,17 +10,12 @@ import { CollectionListToolbar } from './toolbar' export function DefaultCollectionListPage() { const context = useCollectionList() - const columns = useMemo(() => { - if (context.isQuerying) return context.columns - - return context.columns - }, [context.columns, context.isQuerying]) return ( <> - columns={columns} onRowClick={undefined} /> + columns={context.columns} onRowClick={undefined} /> From 5aa9e5c8517acbf28265254accd827d734b3aa61 Mon Sep 17 00:00:00 2001 From: jettapat Date: Wed, 10 Sep 2025 18:19:38 +0700 Subject: [PATCH 9/9] fix: remove context in props --- .../collections/list/helper/actionsColumn.tsx | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/packages/react/src/react/views/collections/list/helper/actionsColumn.tsx b/packages/react/src/react/views/collections/list/helper/actionsColumn.tsx index 74f25e56..139fedf5 100644 --- a/packages/react/src/react/views/collections/list/helper/actionsColumn.tsx +++ b/packages/react/src/react/views/collections/list/helper/actionsColumn.tsx @@ -1,7 +1,8 @@ +import { useId } from 'react' + import type { Icon } from '@phosphor-icons/react' import { DotsThreeVerticalIcon } from '@phosphor-icons/react/dist/ssr' import { createColumnHelper, type Row } from '@tanstack/react-table' -import * as R from 'remeda' import { BaseIcon, @@ -15,19 +16,9 @@ import { type NavigationContextValue, useNavigation } from '../../../../provider import type { BaseData } from '../../types' import { useCollectionList } from '../context' -type ActionItem = ( - context: ReturnType, - row: Row, - key: string -) => React.ReactNode +type ActionItem = (row: Row, key: string) => React.ReactNode -export function createActionItem( - render: ( - context: ReturnType, - row: Row, - key: string - ) => React.ReactNode -) { +export function createActionItem(render: (row: Row, key: string) => React.ReactNode) { return render } @@ -41,8 +32,10 @@ function createDefaultActionItem( ) => string | void, isDanger?: boolean ) { - return createActionItem((context, row, key) => { + return createActionItem((row, key) => { + const context = useCollectionList() const navigation = useNavigation() + return ( ) + return createActionItem((row, key) => ) } export function actionsColumn(actionItems: ActionItem[] = []) { @@ -99,8 +92,6 @@ export function actionsColumn(actionItems: ActionItem[] = []) { return columnHelper.display({ id: 'actions', cell: ({ row }) => { - const context = useCollectionList() - return (
@@ -109,8 +100,8 @@ export function actionsColumn(actionItems: ActionItem[] = []) { {actionItems.length !== 0 ? ( - {(actionItems ?? []).map((createActionItem) => { - return createActionItem(context, row, R.randomString(6)) + {actionItems.map((createActionItem) => { + return createActionItem(row, useId()) })} ) : null}