├── .gitignore ├── finished-application ├── backend │ ├── .npmrc │ ├── .vscode │ │ ├── extensions.json │ │ └── settings.json │ ├── access.ts │ ├── keystone.ts │ ├── lib │ │ ├── formatMoney.ts │ │ ├── mail.ts │ │ └── stripe.ts │ ├── mutations │ │ ├── .gitkeep │ │ ├── addToCart.ts │ │ ├── checkout.ts │ │ └── index.ts │ ├── package-lock.json │ ├── package.json │ ├── schemas │ │ ├── .gitkeep │ │ ├── CartItem.ts │ │ ├── Order.ts │ │ ├── OrderItem.ts │ │ ├── Product.ts │ │ ├── ProductImage.ts │ │ ├── Role.ts │ │ ├── User.ts │ │ └── fields.ts │ ├── seed-data │ │ ├── data.ts │ │ └── index.ts │ ├── tsconfig.json │ └── types.ts └── frontend │ ├── .npmrc │ ├── .vscode │ ├── extensions.json │ └── settings.json │ ├── __tests__ │ ├── CartCount.test.js │ ├── CreateProduct.test.js │ ├── Nav.test.js │ ├── Pagination.test.js │ ├── Product.test.js │ ├── RequestReset.test.js │ ├── Signup.test.js │ ├── SingleProduct.test.js │ ├── __snapshots__ │ │ ├── CartCount.test.js.snap │ │ ├── CreateProduct.test.js.snap │ │ ├── Nav.test.js.snap │ │ ├── Pagination.test.js.snap │ │ ├── Product.test.js.snap │ │ ├── RequestReset.test.js.snap │ │ ├── Signup.test.js.snap │ │ └── SingleProduct.test.js.snap │ ├── formatMoney.test.js │ └── sample.test.js │ ├── components │ ├── .gitkeep │ ├── AddToCart.js │ ├── Cart.js │ ├── CartCount.js │ ├── Checkout.js │ ├── CreateProduct.js │ ├── DeleteProduct.js │ ├── ErrorMessage.js │ ├── Header.js │ ├── Nav.js │ ├── Page.js │ ├── Pagination.js │ ├── PleaseSignIn.js │ ├── Product.js │ ├── Products.js │ ├── RemoveFromCart.js │ ├── RequestReset.js │ ├── Reset.js │ ├── Search.js │ ├── SignIn.js │ ├── SignOut.js │ ├── SignUp.js │ ├── SingleProduct.js │ ├── UpdateProduct.js │ ├── User.js │ └── styles │ │ ├── .gitkeep │ │ ├── CartStyles.js │ │ ├── CloseButton.js │ │ ├── DropDown.js │ │ ├── Form.js │ │ ├── ItemStyles.js │ │ ├── NavStyles.js │ │ ├── OrderItemStyles.js │ │ ├── OrderStyles.js │ │ ├── PaginationStyles.js │ │ ├── PriceTag.js │ │ ├── SickButton.js │ │ ├── Supreme.js │ │ ├── Table.js │ │ ├── Title.js │ │ └── nprogress.css │ ├── config.js │ ├── jest.setup.js │ ├── lib │ ├── .gitkeep │ ├── calcTotalPrice.js │ ├── cartState.js │ ├── formatMoney.js │ ├── paginationField.js │ ├── testUtils.js │ ├── useForm.js │ └── withData.js │ ├── package-lock.json │ ├── package.json │ ├── pages │ ├── .gitkeep │ ├── _app.js │ ├── _document.js │ ├── account.js │ ├── index.js │ ├── order │ │ └── [id].js │ ├── orders.js │ ├── product │ │ └── [id].js │ ├── products │ │ ├── [page].js │ │ └── index.js │ ├── reset.js │ ├── sell.js │ ├── signin.js │ └── update.js │ └── public │ └── static │ ├── favicon.png │ └── radnikanext-medium-webfont.woff2 ├── readme.md ├── sick-fits ├── backend │ ├── .npmrc │ ├── .vscode │ │ ├── extensions.json │ │ └── settings.json │ ├── lib │ │ └── formatMoney.ts │ ├── mutations │ │ └── .gitkeep │ ├── package-lock.json │ ├── package.json │ ├── sample.env │ ├── schemas │ │ └── .gitkeep │ ├── seed-data │ │ ├── data.ts │ │ └── index.ts │ ├── tsconfig.json │ └── types.ts └── frontend │ ├── .npmrc │ ├── .vscode │ ├── extensions.json │ └── settings.json │ ├── components │ ├── .gitkeep │ ├── ErrorMessage.js │ └── styles │ │ ├── .gitkeep │ │ ├── CartStyles.js │ │ ├── CloseButton.js │ │ ├── DropDown.js │ │ ├── Form.js │ │ ├── ItemStyles.js │ │ ├── NavStyles.js │ │ ├── OrderItemStyles.js │ │ ├── OrderStyles.js │ │ ├── PaginationStyles.js │ │ ├── PriceTag.js │ │ ├── SickButton.js │ │ ├── Supreme.js │ │ ├── Table.js │ │ ├── Title.js │ │ └── nprogress.css │ ├── config.js │ ├── jest.setup.js │ ├── lib │ ├── .gitkeep │ ├── testUtils.js │ └── withData.js │ ├── package-lock.json │ ├── package.json │ ├── pages │ └── .gitkeep │ └── public │ └── static │ ├── favicon.png │ └── radnikanext-medium-webfont.woff2 └── stepped-solutions ├── 10 └── _document.js ├── 12 └── keystone.ts ├── 14 └── User.ts ├── 15 └── keystone.ts ├── 16 ├── Product.ts └── keystone.ts ├── 17 ├── ProductImage.ts └── keystone.ts ├── 18 ├── Product.ts └── ProductImage.ts ├── 20 └── _app.js ├── 21 ├── Product.js ├── Products.js └── products(page, rename me to just products).js ├── 22 ├── Header.js └── Nav.js ├── 23 ├── CreateProduct.js └── useForm.js ├── 24 ├── CreateProduct.js └── useForm.js ├── 25 └── CreateProduct.js ├── 26 ├── CreateProduct.js └── Products.js ├── 28 ├── SingleProduct.js └── product │ └── [id].js ├── 29 ├── Product.js ├── UpdateProduct.js └── update.js ├── 30 └── useForm.js ├── 31 ├── DeleteProduct.js └── Product.js ├── 32 └── DeleteProduct.js ├── 33 ├── Pagination.js └── Products.js ├── 34 └── products │ ├── [page].js │ └── index.js ├── 35 ├── Products.js └── products │ └── index.js ├── 36 ├── paginationField.js └── withData.js ├── 37 ├── Nav.js ├── User.js └── signin.js ├── 38 └── SignIn.js ├── 39 └── SignOut.js ├── 40 ├── SignUp.js └── signin.js ├── 41 ├── RequestReset.js ├── keystone.ts └── signin.js ├── 42 ├── components │ └── Reset.js └── pages │ └── reset.js ├── 44 ├── keystone.ts └── mail.ts ├── 45 ├── Cart.js ├── Header.js ├── User.js └── calcTotalPrice.js ├── 46 ├── Cart.js ├── Header.js ├── Nav.js ├── _app.js └── cartState.js ├── 47 ├── keystone.ts └── mutations │ ├── .gitkeep │ ├── addToCart.ts │ └── index.ts ├── 48 ├── AddToCart.js └── Product.js ├── 49 ├── CartCount.js └── Nav.js ├── 50 ├── Cart.js └── RemoveFromCart.js ├── 51 └── RemoveFromCart.js ├── 52 ├── Nav.js └── Search.js ├── 53 └── Checkout.js ├── 54 └── Checkout.js ├── 55 ├── Order.ts ├── OrderItem.ts ├── User.ts └── keystone.ts ├── 56 ├── checkout.ts └── stripe.ts ├── 57 ├── backend │ └── checkout.ts └── frontend │ └── Checkout.js ├── 58 ├── OrderItem.ts └── checkout.ts ├── 59 ├── Cart.js ├── Checkout.js ├── Nav.js └── checkout.ts ├── 60 └── order │ └── [id].js ├── 61 └── orders.js ├── 63 ├── Role.ts ├── User.ts ├── fields.ts └── keystone.ts ├── 64 ├── Product.ts ├── access.ts └── keystone.ts ├── 65 └── access.ts ├── 66 ├── Product.ts └── access.ts ├── 67 └── Role.ts ├── 68 ├── CartItem.ts ├── Order.ts ├── OrderItem.ts └── access.ts ├── 69 ├── User.ts └── access.ts ├── 04 ├── account.js ├── index.js ├── orders.js ├── products.js └── sell.js ├── 05 ├── _app.js └── _document.js ├── 06 ├── Header.js ├── Nav.js └── Page.js ├── 07 └── Header.js ├── 08 └── Page.js ├── 09 ├── _app.js └── _document.js └── 71 - 83 (tests) └── __tests__ ├── CartCount.test.js ├── CreateProduct.test.js ├── Nav.test.js ├── Pagination.test.js ├── Product.test.js ├── RequestReset.test.js ├── Signup.test.js ├── SingleProduct.test.js ├── __snapshots__ ├── CartCount.test.js.snap ├── CreateProduct.test.js.snap ├── Nav.test.js.snap ├── Pagination.test.js.snap ├── Product.test.js.snap ├── RequestReset.test.js.snap ├── Signup.test.js.snap └── SingleProduct.test.js.snap ├── formatMoney.test.js └── sample.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | *.log 4 | haters/ 5 | .next/ 6 | .build/ 7 | layout.md 8 | variables.env 9 | *.env 10 | .keystone 11 | *.db 12 | -------------------------------------------------------------------------------- /finished-application/backend/.npmrc: -------------------------------------------------------------------------------- 1 | fund=false 2 | audit=false 3 | legacy-peer-deps=true 4 | -------------------------------------------------------------------------------- /finished-application/backend/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "wesbos.theme-cobalt2", 5 | "formulahendry.auto-rename-tag", 6 | "graphql.vscode-graphql", 7 | "styled-components.vscode-styled-components" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /finished-application/backend/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "titleBar.activeForeground": "#fff", 4 | "titleBar.inactiveForeground": "#ffffffcc", 5 | "titleBar.activeBackground": "#FF2C70", 6 | "titleBar.inactiveBackground": "#FF2C70CC" 7 | }, 8 | "editor.formatOnSave": true, 9 | "[javascript]": { 10 | "editor.formatOnSave": false 11 | }, 12 | "[javascriptreact]": { 13 | "editor.formatOnSave": false 14 | }, 15 | "eslint.alwaysShowStatus": true, 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll": true 18 | }, 19 | "prettier.disableLanguages": ["javascript", "javascriptreact"] 20 | } 21 | -------------------------------------------------------------------------------- /finished-application/backend/lib/formatMoney.ts: -------------------------------------------------------------------------------- 1 | const formatter = new Intl.NumberFormat('en-US', { 2 | style: 'currency', 3 | currency: 'USD', 4 | }); 5 | 6 | export default function formatMoney(cents: number) { 7 | const dollars = cents / 100; 8 | return formatter.format(dollars); 9 | } 10 | -------------------------------------------------------------------------------- /finished-application/backend/lib/mail.ts: -------------------------------------------------------------------------------- 1 | import { createTransport, getTestMessageUrl } from 'nodemailer'; 2 | 3 | const transport = createTransport({ 4 | host: process.env.MAIL_HOST, 5 | port: process.env.MAIL_PORT, 6 | auth: { 7 | user: process.env.MAIL_USER, 8 | pass: process.env.MAIL_PASS, 9 | }, 10 | }); 11 | 12 | function makeANiceEmail(text: string) { 13 | return ` 14 |
21 |

