├── .editorconfig ├── .env.defaults ├── .env.example ├── .gitignore ├── .nvmrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── api ├── db │ ├── migrations │ │ ├── 20211211163440_init │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma ├── jest.config.js ├── package.json ├── src │ ├── directives │ │ ├── requireAuth │ │ │ ├── requireAuth.test.ts │ │ │ └── requireAuth.ts │ │ └── skipAuth │ │ │ ├── skipAuth.test.ts │ │ │ └── skipAuth.ts │ ├── functions │ │ └── graphql.ts │ ├── graphql │ │ ├── .keep │ │ ├── objectIdentification.sdl.ts │ │ └── users.sdl.ts │ ├── lib │ │ ├── auth.ts │ │ ├── db.ts │ │ └── logger.ts │ └── services │ │ ├── .keep │ │ ├── objectIdentification.ts │ │ └── users │ │ ├── users.scenarios.ts │ │ ├── users.test.ts │ │ └── users.ts └── tsconfig.json ├── graphql.config.js ├── package.json ├── prettier.config.js ├── redwood.toml ├── scripts ├── .keep └── seed.ts ├── web ├── jest.config.js ├── package.json ├── public │ ├── README.md │ ├── favicon.png │ └── robots.txt ├── src │ ├── App.tsx │ ├── Routes.tsx │ ├── components │ │ ├── .keep │ │ ├── DeleteButton.tsx │ │ └── User │ │ │ ├── EditUserCell │ │ │ └── EditUserCell.tsx │ │ │ ├── NewUser │ │ │ └── NewUser.tsx │ │ │ ├── User │ │ │ └── User.tsx │ │ │ ├── UserCell │ │ │ └── UserCell.tsx │ │ │ ├── UserForm │ │ │ └── UserForm.tsx │ │ │ ├── Users │ │ │ └── Users.tsx │ │ │ └── UsersCell │ │ │ └── UsersCell.tsx │ ├── index.css │ ├── index.html │ ├── layouts │ │ ├── .keep │ │ └── UsersLayout │ │ │ └── UsersLayout.tsx │ ├── pages │ │ ├── FatalErrorPage │ │ │ └── FatalErrorPage.tsx │ │ ├── NotFoundPage │ │ │ └── NotFoundPage.tsx │ │ └── User │ │ │ ├── EditUserPage │ │ │ └── EditUserPage.tsx │ │ │ ├── NewUserPage │ │ │ └── NewUserPage.tsx │ │ │ ├── UserPage │ │ │ └── UserPage.tsx │ │ │ └── UsersPage │ │ │ └── UsersPage.tsx │ └── scaffold.css └── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.env.defaults: -------------------------------------------------------------------------------- 1 | # These environment variables will be used by default if you do not create any 2 | # yourself in .env. This file should be safe to check into your version control 3 | # system. Any custom values should go in .env and .env should *not* be checked 4 | # into version control. 5 | 6 | # schema.prisma defaults 7 | DATABASE_URL=file:./dev.db 8 | 9 | # location of the test database for api service scenarios (defaults to ./.redwood/test.db if not set) 10 | # TEST_DATABASE_URL=file:./.redwood/test.db 11 | 12 | # disables Prisma CLI update notifier 13 | PRISMA_HIDE_UPDATE_MESSAGE=true 14 | 15 | 16 | # Option to override the current environment's default api-side log level 17 | # See: https://redwoodjs.com/docs/logger for level options: 18 | # trace | info | debug | warn | error | silent 19 | # LOG_LEVEL=debug 20 | 21 | # Sets an app-specific secret used to sign and verify your own app's webhooks. 22 | # For example if you schedule a cron job with a signed payload that later will 23 | # then invoke your api-side webhook function you will use this secret to sign and the verify. 24 | # Important: Please change this default to a strong password or other secret 25 | WEBHOOK_SECRET=THIS_IS_NOT_SECRET_PLEASE_CHANGE 26 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # DATABASE_URL=file:./dev.db 2 | # TEST_DATABASE_URL=file:./.redwood/test.db 3 | # PRISMA_HIDE_UPDATE_MESSAGE=true 4 | # LOG_LEVEL=trace 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | .env 4 | .netlify 5 | .redwood 6 | dev.db* 7 | dist 8 | dist-babel 9 | node_modules 10 | yarn-error.log 11 | web/public/mockServiceWorker.js 12 | web/types/graphql.d.ts 13 | api/types/graphql.d.ts 14 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | ], 4 | "unwantedRecommendations": [] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "yarn redwood dev", 6 | "name": "launch development", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "files.trimTrailingWhitespace": true, 4 | "editor.formatOnSave": false, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": true 7 | }, 8 | "[prisma]": { 9 | "editor.formatOnSave": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Redwood 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redwood Object Identification Pattern Example 2 | 3 | The [GraphQL Object Identification Pattern](https://relay.dev/graphql/objectidentification.htm) is a design pattern where you ensure that every object in your GraphQL schema conforms to a single interface: 4 | 5 | ```graphql 6 | interface Node { 7 | id: ID! 8 | } 9 | ``` 10 | 11 | Which means you can write something like: 12 | 13 | ```graphql 14 | type Query { 15 | node(id: ID): Node! @skipAuth 16 | } 17 | ``` 18 | 19 | This is cool, because now you have a guaranteed query to be able to get the info for any object in your graph! This feature gives you a [bunch of caching super-powers in Relay](https://relay.dev/docs/guided-tour/reusing-cached-data/) and probably with Apollo (I don't know their caching strats intimately, but it would make re-fetching any object trivial). 20 | 21 | ## This Repo 22 | 23 | That said, for my case this repo currently handles `Node` in a different place, I wanted to create the anti-`node` resolver: 24 | 25 | ```graphql 26 | type Mutation { 27 | deleteNode(id: ID): Node! @requireAuth 28 | } 29 | ``` 30 | 31 | This is useful for the sample because I only need one model to be useful and also because [queries](https://github.com/redwoodjs/redwood/issues/3873) with inline fragments crash with RedwoodJS' `gql` ATM, [I sent a fix](https://github.com/redwoodjs/redwood/pull/3891). 32 | 33 | ## Getting Set Up 34 | 35 | ### 1. SDL + Resolvers 36 | 37 | We're going to need some GraphQL SDL and corresponding resolvers 38 | 39 | > [`api/src/graphql/objectIdentification.sdl.ts`](./api/src/graphql/objectIdentification.sdl.ts): 40 | 41 | ```graphql 42 | export const schema = gql` 43 | scalar ID 44 | 45 | interface Node { 46 | id: ID! 47 | } 48 | 49 | type Query { 50 | node(id: ID): Node! @skipAuth 51 | } 52 | 53 | type Mutation { 54 | deleteNode(id: ID): Node! @requireAuth 55 | } 56 | ` 57 | ``` 58 | 59 | This sets up some new graphql fields, and declares the new primitive `ID` which is an arbitrary string under the hood. 60 | 61 | To understand the `ID`, let's look at how I implement it in the `createUser` resolver 62 | 63 | > [`./api/src/services/users/users.ts`](./api/src/services/users/users.ts`): 64 | ```ts 65 | import cuid from "cuid" 66 | import { db } from "src/lib/db" 67 | 68 | export const createUser = ({ input }: CreateUserArgs) => { 69 | input.id = cuid() + ":user" 70 | input.slug = cuid.slug() 71 | 72 | return db.user.create({ 73 | data: input, 74 | }) 75 | } 76 | ``` 77 | 78 | Prior to setting up for Object Identification, I would have made a prisma schema like: 79 | 80 | ```prisma 81 | model User { 82 | id String @id @default(cuid()) 83 | } 84 | ``` 85 | 86 | This... doesn't _really_ work in the Object Identification era because a `cuid` is as good UUID, but there's no (safe/simple/easy) way of going from the UUID string back to the original object because it's basically random digits. A route we use at Artsy was to base64 encode that [metadata into the id](https://github.com/artsy/README/blob/main/playbooks/graphql-schema-design.md#global-object-identification). 87 | 88 |
89 | Really though? 90 | 91 | I had a few ideas for generating thse keys within the framework of letting prisma handle it, starting with making an object-identification query that looks in all potential db tables via a custom query... That's a bit dangerous and then you need to figure out which table you found the object in and _then_ start thinking about that objects access rights. That's tricky. 92 | 93 | Another alternative I explored was having prisma generate a `dbID` via `dbID String @id @default(cuid())` then have a postgres function run on a row write to generate an `id` with the suffix indicating the type. This kinda worked, but was a bit meh answer to me. At that point I gave up on letting prisma handle it at all. 94 | 95 | So, I recommend _you_ taking control of generating the id in your app's code by having a totally globally unique `id` via a cuid + prefix, and then have a `slug` if you ever need to present it to the user via a URL. 96 | 97 | To handle this case, I've been using this for resolving a single item: 98 | 99 | ```ts 100 | export const user = async (args: { id: string }) => { 101 | // Allow looking up with the same function with either slug or id 102 | const query = args.id.length > 10 ? { id: args.id } : { slug: args.id } 103 | const user = await db.user.findUnique({ where: query }) 104 | 105 | return user 106 | } 107 | ``` 108 | 109 | Which allows you to resolve a user with either `slug` or `id`. 110 | 111 |
112 | 113 | So instead now it looks like: 114 | 115 | ```diff 116 | model User { 117 | + id String @id @unique 118 | - id String @id @default(cuid()) 119 | } 120 | ``` 121 | 122 | ### 2. ID Implementation 123 | 124 | Under the hood `ID` is a real `cuid` mixed with an identifier prefix which lets you know which model it came from. The simplest implementation would of the `node` resolver look like this: 125 | 126 | ```ts 127 | import { user } from "./users/users" 128 | 129 | export const node = (args: { id: string }) => { 130 | if (args.id.endsWith(":user")) { 131 | return user({ id: args.id }) 132 | } 133 | 134 | throw new Error(`Did not find a resolver for node with ${args.id}`) 135 | } 136 | ``` 137 | 138 | Basically, by looking at the end of the `ID` we can know which underlying graphql resolver we should forward the request to, this means no duplication of access control inside the `node` function - it just forwards to the other existing GraphQL resolvers. 139 | 140 | ### 3. Disambiguation 141 | 142 | The next thing you would hit is kind of only something you hit when you try this in practice. We're now writing to `interface`s and not concrete types, which means there are new GraphQL things to handle. We need to have [a way in](https://github.com/graphql/graphql-js/issues/876#issuecomment-304398882) the GraphQL server to go from an `interface` (or `union`) to the concrete type. 143 | 144 | That is done by one of two methods, depending on your needs: 145 | 146 | - A single function on the interface which can disambiguate the types ( `Node.resolveType` ) 147 | - Or each concrete type can have a way to declare if the JS object / ID is one of it's own GraphQL type ( `User.isTypeOf` (and for every other model) ) 148 | 149 | Now, today (as of RedwoodJS v1.0rc), doing either of these things isn't possible via the normal RedwoodJS APIs, it's complicated but roughly the `*.sdl.ts` files only let you create resolvers and not manipulate the schema objects in your app. So, we'll write a quick `envelop` plugin do handle that for us: 150 | 151 | ```ts 152 | export const createNodeResolveEnvelopPlugin = (): Plugin => { 153 | return { 154 | onSchemaChange({ schema }) { 155 | const node: { resolveType?: (obj: { id: string }) => string } = schema.getType("Node") as unknown 156 | node.resolveType = (obj) => { 157 | if (obj.id.endsWith(":user")) { 158 | return "User" 159 | } 160 | 161 | throw new Error(`Did not find a resolver for deleteNode with ${args.id}`) 162 | } 163 | } 164 | } 165 | } 166 | ``` 167 | 168 | And then add that to the graphql function: 169 | 170 | ```diff 171 | + import { createNodeResolveEnvelopPlugin } from "src/services/objectIdentification" 172 | 173 | export const handler = createGraphQLHandler({ 174 | loggerConfig: { logger, options: {} }, 175 | directives, 176 | sdls, 177 | services, 178 | + extraPlugins: [createNodeResolveEnvelopPlugin()], 179 | onException: () => { 180 | // Disconnect from your database with an unhandled exception. 181 | db.$disconnect() 182 | }, 183 | }) 184 | 185 | ``` 186 | 187 | The real implementation in this app is a little more abstract [`/api/src/services/objectIdentification.ts](./api/src/services/objectIdentification.ts) but it does the work well. 188 | 189 | ### 4. Usage 190 | 191 | Finally, an actual outcome, you can see the new `DeleteButton` which I added in this repo using the `deleteNode` resolver which has a lot of similar patterns as the `node` resolver under the hood: 192 | 193 | ```ts 194 | import { navigate, routes } from "@redwoodjs/router" 195 | import { useMutation } from "@redwoodjs/web" 196 | import { toast } from "@redwoodjs/web/dist/toast" 197 | 198 | const DELETE_NODE_MUTATION = gql` 199 | mutation DeleteNodeMutation($id: ID!) { 200 | deleteNode(id: $id) { 201 | id 202 | } 203 | } 204 | ` 205 | 206 | export const DeleteButton = (props: { id: string; displayName: string }) => { 207 | const [deleteUser] = useMutation(DELETE_NODE_MUTATION, { 208 | onCompleted: () => { 209 | toast.success(`${props.displayName} deleted`) 210 | navigate(routes.users()) 211 | }, 212 | onError: (error) => { 213 | toast.error(error.message) 214 | }, 215 | }) 216 | 217 | const onDeleteClick = () => { 218 | if (confirm(`Are you sure you want to delete ${props.displayName}?`)) { 219 | deleteUser({ variables: { id: props.id } }) 220 | } 221 | } 222 | return ( 223 | 226 | ) 227 | } 228 | ``` 229 | 230 | It can delete any object which conforms to the `Node` protocol in your app, making it DRY and type-safe - and because it also forwards to each model's "delete node" resolver then it also gets all of the access control right checks in those functions too. :+1: 231 | -------------------------------------------------------------------------------- /api/db/migrations/20211211163440_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "slug" TEXT NOT NULL, 5 | "email" TEXT NOT NULL, 6 | "name" TEXT 7 | ); 8 | 9 | -- CreateIndex 10 | CREATE UNIQUE INDEX "User_id_key" ON "User"("id"); 11 | 12 | -- CreateIndex 13 | CREATE UNIQUE INDEX "User_slug_key" ON "User"("slug"); 14 | 15 | -- CreateIndex 16 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 17 | -------------------------------------------------------------------------------- /api/db/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /api/db/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "sqlite" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | binaryTargets = "native" 9 | } 10 | 11 | model User { 12 | id String @id @unique 13 | slug String @unique 14 | email String @unique 15 | name String? 16 | } 17 | -------------------------------------------------------------------------------- /api/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@redwoodjs/testing/config/jest/api') 2 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@redwoodjs/api": "0.39.4", 7 | "@redwoodjs/graphql-server": "0.39.4" 8 | }, 9 | "devDependencies": { 10 | "cuid": "^2.1.8", 11 | "guid": "^0.0.12" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /api/src/directives/requireAuth/requireAuth.test.ts: -------------------------------------------------------------------------------- 1 | import { mockRedwoodDirective, getDirectiveName } from "@redwoodjs/testing/api" 2 | 3 | import requireAuth from "./requireAuth" 4 | 5 | describe("requireAuth directive", () => { 6 | it("declares the directive sdl as schema, with the correct name", () => { 7 | expect(requireAuth.schema).toBeTruthy() 8 | expect(getDirectiveName(requireAuth.schema)).toBe("requireAuth") 9 | }) 10 | 11 | it("requireAuth has stub implementation. Should not throw when current user", () => { 12 | // If you want to set values in context, pass it through e.g. 13 | // mockRedwoodDirective(requireAuth, { context: { currentUser: { id: 1, name: 'Lebron McGretzky' } }}) 14 | const mockExecution = mockRedwoodDirective(requireAuth, { context: {} }) 15 | 16 | expect(mockExecution).not.toThrowError() 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /api/src/directives/requireAuth/requireAuth.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag" 2 | 3 | import { createValidatorDirective } from "@redwoodjs/graphql-server" 4 | 5 | import { requireAuth as applicationRequireAuth } from "src/lib/auth" 6 | 7 | export const schema = gql` 8 | """ 9 | Use to check whether or not a user is authenticated and is associated 10 | with an optional set of roles. 11 | """ 12 | directive @requireAuth(roles: [String]) on FIELD_DEFINITION 13 | ` 14 | 15 | const validate = ({ directiveArgs }) => { 16 | const { roles } = directiveArgs 17 | applicationRequireAuth({ roles }) 18 | } 19 | 20 | const requireAuth = createValidatorDirective(schema, validate) 21 | 22 | export default requireAuth 23 | -------------------------------------------------------------------------------- /api/src/directives/skipAuth/skipAuth.test.ts: -------------------------------------------------------------------------------- 1 | import { getDirectiveName } from "@redwoodjs/testing/api" 2 | 3 | import skipAuth from "./skipAuth" 4 | 5 | describe("skipAuth directive", () => { 6 | it("declares the directive sdl as schema, with the correct name", () => { 7 | expect(skipAuth.schema).toBeTruthy() 8 | expect(getDirectiveName(skipAuth.schema)).toBe("skipAuth") 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /api/src/directives/skipAuth/skipAuth.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag" 2 | 3 | import { createValidatorDirective } from "@redwoodjs/graphql-server" 4 | 5 | export const schema = gql` 6 | """ 7 | Use to skip authentication checks and allow public access. 8 | """ 9 | directive @skipAuth on FIELD_DEFINITION 10 | ` 11 | 12 | const skipAuth = createValidatorDirective(schema, () => { 13 | return 14 | }) 15 | 16 | export default skipAuth 17 | -------------------------------------------------------------------------------- /api/src/functions/graphql.ts: -------------------------------------------------------------------------------- 1 | import { createGraphQLHandler } from "@redwoodjs/graphql-server" 2 | 3 | import directives from "src/directives/**/*.{js,ts}" 4 | import sdls from "src/graphql/**/*.sdl.{js,ts}" 5 | import services from "src/services/**/*.{js,ts}" 6 | 7 | import { db } from "src/lib/db" 8 | import { logger } from "src/lib/logger" 9 | 10 | import { createNodeResolveEnvelopPlugin } from "src/services/objectIdentification" 11 | 12 | export const handler = createGraphQLHandler({ 13 | loggerConfig: { logger, options: {} }, 14 | directives, 15 | sdls, 16 | services, 17 | extraPlugins: [createNodeResolveEnvelopPlugin()], 18 | onException: () => { 19 | // Disconnect from your database with an unhandled exception. 20 | db.$disconnect() 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /api/src/graphql/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orta/redwood-object-identification/335ce11b67bc302d75145dc1e2db37bdaeee4736/api/src/graphql/.keep -------------------------------------------------------------------------------- /api/src/graphql/objectIdentification.sdl.ts: -------------------------------------------------------------------------------- 1 | export const schema = gql` 2 | scalar ID 3 | 4 | # An object with a Globally Unique ID 5 | interface Node { 6 | id: ID! 7 | } 8 | 9 | type Query { 10 | node(id: ID): Node! @skipAuth 11 | } 12 | 13 | type Mutation { 14 | deleteNode(id: ID): Node! @requireAuth 15 | } 16 | ` 17 | -------------------------------------------------------------------------------- /api/src/graphql/users.sdl.ts: -------------------------------------------------------------------------------- 1 | export const schema = gql` 2 | type User implements Node { 3 | id: ID! 4 | slug: String! 5 | email: String! 6 | name: String 7 | } 8 | 9 | type Query { 10 | users: [User!]! @requireAuth 11 | user(id: String!): User @requireAuth 12 | } 13 | 14 | input CreateUserInput { 15 | email: String! 16 | name: String 17 | } 18 | 19 | input UpdateUserInput { 20 | email: String 21 | name: String 22 | } 23 | 24 | type Mutation { 25 | createUser(input: CreateUserInput!): User! @requireAuth 26 | updateUser(id: String!, input: UpdateUserInput!): User! @requireAuth 27 | deleteUser(id: String!): User! @requireAuth 28 | } 29 | ` 30 | -------------------------------------------------------------------------------- /api/src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Once you are ready to add authentication to your application 3 | * you'll build out requireAuth() with real functionality. For 4 | * now we just return `true` so that the calls in services 5 | * have something to check against, simulating a logged 6 | * in user that is allowed to access that service. 7 | * 8 | * See https://redwoodjs.com/docs/authentication for more info. 9 | */ 10 | export const isAuthenticated = () => { 11 | return true 12 | } 13 | 14 | export const hasRole = ({ roles }) => { 15 | return roles !== undefined 16 | } 17 | 18 | // This is used by the redwood directive 19 | // in ./api/src/directives/requireAuth 20 | 21 | // Roles are passed in by the requireAuth directive if you have auth setup 22 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 23 | export const requireAuth = ({ roles }) => { 24 | return isAuthenticated() 25 | } 26 | -------------------------------------------------------------------------------- /api/src/lib/db.ts: -------------------------------------------------------------------------------- 1 | // See https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/constructor 2 | // for options. 3 | 4 | import { PrismaClient } from "@prisma/client" 5 | 6 | import { emitLogLevels, handlePrismaLogging } from "@redwoodjs/api/logger" 7 | 8 | import { logger } from "./logger" 9 | 10 | /* 11 | * Instance of the Prisma Client 12 | */ 13 | export const db = new PrismaClient({ 14 | log: emitLogLevels(["info", "warn", "error"]), 15 | }) 16 | 17 | handlePrismaLogging({ 18 | db, 19 | logger, 20 | logLevels: ["info", "warn", "error"], 21 | }) 22 | -------------------------------------------------------------------------------- /api/src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger } from "@redwoodjs/api/logger" 2 | 3 | /** 4 | * Creates a logger with RedwoodLoggerOptions 5 | * 6 | * These extend and override default LoggerOptions, 7 | * can define a destination like a file or other supported pino log transport stream, 8 | * and sets whether or not to show the logger configuration settings (defaults to false) 9 | * 10 | * @param RedwoodLoggerOptions 11 | * 12 | * RedwoodLoggerOptions have 13 | * @param {options} LoggerOptions - defines how to log, such as pretty printing, redaction, and format 14 | * @param {string | DestinationStream} destination - defines where to log, such as a transport stream or file 15 | * @param {boolean} showConfig - whether to display logger configuration on initialization 16 | */ 17 | export const logger = createLogger({}) 18 | -------------------------------------------------------------------------------- /api/src/services/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orta/redwood-object-identification/335ce11b67bc302d75145dc1e2db37bdaeee4736/api/src/services/.keep -------------------------------------------------------------------------------- /api/src/services/objectIdentification.ts: -------------------------------------------------------------------------------- 1 | import { deleteUser, user } from "./users/users" 2 | import type { Plugin } from "@envelop/types" 3 | 4 | const nodeTypes = { 5 | ":user": { 6 | type: "User", 7 | get: user, 8 | delete: deleteUser, 9 | }, 10 | } 11 | 12 | const keys = Object.keys(nodeTypes) 13 | 14 | export const node = (args: { id: string }) => { 15 | for (const key of keys) { 16 | if (args.id.endsWith(key)) { 17 | return nodeTypes[key].get({ id: args.id }) 18 | } 19 | } 20 | 21 | throw new Error(`Did not find a resolver for node with ${args.id}`) 22 | } 23 | 24 | export const deleteNode = (args) => { 25 | for (const key of keys) { 26 | if (args.id.endsWith(key)) { 27 | return nodeTypes[key].delete({ id: args.id }) 28 | } 29 | } 30 | throw new Error(`Did not find a resolver for deleteNode with ${args.id}`) 31 | } 32 | 33 | export const createNodeResolveEnvelopPlugin = (): Plugin => { 34 | return { 35 | onSchemaChange({ schema }) { 36 | const node: { resolveType?: (obj: { id: string }) => string } = schema.getType("Node") as unknown 37 | node.resolveType = (obj) => { 38 | for (const key of keys) { 39 | if (obj.id.endsWith(key)) { 40 | return nodeTypes[key].type 41 | } 42 | } 43 | throw new Error(`Did not find a resolver for deleteNode with ${args.id}`) 44 | } 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /api/src/services/users/users.scenarios.ts: -------------------------------------------------------------------------------- 1 | import type { Prisma } from "@prisma/client" 2 | 3 | export const standard = defineScenario({ 4 | user: { 5 | one: { data: { email: "String7803398" } }, 6 | two: { data: { email: "String9118803" } }, 7 | }, 8 | }) 9 | 10 | export type StandardScenario = typeof standard 11 | -------------------------------------------------------------------------------- /api/src/services/users/users.test.ts: -------------------------------------------------------------------------------- 1 | import { users, user, createUser, updateUser, deleteUser } from "./users" 2 | import type { StandardScenario } from "./users.scenarios" 3 | 4 | describe("users", () => { 5 | scenario("returns all users", async (scenario: StandardScenario) => { 6 | const result = await users() 7 | 8 | expect(result.length).toEqual(Object.keys(scenario.user).length) 9 | }) 10 | 11 | scenario("returns a single user", async (scenario: StandardScenario) => { 12 | const result = await user({ id: scenario.user.one.id }) 13 | 14 | expect(result).toEqual(scenario.user.one) 15 | }) 16 | 17 | scenario("creates a user", async () => { 18 | const result = await createUser({ 19 | input: { email: "String7075649" }, 20 | }) 21 | 22 | expect(result.email).toEqual("String7075649") 23 | }) 24 | 25 | scenario("updates a user", async (scenario: StandardScenario) => { 26 | const original = await user({ id: scenario.user.one.id }) 27 | const result = await updateUser({ 28 | id: original.id, 29 | input: { email: "String81914542" }, 30 | }) 31 | 32 | expect(result.email).toEqual("String81914542") 33 | }) 34 | 35 | scenario("deletes a user", async (scenario: StandardScenario) => { 36 | const original = await deleteUser({ id: scenario.user.one.id }) 37 | const result = await user({ id: original.id }) 38 | 39 | expect(result).toEqual(null) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /api/src/services/users/users.ts: -------------------------------------------------------------------------------- 1 | import type { Prisma } from "@prisma/client" 2 | import cuid from "cuid" 3 | import { db } from "src/lib/db" 4 | 5 | export const users = () => { 6 | return db.user.findMany() 7 | } 8 | 9 | export const user = async (args: { id: string }) => { 10 | // Allow looking up with the same function with either slug or id 11 | const query = args.id.length > 10 ? { id: args.id } : { slug: args.id } 12 | const user = await db.user.findUnique({ where: query }) 13 | 14 | return user 15 | } 16 | 17 | interface CreateUserArgs { 18 | input: Prisma.UserCreateInput 19 | } 20 | 21 | export const createUser = ({ input }: CreateUserArgs) => { 22 | input.id = cuid() + ":user" 23 | input.slug = cuid.slug() 24 | 25 | return db.user.create({ 26 | data: input, 27 | }) 28 | } 29 | 30 | export const updateUser = (args: { id: string; input: Prisma.UserUpdateInput }) => { 31 | return db.user.update({ 32 | data: args.input, 33 | where: { id: args.id }, 34 | }) 35 | } 36 | 37 | export const deleteUser = ({ id }: { id: string }) => { 38 | return db.user.delete({ 39 | where: { id }, 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "allowJs": true, 5 | "esModuleInterop": true, 6 | "target": "esnext", 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "baseUrl": "./", 10 | "rootDirs": [ 11 | "./src", 12 | "../.redwood/types/mirror/api/src" 13 | ], 14 | "paths": { 15 | "src/*": [ 16 | "./src/*", 17 | "../.redwood/types/mirror/api/src/*" 18 | ], 19 | "types/*": ["./types/*"], 20 | "@redwoodjs/testing": ["../node_modules/@redwoodjs/testing/api"] 21 | }, 22 | "typeRoots": [ 23 | "../node_modules/@types", 24 | "./node_modules/@types" 25 | ], 26 | "types": ["jest"], 27 | }, 28 | "include": [ 29 | "src", 30 | "../.redwood/types/includes/all-*", 31 | "../.redwood/types/includes/api-*", 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /graphql.config.js: -------------------------------------------------------------------------------- 1 | const { getPaths } = require('@redwoodjs/internal') 2 | 3 | module.exports = { 4 | schema: getPaths().generated.schema, 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": { 4 | "packages": [ 5 | "api", 6 | "web", 7 | "packages/*" 8 | ] 9 | }, 10 | "devDependencies": { 11 | "@redwoodjs/core": "0.39.4" 12 | }, 13 | "eslintConfig": { 14 | "extends": "@redwoodjs/eslint-config", 15 | "root": true 16 | }, 17 | "engines": { 18 | "node": ">=14.17 <=16.x", 19 | "yarn": ">=1.15 <2" 20 | }, 21 | "prisma": { 22 | "seed": "yarn rw exec seed" 23 | }, 24 | "prettier": { 25 | "printWidth": 140, 26 | "semi": false 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // https://prettier.io/docs/en/options.html 2 | /** @type {import('prettier').RequiredOptions} */ 3 | module.exports = { 4 | trailingComma: 'es5', 5 | bracketSpacing: true, 6 | tabWidth: 2, 7 | semi: false, 8 | singleQuote: true, 9 | arrowParens: 'always', 10 | overrides: [ 11 | { 12 | files: 'Routes.*', 13 | options: { 14 | printWidth: 999, 15 | }, 16 | }, 17 | ], 18 | } 19 | -------------------------------------------------------------------------------- /redwood.toml: -------------------------------------------------------------------------------- 1 | # This file contains the configuration settings for your Redwood app. 2 | # This file is also what makes your Redwood app a Redwood app. 3 | # If you remove it and try to run `yarn rw dev`, you'll get an error. 4 | # 5 | # For the full list of options, see the "App Configuration: redwood.toml" doc: 6 | # https://redwoodjs.com/docs/app-configuration-redwood-toml 7 | 8 | [web] 9 | title = "Redwood App" 10 | port = 8910 11 | apiUrl = "/.redwood/functions" # you can customise graphql and dbauth urls individually too: see https://redwoodjs.com/docs/app-configuration-redwood-toml#api-paths 12 | includeEnvironmentVariables = [] # any ENV vars that should be available to the web side, see https://redwoodjs.com/docs/environment-variables#web 13 | [api] 14 | port = 8911 15 | [browser] 16 | open = true 17 | -------------------------------------------------------------------------------- /scripts/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orta/redwood-object-identification/335ce11b67bc302d75145dc1e2db37bdaeee4736/scripts/.keep -------------------------------------------------------------------------------- /scripts/seed.ts: -------------------------------------------------------------------------------- 1 | import type { Prisma } from "@prisma/client" 2 | import { db } from "api/src/lib/db" 3 | import cuid from "cuid" 4 | 5 | export default async () => { 6 | type Data = Omit 7 | 8 | const data: Data[] = [ 9 | { name: "alice", email: "alice@example.com" }, 10 | { name: "bob", email: "bob@example.com" }, 11 | { 12 | name: "charlie", 13 | email: "charlie@example.com", 14 | }, 15 | { 16 | name: "danielle", 17 | email: "dani@example.com", 18 | }, 19 | { name: "eli", email: "eli@example.com" }, 20 | ] 21 | 22 | Promise.all( 23 | data.map(async (userExample) => { 24 | const record = await db.user.create({ 25 | data: { 26 | id: cuid() + ":user", 27 | slug: cuid.slug(), 28 | ...userExample, 29 | }, 30 | }) 31 | console.log(record) 32 | }) 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /web/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@redwoodjs/testing/config/jest/web') 2 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.0.0", 4 | "private": true, 5 | "browserslist": { 6 | "development": [ 7 | "last 1 version" 8 | ], 9 | "production": [ 10 | "defaults", 11 | "not IE 11", 12 | "not IE_Mob 11" 13 | ] 14 | }, 15 | "dependencies": { 16 | "@redwoodjs/forms": "0.39.4", 17 | "@redwoodjs/router": "0.39.4", 18 | "@redwoodjs/web": "0.39.4", 19 | "prop-types": "15.7.2", 20 | "react": "17.0.2", 21 | "react-dom": "17.0.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/public/README.md: -------------------------------------------------------------------------------- 1 | # Static Assets 2 | Use this folder to add static files directly to your app. All included files and folders will be copied directly into the `/dist` folder (created when Webpack builds for production). They will also be available during development when you run `yarn rw dev`. 3 | >Note: files will *not* hot reload while the development server is running. You'll need to manually stop/start to access file changes. 4 | 5 | ### Example Use 6 | A file like `favicon.png` will be copied to `/dist/favicon.png`. A folder containing a file such as `static-files/my-logo.jpg` will be copied to `/dist/static-files/my-logo.jpg`. These can be referenced in your code directly without any special handling, e.g. 7 | ``` 8 | 9 | ``` 10 | and 11 | ``` 12 | alt="Logo" /> 13 | ``` 14 | 15 | Behind the scenes, we are using Webpack's ["copy-webpack-plugin"](https://github.com/webpack-contrib/copy-webpack-plugin). 16 | 17 | ## Best Practices 18 | Because assets in this folder are bypassing the javascript module system, **this folder should be used sparingly** for assets such as favicons, robots.txt, manifests, libraries incompatible with Webpack, etc. 19 | 20 | In general, it's best to import files directly into a template, page, or component. This allows Webpack to include that file in the bundle, which ensures Webpack will correctly process and move assets into the distribution folder, providing error checks and correct paths along the way. 21 | 22 | ### Example Asset Import with Webpack 23 | Instead of handling our logo image as a static file per the example above, we can do the following: 24 | ``` 25 | import React from "react" 26 | import logo from "./my-logo.jpg" 27 | 28 | 29 | function Header() { 30 | return Logo 31 | } 32 | 33 | export default Header 34 | ``` 35 | 36 | Behind the scenes, we are using Webpack's ["file-loader"](https://webpack.js.org/loaders/file-loader/) and ["url-loader](https://webpack.js.org/loaders/url-loader/) (for files smaller than 10kb). 37 | -------------------------------------------------------------------------------- /web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orta/redwood-object-identification/335ce11b67bc302d75145dc1e2db37bdaeee4736/web/public/favicon.png -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { FatalErrorBoundary, RedwoodProvider } from "@redwoodjs/web" 2 | import { RedwoodApolloProvider } from "@redwoodjs/web/apollo" 3 | 4 | import FatalErrorPage from "src/pages/FatalErrorPage" 5 | import Routes from "src/Routes" 6 | 7 | import "./scaffold.css" 8 | import "./index.css" 9 | 10 | const App = () => ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | 20 | export default App 21 | -------------------------------------------------------------------------------- /web/src/Routes.tsx: -------------------------------------------------------------------------------- 1 | // In this file, all Page components from 'src/pages` are auto-imported. Nested 2 | // directories are supported, and should be uppercase. Each subdirectory will be 3 | // prepended onto the component name. 4 | // 5 | // Examples: 6 | // 7 | // 'src/pages/HomePage/HomePage.js' -> HomePage 8 | // 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage 9 | 10 | import { Set, Router, Route } from "@redwoodjs/router" 11 | import UsersLayout from "src/layouts/UsersLayout" 12 | 13 | const Routes = () => { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ) 25 | } 26 | 27 | export default Routes 28 | -------------------------------------------------------------------------------- /web/src/components/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orta/redwood-object-identification/335ce11b67bc302d75145dc1e2db37bdaeee4736/web/src/components/.keep -------------------------------------------------------------------------------- /web/src/components/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import { navigate, routes } from "@redwoodjs/router" 2 | import { useMutation } from "@redwoodjs/web" 3 | import { toast } from "@redwoodjs/web/dist/toast" 4 | 5 | const DELETE_NODE_MUTATION = gql` 6 | mutation DeleteNodeMutation($id: ID!) { 7 | deleteNode(id: $id) { 8 | id 9 | } 10 | } 11 | ` 12 | 13 | export const DeleteButton = (props: { id: string; displayName: string }) => { 14 | const [deleteUser] = useMutation(DELETE_NODE_MUTATION, { 15 | onCompleted: () => { 16 | toast.success(`${props.displayName} deleted`) 17 | navigate(routes.users()) 18 | }, 19 | onError: (error) => { 20 | toast.error(error.message) 21 | }, 22 | }) 23 | 24 | const onDeleteClick = () => { 25 | if (confirm(`Are you sure you want to delete ${props.displayName}?`)) { 26 | deleteUser({ variables: { id: props.id } }) 27 | } 28 | } 29 | return ( 30 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /web/src/components/User/EditUserCell/EditUserCell.tsx: -------------------------------------------------------------------------------- 1 | import type { EditUserById } from "types/graphql" 2 | 3 | import type { CellSuccessProps, CellFailureProps } from "@redwoodjs/web" 4 | import { useMutation } from "@redwoodjs/web" 5 | import { toast } from "@redwoodjs/web/toast" 6 | import { navigate, routes } from "@redwoodjs/router" 7 | 8 | import UserForm from "src/components/User/UserForm" 9 | 10 | export const QUERY = gql` 11 | query EditUserById($id: String!) { 12 | user: user(id: $id) { 13 | id 14 | slug 15 | email 16 | name 17 | } 18 | } 19 | ` 20 | const UPDATE_USER_MUTATION = gql` 21 | mutation UpdateUserMutation($id: String!, $input: UpdateUserInput!) { 22 | updateUser(id: $id, input: $input) { 23 | id 24 | slug 25 | email 26 | name 27 | } 28 | } 29 | ` 30 | 31 | export const Loading = () =>
Loading...
32 | 33 | export const Failure = ({ error }: CellFailureProps) =>
{error.message}
34 | 35 | export const Success = ({ user }: CellSuccessProps) => { 36 | const [updateUser, { loading, error }] = useMutation(UPDATE_USER_MUTATION, { 37 | onCompleted: () => { 38 | toast.success("User updated") 39 | navigate(routes.users()) 40 | }, 41 | onError: (error) => { 42 | toast.error(error.message) 43 | }, 44 | }) 45 | 46 | const onSave = (input, id) => { 47 | updateUser({ variables: { id, input } }) 48 | } 49 | 50 | return ( 51 |
52 |
53 |

Edit User {user.id}

54 |
55 |
56 | 57 |
58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /web/src/components/User/NewUser/NewUser.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from "@redwoodjs/web" 2 | import { toast } from "@redwoodjs/web/toast" 3 | import { navigate, routes } from "@redwoodjs/router" 4 | import UserForm from "src/components/User/UserForm" 5 | 6 | const CREATE_USER_MUTATION = gql` 7 | mutation CreateUserMutation($input: CreateUserInput!) { 8 | createUser(input: $input) { 9 | id 10 | } 11 | } 12 | ` 13 | 14 | const NewUser = () => { 15 | const [createUser, { loading, error }] = useMutation(CREATE_USER_MUTATION, { 16 | onCompleted: () => { 17 | toast.success("User created") 18 | navigate(routes.users()) 19 | }, 20 | onError: (error) => { 21 | toast.error(error.message) 22 | }, 23 | }) 24 | 25 | const onSave = (input) => { 26 | createUser({ variables: { input } }) 27 | } 28 | 29 | return ( 30 |
31 |
32 |

New User

33 |
34 |
35 | 36 |
37 |
38 | ) 39 | } 40 | 41 | export default NewUser 42 | -------------------------------------------------------------------------------- /web/src/components/User/User/User.tsx: -------------------------------------------------------------------------------- 1 | import { Link, routes } from "@redwoodjs/router" 2 | import { DeleteButton } from "src/components/DeleteButton" 3 | 4 | const User = ({ user }) => { 5 | return ( 6 | <> 7 |
8 |
9 |

User {user.id} Detail

10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
id{user.id}
slug{user.slug}
Email{user.email}
Name{user.name}
31 |
32 | 38 | 39 | ) 40 | } 41 | 42 | export default User 43 | -------------------------------------------------------------------------------- /web/src/components/User/UserCell/UserCell.tsx: -------------------------------------------------------------------------------- 1 | import type { FindUserById } from "types/graphql" 2 | import type { CellSuccessProps, CellFailureProps } from "@redwoodjs/web" 3 | 4 | import User from "src/components/User/User" 5 | 6 | export const QUERY = gql` 7 | query FindUserById($slug: String!) { 8 | user: user(id: $slug) { 9 | id 10 | slug 11 | email 12 | name 13 | } 14 | } 15 | ` 16 | 17 | export const Loading = () =>
Loading...
18 | 19 | export const Empty = () =>
User not found
20 | 21 | export const Failure = ({ error }: CellFailureProps) =>
{error.message}
22 | 23 | export const Success = ({ user }: CellSuccessProps) => { 24 | return 25 | } 26 | -------------------------------------------------------------------------------- /web/src/components/User/UserForm/UserForm.tsx: -------------------------------------------------------------------------------- 1 | import { Form, FormError, FieldError, Label, TextField, Submit } from "@redwoodjs/forms" 2 | 3 | const UserForm = (props) => { 4 | const onSubmit = (data) => { 5 | props.onSave(data, props?.user?.id) 6 | } 7 | 8 | return ( 9 |
10 |
11 | 17 | 18 | 21 | 28 | 29 | 30 | 33 | 34 | 35 | 36 |
37 | 38 | Save 39 | 40 |
41 | 42 |
43 | ) 44 | } 45 | 46 | export default UserForm 47 | -------------------------------------------------------------------------------- /web/src/components/User/Users/Users.tsx: -------------------------------------------------------------------------------- 1 | import { Link, routes } from "@redwoodjs/router" 2 | import { DeleteButton } from "src/components/DeleteButton" 3 | 4 | const MAX_STRING_LENGTH = 150 5 | 6 | const truncate = (text) => { 7 | let output = text 8 | if (text && text.length > MAX_STRING_LENGTH) { 9 | output = output.substring(0, MAX_STRING_LENGTH) + "..." 10 | } 11 | return output 12 | } 13 | 14 | const UsersList = ({ users }) => { 15 | return ( 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {users.map((user) => ( 29 | 30 | 31 | 32 | 33 | 34 | 53 | 54 | ))} 55 | 56 |
idslugEmailName 
{truncate(user.id)}{truncate(user.slug)}{truncate(user.email)}{truncate(user.name)} 35 | 52 |
57 |
58 | ) 59 | } 60 | 61 | export default UsersList 62 | -------------------------------------------------------------------------------- /web/src/components/User/UsersCell/UsersCell.tsx: -------------------------------------------------------------------------------- 1 | import type { FindUsers } from "types/graphql" 2 | import type { CellSuccessProps, CellFailureProps } from "@redwoodjs/web" 3 | 4 | import { Link, routes } from "@redwoodjs/router" 5 | 6 | import Users from "src/components/User/Users" 7 | 8 | export const QUERY = gql` 9 | query FindUsers { 10 | users { 11 | id 12 | slug 13 | email 14 | name 15 | } 16 | } 17 | ` 18 | 19 | export const Loading = () =>
Loading...
20 | 21 | export const Empty = () => { 22 | return ( 23 |
24 | {"No users yet. "} 25 | 26 | {"Create one?"} 27 | 28 |
29 | ) 30 | } 31 | 32 | export const Failure = ({ error }: CellFailureProps) =>
{error.message}
33 | 34 | export const Success = ({ users }: CellSuccessProps) => { 35 | return 36 | } 37 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orta/redwood-object-identification/335ce11b67bc302d75145dc1e2db37bdaeee4736/web/src/index.css -------------------------------------------------------------------------------- /web/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | <%= prerenderPlaceholder %> 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /web/src/layouts/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orta/redwood-object-identification/335ce11b67bc302d75145dc1e2db37bdaeee4736/web/src/layouts/.keep -------------------------------------------------------------------------------- /web/src/layouts/UsersLayout/UsersLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Link, routes } from "@redwoodjs/router" 2 | import { Toaster } from "@redwoodjs/web/toast" 3 | 4 | type UserLayoutProps = { 5 | children: React.ReactNode 6 | } 7 | 8 | const UsersLayout = ({ children }: UserLayoutProps) => { 9 | return ( 10 |
11 | 12 |
13 |

14 | 15 | Users 16 | 17 |

18 | 19 |
+
New User 20 | 21 |
22 |
{children}
23 |
24 | ) 25 | } 26 | 27 | export default UsersLayout 28 | -------------------------------------------------------------------------------- /web/src/pages/FatalErrorPage/FatalErrorPage.tsx: -------------------------------------------------------------------------------- 1 | // This page will be rendered when an error makes it all the way to the top of the 2 | // application without being handled by a Javascript catch statement or React error 3 | // boundary. 4 | // 5 | // You can modify this page as you wish, but it is important to keep things simple to 6 | // avoid the possibility that it will cause its own error. If it does, Redwood will 7 | // still render a generic error page, but your users will prefer something a bit more 8 | // thoughtful. =) 9 | 10 | export default () => ( 11 |
12 |