diff --git a/package.json b/package.json index 603d0c2d8..bb134e6c5 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,10 @@ "version": "1.0.0", "description": "", "main": "index.js", - "scripts": {}, + "scripts": { + "build": "pnpm -r build", + "test": "pnpm -r test" + }, "keywords": [], "author": "", "license": "ISC" diff --git a/packages/internal/package.json b/packages/internal/package.json index a4f35c6fa..0a43eea65 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/internal", - "version": "0.1.6", + "version": "0.1.18", "description": "ZenStack internal runtime library", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/packages/internal/src/handler/data/handler.ts b/packages/internal/src/handler/data/handler.ts index ca9cee526..9d8c9271f 100644 --- a/packages/internal/src/handler/data/handler.ts +++ b/packages/internal/src/handler/data/handler.ts @@ -12,6 +12,7 @@ import { QueryProcessor } from './query-processor'; const PRISMA_ERROR_MAPPING: Record = { P2002: ServerErrorCode.UNIQUE_CONSTRAINT_VIOLATION, P2003: ServerErrorCode.REFERENCE_CONSTRAINT_VIOLATION, + P2025: ServerErrorCode.REFERENCE_CONSTRAINT_VIOLATION, }; export default class DataHandler implements RequestHandler { @@ -125,9 +126,7 @@ export default class DataHandler implements RequestHandler { r = await db.findMany(processedArgs); } - console.log( - `Finding ${model}:\n${JSON.stringify(processedArgs, undefined, 2)}` - ); + console.log(`Finding ${model}:\n${JSON.stringify(processedArgs)}`); await this.queryProcessor.postProcess( model, processedArgs, @@ -163,13 +162,7 @@ export default class DataHandler implements RequestHandler { ); const r = await db.$transaction(async (tx: any) => { - console.log( - `Create ${model}:\n${JSON.stringify( - processedArgs, - undefined, - 2 - )}` - ); + console.log(`Create ${model}:\n${JSON.stringify(processedArgs)}`); const created = await tx[model].create(processedArgs); let queryArgs = { @@ -184,11 +177,7 @@ export default class DataHandler implements RequestHandler { context ); console.log( - `Finding created ${model}:\n${JSON.stringify( - queryArgs, - undefined, - 2 - )}` + `Finding created ${model}:\n${JSON.stringify(queryArgs)}` ); const found = await tx[model].findFirst(queryArgs); if (!found) { @@ -247,9 +236,7 @@ export default class DataHandler implements RequestHandler { updateArgs.where = { ...updateArgs.where, id }; const r = await db.$transaction(async (tx: any) => { - console.log( - `Update ${model}:\n${JSON.stringify(updateArgs, undefined, 2)}` - ); + console.log(`Update ${model}:\n${JSON.stringify(updateArgs)}`); const updated = await tx[model].update(updateArgs); // make sure after update, the entity passes policy check @@ -265,11 +252,7 @@ export default class DataHandler implements RequestHandler { context ); console.log( - `Finding post-updated ${model}:\n${JSON.stringify( - queryArgs, - undefined, - 2 - )}` + `Finding post-updated ${model}:\n${JSON.stringify(queryArgs)}` ); const found = await tx[model].findFirst(queryArgs); if (!found) { @@ -321,9 +304,7 @@ export default class DataHandler implements RequestHandler { ); delArgs.where = { ...delArgs.where, id }; - console.log( - `Deleting ${model}:\n${JSON.stringify(delArgs, undefined, 2)}` - ); + console.log(`Deleting ${model}:\n${JSON.stringify(delArgs)}`); const db = (this.service.db as any)[model]; const r = await db.delete(delArgs); await this.queryProcessor.postProcess( @@ -353,11 +334,7 @@ export default class DataHandler implements RequestHandler { context ); console.log( - `Finding to-be-deleted ${model}:\n${JSON.stringify( - readArgs, - undefined, - 2 - )}` + `Finding to-be-deleted ${model}:\n${JSON.stringify(readArgs)}` ); const read = await db.findFirst(readArgs); if (!read) { diff --git a/packages/internal/src/index.ts b/packages/internal/src/index.ts index 2e3073df3..e50b82227 100644 --- a/packages/internal/src/index.ts +++ b/packages/internal/src/index.ts @@ -1,2 +1,3 @@ export * from './types'; export * from './request-handler'; +export * as request from './request'; diff --git a/packages/internal/src/request.ts b/packages/internal/src/request.ts index b62269e7b..d596e4104 100644 --- a/packages/internal/src/request.ts +++ b/packages/internal/src/request.ts @@ -1,5 +1,5 @@ import useSWR, { useSWRConfig } from 'swr'; -import type { ScopedMutator } from 'swr/dist/types'; +import type { MutatorCallback, MutatorOptions } from 'swr/dist/types'; const fetcher = async (url: string, options?: RequestInit) => { const res = await fetch(url, options); @@ -15,17 +15,17 @@ const fetcher = async (url: string, options?: RequestInit) => { }; function makeUrl(url: string, args: unknown) { - return args ? url + `q=${encodeURIComponent(JSON.stringify(args))}` : url; + return args ? url + `?q=${encodeURIComponent(JSON.stringify(args))}` : url; } -export function get(url: string, args?: unknown) { - return useSWR(makeUrl(url, args), fetcher); +export function get(url: string | null, args?: unknown) { + return useSWR(url && makeUrl(url, args), fetcher); } export async function post( url: string, data: Data, - mutate: ScopedMutator + mutate: Mutator ) { const r: Result = await fetcher(url, { method: 'POST', @@ -34,14 +34,14 @@ export async function post( }, body: JSON.stringify(data), }); - mutate(url); + mutate(url, true); return r; } export async function put( url: string, data: Data, - mutate: ScopedMutator + mutate: Mutator ) { const r: Result = await fetcher(url, { method: 'PUT', @@ -50,26 +50,52 @@ export async function put( }, body: JSON.stringify(data), }); - mutate(url, r); + mutate(url, true); return r; } -export async function del( - url: string, - args: unknown, - mutate: ScopedMutator -) { +export async function del(url: string, args: unknown, mutate: Mutator) { const reqUrl = makeUrl(url, args); const r: Result = await fetcher(reqUrl, { method: 'DELETE', }); const path = url.split('/'); path.pop(); - mutate(path.join('/')); + mutate(path.join('/'), true); return r; } -export function getMutate() { - const { mutate } = useSWRConfig(); - return mutate; +type Mutator = ( + key: string, + prefix: boolean, + data?: any | Promise | MutatorCallback, + opts?: boolean | MutatorOptions +) => Promise; + +export function getMutate(): Mutator { + // https://swr.vercel.app/docs/advanced/cache#mutate-multiple-keys-from-regex + const { cache, mutate } = useSWRConfig(); + return ( + key: string, + prefix: boolean, + data?: any | Promise | MutatorCallback, + opts?: boolean | MutatorOptions + ) => { + if (!prefix) { + return mutate(key, data, opts); + } + + if (!(cache instanceof Map)) { + throw new Error( + 'mutate requires the cache provider to be a Map instance' + ); + } + + const keys = Array.from(cache.keys()).filter( + (k) => typeof k === 'string' && k.startsWith(key) + ) as string[]; + console.log('Mutating keys:', JSON.stringify(keys)); + const mutations = keys.map((key) => mutate(key, data, opts)); + return Promise.all(mutations); + }; } diff --git a/packages/internal/src/request/index.ts b/packages/internal/src/request/index.ts deleted file mode 100644 index b62269e7b..000000000 --- a/packages/internal/src/request/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import useSWR, { useSWRConfig } from 'swr'; -import type { ScopedMutator } from 'swr/dist/types'; - -const fetcher = async (url: string, options?: RequestInit) => { - const res = await fetch(url, options); - if (!res.ok) { - const error: Error & { info?: any; status?: number } = new Error( - 'An error occurred while fetching the data.' - ); - error.info = await res.json(); - error.status = res.status; - throw error; - } - return res.json(); -}; - -function makeUrl(url: string, args: unknown) { - return args ? url + `q=${encodeURIComponent(JSON.stringify(args))}` : url; -} - -export function get(url: string, args?: unknown) { - return useSWR(makeUrl(url, args), fetcher); -} - -export async function post( - url: string, - data: Data, - mutate: ScopedMutator -) { - const r: Result = await fetcher(url, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(data), - }); - mutate(url); - return r; -} - -export async function put( - url: string, - data: Data, - mutate: ScopedMutator -) { - const r: Result = await fetcher(url, { - method: 'PUT', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(data), - }); - mutate(url, r); - return r; -} - -export async function del( - url: string, - args: unknown, - mutate: ScopedMutator -) { - const reqUrl = makeUrl(url, args); - const r: Result = await fetcher(reqUrl, { - method: 'DELETE', - }); - const path = url.split('/'); - path.pop(); - mutate(path.join('/')); - return r; -} - -export function getMutate() { - const { mutate } = useSWRConfig(); - return mutate; -} diff --git a/packages/runtime/hooks.d.ts b/packages/runtime/hooks.d.ts index 6394aede2..fe3eb2ecd 100644 --- a/packages/runtime/hooks.d.ts +++ b/packages/runtime/hooks.d.ts @@ -1 +1,10 @@ +import { ServerErrorCode } from '@zenstackhq/internal'; + export * from '.zenstack/lib/hooks'; +export type HooksError = { + status: number; + info: { + code: ServerErrorCode; + message: string; + }; +}; diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 7f8ae185d..613eb3636 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/runtime", - "version": "0.1.17", + "version": "0.1.18", "description": "ZenStack generated runtime code", "main": "index.js", "types": "index.d.ts", diff --git a/packages/schema/package.json b/packages/schema/package.json index 22f81d582..3c609835d 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -2,7 +2,7 @@ "name": "zenstack", "displayName": "ZenStack CLI and Language Tools", "description": "ZenStack CLI and Language Tools", - "version": "0.1.22", + "version": "0.1.32", "engines": { "vscode": "^1.56.0" }, diff --git a/packages/schema/src/generator/next-auth/index.ts b/packages/schema/src/generator/next-auth/index.ts index e4947e3e1..77830073c 100644 --- a/packages/schema/src/generator/next-auth/index.ts +++ b/packages/schema/src/generator/next-auth/index.ts @@ -1,6 +1,7 @@ import { Context, Generator } from '../types'; import { Project } from 'ts-morph'; import * as path from 'path'; +import colors from 'colors'; export default class NextAuthGenerator implements Generator { async generate(context: Context) { @@ -11,6 +12,8 @@ export default class NextAuthGenerator implements Generator { this.generateAuthorize(project, context); await project.save(); + + console.log(colors.blue(` ✔️ Next-auth adapter generated`)); } generateIndex(project: Project, context: Context) { @@ -118,6 +121,10 @@ export default class NextAuthGenerator implements Generator { return async ( credentials: Record<'email' | 'password', string> | undefined ) => { + if (!credentials) { + throw new Error('Missing credentials'); + } + try { let maybeUser = await service.db.user.findFirst({ where: { @@ -132,14 +139,14 @@ export default class NextAuthGenerator implements Generator { }); if (!maybeUser) { - if (!credentials!.password || !credentials!.email) { + if (!credentials.password || !credentials.email) { throw new Error('Invalid Credentials'); } maybeUser = await service.db.user.create({ data: { - email: credentials!.email, - password: await hashPassword(credentials!.password), + email: credentials.email, + password: await hashPassword(credentials.password), }, select: { id: true, @@ -149,8 +156,12 @@ export default class NextAuthGenerator implements Generator { }, }); } else { + if (!maybeUser.password) { + throw new Error('Invalid User Record'); + } + const isValid = await verifyPassword( - credentials!.password, + credentials.password, maybeUser.password ); diff --git a/packages/schema/src/generator/prisma/index.ts b/packages/schema/src/generator/prisma/index.ts index 419fc90d3..fe2254677 100644 --- a/packages/schema/src/generator/prisma/index.ts +++ b/packages/schema/src/generator/prisma/index.ts @@ -15,7 +15,9 @@ export default class PrismaGenerator implements Generator { // generate prisma query guard await new QueryGuardGenerator(context).generate(); - console.log(colors.blue(` ✔️ Prisma schema and query code generated`)); + console.log( + colors.blue(` ✔️ Prisma schema and query guard generated`) + ); } async generatePrismaClient(schemaFile: string) { diff --git a/packages/schema/src/generator/react-hooks/index.ts b/packages/schema/src/generator/react-hooks/index.ts index 24fe01a89..66685b6dd 100644 --- a/packages/schema/src/generator/react-hooks/index.ts +++ b/packages/schema/src/generator/react-hooks/index.ts @@ -5,7 +5,7 @@ import { paramCase } from 'change-case'; import { DataModel } from '@lang/generated/ast'; import colors from 'colors'; import { extractDataModelsWithAllowRules } from '../utils'; -import { API_ROUTE_NAME } from '../constants'; +import { API_ROUTE_NAME, INTERNAL_PACKAGE } from '../constants'; export default class ReactHooksGenerator implements Generator { async generate(context: Context) { @@ -14,7 +14,6 @@ export default class ReactHooksGenerator implements Generator { const models = extractDataModelsWithAllowRules(context.schema); this.generateIndex(project, context, models); - this.generateRequestRuntime(project, context); models.forEach((d) => this.generateModelHooks(project, context, d)); @@ -23,93 +22,6 @@ export default class ReactHooksGenerator implements Generator { console.log(colors.blue(' ✔️ React hooks generated')); } - private generateRequestRuntime(project: Project, context: Context) { - const content = ` - import useSWR, { useSWRConfig } from 'swr'; - import type { ScopedMutator } from 'swr/dist/types'; - - const fetcher = async (url: string, options?: RequestInit) => { - const res = await fetch(url, options); - if (!res.ok) { - const error: Error & { info?: any; status?: number } = new Error( - 'An error occurred while fetching the data.' - ); - error.info = await res.json(); - error.status = res.status; - throw error; - } - return res.json(); - }; - - function makeUrl(url: string, args: unknown) { - return args ? url + \`q=\${encodeURIComponent(JSON.stringify(args))}\` : url; - } - - export function get(url: string, args?: unknown) { - return useSWR(makeUrl(url, args), fetcher); - } - - export async function post( - url: string, - data: Data, - mutate: ScopedMutator - ) { - const r: Result = await fetcher(url, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(data), - }); - mutate(url); - return r; - } - - export async function put( - url: string, - data: Data, - mutate: ScopedMutator - ) { - const r: Result = await fetcher(url, { - method: 'PUT', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(data), - }); - mutate(url, r); - return r; - } - - export async function del( - url: string, - args: unknown, - mutate: ScopedMutator - ) { - const reqUrl = makeUrl(url, args); - const r: Result = await fetcher(reqUrl, { - method: 'DELETE', - }); - const path = url.split('/'); - path.pop(); - mutate(path.join('/')); - return r; - } - - export function getMutate() { - const { mutate } = useSWRConfig(); - return mutate; - } - `; - - const sf = project.createSourceFile( - path.join(context.outDir, `src/hooks/request.ts`), - content, - { overwrite: true } - ); - sf.formatText(); - } - private generateModelHooks( project: Project, context: Context, @@ -127,8 +39,7 @@ export default class ReactHooksGenerator implements Generator { isTypeOnly: true, moduleSpecifier: '../../.prisma', }); - - sf.addStatements([`import * as request from './request';`]); + sf.addStatements(`import { request } from '${INTERNAL_PACKAGE}';`); sf.addStatements( `const endpoint = '/api/${API_ROUTE_NAME}/data/${model.name}';` @@ -179,7 +90,6 @@ export default class ReactHooksGenerator implements Generator { useFuncBody .addFunction({ name: 'get', - isAsync: true, typeParameters: [ `T extends P.Subset`, ], @@ -196,7 +106,7 @@ export default class ReactHooksGenerator implements Generator { }) .addBody() .addStatements([ - `return request.get>>(\`\${endpoint}/\${id}\`, args);`, + `return request.get>>(id ? \`\${endpoint}/\${id}\`: null, args);`, ]); // update diff --git a/samples/todo/components/AccessDenied.tsx b/samples/todo/components/AccessDenied.tsx deleted file mode 100644 index 02ec75ff2..000000000 --- a/samples/todo/components/AccessDenied.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { signIn } from 'next-auth/react'; -import Link from 'next/link'; - -export default function AccessDenied() { - return ( - <> -

Access Denied

-

- { - e.preventDefault(); - signIn(); - }} - > - You must be signed in to view this page - -

- - ); -} diff --git a/samples/todo/components/AuthGuard.tsx b/samples/todo/components/AuthGuard.tsx new file mode 100644 index 000000000..d056e0cff --- /dev/null +++ b/samples/todo/components/AuthGuard.tsx @@ -0,0 +1,16 @@ +import { signIn, useSession } from 'next-auth/react'; + +type Props = { + children: JSX.Element | JSX.Element[]; +}; + +export default function AuthGuard({ children }: Props) { + const { status } = useSession(); + if (status === 'loading') { + return

Loading...

; + } + if (status === 'unauthenticated') { + signIn(); + } + return <>{children}; +} diff --git a/samples/todo/components/Avatar.tsx b/samples/todo/components/Avatar.tsx new file mode 100644 index 000000000..ae8626957 --- /dev/null +++ b/samples/todo/components/Avatar.tsx @@ -0,0 +1,22 @@ +import { UserIcon } from '@heroicons/react/24/outline'; +import { User } from 'next-auth'; +import Image from 'next/image'; + +type Props = { + user: User; + size?: number; +}; + +export default function Avatar({ user, size }: Props) { + return ( +
+ avatar +
+ ); +} diff --git a/samples/todo/components/BreadCrumb.tsx b/samples/todo/components/BreadCrumb.tsx new file mode 100644 index 000000000..a02c08a24 --- /dev/null +++ b/samples/todo/components/BreadCrumb.tsx @@ -0,0 +1,44 @@ +import { useList } from '@zenstackhq/runtime/hooks'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useCurrentSpace } from 'pages/context'; + +export default function BreadCrumb() { + const router = useRouter(); + const space = useCurrentSpace(); + const { get: getList } = useList(); + + const parts = router.asPath.split('/').filter((p) => p); + + const [base, slug, listId] = parts; + if (base !== 'space') { + return <>; + } + + const items: Array<{ text: string; link: string }> = []; + + items.push({ text: 'Home', link: '/' }); + items.push({ text: space?.name || '', link: `/space/${slug}` }); + + if (listId) { + const { data } = getList(listId); + items.push({ + text: data?.title || '', + link: `/space/${slug}/${listId}`, + }); + } + + return ( +
+
    + {items.map((item, i) => ( +
  • + + {item.text} + +
  • + ))} +
+
+ ); +} diff --git a/samples/todo/components/LoginButton.tsx b/samples/todo/components/LoginButton.tsx deleted file mode 100644 index f70de7283..000000000 --- a/samples/todo/components/LoginButton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useSession, signIn, signOut } from 'next-auth/react'; - -export default function Component() { - const { data: session } = useSession(); - if (session) { - return ( - <> -
Signed in as {session.user?.email}
- - - ); - } - return ( - <> - - - ); -} diff --git a/samples/todo/components/ManageMembers.tsx b/samples/todo/components/ManageMembers.tsx new file mode 100644 index 000000000..450c20fc7 --- /dev/null +++ b/samples/todo/components/ManageMembers.tsx @@ -0,0 +1,116 @@ +import { PlusIcon } from '@heroicons/react/24/outline'; +import { ServerErrorCode } from '@zenstackhq/internal'; +import { HooksError, useSpaceUser } from '@zenstackhq/runtime/hooks'; +import { Space, SpaceUserRole } from '@zenstackhq/runtime/types'; +import { ChangeEvent, KeyboardEvent, useState } from 'react'; +import { toast } from 'react-toastify'; +import Avatar from './Avatar'; + +type Props = { + space: Space; +}; + +export default function ManageMembers({ space }: Props) { + const [email, setEmail] = useState(''); + const [role, setRole] = useState(SpaceUserRole.USER); + + const { find, create: addMember } = useSpaceUser(); + const { data: members } = find({ + where: { + spaceId: space.id, + }, + include: { + user: true, + }, + }); + + const inviteUser = async () => { + try { + const r = await addMember({ + data: { + user: { + connect: { + email, + }, + }, + space: { + connect: { + id: space.id, + }, + }, + role, + }, + }); + console.log('SpaceUser created:', r); + } catch (err: any) { + console.error(JSON.stringify(err)); + if (err.info?.code) { + const { info } = err as HooksError; + if (info.code === ServerErrorCode.UNIQUE_CONSTRAINT_VIOLATION) { + toast.error('User is already a member of the space'); + } else if ( + info.code === ServerErrorCode.REFERENCE_CONSTRAINT_VIOLATION + ) { + toast.error('User is not found for this email'); + } + } else { + toast.error(`Error occurred: ${err}`); + } + } + }; + + return ( +
+
+ ) => { + setEmail(e.currentTarget.value); + }} + onKeyUp={(e: KeyboardEvent) => { + if (e.key === 'Enter') { + inviteUser(); + } + }} + /> + + + + +
+ +
    + {members?.map((member) => ( +
  • +
    + +

    + {member.user.name || member.user.email} +

    +

    {member.role}

    +
    +
    + +
    +
  • + ))} +
+
+ ); +} diff --git a/samples/todo/components/NavBar.tsx b/samples/todo/components/NavBar.tsx new file mode 100644 index 000000000..4ac9f9824 --- /dev/null +++ b/samples/todo/components/NavBar.tsx @@ -0,0 +1,57 @@ +import Image from 'next/image'; +import Avatar from './Avatar'; +import { signOut } from 'next-auth/react'; +import Link from 'next/link'; +import { Space } from '@zenstackhq/runtime/types'; +import { User } from 'next-auth'; + +type Props = { + space: Space | undefined; + user: User | undefined; +}; + +export default function NavBar({ user, space }: Props) { + return ( +
+ +
+
+ + +
+
+
+ ); +} diff --git a/samples/todo/components/SpaceMembers.tsx b/samples/todo/components/SpaceMembers.tsx new file mode 100644 index 000000000..9be76c06c --- /dev/null +++ b/samples/todo/components/SpaceMembers.tsx @@ -0,0 +1,76 @@ +import { useSpaceUser } from '@zenstackhq/runtime/hooks'; +import { useCurrentSpace } from 'pages/context'; +import { PlusIcon } from '@heroicons/react/24/outline'; +import Avatar from './Avatar'; +import ManageMembers from './ManageMembers'; +import { Space } from '@zenstackhq/runtime/types'; + +function ManagementDialog(space?: Space) { + if (!space) return undefined; + return ( + <> + + + +
+
+

+ Manage Members of {space.name} +

+ +
+ +
+ +
+ +
+
+
+ + ); +} + +export default function SpaceMembers() { + const space = useCurrentSpace(); + + const { find: findMembers } = useSpaceUser(); + const { data: members } = findMembers({ + where: { + spaceId: space?.id, + }, + include: { + user: true, + }, + orderBy: { + role: 'desc', + }, + }); + + return ( +
+ {ManagementDialog(space)} + {members && ( + + )} +
+ ); +} diff --git a/samples/todo/components/Spaces.tsx b/samples/todo/components/Spaces.tsx new file mode 100644 index 000000000..437058979 --- /dev/null +++ b/samples/todo/components/Spaces.tsx @@ -0,0 +1,28 @@ +import { useSpace } from '@zenstackhq/runtime/hooks'; +import Link from 'next/link'; + +export default function Spaces() { + const { find } = useSpace(); + const spaces = find(); + + return ( + + ); +} diff --git a/samples/todo/components/Todo.tsx b/samples/todo/components/Todo.tsx new file mode 100644 index 000000000..c349a57b4 --- /dev/null +++ b/samples/todo/components/Todo.tsx @@ -0,0 +1,61 @@ +import { useTodo } from '@zenstackhq/runtime/hooks'; +import { Todo, User } from '@zenstackhq/runtime/types'; +import moment from 'moment'; +import { ChangeEvent, useEffect, useState } from 'react'; +import Avatar from './Avatar'; + +type Props = { + value: Todo & { owner: User }; + updated?: (value: Todo) => any; +}; + +export default function Component({ value, updated }: Props) { + const [completed, setCompleted] = useState(!!value.completedAt); + const { update } = useTodo(); + + useEffect(() => { + if (!!value.completedAt !== completed) { + update(value.id, { + data: { completedAt: completed ? new Date() : null }, + }).then((newValue) => { + if (updated) { + updated(newValue); + } + }); + } + }); + + return ( +
+
+

+ {value.title} +

+ ) => + setCompleted(e.currentTarget.checked) + } + /> +
+
+

+ {value.completedAt + ? `Completed ${moment(value.completedAt).fromNow()}` + : value.createdAt === value.updatedAt + ? `Created ${moment(value.createdAt).fromNow()}` + : `Updated ${moment(value.updatedAt).fromNow()}`} +

+ +
+
+ ); +} diff --git a/samples/todo/components/TodoList.tsx b/samples/todo/components/TodoList.tsx new file mode 100644 index 000000000..83fc3c524 --- /dev/null +++ b/samples/todo/components/TodoList.tsx @@ -0,0 +1,45 @@ +import Image from 'next/image'; +import { List } from '@zenstackhq/runtime/types'; +import { customAlphabet } from 'nanoid'; +import { LockClosedIcon } from '@heroicons/react/24/outline'; +import { User } from 'next-auth'; +import Avatar from './Avatar'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; + +type Props = { + value: List & { owner: User }; +}; + +export default function TodoList({ value }: Props) { + const router = useRouter(); + return ( + + +
+ Cover +
+
+

+ {value.title || 'Missing Title'} +

+
+ + {value.private && ( +
+ +
+ )} +
+
+
+ + ); +} diff --git a/samples/todo/next.config.js b/samples/todo/next.config.js index ae887958d..f9cb6f102 100644 --- a/samples/todo/next.config.js +++ b/samples/todo/next.config.js @@ -1,7 +1,10 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - reactStrictMode: true, - swcMinify: true, -} + reactStrictMode: true, + swcMinify: true, + images: { + domains: ['lh3.googleusercontent.com', 'picsum.photos'], + }, +}; -module.exports = nextConfig +module.exports = nextConfig; diff --git a/samples/todo/package-lock.json b/samples/todo/package-lock.json index ee1b16bd6..b332ff4fa 100644 --- a/samples/todo/package-lock.json +++ b/samples/todo/package-lock.json @@ -8,16 +8,22 @@ "name": "todo", "version": "0.1.0", "dependencies": { + "@heroicons/react": "^2.0.12", "@prisma/client": "^4.4.0", + "@zenstackhq/internal": "^0.1.18", "@zenstackhq/runtime": "latest", "daisyui": "^2.31.0", + "moment": "^2.29.4", + "nanoid": "^4.0.0", "next": "12.3.1", "next-auth": "^4.10.3", "react": "18.2.0", "react-dom": "18.2.0", + "react-toastify": "^9.0.8", "swr": "^1.3.0" }, "devDependencies": { + "@tailwindcss/line-clamp": "^0.4.2", "@types/node": "^14.17.3", "@types/react": "18.0.21", "@types/react-dom": "18.0.6", @@ -189,6 +195,14 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/@heroicons/react": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.0.12.tgz", + "integrity": "sha512-FZxKh3i9aKIDxyALTgIpSF2t6V6/eZfF5mRu41QlwkX3Oxzecdm1u6dpft6PQGxIBwO7TKYWaMAYYL8mp/EaOg==", + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", @@ -504,6 +518,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@tailwindcss/line-clamp": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.2.tgz", + "integrity": "sha512-HFzAQuqYCjyy/SX9sLGB1lroPzmcnWv1FHkIpmypte10hptf4oPUfucryMKovZh2u0uiS9U5Ty3GghWfEJGwVw==", + "dev": true, + "peerDependencies": { + "tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1" + } + }, "node_modules/@ts-morph/common": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.17.0.tgz", @@ -698,10 +721,11 @@ } }, "node_modules/@zenstackhq/internal": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@zenstackhq/internal/-/internal-0.1.6.tgz", - "integrity": "sha512-E2iPDgih6StV+RioV0NWiLC6/m9i90CYs5t2dHSygKj2614+8oxcvf8OzQGOgtgEm12HaqRJHwonZWIo+NV3ag==", + "version": "0.1.18", + "resolved": "https://registry.npmjs.org/@zenstackhq/internal/-/internal-0.1.18.tgz", + "integrity": "sha512-M+hd3/aEhZ2grOe5FJETN1aA2mKutg7gfPW9eA2iIu0OtwvS1VdS+h7BgmU0rVk/SkuO0qW07r6fJKMr+Okaug==", "dependencies": { + "bcryptjs": "^2.4.3", "deepcopy": "^2.1.0", "swr": "^1.3.0" }, @@ -712,9 +736,9 @@ } }, "node_modules/@zenstackhq/runtime": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.1.17.tgz", - "integrity": "sha512-7wzHGJYjWsvH2njYruZVLrWk6TpLZKYOEjzS/MAxoN0HdIHaQ7DISWfgWQdmSBaWSQoR1qvA1A7A+AhxvahZnA==", + "version": "0.1.18", + "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.1.18.tgz", + "integrity": "sha512-W6F0wkNb7kOyL8DRGXSEWkzdwUJEQIdWkvjSqh0Itdk+OAY66lDMKhKfbEmmlDxhurkoOJ4YQK6Qxj6UA+AjkQ==", "peerDependencies": { "@types/bcryptjs": "^2.4.2", "@zenstackhq/internal": "^0.1.0", @@ -982,8 +1006,7 @@ "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", - "peer": true + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" }, "node_modules/binary-extensions": { "version": "2.2.0", @@ -1180,6 +1203,14 @@ "fsevents": "~2.3.2" } }, + "node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/code-block-writer": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-11.0.3.tgz", @@ -2863,6 +2894,14 @@ "node": ">=10" } }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -2870,14 +2909,14 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.0.tgz", + "integrity": "sha512-IgBP8piMxe/gf73RTQx7hmnhwz0aaEXYakvqZyE302IXW3HyVNhdNGC+O2MwMAVhLEnvXlvKtGbtJf6wvHihCg==", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^14 || ^16 || >=18" } }, "node_modules/natural-compare": { @@ -2968,6 +3007,17 @@ } } }, + "node_modules/next/node_modules/nanoid": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", @@ -3413,6 +3463,17 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/preact": { "version": "10.11.1", "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.1.tgz", @@ -3561,6 +3622,18 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/react-toastify": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.0.8.tgz", + "integrity": "sha512-EwM+teWt49HSHx+67qI08yLAW1zAsBxCXLCsUfxHYv1W7/R3ZLhrqKalh7j+kjgPna1h5LQMSMwns4tB4ww2yQ==", + "dependencies": { + "clsx": "^1.1.1" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -4437,12 +4510,12 @@ } }, "node_modules/zenstack": { - "version": "0.1.21", - "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.1.21.tgz", - "integrity": "sha512-uhCnyuhUY4XzYz7zeiidq/vIdAAF354t2Nc1YGxX+uvON781w26IUxy2VOmG02P7W3lLBL8zNfE6hNLpqRbzHA==", + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.1.32.tgz", + "integrity": "sha512-xu/248b/PzV8AEwEfL1rpXcNMAcf9wTCIOC6xkurrl4Cba/J1DxFdsLvmJEg88lJlNplNSUAjdD4zMnWhVdEpA==", "dev": true, "dependencies": { - "@zenstackhq/internal": "0.1.6", + "@zenstackhq/internal": "0.1.9", "change-case": "^4.1.2", "chevrotain": "^9.1.0", "colors": "^1.4.0", @@ -4462,6 +4535,22 @@ "engines": { "vscode": "^1.56.0" } + }, + "node_modules/zenstack/node_modules/@zenstackhq/internal": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@zenstackhq/internal/-/internal-0.1.9.tgz", + "integrity": "sha512-OIwUkfHoK7zNa8EWiveyIAa8KOngcXfuYpi//r/kkjS238wLbnf5CCElx4KKp4zKCeWd1urhb6nstcN6HBjtwg==", + "dev": true, + "dependencies": { + "bcryptjs": "^2.4.3", + "deepcopy": "^2.1.0", + "swr": "^1.3.0" + }, + "peerDependencies": { + "next": "12.3.1", + "react": "^17.0.2 || ^18", + "react-dom": "^17.0.2 || ^18" + } } }, "dependencies": { @@ -4596,6 +4685,12 @@ "strip-json-comments": "^3.1.1" } }, + "@heroicons/react": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.0.12.tgz", + "integrity": "sha512-FZxKh3i9aKIDxyALTgIpSF2t6V6/eZfF5mRu41QlwkX3Oxzecdm1u6dpft6PQGxIBwO7TKYWaMAYYL8mp/EaOg==", + "requires": {} + }, "@humanwhocodes/config-array": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", @@ -4766,6 +4861,13 @@ "tslib": "^2.4.0" } }, + "@tailwindcss/line-clamp": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.2.tgz", + "integrity": "sha512-HFzAQuqYCjyy/SX9sLGB1lroPzmcnWv1FHkIpmypte10hptf4oPUfucryMKovZh2u0uiS9U5Ty3GghWfEJGwVw==", + "dev": true, + "requires": {} + }, "@ts-morph/common": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.17.0.tgz", @@ -4910,18 +5012,19 @@ } }, "@zenstackhq/internal": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@zenstackhq/internal/-/internal-0.1.6.tgz", - "integrity": "sha512-E2iPDgih6StV+RioV0NWiLC6/m9i90CYs5t2dHSygKj2614+8oxcvf8OzQGOgtgEm12HaqRJHwonZWIo+NV3ag==", + "version": "0.1.18", + "resolved": "https://registry.npmjs.org/@zenstackhq/internal/-/internal-0.1.18.tgz", + "integrity": "sha512-M+hd3/aEhZ2grOe5FJETN1aA2mKutg7gfPW9eA2iIu0OtwvS1VdS+h7BgmU0rVk/SkuO0qW07r6fJKMr+Okaug==", "requires": { + "bcryptjs": "^2.4.3", "deepcopy": "^2.1.0", "swr": "^1.3.0" } }, "@zenstackhq/runtime": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.1.17.tgz", - "integrity": "sha512-7wzHGJYjWsvH2njYruZVLrWk6TpLZKYOEjzS/MAxoN0HdIHaQ7DISWfgWQdmSBaWSQoR1qvA1A7A+AhxvahZnA==", + "version": "0.1.18", + "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.1.18.tgz", + "integrity": "sha512-W6F0wkNb7kOyL8DRGXSEWkzdwUJEQIdWkvjSqh0Itdk+OAY66lDMKhKfbEmmlDxhurkoOJ4YQK6Qxj6UA+AjkQ==", "requires": {} }, "acorn": { @@ -5106,8 +5209,7 @@ "bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", - "peer": true + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" }, "binary-extensions": { "version": "2.2.0", @@ -5246,6 +5348,11 @@ "readdirp": "~3.6.0" } }, + "clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==" + }, "code-block-writer": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-11.0.3.tgz", @@ -6513,6 +6620,11 @@ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true }, + "moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -6520,9 +6632,9 @@ "dev": true }, "nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.0.tgz", + "integrity": "sha512-IgBP8piMxe/gf73RTQx7hmnhwz0aaEXYakvqZyE302IXW3HyVNhdNGC+O2MwMAVhLEnvXlvKtGbtJf6wvHihCg==" }, "natural-compare": { "version": "1.4.0", @@ -6556,6 +6668,11 @@ "use-sync-external-store": "1.2.0" }, "dependencies": { + "nanoid": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==" + }, "postcss": { "version": "8.4.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", @@ -6822,6 +6939,13 @@ "nanoid": "^3.3.4", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" + }, + "dependencies": { + "nanoid": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==" + } } }, "postcss-import": { @@ -6971,6 +7095,14 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "react-toastify": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.0.8.tgz", + "integrity": "sha512-EwM+teWt49HSHx+67qI08yLAW1zAsBxCXLCsUfxHYv1W7/R3ZLhrqKalh7j+kjgPna1h5LQMSMwns4tB4ww2yQ==", + "requires": { + "clsx": "^1.1.1" + } + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -7623,12 +7755,12 @@ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" }, "zenstack": { - "version": "0.1.21", - "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.1.21.tgz", - "integrity": "sha512-uhCnyuhUY4XzYz7zeiidq/vIdAAF354t2Nc1YGxX+uvON781w26IUxy2VOmG02P7W3lLBL8zNfE6hNLpqRbzHA==", + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.1.32.tgz", + "integrity": "sha512-xu/248b/PzV8AEwEfL1rpXcNMAcf9wTCIOC6xkurrl4Cba/J1DxFdsLvmJEg88lJlNplNSUAjdD4zMnWhVdEpA==", "dev": true, "requires": { - "@zenstackhq/internal": "0.1.6", + "@zenstackhq/internal": "0.1.9", "change-case": "^4.1.2", "chevrotain": "^9.1.0", "colors": "^1.4.0", @@ -7641,6 +7773,19 @@ "vscode-languageclient": "^7.0.0", "vscode-languageserver": "^7.0.0", "vscode-uri": "^3.0.2" + }, + "dependencies": { + "@zenstackhq/internal": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@zenstackhq/internal/-/internal-0.1.9.tgz", + "integrity": "sha512-OIwUkfHoK7zNa8EWiveyIAa8KOngcXfuYpi//r/kkjS238wLbnf5CCElx4KKp4zKCeWd1urhb6nstcN6HBjtwg==", + "dev": true, + "requires": { + "bcryptjs": "^2.4.3", + "deepcopy": "^2.1.0", + "swr": "^1.3.0" + } + } } } } diff --git a/samples/todo/package.json b/samples/todo/package.json index f417bab70..8e1ec46a2 100644 --- a/samples/todo/package.json +++ b/samples/todo/package.json @@ -7,23 +7,28 @@ "build": "next build", "start": "next start", "lint": "next lint", - "db-gen": "prisma generate --schema .zenstack/schema.prisma", - "db-push": "prisma db push --schema .zenstack/schema.prisma", - "db-migrate": "prisma migrate dev --schema .zenstack/schema.prisma", - "db-reset": "prisma migrate reset --schema .zenstack/schema.prisma", + "db-gen": "prisma generate --schema node_modules/.zenstack/schema.prisma", + "db-push": "prisma db push --schema node_modules/.zenstack/schema.prisma", + "db-migrate": "prisma migrate dev --schema node_modules/.zenstack/schema.prisma", + "db-reset": "prisma migrate reset --schema node_modules/.zenstack/schema.prisma", "generate": "zenstack generate ./schema.zmodel" }, "dependencies": { + "@heroicons/react": "^2.0.12", "@prisma/client": "^4.4.0", "@zenstackhq/runtime": "latest", "daisyui": "^2.31.0", + "moment": "^2.29.4", + "nanoid": "^4.0.0", "next": "12.3.1", "next-auth": "^4.10.3", "react": "18.2.0", "react-dom": "18.2.0", + "react-toastify": "^9.0.8", "swr": "^1.3.0" }, "devDependencies": { + "@tailwindcss/line-clamp": "^0.4.2", "@types/node": "^14.17.3", "@types/react": "18.0.21", "@types/react-dom": "18.0.6", diff --git a/samples/todo/pages/_app.tsx b/samples/todo/pages/_app.tsx index 505acdf46..8a02e1ce0 100644 --- a/samples/todo/pages/_app.tsx +++ b/samples/todo/pages/_app.tsx @@ -1,11 +1,45 @@ import '../styles/globals.css'; import type { AppProps } from 'next/app'; import { SessionProvider } from 'next-auth/react'; +import 'react-toastify/dist/ReactToastify.css'; +import { ToastContainer } from 'react-toastify'; +import NavBar from 'components/NavBar'; +import { + SpaceContext, + useCurrentSpace, + useCurrentUser, + UserContext, +} from './context'; + +function AppContent(props: { children: JSX.Element | JSX.Element[] }) { + const user = useCurrentUser(); + const space = useCurrentSpace(); + + return ( + + +
+ + {props.children} +
+
+
+ ); +} function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) { return ( - + +
+ + +
+
); } diff --git a/samples/todo/pages/api/auth/[...nextauth].ts b/samples/todo/pages/api/auth/[...nextauth].ts index b0df63407..c6c632b57 100644 --- a/samples/todo/pages/api/auth/[...nextauth].ts +++ b/samples/todo/pages/api/auth/[...nextauth].ts @@ -1,4 +1,4 @@ -import NextAuth, { NextAuthOptions } from 'next-auth'; +import NextAuth, { NextAuthOptions, User } from 'next-auth'; import CredentialsProvider from 'next-auth/providers/credentials'; import GoogleProvider from 'next-auth/providers/google'; import { @@ -6,6 +6,9 @@ import { NextAuthAdapter as Adapter, } from '@zenstackhq/runtime/auth'; import service from '@zenstackhq/runtime'; +import { nanoid } from 'nanoid'; +import { SpaceUserRole } from '@zenstackhq/runtime/types'; +import { signIn } from 'next-auth/react'; export const authOptions: NextAuthOptions = { // Configure one or more authentication providers @@ -21,6 +24,7 @@ export const authOptions: NextAuthOptions = { clientId: process.env.GOOGLE_ID!, clientSecret: process.env.GOOGLE_SECRET!, }), + CredentialsProvider({ credentials: { email: { @@ -49,6 +53,38 @@ export const authOptions: NextAuthOptions = { }; }, }, + + events: { + async signIn({ user }: { user: User }) { + const spaceCount = await service.db.spaceUser.count({ + where: { + userId: user.id, + }, + }); + if (spaceCount > 0) { + return; + } + + console.log( + `User ${user.id} doesn't belong to any space. Creating one.` + ); + const space = await service.db.space.create({ + data: { + name: `${user.name || user.email}'s space`, + slug: nanoid(8), + members: { + create: [ + { + userId: user.id, + role: SpaceUserRole.ADMIN, + }, + ], + }, + }, + }); + console.log(`Space created:`, space); + }, + }, }; export default NextAuth(authOptions); diff --git a/samples/todo/pages/context.ts b/samples/todo/pages/context.ts new file mode 100644 index 000000000..0fa24e751 --- /dev/null +++ b/samples/todo/pages/context.ts @@ -0,0 +1,31 @@ +import { useSpace } from '@zenstackhq/runtime/hooks'; +import { Space } from '@zenstackhq/runtime/types'; +import { User } from 'next-auth'; +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/router'; +import { createContext } from 'react'; + +export const UserContext = createContext(undefined); + +export function useCurrentUser() { + const { data: session } = useSession(); + return session?.user; +} + +export const SpaceContext = createContext(undefined); + +export function useCurrentSpace() { + const router = useRouter(); + const { find } = useSpace(); + const spaces = find({ + where: { + slug: router.query.slug as string, + }, + }); + + if (!router.query.slug) { + return undefined; + } + + return spaces.data?.[0]; +} diff --git a/samples/todo/pages/create-space.tsx b/samples/todo/pages/create-space.tsx new file mode 100644 index 000000000..b8b2023e2 --- /dev/null +++ b/samples/todo/pages/create-space.tsx @@ -0,0 +1,117 @@ +import { NextPage } from 'next'; +import { FormEvent, useState } from 'react'; +import { useSpace, type HooksError } from '@zenstackhq/runtime/hooks'; +import AuthGuard from 'components/AuthGuard'; +import { ServerErrorCode } from '@zenstackhq/runtime/server'; +import { toast } from 'react-toastify'; +import { useRouter } from 'next/router'; +import { useSession } from 'next-auth/react'; +import { SpaceUserRole } from '@zenstackhq/runtime/types'; + +const CreateSpace: NextPage = () => { + const { data: session } = useSession(); + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + + const { create } = useSpace(); + const router = useRouter(); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + try { + const space = await create({ + data: { + name, + slug, + members: { + create: [ + { + userId: session!.user.id, + role: SpaceUserRole.ADMIN, + }, + ], + }, + }, + }); + console.log('Space created:', space); + toast.success("Space created successfull! You'll be redirected."); + + setTimeout(() => { + router.push(`/space/${space.slug}`); + }, 2000); + } catch (err) { + console.error(err); + if ( + (err as HooksError).info?.code === + ServerErrorCode.UNIQUE_CONSTRAINT_VIOLATION + ) { + toast.error('Space slug alread in use'); + } else { + toast.error(`Error occurred: ${err}`); + } + } + }; + + return ( + +
+
+

Create a space

+
+
+ + ) => + setName(e.currentTarget.value) + } + /> +
+
+ + ) => + setSlug(e.currentTarget.value) + } + /> +
+
+ +
+ 20 || + !slug.match(/^[0-9a-zA-Z]{4,16}$/) + } + value="Create" + className="btn btn-primary px-8" + /> + +
+
+
+
+ ); +}; + +export default CreateSpace; diff --git a/samples/todo/pages/index.tsx b/samples/todo/pages/index.tsx index b290e3fae..5ab7a821c 100644 --- a/samples/todo/pages/index.tsx +++ b/samples/todo/pages/index.tsx @@ -1,97 +1,39 @@ import type { NextPage } from 'next'; -import LoginButton from '../components/LoginButton'; -import { useSession } from 'next-auth/react'; -import { useTodoCollection } from '@zenstackhq/runtime/hooks'; -import { TodoCollection } from '@zenstackhq/runtime/types'; +import { useSession, signIn } from 'next-auth/react'; +import Spaces from 'components/Spaces'; +import Link from 'next/link'; const Home: NextPage = () => { - const { data: session } = useSession(); - const { - create: createTodoCollection, - find: findTodoCollection, - del: deleteTodoCollection, - } = useTodoCollection(); - const { data: todoCollections } = findTodoCollection(); + const { data: session, status: sessionStatus } = useSession(); - async function onCreateTodoCollection() { - await createTodoCollection({ - data: { - title: 'My Todo Collection', - ownerId: session!.user.id, - spaceId: 'f0c9fc5c-e6e5-4146-a540-214f6ac5701c', - }, - }); + if (sessionStatus === 'unauthenticated') { + // kick back to signin + signIn(); } - async function onCreateFilledTodoCollection() { - await createTodoCollection({ - data: { - title: 'My Todo Collection', - ownerId: session!.user.id, - spaceId: 'f0c9fc5c-e6e5-4146-a540-214f6ac5701c', - todos: { - create: [ - { title: 'First Todo', ownerId: session!.user.id }, - ], - }, - }, - }); - } - - async function onDeleteTodoCollection(todoList: TodoCollection) { - await deleteTodoCollection(todoList.id); - } - - function renderTodoCollections() { - return ( - <> -
    - {todoCollections?.map((collection) => ( -
  • -

    {collection.title}

    - -
  • - ))} -
- - ); + if (!session) { + return
Loading ...
; } return ( -
-

Wonderful Todo

-
- + <> +
+

+ Welcome {session.user.name || session.user.email}! +

+
+

+ Choose a space to start, or{' '} + + + create a new one. + + +

+ +
- - {session && ( - <> - - - - -

Todo Lists

- {renderTodoCollections()} - - )} -
+ ); }; diff --git a/samples/todo/pages/space/[slug]/[listId]/index.tsx b/samples/todo/pages/space/[slug]/[listId]/index.tsx new file mode 100644 index 000000000..73ca504e6 --- /dev/null +++ b/samples/todo/pages/space/[slug]/[listId]/index.tsx @@ -0,0 +1,85 @@ +import { useList, useTodo } from '@zenstackhq/runtime/hooks'; +import { useRouter } from 'next/router'; +import { PlusIcon } from '@heroicons/react/24/outline'; +import { ChangeEvent, KeyboardEvent, useState } from 'react'; +import { useCurrentUser } from 'pages/context'; +import TodoComponent from 'components/Todo'; +import BreadCrumb from 'components/BreadCrumb'; + +export default function TodoList() { + const user = useCurrentUser(); + const router = useRouter(); + const { get: getList } = useList(); + const { create: createTodo, find: findTodos } = useTodo(); + const [title, setTitle] = useState(''); + + const { data: list } = getList(router.query.listId as string); + const { data: todos, mutate: invalidateTodos } = findTodos({ + where: { + listId: list?.id, + }, + include: { + owner: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + + if (!list) { + return

Loading ...

; + } + + const _createTodo = async () => { + const todo = await createTodo({ + data: { + title, + ownerId: user!.id, + listId: list!.id, + }, + }); + console.log(`Todo created: ${todo}`); + setTitle(''); + }; + + return ( + <> +
+ +
+
+

{list?.title}

+
+ ) => { + if (e.key === 'Enter') { + _createTodo(); + } + }} + onChange={(e: ChangeEvent) => { + setTitle(e.currentTarget.value); + }} + /> + +
+
    + {todos?.map((todo) => ( + { + invalidateTodos(); + }} + /> + ))} +
+
+ + ); +} diff --git a/samples/todo/pages/space/[slug]/index.tsx b/samples/todo/pages/space/[slug]/index.tsx new file mode 100644 index 000000000..277fd5f73 --- /dev/null +++ b/samples/todo/pages/space/[slug]/index.tsx @@ -0,0 +1,165 @@ +import { SpaceContext, UserContext } from '../../context'; +import { ChangeEvent, FormEvent, useContext, useState } from 'react'; +import { useList } from '@zenstackhq/runtime/hooks'; +import { toast } from 'react-toastify'; +import TodoList from 'components/TodoList'; +import BreadCrumb from 'components/BreadCrumb'; +import SpaceMembers from 'components/SpaceMembers'; + +function CreateDialog() { + const user = useContext(UserContext); + const space = useContext(SpaceContext); + + const [modalOpen, setModalOpen] = useState(false); + const [title, setTitle] = useState(''); + const [_private, setPrivate] = useState(false); + + const { create } = useList(); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + + try { + await create({ + data: { + title, + private: _private, + spaceId: space!.id, + ownerId: user!.id, + }, + }); + } catch (err) { + toast.error(`Failed to create list: ${err}`); + return; + } + + toast.success('List created successfully!'); + + // reset states + setTitle(''); + setPrivate(false); + + // close modal + setModalOpen(false); + }; + + return ( + <> + ) => { + setModalOpen(e.currentTarget.checked); + }} + /> +
+
+

+ Create a Todo list +

+
+
+
+ + + ) => setTitle(e.currentTarget.value)} + /> +
+
+ + + ) => setPrivate(e.currentTarget.checked)} + /> +
+
+
+ + +
+
+
+
+ + ); +} + +export default function SpaceHome() { + const space = useContext(SpaceContext); + const { find } = useList(); + + const lists = find({ + where: { + space: { + id: space?.id, + }, + }, + include: { + owner: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + + return ( + <> +
+ +
+
+
+ + +
+ +
    + {lists.data?.map((list) => ( +
  • + +
  • + ))} +
+ + +
+ + ); +} diff --git a/samples/todo/public/avatar.jpg b/samples/todo/public/avatar.jpg new file mode 100644 index 000000000..34e82838c Binary files /dev/null and b/samples/todo/public/avatar.jpg differ diff --git a/samples/todo/public/logo.png b/samples/todo/public/logo.png new file mode 100644 index 000000000..48bbe06d9 Binary files /dev/null and b/samples/todo/public/logo.png differ diff --git a/samples/todo/schema.zmodel b/samples/todo/schema.zmodel index 392b9dea7..c9666a738 100644 --- a/samples/todo/schema.zmodel +++ b/samples/todo/schema.zmodel @@ -1,5 +1,5 @@ /* -* A sample model for a collaborative Todo app +* Sample model for a collaborative Todo app */ datasource db { @@ -19,7 +19,7 @@ model Space { name String @length(1, 100) slug String @unique @length(1, 20) members SpaceUser[] - todoCollections TodoCollection[] + lists List[] // require login @@deny('all', auth() == null) @@ -62,12 +62,14 @@ model User { updatedAt DateTime @updatedAt email String @unique @email emailVerified DateTime? - password String + password String? name String? @length(1, 100) spaces SpaceUser[] image String? @url - todoCollections TodoCollection[] + lists List[] todos Todo[] + + // next-auth accounts Account[] sessions Session[] @@ -81,41 +83,7 @@ model User { @@allow('update,delete', auth() == this) } -model Account { - id String @id @default(uuid()) - userId String - type String - provider String - providerAccountId String - refresh_token String? - access_token String? - expires_at Int? - token_type String? - scope String? - id_token String? - session_state String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([provider, providerAccountId]) -} - -model Session { - id String @id @default(uuid()) - sessionToken String @unique - userId String - expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) -} - -model VerificationToken { - identifier String - token String @unique - expires DateTime - - @@unique([identifier, token]) -} - -model TodoCollection { +model List { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -143,15 +111,51 @@ model Todo { updatedAt DateTime @updatedAt owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) ownerId String - todoCollection TodoCollection @relation(fields: [todoCollectionId], references: [id], onDelete: Cascade) - todoCollectionId String + list List @relation(fields: [listId], references: [id], onDelete: Cascade) + listId String title String completedAt DateTime? // require login @@deny('all', auth() == null) - // owner has full access, also space members have full access (if the parent TodoCollection is not private) - @@allow('all', todoCollection.owner == auth()) - @@allow('all', todoCollection.space.members?[user == auth()] && !todoCollection.private) + // owner has full access, also space members have full access (if the parent List is not private) + @@allow('all', list.owner == auth()) + @@allow('all', list.space.members?[user == auth()] && !list.private) } + +// Models used by next-auth + +model Account { + id String @id @default(uuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + session_state String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(uuid()) + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) +} \ No newline at end of file diff --git a/samples/todo/styles/globals.css b/samples/todo/styles/globals.css index b5c61c956..1d6a5711d 100644 --- a/samples/todo/styles/globals.css +++ b/samples/todo/styles/globals.css @@ -1,3 +1,7 @@ @tailwind base; @tailwind components; @tailwind utilities; + +body { + @apply text-gray-800; +} diff --git a/samples/todo/tailwind.config.js b/samples/todo/tailwind.config.js index 89705bc79..d8efff5e9 100644 --- a/samples/todo/tailwind.config.js +++ b/samples/todo/tailwind.config.js @@ -7,5 +7,5 @@ module.exports = { theme: { extend: {}, }, - plugins: [require('daisyui')], + plugins: [require('daisyui'), require('@tailwindcss/line-clamp')], }; diff --git a/samples/todo/types/next-auth.d.ts b/samples/todo/types/next-auth.d.ts index 3fdf4e165..891911794 100644 --- a/samples/todo/types/next-auth.d.ts +++ b/samples/todo/types/next-auth.d.ts @@ -4,7 +4,7 @@ import { JWT } from 'next-auth/jwt'; /** Example on how to extend the built-in session types */ declare module 'next-auth' { interface Session { - user: { id: string; name: string; email: string }; + user: { id: string; name: string; email: string; image?: string }; } }