Hello There!

22 |

${text}

23 | 24 |

😘, Wes Bos

25 |
26 | `; 27 | } 28 | 29 | export interface MailResponse { 30 | accepted?: (string)[] | null; 31 | rejected?: (null)[] | null; 32 | envelopeTime: number; 33 | messageTime: number; 34 | messageSize: number; 35 | response: string; 36 | envelope: Envelope; 37 | messageId: string; 38 | } 39 | export interface Envelope { 40 | from: string; 41 | to?: (string)[] | null; 42 | } 43 | 44 | 45 | export async function sendPasswordResetEmail( 46 | resetToken: string, 47 | to: string 48 | ): Promise { 49 | // email the user a token 50 | const info = (await transport.sendMail({ 51 | to, 52 | from: 'wes@wesbos.com', 53 | subject: 'Your password reset token!', 54 | html: makeANiceEmail(`Your Password Reset Token is here! 55 | Click Here to reset 56 | `), 57 | })) as MailResponse; 58 | if(process.env.MAIL_USER.includes('ethereal.email')) { 59 | console.log(`💌 Message Sent! Preview it at ${getTestMessageUrl(info)}`); 60 | 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /finished-application/backend/lib/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | 3 | const stripeConfig = new Stripe(process.env.STRIPE_SECRET || '', { 4 | apiVersion: '2020-08-27', 5 | }); 6 | 7 | export default stripeConfig; 8 | -------------------------------------------------------------------------------- /finished-application/backend/mutations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wesbos/Advanced-React/64b8cd13a9b31feb47f12aed3e7aef5d0d5c8e9d/finished-application/backend/mutations/.gitkeep -------------------------------------------------------------------------------- /finished-application/backend/mutations/addToCart.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { KeystoneContext, SessionStore } from '@keystone-next/types'; 3 | import { CartItem } from '../schemas/CartItem'; 4 | import { Session } from '../types'; 5 | 6 | import { CartItemCreateInput } from '../.keystone/schema-types'; 7 | 8 | async function addToCart( 9 | root: any, 10 | { productId }: { productId: string }, 11 | context: KeystoneContext 12 | ): Promise { 13 | console.log('ADDING TO CART!'); 14 | // 1. Query the current user see if they are signed in 15 | const sesh = context.session as Session; 16 | if (!sesh.itemId) { 17 | throw new Error('You must be logged in to do this!'); 18 | } 19 | // 2. Query the current users cart 20 | const allCartItems = await context.lists.CartItem.findMany({ 21 | where: { user: { id: sesh.itemId }, product: { id: productId } }, 22 | resolveFields: 'id,quantity' 23 | }); 24 | 25 | const [existingCartItem] = allCartItems; 26 | if (existingCartItem) { 27 | console.log(existingCartItem) 28 | console.log( 29 | `There are already ${existingCartItem.quantity}, increment by 1!` 30 | ); 31 | // 3. See if the current item is in their cart 32 | // 4. if itis, increment by 1 33 | return await context.lists.CartItem.updateOne({ 34 | id: existingCartItem.id, 35 | data: { quantity: existingCartItem.quantity + 1 }, 36 | resolveFields: false, 37 | }); 38 | } 39 | // 4. if it isnt, create a new cart item! 40 | return await context.lists.CartItem.createOne({ 41 | data: { 42 | product: { connect: { id: productId }}, 43 | user: { connect: { id: sesh.itemId }}, 44 | }, 45 | resolveFields: false, 46 | }) 47 | } 48 | 49 | export default addToCart; 50 | -------------------------------------------------------------------------------- /finished-application/backend/mutations/index.ts: -------------------------------------------------------------------------------- 1 | import { graphQLSchemaExtension } from '@keystone-next/keystone/schema'; 2 | import addToCart from './addToCart'; 3 | import checkout from './checkout'; 4 | 5 | // make a fake graphql tagged template literal 6 | const graphql = String.raw; 7 | export const extendGraphqlSchema = graphQLSchemaExtension({ 8 | typeDefs: graphql` 9 | type Mutation { 10 | addToCart(productId: ID): CartItem 11 | checkout(token: String!): Order 12 | } 13 | `, 14 | resolvers: { 15 | Mutation: { 16 | addToCart, 17 | checkout, 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /finished-application/backend/schemas/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wesbos/Advanced-React/64b8cd13a9b31feb47f12aed3e7aef5d0d5c8e9d/finished-application/backend/schemas/.gitkeep -------------------------------------------------------------------------------- /finished-application/backend/schemas/CartItem.ts: -------------------------------------------------------------------------------- 1 | import { integer, select, text, relationship } from '@keystone-next/fields'; 2 | import { list } from '@keystone-next/keystone/schema'; 3 | import { rules, isSignedIn } from '../access'; 4 | 5 | export const CartItem = list({ 6 | access: { 7 | create: isSignedIn, 8 | read: rules.canOrder, 9 | update: rules.canOrder, 10 | delete: rules.canOrder, 11 | }, 12 | ui: { 13 | listView: { 14 | initialColumns: ['product', 'quantity', 'user'], 15 | }, 16 | }, 17 | fields: { 18 | // TODO: Custom Label in here 19 | quantity: integer({ 20 | defaultValue: 1, 21 | isRequired: true, 22 | }), 23 | product: relationship({ ref: 'Product' }), 24 | user: relationship({ ref: 'User.cart' }), 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /finished-application/backend/schemas/Order.ts: -------------------------------------------------------------------------------- 1 | import { 2 | integer, 3 | select, 4 | text, 5 | relationship, 6 | virtual, 7 | } from '@keystone-next/fields'; 8 | import { list } from '@keystone-next/keystone/schema'; 9 | import { isSignedIn, rules } from '../access'; 10 | import formatMoney from '../lib/formatMoney'; 11 | 12 | export const Order = list({ 13 | access: { 14 | create: isSignedIn, 15 | read: rules.canOrder, 16 | update: () => false, 17 | delete: () => false, 18 | }, 19 | fields: { 20 | label: virtual({ 21 | graphQLReturnType: 'String', 22 | resolver(item) { 23 | return `${formatMoney(item.total)}`; 24 | }, 25 | }), 26 | total: integer(), 27 | items: relationship({ ref: 'OrderItem.order', many: true }), 28 | user: relationship({ ref: 'User.orders' }), 29 | charge: text(), 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /finished-application/backend/schemas/OrderItem.ts: -------------------------------------------------------------------------------- 1 | import { integer, select, text, relationship } from '@keystone-next/fields'; 2 | import { list } from '@keystone-next/keystone/schema'; 3 | import { isSignedIn, rules } from '../access'; 4 | 5 | export const OrderItem = list({ 6 | access: { 7 | create: isSignedIn, 8 | read: rules.canManageOrderItems, 9 | update: () => false, 10 | delete: () => false, 11 | }, 12 | fields: { 13 | name: text({ isRequired: true }), 14 | description: text({ 15 | ui: { 16 | displayMode: 'textarea', 17 | }, 18 | }), 19 | photo: relationship({ 20 | ref: 'ProductImage', 21 | ui: { 22 | displayMode: 'cards', 23 | cardFields: ['image', 'altText'], 24 | inlineCreate: { fields: ['image', 'altText'] }, 25 | inlineEdit: { fields: ['image', 'altText'] }, 26 | }, 27 | }), 28 | price: integer(), 29 | quantity: integer(), 30 | order: relationship({ ref: 'Order.items' }), 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /finished-application/backend/schemas/Product.ts: -------------------------------------------------------------------------------- 1 | import { integer, select, text, relationship } from '@keystone-next/fields'; 2 | import { list } from '@keystone-next/keystone/schema'; 3 | import { rules, isSignedIn } from '../access'; 4 | 5 | export const Product = list({ 6 | access: { 7 | create: isSignedIn, 8 | read: rules.canReadProducts, 9 | update: rules.canManageProducts, 10 | delete: rules.canManageProducts, 11 | }, 12 | fields: { 13 | name: text({ isRequired: true }), 14 | description: text({ 15 | ui: { 16 | displayMode: 'textarea', 17 | }, 18 | }), 19 | photo: relationship({ 20 | ref: 'ProductImage.product', 21 | ui: { 22 | displayMode: 'cards', 23 | cardFields: ['image', 'altText'], 24 | inlineCreate: { fields: ['image', 'altText'] }, 25 | inlineEdit: { fields: ['image', 'altText'] }, 26 | }, 27 | }), 28 | status: select({ 29 | options: [ 30 | { label: 'Draft', value: 'DRAFT' }, 31 | { label: 'Available', value: 'AVAILABLE' }, 32 | { label: 'Unavailable', value: 'UNAVAILABLE' }, 33 | ], 34 | defaultValue: 'DRAFT', 35 | ui: { 36 | displayMode: 'segmented-control', 37 | createView: { fieldMode: 'hidden' }, 38 | }, 39 | }), 40 | price: integer(), 41 | user: relationship({ 42 | ref: 'User.products', 43 | defaultValue: ({ context }) => ({ 44 | connect: { id: context.session.itemId }, 45 | }), 46 | }), 47 | }, 48 | }); 49 | -------------------------------------------------------------------------------- /finished-application/backend/schemas/ProductImage.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { relationship, text } from '@keystone-next/fields'; 3 | import { list } from '@keystone-next/keystone/schema'; 4 | import { cloudinaryImage } from '@keystone-next/cloudinary'; 5 | import { isSignedIn, permissions } from '../access'; 6 | 7 | export const cloudinary = { 8 | cloudName: process.env.CLOUDINARY_CLOUD_NAME, 9 | apiKey: process.env.CLOUDINARY_KEY, 10 | apiSecret: process.env.CLOUDINARY_SECRET, 11 | folder: 'sickfits', 12 | }; 13 | 14 | export const ProductImage = list({ 15 | access: { 16 | create: isSignedIn, 17 | read: () => true, 18 | update: permissions.canManageProducts, 19 | delete: permissions.canManageProducts, 20 | }, 21 | fields: { 22 | image: cloudinaryImage({ 23 | cloudinary, 24 | label: 'Source', 25 | }), 26 | altText: text(), 27 | product: relationship({ ref: 'Product.photo' }), 28 | }, 29 | ui: { 30 | listView: { 31 | initialColumns: ['image', 'altText', 'product'], 32 | }, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /finished-application/backend/schemas/Role.ts: -------------------------------------------------------------------------------- 1 | import { relationship, text } from '@keystone-next/fields'; 2 | import { list } from '@keystone-next/keystone/schema'; 3 | import { permissions } from '../access'; 4 | import { permissionFields } from './fields'; 5 | 6 | export const Role = list({ 7 | access: { 8 | create: permissions.canManageRoles, 9 | read: permissions.canManageRoles, 10 | update: permissions.canManageRoles, 11 | delete: permissions.canManageRoles, 12 | }, 13 | ui: { 14 | hideCreate: (args) => !permissions.canManageRoles(args), 15 | hideDelete: (args) => !permissions.canManageRoles(args), 16 | isHidden: (args) => !permissions.canManageRoles(args), 17 | }, 18 | fields: { 19 | name: text({ isRequired: true }), 20 | ...permissionFields, 21 | assignedTo: relationship({ 22 | ref: 'User.role', // TODO: Add this to the User 23 | many: true, 24 | ui: { 25 | itemView: { fieldMode: 'read' }, 26 | }, 27 | }), 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /finished-application/backend/schemas/User.ts: -------------------------------------------------------------------------------- 1 | import { list } from '@keystone-next/keystone/schema'; 2 | import { text, password, relationship } from '@keystone-next/fields'; 3 | import { permissions, rules } from '../access'; 4 | 5 | export const User = list({ 6 | access: { 7 | create: () => true, 8 | read: rules.canManageUsers, 9 | update: rules.canManageUsers, 10 | // only people with the permission can delete themselves! 11 | // You can't delete yourself 12 | delete: permissions.canManageUsers, 13 | }, 14 | ui: { 15 | // hide the backend UI from regular users 16 | hideCreate: (args) => !permissions.canManageUsers(args), 17 | hideDelete: (args) => !permissions.canManageUsers(args), 18 | }, 19 | fields: { 20 | name: text({ isRequired: true }), 21 | email: text({ isRequired: true, isUnique: true }), 22 | password: password(), 23 | cart: relationship({ 24 | ref: 'CartItem.user', 25 | many: true, 26 | ui: { 27 | createView: { fieldMode: 'hidden' }, 28 | itemView: { fieldMode: 'read' }, 29 | }, 30 | }), 31 | orders: relationship({ ref: 'Order.user', many: true }), 32 | role: relationship({ 33 | ref: 'Role.assignedTo', 34 | access: { 35 | create: permissions.canManageUsers, 36 | update: permissions.canManageUsers, 37 | }, 38 | }), 39 | products: relationship({ 40 | ref: 'Product.user', 41 | many: true, 42 | }), 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /finished-application/backend/schemas/fields.ts: -------------------------------------------------------------------------------- 1 | import { checkbox } from '@keystone-next/fields'; 2 | 3 | export const permissionFields = { 4 | canManageProducts: checkbox({ 5 | defaultValue: false, 6 | label: 'User can Update and delete any product', 7 | }), 8 | canSeeOtherUsers: checkbox({ 9 | defaultValue: false, 10 | label: 'User can query other users', 11 | }), 12 | canManageUsers: checkbox({ 13 | defaultValue: false, 14 | label: 'User can Edit other users', 15 | }), 16 | canManageRoles: checkbox({ 17 | defaultValue: false, 18 | label: 'User can CRUD roles', 19 | }), 20 | canManageCart: checkbox({ 21 | defaultValue: false, 22 | label: 'User can see and manage cart and cart items', 23 | }), 24 | canManageOrders: checkbox({ 25 | defaultValue: false, 26 | label: 'User can see and manage orders', 27 | }), 28 | }; 29 | 30 | export type Permission = keyof typeof permissionFields; 31 | 32 | export const permissionsList: Permission[] = Object.keys( 33 | permissionFields 34 | ) as Permission[]; 35 | -------------------------------------------------------------------------------- /finished-application/backend/seed-data/index.ts: -------------------------------------------------------------------------------- 1 | import { products } from './data'; 2 | 3 | export async function insertSeedData(ks: any) { 4 | // Keystone API changed, so we need to check for both versions to get keystone 5 | const keystone = ks.keystone || ks; 6 | const adapter = keystone.adapters?.MongooseAdapter || keystone.adapter; 7 | 8 | console.log(`🌱 Inserting Seed Data: ${products.length} Products`); 9 | const { mongoose } = adapter; 10 | for (const product of products) { 11 | console.log(` 🛍️ Adding Product: ${product.name}`); 12 | const { _id } = await mongoose 13 | .model('ProductImage') 14 | .create({ image: product.photo, altText: product.description }); 15 | product.photo = _id; 16 | await mongoose.model('Product').create(product); 17 | } 18 | console.log(`✅ Seed Data Inserted: ${products.length} Products`); 19 | console.log(`👋 Please start the process with \`yarn dev\` or \`npm run dev\``); 20 | process.exit(); 21 | } 22 | -------------------------------------------------------------------------------- /finished-application/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /finished-application/backend/types.ts: -------------------------------------------------------------------------------- 1 | import { KeystoneGraphQLAPI, KeystoneListsAPI } from '@keystone-next/types'; 2 | 3 | // NOTE -- these types are commented out in master because they aren't generated by the build (yet) 4 | // To get full List and GraphQL API type support, uncomment them here and use them below 5 | // import type { KeystoneListsTypeInfo } from './.keystone/schema-types'; 6 | 7 | import type { Permission } from './schemas/fields'; 8 | export type { Permission } from './schemas/fields'; 9 | 10 | export type Session = { 11 | itemId: string; 12 | listKey: string; 13 | data: { 14 | name: string; 15 | role?: { 16 | id: string; 17 | name: string; 18 | } & { 19 | [key in Permission]: boolean; 20 | }; 21 | }; 22 | }; 23 | 24 | export type ListsAPI = KeystoneListsAPI; 25 | export type GraphqlAPI = KeystoneGraphQLAPI; 26 | 27 | export type AccessArgs = { 28 | session?: Session; 29 | item?: any; 30 | }; 31 | 32 | export type AccessControl = { 33 | [key: string]: (args: AccessArgs) => any; 34 | }; 35 | 36 | export type ListAccessArgs = { 37 | itemId?: string; 38 | session?: Session; 39 | }; 40 | -------------------------------------------------------------------------------- /finished-application/frontend/.npmrc: -------------------------------------------------------------------------------- 1 | fund=false 2 | audit=false 3 | legacy-peer-deps=true 4 | -------------------------------------------------------------------------------- /finished-application/frontend/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "wesbos.theme-cobalt2", 5 | "formulahendry.auto-rename-tag", 6 | "graphql.vscode-graphql", 7 | "styled-components.vscode-styled-components" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /finished-application/frontend/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "titleBar.activeForeground": "#000", 4 | "titleBar.inactiveForeground": "#000000CC", 5 | "titleBar.activeBackground": "#FFC600", 6 | "titleBar.inactiveBackground": "#FFC600CC" 7 | }, 8 | "editor.formatOnSave": true, 9 | "[javascript]": { 10 | "editor.formatOnSave": false 11 | }, 12 | "[javascriptreact]": { 13 | "editor.formatOnSave": false 14 | }, 15 | "eslint.alwaysShowStatus": true, 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll": true 18 | }, 19 | "prettier.disableLanguages": ["javascript", "javascriptreact"] 20 | } 21 | -------------------------------------------------------------------------------- /finished-application/frontend/__tests__/CartCount.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import wait from 'waait'; 3 | import CartCount from '../components/CartCount'; 4 | 5 | describe('', () => { 6 | it('Renders', () => { 7 | render(); 8 | }); 9 | it('Matches snapshot', () => { 10 | const { container } = render(); 11 | expect(container).toMatchSnapshot(); 12 | }); 13 | it('updates via props', async () => { 14 | const { container, rerender, debug } = render(); 15 | expect(container.textContent).toBe('11'); 16 | // expect(container).toHaveTextContent('11'); 17 | // Update the props 18 | rerender(); 19 | // wait for __ ms 20 | expect(container.textContent).toBe('1211'); 21 | await wait(400); 22 | expect(container.textContent).toBe('12'); 23 | expect(container).toMatchSnapshot(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /finished-application/frontend/__tests__/Product.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { MockedProvider } from '@apollo/react-testing'; 3 | import Product from '../components/Product'; 4 | import { fakeItem } from '../lib/testUtils'; 5 | 6 | const product = fakeItem(); 7 | 8 | describe('', () => { 9 | it('renders out the price tag and title', () => { 10 | const { container, debug } = render( 11 | 12 | 13 | 14 | ); 15 | const priceTag = screen.getByText('$50'); 16 | expect(priceTag).toBeInTheDocument(); 17 | const link = container.querySelector('a'); 18 | expect(link).toHaveAttribute('href', '/product/abc123'); 19 | expect(link).toHaveTextContent(product.name); 20 | }); 21 | 22 | it('Renders and matches the snapshot', () => { 23 | const { container, debug } = render( 24 | 25 | 26 | 27 | ); 28 | expect(container).toMatchSnapshot(); 29 | }); 30 | 31 | it('renders the image properly', () => { 32 | const { container, debug } = render( 33 | 34 | 35 | 36 | ); 37 | // grab the image 38 | const img = screen.getByAltText(product.name); 39 | expect(img).toBeInTheDocument(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /finished-application/frontend/__tests__/RequestReset.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { MockedProvider } from '@apollo/react-testing'; 3 | import userEvent from '@testing-library/user-event'; 4 | import RequestReset, { 5 | REQUEST_RESET_MUTATION, 6 | } from '../components/RequestReset'; 7 | 8 | const email = 'wesbos@gmail.com'; 9 | const mocks = [ 10 | { 11 | request: { 12 | query: REQUEST_RESET_MUTATION, 13 | variables: { email }, 14 | }, 15 | result: { 16 | data: { sendUserPasswordResetLink: null }, 17 | }, 18 | }, 19 | ]; 20 | 21 | describe('', () => { 22 | it('renders and matches snapshot', () => { 23 | const { container } = render( 24 | 25 | 26 | 27 | ); 28 | expect(container).toMatchSnapshot(); 29 | }); 30 | 31 | it('calls the mutation when submitted', async () => { 32 | const { container, debug } = render( 33 | 34 | 35 | 36 | ); 37 | // type into the email box 38 | userEvent.type(screen.getByPlaceholderText(/email/i), email); 39 | // click submit 40 | userEvent.click(screen.getByText(/Request Reset/)); 41 | const success = await screen.findByText(/Success/i); 42 | expect(success).toBeInTheDocument(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /finished-application/frontend/__tests__/SingleProduct.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { MockedProvider } from '@apollo/react-testing'; 3 | import SingleProduct, { SINGLE_ITEM_QUERY } from '../components/SingleProduct'; 4 | import { fakeItem } from '../lib/testUtils'; 5 | 6 | const product = fakeItem(); 7 | 8 | const mocks = [ 9 | { 10 | // When someone requests this query and variable combo 11 | request: { 12 | query: SINGLE_ITEM_QUERY, 13 | variables: { 14 | id: '123', 15 | }, 16 | }, 17 | // Return this data 18 | result: { 19 | data: { 20 | Product: product, 21 | }, 22 | }, 23 | }, 24 | ]; 25 | 26 | describe('', () => { 27 | it('renders with proper data', async () => { 28 | // We need to make some fake data 29 | const { container, debug } = render( 30 | 31 | 32 | 33 | ); 34 | // Wait for the test ID to show up 35 | await screen.findByTestId('singleProduct'); 36 | expect(container).toMatchSnapshot(); 37 | }); 38 | 39 | it('Errors out when an item is no found', async () => { 40 | const errorMock = [ 41 | { 42 | request: { 43 | query: SINGLE_ITEM_QUERY, 44 | variables: { 45 | id: '123', 46 | }, 47 | }, 48 | result: { 49 | errors: [{ message: 'Item not found!!!' }], 50 | }, 51 | }, 52 | ]; 53 | const { container, debug } = render( 54 | 55 | 56 | 57 | ); 58 | await screen.findByTestId('graphql-error'); 59 | expect(container).toHaveTextContent('Shoot!'); 60 | expect(container).toHaveTextContent('Item not found!!!'); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /finished-application/frontend/__tests__/__snapshots__/CartCount.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Matches snapshot 1`] = ` 4 |
5 | 8 |
9 |
12 | 11 13 |
14 |
15 |
16 |
17 | `; 18 | 19 | exports[` updates via props 1`] = ` 20 |
21 | 24 |
25 |
28 | 12 29 |
30 |
31 |
32 |
33 | `; 34 | -------------------------------------------------------------------------------- /finished-application/frontend/__tests__/__snapshots__/CreateProduct.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders and matches snapshot 1`] = ` 4 |
5 |
8 |
11 | 22 | 34 | 46 |