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/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/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/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/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 |
31 | Delete
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
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 | id
15 | {user.id}
16 |
17 |
18 | slug
19 | {user.slug}
20 |
21 |
22 | Email
23 | {user.email}
24 |
25 |
26 | Name
27 | {user.name}
28 |
29 |
30 |
31 |
32 |
33 |
34 | Edit
35 |
36 |
37 |
38 | >
39 | )
40 | }
41 |
42 | export default User
43 |
--------------------------------------------------------------------------------
/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.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 |
--------------------------------------------------------------------------------
/web/src/pages/NotFoundPage/NotFoundPage.tsx:
--------------------------------------------------------------------------------
1 | export default () => (
2 |
3 |
38 |
39 |
40 | 404 Page Not Found
41 |
42 |
43 |
44 | )
45 |
--------------------------------------------------------------------------------
/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 |
43 | )
44 | }
45 |
46 | export default UserForm
47 |
--------------------------------------------------------------------------------
/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/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 |
47 |
48 |
49 | Something went wrong
50 |
51 |
52 |
53 | )
54 |
--------------------------------------------------------------------------------
/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 | id
21 | slug
22 | Email
23 | Name
24 |
25 |
26 |
27 |
28 | {users.map((user) => (
29 |
30 | {truncate(user.id)}
31 | {truncate(user.slug)}
32 | {truncate(user.email)}
33 | {truncate(user.name)}
34 |
35 |
36 |
41 | Show
42 |
43 |
48 | Edit
49 |
50 |
51 |
52 |
53 |
54 | ))}
55 |
56 |
57 |
58 | )
59 | }
60 |
61 | export default UsersList
62 |
--------------------------------------------------------------------------------
/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
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/src/scaffold.css:
--------------------------------------------------------------------------------
1 | /*
2 | normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css
3 | */
4 |
5 | .rw-scaffold *,
6 | .rw-scaffold ::after,
7 | .rw-scaffold ::before {
8 | box-sizing: inherit;
9 | }
10 | .rw-scaffold main {
11 | color: #4a5568;
12 | display: block;
13 | }
14 | .rw-scaffold h1,
15 | .rw-scaffold h2 {
16 | margin: 0;
17 | }
18 | .rw-scaffold a {
19 | background-color: transparent;
20 | }
21 | .rw-scaffold ul {
22 | margin: 0;
23 | padding: 0;
24 | }
25 | .rw-scaffold input {
26 | font-family: inherit;
27 | font-size: 100%;
28 | overflow: visible;
29 | }
30 | .rw-scaffold input:-ms-input-placeholder {
31 | color: #a0aec0;
32 | }
33 | .rw-scaffold input::-ms-input-placeholder {
34 | color: #a0aec0;
35 | }
36 | .rw-scaffold input::placeholder {
37 | color: #a0aec0;
38 | }
39 | .rw-scaffold table {
40 | border-collapse: collapse;
41 | }
42 |
43 | /*
44 | Style
45 | */
46 |
47 | .rw-scaffold,
48 | .rw-toast {
49 | background-color: #fff;
50 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
51 | 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
52 | 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
53 | }
54 | .rw-header {
55 | display: flex;
56 | justify-content: space-between;
57 | padding: 1rem 2rem 1rem 2rem;
58 | }
59 | .rw-main {
60 | margin-left: 1rem;
61 | margin-right: 1rem;
62 | padding-bottom: 1rem;
63 | }
64 | .rw-segment {
65 | border-radius: 0.5rem;
66 | overflow: hidden;
67 | width: 100%;
68 | }
69 | .rw-segment-header {
70 | background-color: #e2e8f0;
71 | color: #4a5568;
72 | padding: 0.75rem 1rem;
73 | }
74 | .rw-segment-main {
75 | background-color: #f7fafc;
76 | padding: 1rem;
77 | }
78 | .rw-link {
79 | color: #4299e1;
80 | text-decoration: underline;
81 | }
82 | .rw-link:hover {
83 | color: #2b6cb0;
84 | }
85 | .rw-forgot-link {
86 | font-size: 0.75rem;
87 | color: #a0aec0;
88 | text-align: right;
89 | margin-top: 0.1rem;
90 | }
91 | .rw-forgot-link:hover {
92 | font-size: 0.75rem;
93 | color: #4299e1;
94 | }
95 | .rw-heading {
96 | font-weight: 600;
97 | }
98 | .rw-heading.rw-heading-primary {
99 | font-size: 1.25rem;
100 | }
101 | .rw-heading.rw-heading-secondary {
102 | font-size: 0.875rem;
103 | }
104 | .rw-heading .rw-link {
105 | color: #4a5568;
106 | text-decoration: none;
107 | }
108 | .rw-heading .rw-link:hover {
109 | color: #1a202c;
110 | text-decoration: underline;
111 | }
112 | .rw-cell-error {
113 | font-size: 90%;
114 | font-weight: 600;
115 | }
116 | .rw-form-wrapper {
117 | box-sizing: border-box;
118 | font-size: 0.875rem;
119 | margin-top: -1rem;
120 | }
121 | .rw-cell-error,
122 | .rw-form-error-wrapper {
123 | padding: 1rem;
124 | background-color: #fff5f5;
125 | color: #c53030;
126 | border-width: 1px;
127 | border-color: #feb2b2;
128 | border-radius: 0.25rem;
129 | margin: 1rem 0;
130 | }
131 | .rw-form-error-title {
132 | margin-top: 0;
133 | margin-bottom: 0;
134 | font-weight: 600;
135 | }
136 | .rw-form-error-list {
137 | margin-top: 0.5rem;
138 | list-style-type: disc;
139 | list-style-position: inside;
140 | }
141 | .rw-button {
142 | border: none;
143 | color: #718096;
144 | cursor: pointer;
145 | display: flex;
146 | justify-content: center;
147 | font-size: 0.75rem;
148 | font-weight: 600;
149 | padding: 0.25rem 1rem;
150 | text-transform: uppercase;
151 | text-decoration: none;
152 | letter-spacing: 0.025em;
153 | border-radius: 0.25rem;
154 | line-height: 2;
155 | border: 0;
156 | }
157 | .rw-button:hover {
158 | background-color: #718096;
159 | color: #fff;
160 | }
161 | .rw-button.rw-button-small {
162 | font-size: 0.75rem;
163 | border-radius: 0.125rem;
164 | padding: 0.25rem 0.5rem;
165 | line-height: inherit;
166 | }
167 | .rw-button.rw-button-green {
168 | background-color: #48bb78;
169 | color: #fff;
170 | }
171 | .rw-button.rw-button-green:hover {
172 | background-color: #38a169;
173 | color: #fff;
174 | }
175 | .rw-button.rw-button-blue {
176 | background-color: #3182ce;
177 | color: #fff;
178 | }
179 | .rw-button.rw-button-blue:hover {
180 | background-color: #2b6cb0;
181 | }
182 | .rw-button.rw-button-red {
183 | background-color: #e53e3e;
184 | color: #fff;
185 | }
186 | .rw-button.rw-button-red:hover {
187 | background-color: #c53030;
188 | }
189 | .rw-button-icon {
190 | font-size: 1.25rem;
191 | line-height: 1;
192 | margin-right: 0.25rem;
193 | }
194 | .rw-button-group {
195 | display: flex;
196 | justify-content: center;
197 | margin: 0.75rem 0.5rem;
198 | }
199 | .rw-button-group .rw-button {
200 | margin: 0 0.25rem;
201 | }
202 | .rw-form-wrapper .rw-button-group {
203 | margin-top: 2rem;
204 | margin-bottom: 0;
205 | }
206 | .rw-label {
207 | display: block;
208 | margin-top: 1.5rem;
209 | color: #4a5568;
210 | font-weight: 600;
211 | }
212 | .rw-label.rw-label-error {
213 | color: #c53030;
214 | }
215 | .rw-input {
216 | display: block;
217 | margin-top: 0.5rem;
218 | width: 100%;
219 | padding: 0.5rem;
220 | border-width: 1px;
221 | border-style: solid;
222 | border-color: #e2e8f0;
223 | color: #4a5568;
224 | border-radius: 0.25rem;
225 | outline: none;
226 | }
227 | .rw-input[type='checkbox'],
228 | .rw-input[type='radio'] {
229 | width: 1rem;
230 | margin-left: 0;
231 | }
232 | .rw-input:focus {
233 | border-color: #a0aec0;
234 | }
235 | .rw-input-error {
236 | border-color: #c53030;
237 | color: #c53030;
238 | }
239 |
240 | .rw-input-error:focus {
241 | outline: none;
242 | border-color: #c53030;
243 | box-shadow: 0 0 5px #c53030;
244 | }
245 |
246 | .rw-field-error {
247 | display: block;
248 | margin-top: 0.25rem;
249 | font-weight: 600;
250 | text-transform: uppercase;
251 | font-size: 0.75rem;
252 | color: #c53030;
253 | }
254 | .rw-table-wrapper-responsive {
255 | overflow-x: scroll;
256 | }
257 | .rw-table-wrapper-responsive .rw-table {
258 | min-width: 48rem;
259 | }
260 | .rw-table {
261 | table-layout: auto;
262 | width: 100%;
263 | font-size: 0.875rem;
264 | }
265 | .rw-table th,
266 | .rw-table td {
267 | padding: 0.75rem;
268 | }
269 | .rw-table thead tr {
270 | background-color: #e2e8f0;
271 | color: #4a5568;
272 | }
273 | .rw-table th {
274 | font-weight: 600;
275 | text-align: left;
276 | }
277 | .rw-table thead th {
278 | text-align: left;
279 | }
280 | .rw-table tbody th {
281 | text-align: right;
282 | }
283 | @media (min-width: 768px) {
284 | .rw-table tbody th {
285 | width: 20%;
286 | }
287 | }
288 | .rw-table tbody tr {
289 | background-color: #f7fafc;
290 | border-top-width: 1px;
291 | }
292 | .rw-table tbody tr:nth-child(even) {
293 | background-color: #fff;
294 | }
295 | .rw-table input {
296 | margin-left: 0;
297 | }
298 | .rw-table-actions {
299 | display: flex;
300 | justify-content: flex-end;
301 | align-items: center;
302 | height: 17px;
303 | padding-right: 0.25rem;
304 | }
305 | .rw-table-actions .rw-button {
306 | background-color: transparent;
307 | }
308 | .rw-table-actions .rw-button:hover {
309 | background-color: #718096;
310 | color: #fff;
311 | }
312 | .rw-table-actions .rw-button-blue {
313 | color: #3182ce;
314 | }
315 | .rw-table-actions .rw-button-blue:hover {
316 | background-color: #3182ce;
317 | color: #fff;
318 | }
319 | .rw-table-actions .rw-button-red {
320 | color: #e53e3e;
321 | }
322 | .rw-table-actions .rw-button-red:hover {
323 | background-color: #e53e3e;
324 | color: #fff;
325 | }
326 | .rw-text-center {
327 | text-align: center;
328 | }
329 | .rw-login-container {
330 | display: flex;
331 | align-items: center;
332 | justify-content: center;
333 | width: 24rem;
334 | margin: 4rem auto;
335 | flex-wrap: wrap;
336 | }
337 | .rw-login-container .rw-form-wrapper {
338 | width: 100%;
339 | }
340 | .rw-login-link {
341 | margin-top: 1rem;
342 | color: #4a5568;
343 | font-size: 90%;
344 | text-align: center;
345 | flex-basis: 100%;
346 | }
347 |
--------------------------------------------------------------------------------
/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 |
224 | Delete
225 |
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 |
--------------------------------------------------------------------------------