diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..28f1ba7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+.DS_Store
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a7760d1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,13 @@
+# Cypress End-to-End Testing - Getting Started Course Resources
+
+This repository contains the [slides](/slides) and [code snapshots](/code) for our [Cypress End-to-End Testing - Getting Started Course](https://acad.link/cypress-e2e).
+
+The [attachments](/attachments) folder contains lecture-specific attachments (i.e., course lectures are linking to those attachments).
+
+## Using the Code Snapshots
+
+The code snapshots are there to help you debug your code and find possible errors in your projects.
+
+If you can't reproduce the results shown in the course videos, take a look at the provided snapshots and compare that code to yours to detect possible error sources. You can also replace your code with ours (step-by-step and only temporarily) to narrow down the issue.
+
+The folders in the [/code directory](/code) map to the different course sections. Inside the section folders, you find multiple folders (i.e., multiple snapshots per section).
\ No newline at end of file
diff --git a/attachments/01-getting-started-starting-project.zip b/attachments/01-getting-started-starting-project.zip
new file mode 100644
index 0000000..0d29cb8
Binary files /dev/null and b/attachments/01-getting-started-starting-project.zip differ
diff --git a/attachments/02-basics-starting-project.zip b/attachments/02-basics-starting-project.zip
new file mode 100644
index 0000000..10b78bb
Binary files /dev/null and b/attachments/02-basics-starting-project.zip differ
diff --git a/attachments/03-diving-deeper-starting-project.zip b/attachments/03-diving-deeper-starting-project.zip
new file mode 100644
index 0000000..56af85e
Binary files /dev/null and b/attachments/03-diving-deeper-starting-project.zip differ
diff --git a/attachments/04-config-starting-project.zip b/attachments/04-config-starting-project.zip
new file mode 100644
index 0000000..505c0af
Binary files /dev/null and b/attachments/04-config-starting-project.zip differ
diff --git a/attachments/05-stubs-starting-project.zip b/attachments/05-stubs-starting-project.zip
new file mode 100644
index 0000000..1e6aadf
Binary files /dev/null and b/attachments/05-stubs-starting-project.zip differ
diff --git a/attachments/06-network-auth-starting-project.zip b/attachments/06-network-auth-starting-project.zip
new file mode 100644
index 0000000..5748590
Binary files /dev/null and b/attachments/06-network-auth-starting-project.zip differ
diff --git a/code/01 Getting Started/01 Starting Project/index.html b/code/01 Getting Started/01 Starting Project/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/01 Getting Started/01 Starting Project/package.json b/code/01 Getting Started/01 Starting Project/package.json
new file mode 100644
index 0000000..f8364db
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "getting-started",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-icons": "^4.7.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.27",
+ "@types/react-dom": "^18.0.10",
+ "@vitejs/plugin-react": "^3.1.0",
+ "vite": "^4.1.0"
+ }
+}
diff --git a/code/01 Getting Started/01 Starting Project/public/vite.svg b/code/01 Getting Started/01 Starting Project/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/01 Getting Started/01 Starting Project/src/App.jsx b/code/01 Getting Started/01 Starting Project/src/App.jsx
new file mode 100644
index 0000000..b408f71
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/src/App.jsx
@@ -0,0 +1,15 @@
+import CourseGoals from './components/CourseGoals';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/01 Getting Started/01 Starting Project/src/assets/react.svg b/code/01 Getting Started/01 Starting Project/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/01 Getting Started/01 Starting Project/src/components/CourseGoal.jsx b/code/01 Getting Started/01 Starting Project/src/components/CourseGoal.jsx
new file mode 100644
index 0000000..8a3b750
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/src/components/CourseGoal.jsx
@@ -0,0 +1,12 @@
+import classes from './CourseGoal.module.css';
+
+function CourseGoal({ icon, text }) {
+ return (
+
+ {icon}
+ {text}
+
+ );
+}
+
+export default CourseGoal;
diff --git a/code/01 Getting Started/01 Starting Project/src/components/CourseGoal.module.css b/code/01 Getting Started/01 Starting Project/src/components/CourseGoal.module.css
new file mode 100644
index 0000000..3d1d0ef
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/src/components/CourseGoal.module.css
@@ -0,0 +1,26 @@
+.goal {
+ margin: 2rem 0;
+ padding: 2rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ align-items: center;
+ background-color: var(--gray-1000);
+ border-radius: 6px;
+ border: 1px solid var(--indigo-300);
+ text-align: center;
+ color: var(--indigo-200);
+ position: relative;
+}
+
+.icon {
+ background-color: var(--indigo-500);
+ width: 3rem;
+ height: 3rem;
+ border-radius: 50%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: absolute;
+ top: -1.55rem;
+}
diff --git a/code/01 Getting Started/01 Starting Project/src/components/CourseGoals.jsx b/code/01 Getting Started/01 Starting Project/src/components/CourseGoals.jsx
new file mode 100644
index 0000000..952e9fe
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/src/components/CourseGoals.jsx
@@ -0,0 +1,50 @@
+import {
+ GrInstall,
+ GrEdit,
+ GrTerminal,
+ GrResources,
+ GrUserExpert,
+ GrKey,
+} from 'react-icons/gr';
+
+import CourseGoal from './CourseGoal';
+import classes from './CourseGoals.module.css';
+
+const GOALS = [
+ {
+ icon: ,
+ text: 'Learn how to install & start Cypress',
+ },
+ {
+ icon: ,
+ text: 'Learn how to write tests with Cypress',
+ },
+ {
+ icon: ,
+ text: 'Understand the core Cypress features & commands',
+ },
+ {
+ icon: ,
+ text: 'Customize & configure Cypress for your requirements',
+ },
+ {
+ icon: ,
+ text: 'Learn how to write good tests & follow best practices',
+ },
+ {
+ icon: ,
+ text: 'Dive into more complex problems - e.g., user authentication testing',
+ },
+];
+
+function CourseGoals() {
+ return (
+
+ {GOALS.map((goal) => (
+
+ ))}
+
+ );
+}
+
+export default CourseGoals;
diff --git a/code/01 Getting Started/01 Starting Project/src/components/CourseGoals.module.css b/code/01 Getting Started/01 Starting Project/src/components/CourseGoals.module.css
new file mode 100644
index 0000000..d01cc44
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/src/components/CourseGoals.module.css
@@ -0,0 +1,7 @@
+.goals {
+ max-width: 60rem;
+ margin: 3rem auto;
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
+}
\ No newline at end of file
diff --git a/code/01 Getting Started/01 Starting Project/src/components/Header.jsx b/code/01 Getting Started/01 Starting Project/src/components/Header.jsx
new file mode 100644
index 0000000..42ca0de
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/src/components/Header.jsx
@@ -0,0 +1,10 @@
+function Header() {
+ return (
+
+
+ Getting Started with Cypress
+
+ );
+}
+
+export default Header;
diff --git a/code/01 Getting Started/01 Starting Project/src/index.css b/code/01 Getting Started/01 Starting Project/src/index.css
new file mode 100644
index 0000000..cdc787a
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/src/index.css
@@ -0,0 +1,70 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ --gray-100: #f9f9f9;
+ --gray-200: #d8d8d8;
+ --gray-300: #c4c1c1;
+ --gray-400: #aeadad;
+ --gray-500: #818080;
+ --gray-600: #6c6c6c;
+ --gray-700: #5c5b5b;
+ --gray-800: #403f3f;
+ --gray-900: #2c2b2b;
+ --gray-1000: #1a1a1a;
+
+ --indigo-100: #e8e9ff;
+ --indigo-200: #c7c9ff;
+ --indigo-300: #a6a9ff;
+ --indigo-400: #858aff;
+ --indigo-500: #646cff;
+ --indigo-600: #535bf2;
+ --indigo-700: #424ae6;
+ --indigo-800: #3239da;
+ --indigo-900: #2228ce;
+
+ --pink-100: #ffe8f0;
+ --pink-200: #ffcfe3;
+ --pink-300: #ffb5d6;
+ --pink-400: #ff9cc9;
+ --pink-500: #ff82bc;
+ --pink-600: #f26ba2;
+ --pink-700: #e65f88;
+ --pink-800: #da537e;
+ --pink-900: #ce4764;
+
+ color-scheme: light dark;
+ color: var(--gray-100);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ height: 100vh;
+ background: linear-gradient(180deg, var(--gray-1000), var(--gray-900));
+}
+
+h1 {
+ margin: 6rem 0;
+ font-size: 3.2em;
+ line-height: 1.1;
+ background: linear-gradient(90deg, var(--indigo-600), var(--pink-800));
+ background-clip: text;
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ text-align: center;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
\ No newline at end of file
diff --git a/code/01 Getting Started/01 Starting Project/src/main.jsx b/code/01 Getting Started/01 Starting Project/src/main.jsx
new file mode 100644
index 0000000..5cc5991
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/code/01 Getting Started/01 Starting Project/vite.config.js b/code/01 Getting Started/01 Starting Project/vite.config.js
new file mode 100644
index 0000000..5a33944
--- /dev/null
+++ b/code/01 Getting Started/01 Starting Project/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/code/01 Getting Started/02 Finished First Test/cypress.config.js b/code/01 Getting Started/02 Finished First Test/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/01 Getting Started/02 Finished First Test/cypress/e2e/first-test.cy.js b/code/01 Getting Started/02 Finished First Test/cypress/e2e/first-test.cy.js
new file mode 100644
index 0000000..ba9b4d6
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/cypress/e2e/first-test.cy.js
@@ -0,0 +1,6 @@
+describe('template spec', () => {
+ it('passes', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('li').should('have.length', 6);
+ });
+});
diff --git a/code/01 Getting Started/02 Finished First Test/cypress/fixtures/example.json b/code/01 Getting Started/02 Finished First Test/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/01 Getting Started/02 Finished First Test/cypress/support/commands.js b/code/01 Getting Started/02 Finished First Test/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/01 Getting Started/02 Finished First Test/cypress/support/e2e.js b/code/01 Getting Started/02 Finished First Test/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/01 Getting Started/02 Finished First Test/index.html b/code/01 Getting Started/02 Finished First Test/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/01 Getting Started/02 Finished First Test/package.json b/code/01 Getting Started/02 Finished First Test/package.json
new file mode 100644
index 0000000..8a46db2
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "getting-started",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-icons": "^4.7.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.27",
+ "@types/react-dom": "^18.0.10",
+ "@vitejs/plugin-react": "^3.1.0",
+ "vite": "^4.1.0"
+ }
+}
diff --git a/code/01 Getting Started/02 Finished First Test/public/vite.svg b/code/01 Getting Started/02 Finished First Test/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/01 Getting Started/02 Finished First Test/src/App.jsx b/code/01 Getting Started/02 Finished First Test/src/App.jsx
new file mode 100644
index 0000000..b408f71
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/src/App.jsx
@@ -0,0 +1,15 @@
+import CourseGoals from './components/CourseGoals';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/01 Getting Started/02 Finished First Test/src/components/CourseGoal.jsx b/code/01 Getting Started/02 Finished First Test/src/components/CourseGoal.jsx
new file mode 100644
index 0000000..8a3b750
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/src/components/CourseGoal.jsx
@@ -0,0 +1,12 @@
+import classes from './CourseGoal.module.css';
+
+function CourseGoal({ icon, text }) {
+ return (
+
+ {icon}
+ {text}
+
+ );
+}
+
+export default CourseGoal;
diff --git a/code/01 Getting Started/02 Finished First Test/src/components/CourseGoal.module.css b/code/01 Getting Started/02 Finished First Test/src/components/CourseGoal.module.css
new file mode 100644
index 0000000..3d1d0ef
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/src/components/CourseGoal.module.css
@@ -0,0 +1,26 @@
+.goal {
+ margin: 2rem 0;
+ padding: 2rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ align-items: center;
+ background-color: var(--gray-1000);
+ border-radius: 6px;
+ border: 1px solid var(--indigo-300);
+ text-align: center;
+ color: var(--indigo-200);
+ position: relative;
+}
+
+.icon {
+ background-color: var(--indigo-500);
+ width: 3rem;
+ height: 3rem;
+ border-radius: 50%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: absolute;
+ top: -1.55rem;
+}
diff --git a/code/01 Getting Started/02 Finished First Test/src/components/CourseGoals.jsx b/code/01 Getting Started/02 Finished First Test/src/components/CourseGoals.jsx
new file mode 100644
index 0000000..952e9fe
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/src/components/CourseGoals.jsx
@@ -0,0 +1,50 @@
+import {
+ GrInstall,
+ GrEdit,
+ GrTerminal,
+ GrResources,
+ GrUserExpert,
+ GrKey,
+} from 'react-icons/gr';
+
+import CourseGoal from './CourseGoal';
+import classes from './CourseGoals.module.css';
+
+const GOALS = [
+ {
+ icon: ,
+ text: 'Learn how to install & start Cypress',
+ },
+ {
+ icon: ,
+ text: 'Learn how to write tests with Cypress',
+ },
+ {
+ icon: ,
+ text: 'Understand the core Cypress features & commands',
+ },
+ {
+ icon: ,
+ text: 'Customize & configure Cypress for your requirements',
+ },
+ {
+ icon: ,
+ text: 'Learn how to write good tests & follow best practices',
+ },
+ {
+ icon: ,
+ text: 'Dive into more complex problems - e.g., user authentication testing',
+ },
+];
+
+function CourseGoals() {
+ return (
+
+ {GOALS.map((goal) => (
+
+ ))}
+
+ );
+}
+
+export default CourseGoals;
diff --git a/code/01 Getting Started/02 Finished First Test/src/components/CourseGoals.module.css b/code/01 Getting Started/02 Finished First Test/src/components/CourseGoals.module.css
new file mode 100644
index 0000000..d01cc44
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/src/components/CourseGoals.module.css
@@ -0,0 +1,7 @@
+.goals {
+ max-width: 60rem;
+ margin: 3rem auto;
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
+}
\ No newline at end of file
diff --git a/code/01 Getting Started/02 Finished First Test/src/components/Header.jsx b/code/01 Getting Started/02 Finished First Test/src/components/Header.jsx
new file mode 100644
index 0000000..42ca0de
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/src/components/Header.jsx
@@ -0,0 +1,10 @@
+function Header() {
+ return (
+
+
+ Getting Started with Cypress
+
+ );
+}
+
+export default Header;
diff --git a/code/01 Getting Started/02 Finished First Test/src/index.css b/code/01 Getting Started/02 Finished First Test/src/index.css
new file mode 100644
index 0000000..cdc787a
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/src/index.css
@@ -0,0 +1,70 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ --gray-100: #f9f9f9;
+ --gray-200: #d8d8d8;
+ --gray-300: #c4c1c1;
+ --gray-400: #aeadad;
+ --gray-500: #818080;
+ --gray-600: #6c6c6c;
+ --gray-700: #5c5b5b;
+ --gray-800: #403f3f;
+ --gray-900: #2c2b2b;
+ --gray-1000: #1a1a1a;
+
+ --indigo-100: #e8e9ff;
+ --indigo-200: #c7c9ff;
+ --indigo-300: #a6a9ff;
+ --indigo-400: #858aff;
+ --indigo-500: #646cff;
+ --indigo-600: #535bf2;
+ --indigo-700: #424ae6;
+ --indigo-800: #3239da;
+ --indigo-900: #2228ce;
+
+ --pink-100: #ffe8f0;
+ --pink-200: #ffcfe3;
+ --pink-300: #ffb5d6;
+ --pink-400: #ff9cc9;
+ --pink-500: #ff82bc;
+ --pink-600: #f26ba2;
+ --pink-700: #e65f88;
+ --pink-800: #da537e;
+ --pink-900: #ce4764;
+
+ color-scheme: light dark;
+ color: var(--gray-100);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ height: 100vh;
+ background: linear-gradient(180deg, var(--gray-1000), var(--gray-900));
+}
+
+h1 {
+ margin: 6rem 0;
+ font-size: 3.2em;
+ line-height: 1.1;
+ background: linear-gradient(90deg, var(--indigo-600), var(--pink-800));
+ background-clip: text;
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ text-align: center;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
\ No newline at end of file
diff --git a/code/01 Getting Started/02 Finished First Test/src/main.jsx b/code/01 Getting Started/02 Finished First Test/src/main.jsx
new file mode 100644
index 0000000..5cc5991
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/code/01 Getting Started/02 Finished First Test/vite.config.js b/code/01 Getting Started/02 Finished First Test/vite.config.js
new file mode 100644
index 0000000..5a33944
--- /dev/null
+++ b/code/01 Getting Started/02 Finished First Test/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/code/02 Basics/01 Starting Project/index.html b/code/02 Basics/01 Starting Project/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/02 Basics/01 Starting Project/package.json b/code/02 Basics/01 Starting Project/package.json
new file mode 100644
index 0000000..6a0a0d9
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "cypress-basics",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.27",
+ "@types/react-dom": "^18.0.10",
+ "@vitejs/plugin-react": "^3.1.0",
+ "vite": "^4.1.0"
+ }
+}
diff --git a/code/02 Basics/01 Starting Project/public/vite.svg b/code/02 Basics/01 Starting Project/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/02 Basics/01 Starting Project/src/App.jsx b/code/02 Basics/01 Starting Project/src/App.jsx
new file mode 100644
index 0000000..43955a9
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/App.jsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import Header from './components/Header';
+import Modal from './components/Modal';
+
+import NewTask from './components/NewTask';
+import TaskControl from './components/TaskControl';
+import TaskList from './components/TaskList';
+
+function App() {
+ const [isAddingTask, setIsAddingTask] = useState(false);
+ const [tasks, setTasks] = useState([]);
+ const [appliedFilter, setAppliedFilter] = useState('all');
+
+ const displayedTasks = tasks.filter((task) => {
+ if (appliedFilter === 'all') {
+ return true;
+ }
+ return task.category === appliedFilter;
+ });
+
+ function startAddTaskHandler() {
+ setIsAddingTask(true);
+ }
+
+ function cancelAddTaskHandler() {
+ setIsAddingTask(false);
+ }
+
+ function addTaskHandler(taskData) {
+ setTasks((prevTasks) => {
+ return [
+ ...prevTasks,
+ {
+ id: Math.random().toString(),
+ ...taskData,
+ },
+ ];
+ });
+ setIsAddingTask(false);
+ }
+
+ function setFilterHandler(category) {
+ setAppliedFilter(category);
+ }
+
+ return (
+ <>
+ {isAddingTask && (
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/02 Basics/01 Starting Project/src/assets/logo.png b/code/02 Basics/01 Starting Project/src/assets/logo.png
new file mode 100644
index 0000000..2a8d015
Binary files /dev/null and b/code/02 Basics/01 Starting Project/src/assets/logo.png differ
diff --git a/code/02 Basics/01 Starting Project/src/components/Filter.jsx b/code/02 Basics/01 Starting Project/src/components/Filter.jsx
new file mode 100644
index 0000000..b0510e6
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/Filter.jsx
@@ -0,0 +1,17 @@
+function Filter({ onFilterChange }) {
+ function filterChangeHandler(event) {
+ onFilterChange(event.target.value);
+ }
+
+ return (
+
+ All
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+ );
+}
+
+export default Filter;
diff --git a/code/02 Basics/01 Starting Project/src/components/Header.css b/code/02 Basics/01 Starting Project/src/components/Header.css
new file mode 100644
index 0000000..2ba4a37
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/Header.css
@@ -0,0 +1,12 @@
+.main-header {
+ margin: 3rem auto;
+ text-align: center;
+ color: var(--color-gray-400);
+}
+
+.main-header img {
+ width: 7rem;
+ height: 7rem;
+ object-fit: contain;
+ transform: rotateZ(10deg);
+}
\ No newline at end of file
diff --git a/code/02 Basics/01 Starting Project/src/components/Header.jsx b/code/02 Basics/01 Starting Project/src/components/Header.jsx
new file mode 100644
index 0000000..f2165e1
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/Header.jsx
@@ -0,0 +1,13 @@
+import './Header.css';
+import logo from '../assets/logo.png';
+
+function Header() {
+ return (
+
+
+ React Tasks
+
+ );
+}
+
+export default Header;
diff --git a/code/02 Basics/01 Starting Project/src/components/Modal.css b/code/02 Basics/01 Starting Project/src/components/Modal.css
new file mode 100644
index 0000000..4c57acd
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/Modal.css
@@ -0,0 +1,24 @@
+.backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 1;
+}
+
+.modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ margin: 0;
+ padding: 2rem;
+ transform: translate(-50%, -50%);
+ width: 40rem;
+ background-color: var(--color-gray-800);
+ border: none;
+ border-radius: 4px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+}
diff --git a/code/02 Basics/01 Starting Project/src/components/Modal.jsx b/code/02 Basics/01 Starting Project/src/components/Modal.jsx
new file mode 100644
index 0000000..7347c66
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/Modal.jsx
@@ -0,0 +1,14 @@
+import './Modal.css';
+
+function Modal({ children, onClose }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/02 Basics/01 Starting Project/src/components/NewTask.css b/code/02 Basics/01 Starting Project/src/components/NewTask.css
new file mode 100644
index 0000000..e9a37e7
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/NewTask.css
@@ -0,0 +1,62 @@
+#new-task-form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+#new-task-form input,
+#new-task-form textarea {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+#new-task-form select {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+#new-task-form button {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-primary-500);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#new-task-form button:hover {
+ background-color: var(--color-primary-600);
+}
+
+#new-task-form button[type="button"] {
+ background-color: transparent;
+ color: var(--color-gray-200);
+}
+
+#new-task-form button[type="button"]:hover {
+ background-color: var(--color-gray-700);
+}
+
+
+
+.error-message {
+ color: var(--color-primary-300);
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+}
diff --git a/code/02 Basics/01 Starting Project/src/components/NewTask.jsx b/code/02 Basics/01 Starting Project/src/components/NewTask.jsx
new file mode 100644
index 0000000..17f1540
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/NewTask.jsx
@@ -0,0 +1,67 @@
+import { useRef, useState } from 'react';
+
+import './NewTask.css';
+
+function NewTask({ onAddTask, onCancel }) {
+ const titleRef = useRef();
+ const summaryRef = useRef();
+ const categoryRef = useRef();
+
+ const [formInvalid, setFormInvalid] = useState(false);
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ const enteredTitle = titleRef.current.value;
+ const enteredSummary = summaryRef.current.value;
+ const chosenCategory = categoryRef.current.value;
+
+ if (
+ enteredTitle.trim().length === 0 ||
+ enteredSummary.trim().length === 0
+ ) {
+ setFormInvalid(true);
+ return;
+ }
+
+ const taskData = {
+ title: enteredTitle,
+ summary: enteredSummary,
+ category: chosenCategory,
+ };
+ onAddTask(taskData);
+ }
+
+ return (
+
+ );
+}
+
+export default NewTask;
diff --git a/code/02 Basics/01 Starting Project/src/components/Task.css b/code/02 Basics/01 Starting Project/src/components/Task.css
new file mode 100644
index 0000000..ee3c6f3
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/Task.css
@@ -0,0 +1,26 @@
+.task {
+ display: flex;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 1rem;
+ border: 1px solid var(--color-gray-600);
+ background-color: var(--color-gray-700);
+ border-radius: 4px;
+}
+
+.task-category {
+ font-size: 1.25rem;
+}
+
+.task h2 {
+ margin: 0;
+ color: var(--color-gray-300);
+ font-size: 1rem;
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.task p {
+ margin: 0;
+ color: var(--color-gray-200);
+}
\ No newline at end of file
diff --git a/code/02 Basics/01 Starting Project/src/components/Task.jsx b/code/02 Basics/01 Starting Project/src/components/Task.jsx
new file mode 100644
index 0000000..9867dab
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/Task.jsx
@@ -0,0 +1,22 @@
+import './Task.css';
+
+const CATEGORY_ICONS = {
+ urgent: '🚨',
+ important: '🔴',
+ moderate: '🔵',
+ low: '🟢',
+};
+
+function Task({ category, title, summary }) {
+ return (
+
+ {CATEGORY_ICONS[category]}
+
+
+ );
+}
+
+export default Task;
diff --git a/code/02 Basics/01 Starting Project/src/components/TaskControl.css b/code/02 Basics/01 Starting Project/src/components/TaskControl.css
new file mode 100644
index 0000000..c334096
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/TaskControl.css
@@ -0,0 +1,28 @@
+#task-control {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+#task-control button {
+ font: inherit;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-gray-800);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#task-control button:hover {
+ background-color: var(--color-gray-700);
+}
+
+#task-control select {
+ font: inherit;
+ padding: 0.5rem;
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
\ No newline at end of file
diff --git a/code/02 Basics/01 Starting Project/src/components/TaskControl.jsx b/code/02 Basics/01 Starting Project/src/components/TaskControl.jsx
new file mode 100644
index 0000000..65f3cfd
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/TaskControl.jsx
@@ -0,0 +1,14 @@
+import Filter from './Filter';
+
+import './TaskControl.css';
+
+function TaskControl({ onStartAddTask, onSetFilter }) {
+ return (
+
+ Add Task
+
+
+ );
+}
+
+export default TaskControl;
diff --git a/code/02 Basics/01 Starting Project/src/components/TaskList.css b/code/02 Basics/01 Starting Project/src/components/TaskList.css
new file mode 100644
index 0000000..111d52b
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/TaskList.css
@@ -0,0 +1,13 @@
+.task-list {
+ list-style: none;
+ margin: 2rem 0;
+ padding: 0;
+}
+
+.no-tasks {
+ text-align: center;
+ font-weight: bold;
+ color: var(--color-gray-400);
+ font-size: 2rem;
+ margin: 3rem auto;
+}
diff --git a/code/02 Basics/01 Starting Project/src/components/TaskList.jsx b/code/02 Basics/01 Starting Project/src/components/TaskList.jsx
new file mode 100644
index 0000000..007b7ed
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/components/TaskList.jsx
@@ -0,0 +1,18 @@
+import Task from './Task';
+import './TaskList.css';
+
+function TaskList({ tasks }) {
+ if (!tasks || tasks.length === 0) {
+ return No tasks found!
;
+ }
+
+ return (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ );
+}
+
+export default TaskList;
diff --git a/code/02 Basics/01 Starting Project/src/index.css b/code/02 Basics/01 Starting Project/src/index.css
new file mode 100644
index 0000000..572e24b
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/index.css
@@ -0,0 +1,61 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+
+ --color-gray-100: #f9f7fc;
+ --color-gray-200: #f3eefc;
+ --color-gray-300: #c7c0da;
+ --color-gray-400: #a396bf;
+ --color-gray-500: #8172a2;
+ --color-gray-600: #6b5f8a;
+ --color-gray-700: #5a4f73;
+ --color-gray-800: #4c4160;
+ --color-gray-900: #3c334d;
+
+ --color-primary-100: #f4ebff;
+ --color-primary-200: #e1d0ff;
+ --color-primary-300: #c9aaff;
+ --color-primary-400: #b085f5;
+ --color-primary-500: #9755f5;
+ --color-primary-600: #7442c8;
+ --color-primary-700: #5a32a3;
+ --color-primary-800: #4c2889;
+ --color-primary-900: #3c1d6b;
+}
+
+html {
+ /* background-color: var(--color-gray-900); */
+ height: 100%;
+ background: linear-gradient(
+ 160deg,
+ #241b33,
+ #281b42
+ );
+ color: var(--color-gray-100);
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ max-width: 40rem;
+ margin: 2rem auto;
+ padding: 3rem;
+ border-radius: 8px;
+ background-color: var(--color-gray-900);
+ border: 2px solid var(--color-gray-300);
+}
diff --git a/code/02 Basics/01 Starting Project/src/main.jsx b/code/02 Basics/01 Starting Project/src/main.jsx
new file mode 100644
index 0000000..5cc5991
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/code/02 Basics/01 Starting Project/vite.config.js b/code/02 Basics/01 Starting Project/vite.config.js
new file mode 100644
index 0000000..5a33944
--- /dev/null
+++ b/code/02 Basics/01 Starting Project/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/code/02 Basics/02 Added Cypress/cypress.config.js b/code/02 Basics/02 Added Cypress/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/02 Basics/02 Added Cypress/cypress/e2e/basics.cy.js b/code/02 Basics/02 Added Cypress/cypress/e2e/basics.cy.js
new file mode 100644
index 0000000..1360e51
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/cypress/e2e/basics.cy.js
@@ -0,0 +1,5 @@
+describe('template spec', () => {
+ it('passes', () => {
+ cy.visit('https://example.cypress.io');
+ });
+});
diff --git a/code/02 Basics/02 Added Cypress/cypress/fixtures/example.json b/code/02 Basics/02 Added Cypress/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/02 Basics/02 Added Cypress/cypress/support/commands.js b/code/02 Basics/02 Added Cypress/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/02 Basics/02 Added Cypress/cypress/support/e2e.js b/code/02 Basics/02 Added Cypress/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/02 Basics/02 Added Cypress/index.html b/code/02 Basics/02 Added Cypress/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/02 Basics/02 Added Cypress/package.json b/code/02 Basics/02 Added Cypress/package.json
new file mode 100644
index 0000000..d6193b3
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "cypress-basics",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.27",
+ "@types/react-dom": "^18.0.10",
+ "@vitejs/plugin-react": "^3.1.0",
+ "vite": "^4.1.0"
+ }
+}
diff --git a/code/02 Basics/02 Added Cypress/public/vite.svg b/code/02 Basics/02 Added Cypress/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/02 Basics/02 Added Cypress/src/App.jsx b/code/02 Basics/02 Added Cypress/src/App.jsx
new file mode 100644
index 0000000..43955a9
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/App.jsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import Header from './components/Header';
+import Modal from './components/Modal';
+
+import NewTask from './components/NewTask';
+import TaskControl from './components/TaskControl';
+import TaskList from './components/TaskList';
+
+function App() {
+ const [isAddingTask, setIsAddingTask] = useState(false);
+ const [tasks, setTasks] = useState([]);
+ const [appliedFilter, setAppliedFilter] = useState('all');
+
+ const displayedTasks = tasks.filter((task) => {
+ if (appliedFilter === 'all') {
+ return true;
+ }
+ return task.category === appliedFilter;
+ });
+
+ function startAddTaskHandler() {
+ setIsAddingTask(true);
+ }
+
+ function cancelAddTaskHandler() {
+ setIsAddingTask(false);
+ }
+
+ function addTaskHandler(taskData) {
+ setTasks((prevTasks) => {
+ return [
+ ...prevTasks,
+ {
+ id: Math.random().toString(),
+ ...taskData,
+ },
+ ];
+ });
+ setIsAddingTask(false);
+ }
+
+ function setFilterHandler(category) {
+ setAppliedFilter(category);
+ }
+
+ return (
+ <>
+ {isAddingTask && (
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/02 Basics/02 Added Cypress/src/assets/logo.png b/code/02 Basics/02 Added Cypress/src/assets/logo.png
new file mode 100644
index 0000000..2a8d015
Binary files /dev/null and b/code/02 Basics/02 Added Cypress/src/assets/logo.png differ
diff --git a/code/02 Basics/02 Added Cypress/src/components/Filter.jsx b/code/02 Basics/02 Added Cypress/src/components/Filter.jsx
new file mode 100644
index 0000000..b0510e6
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/Filter.jsx
@@ -0,0 +1,17 @@
+function Filter({ onFilterChange }) {
+ function filterChangeHandler(event) {
+ onFilterChange(event.target.value);
+ }
+
+ return (
+
+ All
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+ );
+}
+
+export default Filter;
diff --git a/code/02 Basics/02 Added Cypress/src/components/Header.css b/code/02 Basics/02 Added Cypress/src/components/Header.css
new file mode 100644
index 0000000..2ba4a37
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/Header.css
@@ -0,0 +1,12 @@
+.main-header {
+ margin: 3rem auto;
+ text-align: center;
+ color: var(--color-gray-400);
+}
+
+.main-header img {
+ width: 7rem;
+ height: 7rem;
+ object-fit: contain;
+ transform: rotateZ(10deg);
+}
\ No newline at end of file
diff --git a/code/02 Basics/02 Added Cypress/src/components/Header.jsx b/code/02 Basics/02 Added Cypress/src/components/Header.jsx
new file mode 100644
index 0000000..f2165e1
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/Header.jsx
@@ -0,0 +1,13 @@
+import './Header.css';
+import logo from '../assets/logo.png';
+
+function Header() {
+ return (
+
+
+ React Tasks
+
+ );
+}
+
+export default Header;
diff --git a/code/02 Basics/02 Added Cypress/src/components/Modal.css b/code/02 Basics/02 Added Cypress/src/components/Modal.css
new file mode 100644
index 0000000..4c57acd
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/Modal.css
@@ -0,0 +1,24 @@
+.backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 1;
+}
+
+.modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ margin: 0;
+ padding: 2rem;
+ transform: translate(-50%, -50%);
+ width: 40rem;
+ background-color: var(--color-gray-800);
+ border: none;
+ border-radius: 4px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+}
diff --git a/code/02 Basics/02 Added Cypress/src/components/Modal.jsx b/code/02 Basics/02 Added Cypress/src/components/Modal.jsx
new file mode 100644
index 0000000..7347c66
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/Modal.jsx
@@ -0,0 +1,14 @@
+import './Modal.css';
+
+function Modal({ children, onClose }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/02 Basics/02 Added Cypress/src/components/NewTask.css b/code/02 Basics/02 Added Cypress/src/components/NewTask.css
new file mode 100644
index 0000000..e9a37e7
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/NewTask.css
@@ -0,0 +1,62 @@
+#new-task-form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+#new-task-form input,
+#new-task-form textarea {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+#new-task-form select {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+#new-task-form button {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-primary-500);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#new-task-form button:hover {
+ background-color: var(--color-primary-600);
+}
+
+#new-task-form button[type="button"] {
+ background-color: transparent;
+ color: var(--color-gray-200);
+}
+
+#new-task-form button[type="button"]:hover {
+ background-color: var(--color-gray-700);
+}
+
+
+
+.error-message {
+ color: var(--color-primary-300);
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+}
diff --git a/code/02 Basics/02 Added Cypress/src/components/NewTask.jsx b/code/02 Basics/02 Added Cypress/src/components/NewTask.jsx
new file mode 100644
index 0000000..17f1540
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/NewTask.jsx
@@ -0,0 +1,67 @@
+import { useRef, useState } from 'react';
+
+import './NewTask.css';
+
+function NewTask({ onAddTask, onCancel }) {
+ const titleRef = useRef();
+ const summaryRef = useRef();
+ const categoryRef = useRef();
+
+ const [formInvalid, setFormInvalid] = useState(false);
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ const enteredTitle = titleRef.current.value;
+ const enteredSummary = summaryRef.current.value;
+ const chosenCategory = categoryRef.current.value;
+
+ if (
+ enteredTitle.trim().length === 0 ||
+ enteredSummary.trim().length === 0
+ ) {
+ setFormInvalid(true);
+ return;
+ }
+
+ const taskData = {
+ title: enteredTitle,
+ summary: enteredSummary,
+ category: chosenCategory,
+ };
+ onAddTask(taskData);
+ }
+
+ return (
+
+
+ Title
+
+
+
+ Summary
+
+
+
+ Category
+
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+
+ {formInvalid && (
+
+ Please provide values for task title, summary and category!
+
+ )}
+
+ Cancel
+ Add Task
+
+
+ );
+}
+
+export default NewTask;
diff --git a/code/02 Basics/02 Added Cypress/src/components/Task.css b/code/02 Basics/02 Added Cypress/src/components/Task.css
new file mode 100644
index 0000000..ee3c6f3
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/Task.css
@@ -0,0 +1,26 @@
+.task {
+ display: flex;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 1rem;
+ border: 1px solid var(--color-gray-600);
+ background-color: var(--color-gray-700);
+ border-radius: 4px;
+}
+
+.task-category {
+ font-size: 1.25rem;
+}
+
+.task h2 {
+ margin: 0;
+ color: var(--color-gray-300);
+ font-size: 1rem;
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.task p {
+ margin: 0;
+ color: var(--color-gray-200);
+}
\ No newline at end of file
diff --git a/code/02 Basics/02 Added Cypress/src/components/Task.jsx b/code/02 Basics/02 Added Cypress/src/components/Task.jsx
new file mode 100644
index 0000000..9867dab
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/Task.jsx
@@ -0,0 +1,22 @@
+import './Task.css';
+
+const CATEGORY_ICONS = {
+ urgent: '🚨',
+ important: '🔴',
+ moderate: '🔵',
+ low: '🟢',
+};
+
+function Task({ category, title, summary }) {
+ return (
+
+ {CATEGORY_ICONS[category]}
+
+
+ );
+}
+
+export default Task;
diff --git a/code/02 Basics/02 Added Cypress/src/components/TaskControl.css b/code/02 Basics/02 Added Cypress/src/components/TaskControl.css
new file mode 100644
index 0000000..c334096
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/TaskControl.css
@@ -0,0 +1,28 @@
+#task-control {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+#task-control button {
+ font: inherit;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-gray-800);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#task-control button:hover {
+ background-color: var(--color-gray-700);
+}
+
+#task-control select {
+ font: inherit;
+ padding: 0.5rem;
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
\ No newline at end of file
diff --git a/code/02 Basics/02 Added Cypress/src/components/TaskControl.jsx b/code/02 Basics/02 Added Cypress/src/components/TaskControl.jsx
new file mode 100644
index 0000000..65f3cfd
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/TaskControl.jsx
@@ -0,0 +1,14 @@
+import Filter from './Filter';
+
+import './TaskControl.css';
+
+function TaskControl({ onStartAddTask, onSetFilter }) {
+ return (
+
+ Add Task
+
+
+ );
+}
+
+export default TaskControl;
diff --git a/code/02 Basics/02 Added Cypress/src/components/TaskList.css b/code/02 Basics/02 Added Cypress/src/components/TaskList.css
new file mode 100644
index 0000000..111d52b
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/TaskList.css
@@ -0,0 +1,13 @@
+.task-list {
+ list-style: none;
+ margin: 2rem 0;
+ padding: 0;
+}
+
+.no-tasks {
+ text-align: center;
+ font-weight: bold;
+ color: var(--color-gray-400);
+ font-size: 2rem;
+ margin: 3rem auto;
+}
diff --git a/code/02 Basics/02 Added Cypress/src/components/TaskList.jsx b/code/02 Basics/02 Added Cypress/src/components/TaskList.jsx
new file mode 100644
index 0000000..007b7ed
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/components/TaskList.jsx
@@ -0,0 +1,18 @@
+import Task from './Task';
+import './TaskList.css';
+
+function TaskList({ tasks }) {
+ if (!tasks || tasks.length === 0) {
+ return No tasks found!
;
+ }
+
+ return (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ );
+}
+
+export default TaskList;
diff --git a/code/02 Basics/02 Added Cypress/src/index.css b/code/02 Basics/02 Added Cypress/src/index.css
new file mode 100644
index 0000000..572e24b
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/index.css
@@ -0,0 +1,61 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+
+ --color-gray-100: #f9f7fc;
+ --color-gray-200: #f3eefc;
+ --color-gray-300: #c7c0da;
+ --color-gray-400: #a396bf;
+ --color-gray-500: #8172a2;
+ --color-gray-600: #6b5f8a;
+ --color-gray-700: #5a4f73;
+ --color-gray-800: #4c4160;
+ --color-gray-900: #3c334d;
+
+ --color-primary-100: #f4ebff;
+ --color-primary-200: #e1d0ff;
+ --color-primary-300: #c9aaff;
+ --color-primary-400: #b085f5;
+ --color-primary-500: #9755f5;
+ --color-primary-600: #7442c8;
+ --color-primary-700: #5a32a3;
+ --color-primary-800: #4c2889;
+ --color-primary-900: #3c1d6b;
+}
+
+html {
+ /* background-color: var(--color-gray-900); */
+ height: 100%;
+ background: linear-gradient(
+ 160deg,
+ #241b33,
+ #281b42
+ );
+ color: var(--color-gray-100);
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ max-width: 40rem;
+ margin: 2rem auto;
+ padding: 3rem;
+ border-radius: 8px;
+ background-color: var(--color-gray-900);
+ border: 2px solid var(--color-gray-300);
+}
diff --git a/code/02 Basics/02 Added Cypress/src/main.jsx b/code/02 Basics/02 Added Cypress/src/main.jsx
new file mode 100644
index 0000000..5cc5991
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/code/02 Basics/02 Added Cypress/vite.config.js b/code/02 Basics/02 Added Cypress/vite.config.js
new file mode 100644
index 0000000..5a33944
--- /dev/null
+++ b/code/02 Basics/02 Added Cypress/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/code/02 Basics/03 Selecting By Text/cypress.config.js b/code/02 Basics/03 Selecting By Text/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/02 Basics/03 Selecting By Text/cypress/e2e/basics.cy.js b/code/02 Basics/03 Selecting By Text/cypress/e2e/basics.cy.js
new file mode 100644
index 0000000..7dcd858
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/cypress/e2e/basics.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('tasks page', () => {
+ it('should render the main image', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('.main-header img');
+ });
+
+ it('should display the page title', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('h1').contains('My Cypress Course Tasks');
+ // cy.contains('My Cypress Course Tasks');
+ });
+});
diff --git a/code/02 Basics/03 Selecting By Text/cypress/fixtures/example.json b/code/02 Basics/03 Selecting By Text/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/02 Basics/03 Selecting By Text/cypress/support/commands.js b/code/02 Basics/03 Selecting By Text/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/02 Basics/03 Selecting By Text/cypress/support/e2e.js b/code/02 Basics/03 Selecting By Text/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/02 Basics/03 Selecting By Text/index.html b/code/02 Basics/03 Selecting By Text/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/02 Basics/03 Selecting By Text/package.json b/code/02 Basics/03 Selecting By Text/package.json
new file mode 100644
index 0000000..d6193b3
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "cypress-basics",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.27",
+ "@types/react-dom": "^18.0.10",
+ "@vitejs/plugin-react": "^3.1.0",
+ "vite": "^4.1.0"
+ }
+}
diff --git a/code/02 Basics/03 Selecting By Text/public/vite.svg b/code/02 Basics/03 Selecting By Text/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/02 Basics/03 Selecting By Text/src/App.jsx b/code/02 Basics/03 Selecting By Text/src/App.jsx
new file mode 100644
index 0000000..43955a9
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/App.jsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import Header from './components/Header';
+import Modal from './components/Modal';
+
+import NewTask from './components/NewTask';
+import TaskControl from './components/TaskControl';
+import TaskList from './components/TaskList';
+
+function App() {
+ const [isAddingTask, setIsAddingTask] = useState(false);
+ const [tasks, setTasks] = useState([]);
+ const [appliedFilter, setAppliedFilter] = useState('all');
+
+ const displayedTasks = tasks.filter((task) => {
+ if (appliedFilter === 'all') {
+ return true;
+ }
+ return task.category === appliedFilter;
+ });
+
+ function startAddTaskHandler() {
+ setIsAddingTask(true);
+ }
+
+ function cancelAddTaskHandler() {
+ setIsAddingTask(false);
+ }
+
+ function addTaskHandler(taskData) {
+ setTasks((prevTasks) => {
+ return [
+ ...prevTasks,
+ {
+ id: Math.random().toString(),
+ ...taskData,
+ },
+ ];
+ });
+ setIsAddingTask(false);
+ }
+
+ function setFilterHandler(category) {
+ setAppliedFilter(category);
+ }
+
+ return (
+ <>
+ {isAddingTask && (
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/02 Basics/03 Selecting By Text/src/assets/logo.png b/code/02 Basics/03 Selecting By Text/src/assets/logo.png
new file mode 100644
index 0000000..2a8d015
Binary files /dev/null and b/code/02 Basics/03 Selecting By Text/src/assets/logo.png differ
diff --git a/code/02 Basics/03 Selecting By Text/src/components/Filter.jsx b/code/02 Basics/03 Selecting By Text/src/components/Filter.jsx
new file mode 100644
index 0000000..b0510e6
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/Filter.jsx
@@ -0,0 +1,17 @@
+function Filter({ onFilterChange }) {
+ function filterChangeHandler(event) {
+ onFilterChange(event.target.value);
+ }
+
+ return (
+
+ All
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+ );
+}
+
+export default Filter;
diff --git a/code/02 Basics/03 Selecting By Text/src/components/Header.css b/code/02 Basics/03 Selecting By Text/src/components/Header.css
new file mode 100644
index 0000000..2ba4a37
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/Header.css
@@ -0,0 +1,12 @@
+.main-header {
+ margin: 3rem auto;
+ text-align: center;
+ color: var(--color-gray-400);
+}
+
+.main-header img {
+ width: 7rem;
+ height: 7rem;
+ object-fit: contain;
+ transform: rotateZ(10deg);
+}
\ No newline at end of file
diff --git a/code/02 Basics/03 Selecting By Text/src/components/Header.jsx b/code/02 Basics/03 Selecting By Text/src/components/Header.jsx
new file mode 100644
index 0000000..d3bc6d9
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/Header.jsx
@@ -0,0 +1,13 @@
+import './Header.css';
+import logo from '../assets/logo.png';
+
+function Header() {
+ return (
+
+
+ My Cypress Course Tasks
+
+ );
+}
+
+export default Header;
diff --git a/code/02 Basics/03 Selecting By Text/src/components/Modal.css b/code/02 Basics/03 Selecting By Text/src/components/Modal.css
new file mode 100644
index 0000000..4c57acd
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/Modal.css
@@ -0,0 +1,24 @@
+.backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 1;
+}
+
+.modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ margin: 0;
+ padding: 2rem;
+ transform: translate(-50%, -50%);
+ width: 40rem;
+ background-color: var(--color-gray-800);
+ border: none;
+ border-radius: 4px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+}
diff --git a/code/02 Basics/03 Selecting By Text/src/components/Modal.jsx b/code/02 Basics/03 Selecting By Text/src/components/Modal.jsx
new file mode 100644
index 0000000..7347c66
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/Modal.jsx
@@ -0,0 +1,14 @@
+import './Modal.css';
+
+function Modal({ children, onClose }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/02 Basics/03 Selecting By Text/src/components/NewTask.css b/code/02 Basics/03 Selecting By Text/src/components/NewTask.css
new file mode 100644
index 0000000..e9a37e7
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/NewTask.css
@@ -0,0 +1,62 @@
+#new-task-form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+#new-task-form input,
+#new-task-form textarea {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+#new-task-form select {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+#new-task-form button {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-primary-500);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#new-task-form button:hover {
+ background-color: var(--color-primary-600);
+}
+
+#new-task-form button[type="button"] {
+ background-color: transparent;
+ color: var(--color-gray-200);
+}
+
+#new-task-form button[type="button"]:hover {
+ background-color: var(--color-gray-700);
+}
+
+
+
+.error-message {
+ color: var(--color-primary-300);
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+}
diff --git a/code/02 Basics/03 Selecting By Text/src/components/NewTask.jsx b/code/02 Basics/03 Selecting By Text/src/components/NewTask.jsx
new file mode 100644
index 0000000..17f1540
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/NewTask.jsx
@@ -0,0 +1,67 @@
+import { useRef, useState } from 'react';
+
+import './NewTask.css';
+
+function NewTask({ onAddTask, onCancel }) {
+ const titleRef = useRef();
+ const summaryRef = useRef();
+ const categoryRef = useRef();
+
+ const [formInvalid, setFormInvalid] = useState(false);
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ const enteredTitle = titleRef.current.value;
+ const enteredSummary = summaryRef.current.value;
+ const chosenCategory = categoryRef.current.value;
+
+ if (
+ enteredTitle.trim().length === 0 ||
+ enteredSummary.trim().length === 0
+ ) {
+ setFormInvalid(true);
+ return;
+ }
+
+ const taskData = {
+ title: enteredTitle,
+ summary: enteredSummary,
+ category: chosenCategory,
+ };
+ onAddTask(taskData);
+ }
+
+ return (
+
+
+ Title
+
+
+
+ Summary
+
+
+
+ Category
+
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+
+ {formInvalid && (
+
+ Please provide values for task title, summary and category!
+
+ )}
+
+ Cancel
+ Add Task
+
+
+ );
+}
+
+export default NewTask;
diff --git a/code/02 Basics/03 Selecting By Text/src/components/Task.css b/code/02 Basics/03 Selecting By Text/src/components/Task.css
new file mode 100644
index 0000000..ee3c6f3
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/Task.css
@@ -0,0 +1,26 @@
+.task {
+ display: flex;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 1rem;
+ border: 1px solid var(--color-gray-600);
+ background-color: var(--color-gray-700);
+ border-radius: 4px;
+}
+
+.task-category {
+ font-size: 1.25rem;
+}
+
+.task h2 {
+ margin: 0;
+ color: var(--color-gray-300);
+ font-size: 1rem;
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.task p {
+ margin: 0;
+ color: var(--color-gray-200);
+}
\ No newline at end of file
diff --git a/code/02 Basics/03 Selecting By Text/src/components/Task.jsx b/code/02 Basics/03 Selecting By Text/src/components/Task.jsx
new file mode 100644
index 0000000..9867dab
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/Task.jsx
@@ -0,0 +1,22 @@
+import './Task.css';
+
+const CATEGORY_ICONS = {
+ urgent: '🚨',
+ important: '🔴',
+ moderate: '🔵',
+ low: '🟢',
+};
+
+function Task({ category, title, summary }) {
+ return (
+
+ {CATEGORY_ICONS[category]}
+
+
+ );
+}
+
+export default Task;
diff --git a/code/02 Basics/03 Selecting By Text/src/components/TaskControl.css b/code/02 Basics/03 Selecting By Text/src/components/TaskControl.css
new file mode 100644
index 0000000..c334096
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/TaskControl.css
@@ -0,0 +1,28 @@
+#task-control {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+#task-control button {
+ font: inherit;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-gray-800);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#task-control button:hover {
+ background-color: var(--color-gray-700);
+}
+
+#task-control select {
+ font: inherit;
+ padding: 0.5rem;
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
\ No newline at end of file
diff --git a/code/02 Basics/03 Selecting By Text/src/components/TaskControl.jsx b/code/02 Basics/03 Selecting By Text/src/components/TaskControl.jsx
new file mode 100644
index 0000000..65f3cfd
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/TaskControl.jsx
@@ -0,0 +1,14 @@
+import Filter from './Filter';
+
+import './TaskControl.css';
+
+function TaskControl({ onStartAddTask, onSetFilter }) {
+ return (
+
+ Add Task
+
+
+ );
+}
+
+export default TaskControl;
diff --git a/code/02 Basics/03 Selecting By Text/src/components/TaskList.css b/code/02 Basics/03 Selecting By Text/src/components/TaskList.css
new file mode 100644
index 0000000..111d52b
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/TaskList.css
@@ -0,0 +1,13 @@
+.task-list {
+ list-style: none;
+ margin: 2rem 0;
+ padding: 0;
+}
+
+.no-tasks {
+ text-align: center;
+ font-weight: bold;
+ color: var(--color-gray-400);
+ font-size: 2rem;
+ margin: 3rem auto;
+}
diff --git a/code/02 Basics/03 Selecting By Text/src/components/TaskList.jsx b/code/02 Basics/03 Selecting By Text/src/components/TaskList.jsx
new file mode 100644
index 0000000..007b7ed
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/components/TaskList.jsx
@@ -0,0 +1,18 @@
+import Task from './Task';
+import './TaskList.css';
+
+function TaskList({ tasks }) {
+ if (!tasks || tasks.length === 0) {
+ return No tasks found!
;
+ }
+
+ return (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ );
+}
+
+export default TaskList;
diff --git a/code/02 Basics/03 Selecting By Text/src/index.css b/code/02 Basics/03 Selecting By Text/src/index.css
new file mode 100644
index 0000000..572e24b
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/index.css
@@ -0,0 +1,61 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+
+ --color-gray-100: #f9f7fc;
+ --color-gray-200: #f3eefc;
+ --color-gray-300: #c7c0da;
+ --color-gray-400: #a396bf;
+ --color-gray-500: #8172a2;
+ --color-gray-600: #6b5f8a;
+ --color-gray-700: #5a4f73;
+ --color-gray-800: #4c4160;
+ --color-gray-900: #3c334d;
+
+ --color-primary-100: #f4ebff;
+ --color-primary-200: #e1d0ff;
+ --color-primary-300: #c9aaff;
+ --color-primary-400: #b085f5;
+ --color-primary-500: #9755f5;
+ --color-primary-600: #7442c8;
+ --color-primary-700: #5a32a3;
+ --color-primary-800: #4c2889;
+ --color-primary-900: #3c1d6b;
+}
+
+html {
+ /* background-color: var(--color-gray-900); */
+ height: 100%;
+ background: linear-gradient(
+ 160deg,
+ #241b33,
+ #281b42
+ );
+ color: var(--color-gray-100);
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ max-width: 40rem;
+ margin: 2rem auto;
+ padding: 3rem;
+ border-radius: 8px;
+ background-color: var(--color-gray-900);
+ border: 2px solid var(--color-gray-300);
+}
diff --git a/code/02 Basics/03 Selecting By Text/src/main.jsx b/code/02 Basics/03 Selecting By Text/src/main.jsx
new file mode 100644
index 0000000..5cc5991
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/code/02 Basics/03 Selecting By Text/vite.config.js b/code/02 Basics/03 Selecting By Text/vite.config.js
new file mode 100644
index 0000000..5a33944
--- /dev/null
+++ b/code/02 Basics/03 Selecting By Text/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/cypress.config.js b/code/02 Basics/04 Implicit vs Explicit Assertions/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/cypress/e2e/basics.cy.js b/code/02 Basics/04 Implicit vs Explicit Assertions/cypress/e2e/basics.cy.js
new file mode 100644
index 0000000..56a5516
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/cypress/e2e/basics.cy.js
@@ -0,0 +1,15 @@
+///
+
+describe('tasks page', () => {
+ it('should render the main image', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('.main-header img');
+ });
+
+ it('should display the page title', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('h1').should('have.length', 1);
+ cy.get('h1').contains('My Cypress Course Tasks');
+ // cy.contains('My Cypress Course Tasks');
+ });
+});
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/cypress/fixtures/example.json b/code/02 Basics/04 Implicit vs Explicit Assertions/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/cypress/support/commands.js b/code/02 Basics/04 Implicit vs Explicit Assertions/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/cypress/support/e2e.js b/code/02 Basics/04 Implicit vs Explicit Assertions/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/index.html b/code/02 Basics/04 Implicit vs Explicit Assertions/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/package.json b/code/02 Basics/04 Implicit vs Explicit Assertions/package.json
new file mode 100644
index 0000000..d6193b3
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "cypress-basics",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.27",
+ "@types/react-dom": "^18.0.10",
+ "@vitejs/plugin-react": "^3.1.0",
+ "vite": "^4.1.0"
+ }
+}
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/public/vite.svg b/code/02 Basics/04 Implicit vs Explicit Assertions/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/App.jsx b/code/02 Basics/04 Implicit vs Explicit Assertions/src/App.jsx
new file mode 100644
index 0000000..43955a9
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/App.jsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import Header from './components/Header';
+import Modal from './components/Modal';
+
+import NewTask from './components/NewTask';
+import TaskControl from './components/TaskControl';
+import TaskList from './components/TaskList';
+
+function App() {
+ const [isAddingTask, setIsAddingTask] = useState(false);
+ const [tasks, setTasks] = useState([]);
+ const [appliedFilter, setAppliedFilter] = useState('all');
+
+ const displayedTasks = tasks.filter((task) => {
+ if (appliedFilter === 'all') {
+ return true;
+ }
+ return task.category === appliedFilter;
+ });
+
+ function startAddTaskHandler() {
+ setIsAddingTask(true);
+ }
+
+ function cancelAddTaskHandler() {
+ setIsAddingTask(false);
+ }
+
+ function addTaskHandler(taskData) {
+ setTasks((prevTasks) => {
+ return [
+ ...prevTasks,
+ {
+ id: Math.random().toString(),
+ ...taskData,
+ },
+ ];
+ });
+ setIsAddingTask(false);
+ }
+
+ function setFilterHandler(category) {
+ setAppliedFilter(category);
+ }
+
+ return (
+ <>
+ {isAddingTask && (
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/assets/logo.png b/code/02 Basics/04 Implicit vs Explicit Assertions/src/assets/logo.png
new file mode 100644
index 0000000..2a8d015
Binary files /dev/null and b/code/02 Basics/04 Implicit vs Explicit Assertions/src/assets/logo.png differ
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Filter.jsx b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Filter.jsx
new file mode 100644
index 0000000..b0510e6
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Filter.jsx
@@ -0,0 +1,17 @@
+function Filter({ onFilterChange }) {
+ function filterChangeHandler(event) {
+ onFilterChange(event.target.value);
+ }
+
+ return (
+
+ All
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+ );
+}
+
+export default Filter;
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Header.css b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Header.css
new file mode 100644
index 0000000..2ba4a37
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Header.css
@@ -0,0 +1,12 @@
+.main-header {
+ margin: 3rem auto;
+ text-align: center;
+ color: var(--color-gray-400);
+}
+
+.main-header img {
+ width: 7rem;
+ height: 7rem;
+ object-fit: contain;
+ transform: rotateZ(10deg);
+}
\ No newline at end of file
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Header.jsx b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Header.jsx
new file mode 100644
index 0000000..d3bc6d9
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Header.jsx
@@ -0,0 +1,13 @@
+import './Header.css';
+import logo from '../assets/logo.png';
+
+function Header() {
+ return (
+
+
+ My Cypress Course Tasks
+
+ );
+}
+
+export default Header;
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Modal.css b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Modal.css
new file mode 100644
index 0000000..4c57acd
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Modal.css
@@ -0,0 +1,24 @@
+.backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 1;
+}
+
+.modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ margin: 0;
+ padding: 2rem;
+ transform: translate(-50%, -50%);
+ width: 40rem;
+ background-color: var(--color-gray-800);
+ border: none;
+ border-radius: 4px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+}
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Modal.jsx b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Modal.jsx
new file mode 100644
index 0000000..7347c66
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Modal.jsx
@@ -0,0 +1,14 @@
+import './Modal.css';
+
+function Modal({ children, onClose }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/NewTask.css b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/NewTask.css
new file mode 100644
index 0000000..e9a37e7
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/NewTask.css
@@ -0,0 +1,62 @@
+#new-task-form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+#new-task-form input,
+#new-task-form textarea {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+#new-task-form select {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+#new-task-form button {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-primary-500);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#new-task-form button:hover {
+ background-color: var(--color-primary-600);
+}
+
+#new-task-form button[type="button"] {
+ background-color: transparent;
+ color: var(--color-gray-200);
+}
+
+#new-task-form button[type="button"]:hover {
+ background-color: var(--color-gray-700);
+}
+
+
+
+.error-message {
+ color: var(--color-primary-300);
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+}
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/NewTask.jsx b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/NewTask.jsx
new file mode 100644
index 0000000..17f1540
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/NewTask.jsx
@@ -0,0 +1,67 @@
+import { useRef, useState } from 'react';
+
+import './NewTask.css';
+
+function NewTask({ onAddTask, onCancel }) {
+ const titleRef = useRef();
+ const summaryRef = useRef();
+ const categoryRef = useRef();
+
+ const [formInvalid, setFormInvalid] = useState(false);
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ const enteredTitle = titleRef.current.value;
+ const enteredSummary = summaryRef.current.value;
+ const chosenCategory = categoryRef.current.value;
+
+ if (
+ enteredTitle.trim().length === 0 ||
+ enteredSummary.trim().length === 0
+ ) {
+ setFormInvalid(true);
+ return;
+ }
+
+ const taskData = {
+ title: enteredTitle,
+ summary: enteredSummary,
+ category: chosenCategory,
+ };
+ onAddTask(taskData);
+ }
+
+ return (
+
+
+ Title
+
+
+
+ Summary
+
+
+
+ Category
+
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+
+ {formInvalid && (
+
+ Please provide values for task title, summary and category!
+
+ )}
+
+ Cancel
+ Add Task
+
+
+ );
+}
+
+export default NewTask;
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Task.css b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Task.css
new file mode 100644
index 0000000..ee3c6f3
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Task.css
@@ -0,0 +1,26 @@
+.task {
+ display: flex;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 1rem;
+ border: 1px solid var(--color-gray-600);
+ background-color: var(--color-gray-700);
+ border-radius: 4px;
+}
+
+.task-category {
+ font-size: 1.25rem;
+}
+
+.task h2 {
+ margin: 0;
+ color: var(--color-gray-300);
+ font-size: 1rem;
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.task p {
+ margin: 0;
+ color: var(--color-gray-200);
+}
\ No newline at end of file
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Task.jsx b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Task.jsx
new file mode 100644
index 0000000..9867dab
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/Task.jsx
@@ -0,0 +1,22 @@
+import './Task.css';
+
+const CATEGORY_ICONS = {
+ urgent: '🚨',
+ important: '🔴',
+ moderate: '🔵',
+ low: '🟢',
+};
+
+function Task({ category, title, summary }) {
+ return (
+
+ {CATEGORY_ICONS[category]}
+
+
+ );
+}
+
+export default Task;
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/TaskControl.css b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/TaskControl.css
new file mode 100644
index 0000000..c334096
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/TaskControl.css
@@ -0,0 +1,28 @@
+#task-control {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+#task-control button {
+ font: inherit;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-gray-800);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#task-control button:hover {
+ background-color: var(--color-gray-700);
+}
+
+#task-control select {
+ font: inherit;
+ padding: 0.5rem;
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
\ No newline at end of file
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/TaskControl.jsx b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/TaskControl.jsx
new file mode 100644
index 0000000..65f3cfd
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/TaskControl.jsx
@@ -0,0 +1,14 @@
+import Filter from './Filter';
+
+import './TaskControl.css';
+
+function TaskControl({ onStartAddTask, onSetFilter }) {
+ return (
+
+ Add Task
+
+
+ );
+}
+
+export default TaskControl;
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/TaskList.css b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/TaskList.css
new file mode 100644
index 0000000..111d52b
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/TaskList.css
@@ -0,0 +1,13 @@
+.task-list {
+ list-style: none;
+ margin: 2rem 0;
+ padding: 0;
+}
+
+.no-tasks {
+ text-align: center;
+ font-weight: bold;
+ color: var(--color-gray-400);
+ font-size: 2rem;
+ margin: 3rem auto;
+}
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/TaskList.jsx b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/TaskList.jsx
new file mode 100644
index 0000000..007b7ed
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/components/TaskList.jsx
@@ -0,0 +1,18 @@
+import Task from './Task';
+import './TaskList.css';
+
+function TaskList({ tasks }) {
+ if (!tasks || tasks.length === 0) {
+ return No tasks found!
;
+ }
+
+ return (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ );
+}
+
+export default TaskList;
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/index.css b/code/02 Basics/04 Implicit vs Explicit Assertions/src/index.css
new file mode 100644
index 0000000..572e24b
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/index.css
@@ -0,0 +1,61 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+
+ --color-gray-100: #f9f7fc;
+ --color-gray-200: #f3eefc;
+ --color-gray-300: #c7c0da;
+ --color-gray-400: #a396bf;
+ --color-gray-500: #8172a2;
+ --color-gray-600: #6b5f8a;
+ --color-gray-700: #5a4f73;
+ --color-gray-800: #4c4160;
+ --color-gray-900: #3c334d;
+
+ --color-primary-100: #f4ebff;
+ --color-primary-200: #e1d0ff;
+ --color-primary-300: #c9aaff;
+ --color-primary-400: #b085f5;
+ --color-primary-500: #9755f5;
+ --color-primary-600: #7442c8;
+ --color-primary-700: #5a32a3;
+ --color-primary-800: #4c2889;
+ --color-primary-900: #3c1d6b;
+}
+
+html {
+ /* background-color: var(--color-gray-900); */
+ height: 100%;
+ background: linear-gradient(
+ 160deg,
+ #241b33,
+ #281b42
+ );
+ color: var(--color-gray-100);
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ max-width: 40rem;
+ margin: 2rem auto;
+ padding: 3rem;
+ border-radius: 8px;
+ background-color: var(--color-gray-900);
+ border: 2px solid var(--color-gray-300);
+}
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/src/main.jsx b/code/02 Basics/04 Implicit vs Explicit Assertions/src/main.jsx
new file mode 100644
index 0000000..5cc5991
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/code/02 Basics/04 Implicit vs Explicit Assertions/vite.config.js b/code/02 Basics/04 Implicit vs Explicit Assertions/vite.config.js
new file mode 100644
index 0000000..5a33944
--- /dev/null
+++ b/code/02 Basics/04 Implicit vs Explicit Assertions/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/code/02 Basics/05 get() vs find()/cypress.config.js b/code/02 Basics/05 get() vs find()/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/02 Basics/05 get() vs find()/cypress/e2e/basics.cy.js b/code/02 Basics/05 get() vs find()/cypress/e2e/basics.cy.js
new file mode 100644
index 0000000..7a8ee21
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/cypress/e2e/basics.cy.js
@@ -0,0 +1,16 @@
+///
+
+describe('tasks page', () => {
+ it('should render the main image', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('.main-header').find('img');
+ // cy.get('.main-header img'); // => also works!
+ });
+
+ it('should display the page title', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('h1').should('have.length', 1);
+ cy.get('h1').contains('My Cypress Course Tasks');
+ // cy.contains('My Cypress Course Tasks');
+ });
+});
diff --git a/code/02 Basics/05 get() vs find()/cypress/fixtures/example.json b/code/02 Basics/05 get() vs find()/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/02 Basics/05 get() vs find()/cypress/support/commands.js b/code/02 Basics/05 get() vs find()/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/02 Basics/05 get() vs find()/cypress/support/e2e.js b/code/02 Basics/05 get() vs find()/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/02 Basics/05 get() vs find()/index.html b/code/02 Basics/05 get() vs find()/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/02 Basics/05 get() vs find()/package.json b/code/02 Basics/05 get() vs find()/package.json
new file mode 100644
index 0000000..d6193b3
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "cypress-basics",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.27",
+ "@types/react-dom": "^18.0.10",
+ "@vitejs/plugin-react": "^3.1.0",
+ "vite": "^4.1.0"
+ }
+}
diff --git a/code/02 Basics/05 get() vs find()/public/vite.svg b/code/02 Basics/05 get() vs find()/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/02 Basics/05 get() vs find()/src/App.jsx b/code/02 Basics/05 get() vs find()/src/App.jsx
new file mode 100644
index 0000000..43955a9
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/App.jsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import Header from './components/Header';
+import Modal from './components/Modal';
+
+import NewTask from './components/NewTask';
+import TaskControl from './components/TaskControl';
+import TaskList from './components/TaskList';
+
+function App() {
+ const [isAddingTask, setIsAddingTask] = useState(false);
+ const [tasks, setTasks] = useState([]);
+ const [appliedFilter, setAppliedFilter] = useState('all');
+
+ const displayedTasks = tasks.filter((task) => {
+ if (appliedFilter === 'all') {
+ return true;
+ }
+ return task.category === appliedFilter;
+ });
+
+ function startAddTaskHandler() {
+ setIsAddingTask(true);
+ }
+
+ function cancelAddTaskHandler() {
+ setIsAddingTask(false);
+ }
+
+ function addTaskHandler(taskData) {
+ setTasks((prevTasks) => {
+ return [
+ ...prevTasks,
+ {
+ id: Math.random().toString(),
+ ...taskData,
+ },
+ ];
+ });
+ setIsAddingTask(false);
+ }
+
+ function setFilterHandler(category) {
+ setAppliedFilter(category);
+ }
+
+ return (
+ <>
+ {isAddingTask && (
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/02 Basics/05 get() vs find()/src/assets/logo.png b/code/02 Basics/05 get() vs find()/src/assets/logo.png
new file mode 100644
index 0000000..2a8d015
Binary files /dev/null and b/code/02 Basics/05 get() vs find()/src/assets/logo.png differ
diff --git a/code/02 Basics/05 get() vs find()/src/components/Filter.jsx b/code/02 Basics/05 get() vs find()/src/components/Filter.jsx
new file mode 100644
index 0000000..b0510e6
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/Filter.jsx
@@ -0,0 +1,17 @@
+function Filter({ onFilterChange }) {
+ function filterChangeHandler(event) {
+ onFilterChange(event.target.value);
+ }
+
+ return (
+
+ All
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+ );
+}
+
+export default Filter;
diff --git a/code/02 Basics/05 get() vs find()/src/components/Header.css b/code/02 Basics/05 get() vs find()/src/components/Header.css
new file mode 100644
index 0000000..2ba4a37
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/Header.css
@@ -0,0 +1,12 @@
+.main-header {
+ margin: 3rem auto;
+ text-align: center;
+ color: var(--color-gray-400);
+}
+
+.main-header img {
+ width: 7rem;
+ height: 7rem;
+ object-fit: contain;
+ transform: rotateZ(10deg);
+}
\ No newline at end of file
diff --git a/code/02 Basics/05 get() vs find()/src/components/Header.jsx b/code/02 Basics/05 get() vs find()/src/components/Header.jsx
new file mode 100644
index 0000000..d3bc6d9
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/Header.jsx
@@ -0,0 +1,13 @@
+import './Header.css';
+import logo from '../assets/logo.png';
+
+function Header() {
+ return (
+
+
+ My Cypress Course Tasks
+
+ );
+}
+
+export default Header;
diff --git a/code/02 Basics/05 get() vs find()/src/components/Modal.css b/code/02 Basics/05 get() vs find()/src/components/Modal.css
new file mode 100644
index 0000000..4c57acd
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/Modal.css
@@ -0,0 +1,24 @@
+.backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 1;
+}
+
+.modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ margin: 0;
+ padding: 2rem;
+ transform: translate(-50%, -50%);
+ width: 40rem;
+ background-color: var(--color-gray-800);
+ border: none;
+ border-radius: 4px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+}
diff --git a/code/02 Basics/05 get() vs find()/src/components/Modal.jsx b/code/02 Basics/05 get() vs find()/src/components/Modal.jsx
new file mode 100644
index 0000000..7347c66
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/Modal.jsx
@@ -0,0 +1,14 @@
+import './Modal.css';
+
+function Modal({ children, onClose }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/02 Basics/05 get() vs find()/src/components/NewTask.css b/code/02 Basics/05 get() vs find()/src/components/NewTask.css
new file mode 100644
index 0000000..e9a37e7
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/NewTask.css
@@ -0,0 +1,62 @@
+#new-task-form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+#new-task-form input,
+#new-task-form textarea {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+#new-task-form select {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+#new-task-form button {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-primary-500);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#new-task-form button:hover {
+ background-color: var(--color-primary-600);
+}
+
+#new-task-form button[type="button"] {
+ background-color: transparent;
+ color: var(--color-gray-200);
+}
+
+#new-task-form button[type="button"]:hover {
+ background-color: var(--color-gray-700);
+}
+
+
+
+.error-message {
+ color: var(--color-primary-300);
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+}
diff --git a/code/02 Basics/05 get() vs find()/src/components/NewTask.jsx b/code/02 Basics/05 get() vs find()/src/components/NewTask.jsx
new file mode 100644
index 0000000..17f1540
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/NewTask.jsx
@@ -0,0 +1,67 @@
+import { useRef, useState } from 'react';
+
+import './NewTask.css';
+
+function NewTask({ onAddTask, onCancel }) {
+ const titleRef = useRef();
+ const summaryRef = useRef();
+ const categoryRef = useRef();
+
+ const [formInvalid, setFormInvalid] = useState(false);
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ const enteredTitle = titleRef.current.value;
+ const enteredSummary = summaryRef.current.value;
+ const chosenCategory = categoryRef.current.value;
+
+ if (
+ enteredTitle.trim().length === 0 ||
+ enteredSummary.trim().length === 0
+ ) {
+ setFormInvalid(true);
+ return;
+ }
+
+ const taskData = {
+ title: enteredTitle,
+ summary: enteredSummary,
+ category: chosenCategory,
+ };
+ onAddTask(taskData);
+ }
+
+ return (
+
+
+ Title
+
+
+
+ Summary
+
+
+
+ Category
+
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+
+ {formInvalid && (
+
+ Please provide values for task title, summary and category!
+
+ )}
+
+ Cancel
+ Add Task
+
+
+ );
+}
+
+export default NewTask;
diff --git a/code/02 Basics/05 get() vs find()/src/components/Task.css b/code/02 Basics/05 get() vs find()/src/components/Task.css
new file mode 100644
index 0000000..ee3c6f3
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/Task.css
@@ -0,0 +1,26 @@
+.task {
+ display: flex;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 1rem;
+ border: 1px solid var(--color-gray-600);
+ background-color: var(--color-gray-700);
+ border-radius: 4px;
+}
+
+.task-category {
+ font-size: 1.25rem;
+}
+
+.task h2 {
+ margin: 0;
+ color: var(--color-gray-300);
+ font-size: 1rem;
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.task p {
+ margin: 0;
+ color: var(--color-gray-200);
+}
\ No newline at end of file
diff --git a/code/02 Basics/05 get() vs find()/src/components/Task.jsx b/code/02 Basics/05 get() vs find()/src/components/Task.jsx
new file mode 100644
index 0000000..9867dab
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/Task.jsx
@@ -0,0 +1,22 @@
+import './Task.css';
+
+const CATEGORY_ICONS = {
+ urgent: '🚨',
+ important: '🔴',
+ moderate: '🔵',
+ low: '🟢',
+};
+
+function Task({ category, title, summary }) {
+ return (
+
+ {CATEGORY_ICONS[category]}
+
+
+ );
+}
+
+export default Task;
diff --git a/code/02 Basics/05 get() vs find()/src/components/TaskControl.css b/code/02 Basics/05 get() vs find()/src/components/TaskControl.css
new file mode 100644
index 0000000..c334096
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/TaskControl.css
@@ -0,0 +1,28 @@
+#task-control {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+#task-control button {
+ font: inherit;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-gray-800);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#task-control button:hover {
+ background-color: var(--color-gray-700);
+}
+
+#task-control select {
+ font: inherit;
+ padding: 0.5rem;
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
\ No newline at end of file
diff --git a/code/02 Basics/05 get() vs find()/src/components/TaskControl.jsx b/code/02 Basics/05 get() vs find()/src/components/TaskControl.jsx
new file mode 100644
index 0000000..65f3cfd
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/TaskControl.jsx
@@ -0,0 +1,14 @@
+import Filter from './Filter';
+
+import './TaskControl.css';
+
+function TaskControl({ onStartAddTask, onSetFilter }) {
+ return (
+
+ Add Task
+
+
+ );
+}
+
+export default TaskControl;
diff --git a/code/02 Basics/05 get() vs find()/src/components/TaskList.css b/code/02 Basics/05 get() vs find()/src/components/TaskList.css
new file mode 100644
index 0000000..111d52b
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/TaskList.css
@@ -0,0 +1,13 @@
+.task-list {
+ list-style: none;
+ margin: 2rem 0;
+ padding: 0;
+}
+
+.no-tasks {
+ text-align: center;
+ font-weight: bold;
+ color: var(--color-gray-400);
+ font-size: 2rem;
+ margin: 3rem auto;
+}
diff --git a/code/02 Basics/05 get() vs find()/src/components/TaskList.jsx b/code/02 Basics/05 get() vs find()/src/components/TaskList.jsx
new file mode 100644
index 0000000..007b7ed
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/components/TaskList.jsx
@@ -0,0 +1,18 @@
+import Task from './Task';
+import './TaskList.css';
+
+function TaskList({ tasks }) {
+ if (!tasks || tasks.length === 0) {
+ return No tasks found!
;
+ }
+
+ return (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ );
+}
+
+export default TaskList;
diff --git a/code/02 Basics/05 get() vs find()/src/index.css b/code/02 Basics/05 get() vs find()/src/index.css
new file mode 100644
index 0000000..572e24b
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/index.css
@@ -0,0 +1,61 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+
+ --color-gray-100: #f9f7fc;
+ --color-gray-200: #f3eefc;
+ --color-gray-300: #c7c0da;
+ --color-gray-400: #a396bf;
+ --color-gray-500: #8172a2;
+ --color-gray-600: #6b5f8a;
+ --color-gray-700: #5a4f73;
+ --color-gray-800: #4c4160;
+ --color-gray-900: #3c334d;
+
+ --color-primary-100: #f4ebff;
+ --color-primary-200: #e1d0ff;
+ --color-primary-300: #c9aaff;
+ --color-primary-400: #b085f5;
+ --color-primary-500: #9755f5;
+ --color-primary-600: #7442c8;
+ --color-primary-700: #5a32a3;
+ --color-primary-800: #4c2889;
+ --color-primary-900: #3c1d6b;
+}
+
+html {
+ /* background-color: var(--color-gray-900); */
+ height: 100%;
+ background: linear-gradient(
+ 160deg,
+ #241b33,
+ #281b42
+ );
+ color: var(--color-gray-100);
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ max-width: 40rem;
+ margin: 2rem auto;
+ padding: 3rem;
+ border-radius: 8px;
+ background-color: var(--color-gray-900);
+ border: 2px solid var(--color-gray-300);
+}
diff --git a/code/02 Basics/05 get() vs find()/src/main.jsx b/code/02 Basics/05 get() vs find()/src/main.jsx
new file mode 100644
index 0000000..5cc5991
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/code/02 Basics/05 get() vs find()/vite.config.js b/code/02 Basics/05 get() vs find()/vite.config.js
new file mode 100644
index 0000000..5a33944
--- /dev/null
+++ b/code/02 Basics/05 get() vs find()/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/code/02 Basics/06 Simulating User Interaction/cypress.config.js b/code/02 Basics/06 Simulating User Interaction/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/02 Basics/06 Simulating User Interaction/cypress/e2e/basics.cy.js b/code/02 Basics/06 Simulating User Interaction/cypress/e2e/basics.cy.js
new file mode 100644
index 0000000..7a8ee21
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/cypress/e2e/basics.cy.js
@@ -0,0 +1,16 @@
+///
+
+describe('tasks page', () => {
+ it('should render the main image', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('.main-header').find('img');
+ // cy.get('.main-header img'); // => also works!
+ });
+
+ it('should display the page title', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('h1').should('have.length', 1);
+ cy.get('h1').contains('My Cypress Course Tasks');
+ // cy.contains('My Cypress Course Tasks');
+ });
+});
diff --git a/code/02 Basics/06 Simulating User Interaction/cypress/e2e/tasks.cy.js b/code/02 Basics/06 Simulating User Interaction/cypress/e2e/tasks.cy.js
new file mode 100644
index 0000000..6f2e5e1
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/cypress/e2e/tasks.cy.js
@@ -0,0 +1,16 @@
+///
+
+describe('tasks management', () => {
+ it('should open and close the new task modal', () => {
+ cy.visit('http://localhost:5173/');
+ cy.contains('Add Task').click();
+ cy.get('.backdrop').click({ force: true });
+ cy.get('.backdrop').should('not.exist');
+ cy.get('.modal').should('not.exist');
+
+ cy.contains('Add Task').click();
+ cy.contains('Cancel').click();
+ cy.get('.backdrop').should('not.exist');
+ cy.get('.modal').should('not.exist');
+ });
+});
diff --git a/code/02 Basics/06 Simulating User Interaction/cypress/fixtures/example.json b/code/02 Basics/06 Simulating User Interaction/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/02 Basics/06 Simulating User Interaction/cypress/support/commands.js b/code/02 Basics/06 Simulating User Interaction/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/02 Basics/06 Simulating User Interaction/cypress/support/e2e.js b/code/02 Basics/06 Simulating User Interaction/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/02 Basics/06 Simulating User Interaction/index.html b/code/02 Basics/06 Simulating User Interaction/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/02 Basics/06 Simulating User Interaction/package.json b/code/02 Basics/06 Simulating User Interaction/package.json
new file mode 100644
index 0000000..d6193b3
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "cypress-basics",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.27",
+ "@types/react-dom": "^18.0.10",
+ "@vitejs/plugin-react": "^3.1.0",
+ "vite": "^4.1.0"
+ }
+}
diff --git a/code/02 Basics/06 Simulating User Interaction/public/vite.svg b/code/02 Basics/06 Simulating User Interaction/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/02 Basics/06 Simulating User Interaction/src/App.jsx b/code/02 Basics/06 Simulating User Interaction/src/App.jsx
new file mode 100644
index 0000000..43955a9
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/App.jsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import Header from './components/Header';
+import Modal from './components/Modal';
+
+import NewTask from './components/NewTask';
+import TaskControl from './components/TaskControl';
+import TaskList from './components/TaskList';
+
+function App() {
+ const [isAddingTask, setIsAddingTask] = useState(false);
+ const [tasks, setTasks] = useState([]);
+ const [appliedFilter, setAppliedFilter] = useState('all');
+
+ const displayedTasks = tasks.filter((task) => {
+ if (appliedFilter === 'all') {
+ return true;
+ }
+ return task.category === appliedFilter;
+ });
+
+ function startAddTaskHandler() {
+ setIsAddingTask(true);
+ }
+
+ function cancelAddTaskHandler() {
+ setIsAddingTask(false);
+ }
+
+ function addTaskHandler(taskData) {
+ setTasks((prevTasks) => {
+ return [
+ ...prevTasks,
+ {
+ id: Math.random().toString(),
+ ...taskData,
+ },
+ ];
+ });
+ setIsAddingTask(false);
+ }
+
+ function setFilterHandler(category) {
+ setAppliedFilter(category);
+ }
+
+ return (
+ <>
+ {isAddingTask && (
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/02 Basics/06 Simulating User Interaction/src/assets/logo.png b/code/02 Basics/06 Simulating User Interaction/src/assets/logo.png
new file mode 100644
index 0000000..2a8d015
Binary files /dev/null and b/code/02 Basics/06 Simulating User Interaction/src/assets/logo.png differ
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/Filter.jsx b/code/02 Basics/06 Simulating User Interaction/src/components/Filter.jsx
new file mode 100644
index 0000000..b0510e6
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/Filter.jsx
@@ -0,0 +1,17 @@
+function Filter({ onFilterChange }) {
+ function filterChangeHandler(event) {
+ onFilterChange(event.target.value);
+ }
+
+ return (
+
+ All
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+ );
+}
+
+export default Filter;
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/Header.css b/code/02 Basics/06 Simulating User Interaction/src/components/Header.css
new file mode 100644
index 0000000..2ba4a37
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/Header.css
@@ -0,0 +1,12 @@
+.main-header {
+ margin: 3rem auto;
+ text-align: center;
+ color: var(--color-gray-400);
+}
+
+.main-header img {
+ width: 7rem;
+ height: 7rem;
+ object-fit: contain;
+ transform: rotateZ(10deg);
+}
\ No newline at end of file
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/Header.jsx b/code/02 Basics/06 Simulating User Interaction/src/components/Header.jsx
new file mode 100644
index 0000000..d3bc6d9
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/Header.jsx
@@ -0,0 +1,13 @@
+import './Header.css';
+import logo from '../assets/logo.png';
+
+function Header() {
+ return (
+
+
+ My Cypress Course Tasks
+
+ );
+}
+
+export default Header;
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/Modal.css b/code/02 Basics/06 Simulating User Interaction/src/components/Modal.css
new file mode 100644
index 0000000..4c57acd
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/Modal.css
@@ -0,0 +1,24 @@
+.backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 1;
+}
+
+.modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ margin: 0;
+ padding: 2rem;
+ transform: translate(-50%, -50%);
+ width: 40rem;
+ background-color: var(--color-gray-800);
+ border: none;
+ border-radius: 4px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+}
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/Modal.jsx b/code/02 Basics/06 Simulating User Interaction/src/components/Modal.jsx
new file mode 100644
index 0000000..7347c66
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/Modal.jsx
@@ -0,0 +1,14 @@
+import './Modal.css';
+
+function Modal({ children, onClose }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/NewTask.css b/code/02 Basics/06 Simulating User Interaction/src/components/NewTask.css
new file mode 100644
index 0000000..e9a37e7
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/NewTask.css
@@ -0,0 +1,62 @@
+#new-task-form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+#new-task-form input,
+#new-task-form textarea {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+#new-task-form select {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+#new-task-form button {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-primary-500);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#new-task-form button:hover {
+ background-color: var(--color-primary-600);
+}
+
+#new-task-form button[type="button"] {
+ background-color: transparent;
+ color: var(--color-gray-200);
+}
+
+#new-task-form button[type="button"]:hover {
+ background-color: var(--color-gray-700);
+}
+
+
+
+.error-message {
+ color: var(--color-primary-300);
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+}
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/NewTask.jsx b/code/02 Basics/06 Simulating User Interaction/src/components/NewTask.jsx
new file mode 100644
index 0000000..17f1540
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/NewTask.jsx
@@ -0,0 +1,67 @@
+import { useRef, useState } from 'react';
+
+import './NewTask.css';
+
+function NewTask({ onAddTask, onCancel }) {
+ const titleRef = useRef();
+ const summaryRef = useRef();
+ const categoryRef = useRef();
+
+ const [formInvalid, setFormInvalid] = useState(false);
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ const enteredTitle = titleRef.current.value;
+ const enteredSummary = summaryRef.current.value;
+ const chosenCategory = categoryRef.current.value;
+
+ if (
+ enteredTitle.trim().length === 0 ||
+ enteredSummary.trim().length === 0
+ ) {
+ setFormInvalid(true);
+ return;
+ }
+
+ const taskData = {
+ title: enteredTitle,
+ summary: enteredSummary,
+ category: chosenCategory,
+ };
+ onAddTask(taskData);
+ }
+
+ return (
+
+
+ Title
+
+
+
+ Summary
+
+
+
+ Category
+
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+
+ {formInvalid && (
+
+ Please provide values for task title, summary and category!
+
+ )}
+
+ Cancel
+ Add Task
+
+
+ );
+}
+
+export default NewTask;
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/Task.css b/code/02 Basics/06 Simulating User Interaction/src/components/Task.css
new file mode 100644
index 0000000..ee3c6f3
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/Task.css
@@ -0,0 +1,26 @@
+.task {
+ display: flex;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 1rem;
+ border: 1px solid var(--color-gray-600);
+ background-color: var(--color-gray-700);
+ border-radius: 4px;
+}
+
+.task-category {
+ font-size: 1.25rem;
+}
+
+.task h2 {
+ margin: 0;
+ color: var(--color-gray-300);
+ font-size: 1rem;
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.task p {
+ margin: 0;
+ color: var(--color-gray-200);
+}
\ No newline at end of file
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/Task.jsx b/code/02 Basics/06 Simulating User Interaction/src/components/Task.jsx
new file mode 100644
index 0000000..9867dab
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/Task.jsx
@@ -0,0 +1,22 @@
+import './Task.css';
+
+const CATEGORY_ICONS = {
+ urgent: '🚨',
+ important: '🔴',
+ moderate: '🔵',
+ low: '🟢',
+};
+
+function Task({ category, title, summary }) {
+ return (
+
+ {CATEGORY_ICONS[category]}
+
+
+ );
+}
+
+export default Task;
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/TaskControl.css b/code/02 Basics/06 Simulating User Interaction/src/components/TaskControl.css
new file mode 100644
index 0000000..c334096
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/TaskControl.css
@@ -0,0 +1,28 @@
+#task-control {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+#task-control button {
+ font: inherit;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-gray-800);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#task-control button:hover {
+ background-color: var(--color-gray-700);
+}
+
+#task-control select {
+ font: inherit;
+ padding: 0.5rem;
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
\ No newline at end of file
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/TaskControl.jsx b/code/02 Basics/06 Simulating User Interaction/src/components/TaskControl.jsx
new file mode 100644
index 0000000..65f3cfd
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/TaskControl.jsx
@@ -0,0 +1,14 @@
+import Filter from './Filter';
+
+import './TaskControl.css';
+
+function TaskControl({ onStartAddTask, onSetFilter }) {
+ return (
+
+ Add Task
+
+
+ );
+}
+
+export default TaskControl;
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/TaskList.css b/code/02 Basics/06 Simulating User Interaction/src/components/TaskList.css
new file mode 100644
index 0000000..111d52b
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/TaskList.css
@@ -0,0 +1,13 @@
+.task-list {
+ list-style: none;
+ margin: 2rem 0;
+ padding: 0;
+}
+
+.no-tasks {
+ text-align: center;
+ font-weight: bold;
+ color: var(--color-gray-400);
+ font-size: 2rem;
+ margin: 3rem auto;
+}
diff --git a/code/02 Basics/06 Simulating User Interaction/src/components/TaskList.jsx b/code/02 Basics/06 Simulating User Interaction/src/components/TaskList.jsx
new file mode 100644
index 0000000..007b7ed
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/components/TaskList.jsx
@@ -0,0 +1,18 @@
+import Task from './Task';
+import './TaskList.css';
+
+function TaskList({ tasks }) {
+ if (!tasks || tasks.length === 0) {
+ return No tasks found!
;
+ }
+
+ return (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ );
+}
+
+export default TaskList;
diff --git a/code/02 Basics/06 Simulating User Interaction/src/index.css b/code/02 Basics/06 Simulating User Interaction/src/index.css
new file mode 100644
index 0000000..572e24b
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/index.css
@@ -0,0 +1,61 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+
+ --color-gray-100: #f9f7fc;
+ --color-gray-200: #f3eefc;
+ --color-gray-300: #c7c0da;
+ --color-gray-400: #a396bf;
+ --color-gray-500: #8172a2;
+ --color-gray-600: #6b5f8a;
+ --color-gray-700: #5a4f73;
+ --color-gray-800: #4c4160;
+ --color-gray-900: #3c334d;
+
+ --color-primary-100: #f4ebff;
+ --color-primary-200: #e1d0ff;
+ --color-primary-300: #c9aaff;
+ --color-primary-400: #b085f5;
+ --color-primary-500: #9755f5;
+ --color-primary-600: #7442c8;
+ --color-primary-700: #5a32a3;
+ --color-primary-800: #4c2889;
+ --color-primary-900: #3c1d6b;
+}
+
+html {
+ /* background-color: var(--color-gray-900); */
+ height: 100%;
+ background: linear-gradient(
+ 160deg,
+ #241b33,
+ #281b42
+ );
+ color: var(--color-gray-100);
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ max-width: 40rem;
+ margin: 2rem auto;
+ padding: 3rem;
+ border-radius: 8px;
+ background-color: var(--color-gray-900);
+ border: 2px solid var(--color-gray-300);
+}
diff --git a/code/02 Basics/06 Simulating User Interaction/src/main.jsx b/code/02 Basics/06 Simulating User Interaction/src/main.jsx
new file mode 100644
index 0000000..5cc5991
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/code/02 Basics/06 Simulating User Interaction/vite.config.js b/code/02 Basics/06 Simulating User Interaction/vite.config.js
new file mode 100644
index 0000000..5a33944
--- /dev/null
+++ b/code/02 Basics/06 Simulating User Interaction/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/cypress.config.js b/code/02 Basics/07 Simulating Keyboard Typing/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/cypress/e2e/basics.cy.js b/code/02 Basics/07 Simulating Keyboard Typing/cypress/e2e/basics.cy.js
new file mode 100644
index 0000000..7a8ee21
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/cypress/e2e/basics.cy.js
@@ -0,0 +1,16 @@
+///
+
+describe('tasks page', () => {
+ it('should render the main image', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('.main-header').find('img');
+ // cy.get('.main-header img'); // => also works!
+ });
+
+ it('should display the page title', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('h1').should('have.length', 1);
+ cy.get('h1').contains('My Cypress Course Tasks');
+ // cy.contains('My Cypress Course Tasks');
+ });
+});
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/cypress/e2e/tasks.cy.js b/code/02 Basics/07 Simulating Keyboard Typing/cypress/e2e/tasks.cy.js
new file mode 100644
index 0000000..7a0ad71
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/cypress/e2e/tasks.cy.js
@@ -0,0 +1,29 @@
+///
+
+describe('tasks management', () => {
+ it('should open and close the new task modal', () => {
+ cy.visit('http://localhost:5173/');
+ cy.contains('Add Task').click();
+ cy.get('.backdrop').click({ force: true });
+ cy.get('.backdrop').should('not.exist');
+ cy.get('.modal').should('not.exist');
+
+ cy.contains('Add Task').click();
+ cy.contains('Cancel').click();
+ cy.get('.backdrop').should('not.exist');
+ cy.get('.modal').should('not.exist');
+ });
+
+ it('should create a new task', () => {
+ cy.visit('http://localhost:5173/');
+ cy.contains('Add Task').click();
+ cy.get('#title').type('New Task');
+ cy.get('#summary').type('Some description');
+ cy.get('.modal').contains('Add Task').click();
+ cy.get('.backdrop').should('not.exist');
+ cy.get('.modal').should('not.exist');
+ cy.get('.task').should('have.length', 1);
+ cy.get('.task h2').contains('New Task');
+ cy.get('.task p').contains('Some description');
+ });
+});
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/cypress/fixtures/example.json b/code/02 Basics/07 Simulating Keyboard Typing/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/cypress/support/commands.js b/code/02 Basics/07 Simulating Keyboard Typing/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/cypress/support/e2e.js b/code/02 Basics/07 Simulating Keyboard Typing/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/index.html b/code/02 Basics/07 Simulating Keyboard Typing/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/package.json b/code/02 Basics/07 Simulating Keyboard Typing/package.json
new file mode 100644
index 0000000..d6193b3
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "cypress-basics",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.27",
+ "@types/react-dom": "^18.0.10",
+ "@vitejs/plugin-react": "^3.1.0",
+ "vite": "^4.1.0"
+ }
+}
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/public/vite.svg b/code/02 Basics/07 Simulating Keyboard Typing/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/App.jsx b/code/02 Basics/07 Simulating Keyboard Typing/src/App.jsx
new file mode 100644
index 0000000..43955a9
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/App.jsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import Header from './components/Header';
+import Modal from './components/Modal';
+
+import NewTask from './components/NewTask';
+import TaskControl from './components/TaskControl';
+import TaskList from './components/TaskList';
+
+function App() {
+ const [isAddingTask, setIsAddingTask] = useState(false);
+ const [tasks, setTasks] = useState([]);
+ const [appliedFilter, setAppliedFilter] = useState('all');
+
+ const displayedTasks = tasks.filter((task) => {
+ if (appliedFilter === 'all') {
+ return true;
+ }
+ return task.category === appliedFilter;
+ });
+
+ function startAddTaskHandler() {
+ setIsAddingTask(true);
+ }
+
+ function cancelAddTaskHandler() {
+ setIsAddingTask(false);
+ }
+
+ function addTaskHandler(taskData) {
+ setTasks((prevTasks) => {
+ return [
+ ...prevTasks,
+ {
+ id: Math.random().toString(),
+ ...taskData,
+ },
+ ];
+ });
+ setIsAddingTask(false);
+ }
+
+ function setFilterHandler(category) {
+ setAppliedFilter(category);
+ }
+
+ return (
+ <>
+ {isAddingTask && (
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/assets/logo.png b/code/02 Basics/07 Simulating Keyboard Typing/src/assets/logo.png
new file mode 100644
index 0000000..2a8d015
Binary files /dev/null and b/code/02 Basics/07 Simulating Keyboard Typing/src/assets/logo.png differ
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/Filter.jsx b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Filter.jsx
new file mode 100644
index 0000000..b0510e6
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Filter.jsx
@@ -0,0 +1,17 @@
+function Filter({ onFilterChange }) {
+ function filterChangeHandler(event) {
+ onFilterChange(event.target.value);
+ }
+
+ return (
+
+ All
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+ );
+}
+
+export default Filter;
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/Header.css b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Header.css
new file mode 100644
index 0000000..2ba4a37
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Header.css
@@ -0,0 +1,12 @@
+.main-header {
+ margin: 3rem auto;
+ text-align: center;
+ color: var(--color-gray-400);
+}
+
+.main-header img {
+ width: 7rem;
+ height: 7rem;
+ object-fit: contain;
+ transform: rotateZ(10deg);
+}
\ No newline at end of file
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/Header.jsx b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Header.jsx
new file mode 100644
index 0000000..d3bc6d9
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Header.jsx
@@ -0,0 +1,13 @@
+import './Header.css';
+import logo from '../assets/logo.png';
+
+function Header() {
+ return (
+
+
+ My Cypress Course Tasks
+
+ );
+}
+
+export default Header;
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/Modal.css b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Modal.css
new file mode 100644
index 0000000..4c57acd
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Modal.css
@@ -0,0 +1,24 @@
+.backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 1;
+}
+
+.modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ margin: 0;
+ padding: 2rem;
+ transform: translate(-50%, -50%);
+ width: 40rem;
+ background-color: var(--color-gray-800);
+ border: none;
+ border-radius: 4px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+}
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/Modal.jsx b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Modal.jsx
new file mode 100644
index 0000000..7347c66
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Modal.jsx
@@ -0,0 +1,14 @@
+import './Modal.css';
+
+function Modal({ children, onClose }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/NewTask.css b/code/02 Basics/07 Simulating Keyboard Typing/src/components/NewTask.css
new file mode 100644
index 0000000..e9a37e7
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/NewTask.css
@@ -0,0 +1,62 @@
+#new-task-form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+#new-task-form input,
+#new-task-form textarea {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+#new-task-form select {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+#new-task-form button {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-primary-500);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#new-task-form button:hover {
+ background-color: var(--color-primary-600);
+}
+
+#new-task-form button[type="button"] {
+ background-color: transparent;
+ color: var(--color-gray-200);
+}
+
+#new-task-form button[type="button"]:hover {
+ background-color: var(--color-gray-700);
+}
+
+
+
+.error-message {
+ color: var(--color-primary-300);
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+}
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/NewTask.jsx b/code/02 Basics/07 Simulating Keyboard Typing/src/components/NewTask.jsx
new file mode 100644
index 0000000..17f1540
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/NewTask.jsx
@@ -0,0 +1,67 @@
+import { useRef, useState } from 'react';
+
+import './NewTask.css';
+
+function NewTask({ onAddTask, onCancel }) {
+ const titleRef = useRef();
+ const summaryRef = useRef();
+ const categoryRef = useRef();
+
+ const [formInvalid, setFormInvalid] = useState(false);
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ const enteredTitle = titleRef.current.value;
+ const enteredSummary = summaryRef.current.value;
+ const chosenCategory = categoryRef.current.value;
+
+ if (
+ enteredTitle.trim().length === 0 ||
+ enteredSummary.trim().length === 0
+ ) {
+ setFormInvalid(true);
+ return;
+ }
+
+ const taskData = {
+ title: enteredTitle,
+ summary: enteredSummary,
+ category: chosenCategory,
+ };
+ onAddTask(taskData);
+ }
+
+ return (
+
+
+ Title
+
+
+
+ Summary
+
+
+
+ Category
+
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+
+ {formInvalid && (
+
+ Please provide values for task title, summary and category!
+
+ )}
+
+ Cancel
+ Add Task
+
+
+ );
+}
+
+export default NewTask;
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/Task.css b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Task.css
new file mode 100644
index 0000000..ee3c6f3
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Task.css
@@ -0,0 +1,26 @@
+.task {
+ display: flex;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 1rem;
+ border: 1px solid var(--color-gray-600);
+ background-color: var(--color-gray-700);
+ border-radius: 4px;
+}
+
+.task-category {
+ font-size: 1.25rem;
+}
+
+.task h2 {
+ margin: 0;
+ color: var(--color-gray-300);
+ font-size: 1rem;
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.task p {
+ margin: 0;
+ color: var(--color-gray-200);
+}
\ No newline at end of file
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/Task.jsx b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Task.jsx
new file mode 100644
index 0000000..9867dab
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/Task.jsx
@@ -0,0 +1,22 @@
+import './Task.css';
+
+const CATEGORY_ICONS = {
+ urgent: '🚨',
+ important: '🔴',
+ moderate: '🔵',
+ low: '🟢',
+};
+
+function Task({ category, title, summary }) {
+ return (
+
+ {CATEGORY_ICONS[category]}
+
+
+ );
+}
+
+export default Task;
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/TaskControl.css b/code/02 Basics/07 Simulating Keyboard Typing/src/components/TaskControl.css
new file mode 100644
index 0000000..c334096
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/TaskControl.css
@@ -0,0 +1,28 @@
+#task-control {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+#task-control button {
+ font: inherit;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-gray-800);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#task-control button:hover {
+ background-color: var(--color-gray-700);
+}
+
+#task-control select {
+ font: inherit;
+ padding: 0.5rem;
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
\ No newline at end of file
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/TaskControl.jsx b/code/02 Basics/07 Simulating Keyboard Typing/src/components/TaskControl.jsx
new file mode 100644
index 0000000..65f3cfd
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/TaskControl.jsx
@@ -0,0 +1,14 @@
+import Filter from './Filter';
+
+import './TaskControl.css';
+
+function TaskControl({ onStartAddTask, onSetFilter }) {
+ return (
+
+ Add Task
+
+
+ );
+}
+
+export default TaskControl;
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/TaskList.css b/code/02 Basics/07 Simulating Keyboard Typing/src/components/TaskList.css
new file mode 100644
index 0000000..111d52b
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/TaskList.css
@@ -0,0 +1,13 @@
+.task-list {
+ list-style: none;
+ margin: 2rem 0;
+ padding: 0;
+}
+
+.no-tasks {
+ text-align: center;
+ font-weight: bold;
+ color: var(--color-gray-400);
+ font-size: 2rem;
+ margin: 3rem auto;
+}
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/components/TaskList.jsx b/code/02 Basics/07 Simulating Keyboard Typing/src/components/TaskList.jsx
new file mode 100644
index 0000000..007b7ed
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/components/TaskList.jsx
@@ -0,0 +1,18 @@
+import Task from './Task';
+import './TaskList.css';
+
+function TaskList({ tasks }) {
+ if (!tasks || tasks.length === 0) {
+ return No tasks found!
;
+ }
+
+ return (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ );
+}
+
+export default TaskList;
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/index.css b/code/02 Basics/07 Simulating Keyboard Typing/src/index.css
new file mode 100644
index 0000000..572e24b
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/index.css
@@ -0,0 +1,61 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+
+ --color-gray-100: #f9f7fc;
+ --color-gray-200: #f3eefc;
+ --color-gray-300: #c7c0da;
+ --color-gray-400: #a396bf;
+ --color-gray-500: #8172a2;
+ --color-gray-600: #6b5f8a;
+ --color-gray-700: #5a4f73;
+ --color-gray-800: #4c4160;
+ --color-gray-900: #3c334d;
+
+ --color-primary-100: #f4ebff;
+ --color-primary-200: #e1d0ff;
+ --color-primary-300: #c9aaff;
+ --color-primary-400: #b085f5;
+ --color-primary-500: #9755f5;
+ --color-primary-600: #7442c8;
+ --color-primary-700: #5a32a3;
+ --color-primary-800: #4c2889;
+ --color-primary-900: #3c1d6b;
+}
+
+html {
+ /* background-color: var(--color-gray-900); */
+ height: 100%;
+ background: linear-gradient(
+ 160deg,
+ #241b33,
+ #281b42
+ );
+ color: var(--color-gray-100);
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ max-width: 40rem;
+ margin: 2rem auto;
+ padding: 3rem;
+ border-radius: 8px;
+ background-color: var(--color-gray-900);
+ border: 2px solid var(--color-gray-300);
+}
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/src/main.jsx b/code/02 Basics/07 Simulating Keyboard Typing/src/main.jsx
new file mode 100644
index 0000000..5cc5991
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/code/02 Basics/07 Simulating Keyboard Typing/vite.config.js b/code/02 Basics/07 Simulating Keyboard Typing/vite.config.js
new file mode 100644
index 0000000..5a33944
--- /dev/null
+++ b/code/02 Basics/07 Simulating Keyboard Typing/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/code/02 Basics/08 Selecting Dropdown Values/cypress.config.js b/code/02 Basics/08 Selecting Dropdown Values/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/02 Basics/08 Selecting Dropdown Values/cypress/e2e/basics.cy.js b/code/02 Basics/08 Selecting Dropdown Values/cypress/e2e/basics.cy.js
new file mode 100644
index 0000000..7a8ee21
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/cypress/e2e/basics.cy.js
@@ -0,0 +1,16 @@
+///
+
+describe('tasks page', () => {
+ it('should render the main image', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('.main-header').find('img');
+ // cy.get('.main-header img'); // => also works!
+ });
+
+ it('should display the page title', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('h1').should('have.length', 1);
+ cy.get('h1').contains('My Cypress Course Tasks');
+ // cy.contains('My Cypress Course Tasks');
+ });
+});
diff --git a/code/02 Basics/08 Selecting Dropdown Values/cypress/e2e/tasks.cy.js b/code/02 Basics/08 Selecting Dropdown Values/cypress/e2e/tasks.cy.js
new file mode 100644
index 0000000..f4cc8f3
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/cypress/e2e/tasks.cy.js
@@ -0,0 +1,52 @@
+///
+
+describe('tasks management', () => {
+ it('should open and close the new task modal', () => {
+ cy.visit('http://localhost:5173/');
+ cy.contains('Add Task').click();
+ cy.get('.backdrop').click({ force: true });
+ cy.get('.backdrop').should('not.exist');
+ cy.get('.modal').should('not.exist');
+
+ cy.contains('Add Task').click();
+ cy.contains('Cancel').click();
+ cy.get('.backdrop').should('not.exist');
+ cy.get('.modal').should('not.exist');
+ });
+
+ it('should create a new task', () => {
+ cy.visit('http://localhost:5173/');
+ cy.contains('Add Task').click();
+ cy.get('#title').type('New Task');
+ cy.get('#summary').type('Some description');
+ cy.get('.modal').contains('Add Task').click();
+ cy.get('.backdrop').should('not.exist');
+ cy.get('.modal').should('not.exist');
+ cy.get('.task').should('have.length', 1);
+ cy.get('.task h2').contains('New Task');
+ cy.get('.task p').contains('Some description');
+ });
+
+ it('should validate user input', () => {
+ cy.visit('http://localhost:5173/');
+ cy.contains('Add Task').click();
+ cy.get('.modal').contains('Add Task').click();
+ cy.contains('Please provide values');
+ });
+
+ it('should filter tasks', () => {
+ cy.visit('http://localhost:5173/');
+ cy.contains('Add Task').click();
+ cy.get('#title').type('New Task');
+ cy.get('#summary').type('Some description');
+ cy.get('#category').select('urgent');
+ cy.get('.modal').contains('Add Task').click();
+ cy.get('.task').should('have.length', 1);
+ cy.get('#filter').select('moderate');
+ cy.get('.task').should('have.length', 0);
+ cy.get('#filter').select('urgent');
+ cy.get('.task').should('have.length', 1);
+ cy.get('#filter').select('all');
+ cy.get('.task').should('have.length', 1);
+ });
+});
diff --git a/code/02 Basics/08 Selecting Dropdown Values/cypress/fixtures/example.json b/code/02 Basics/08 Selecting Dropdown Values/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/02 Basics/08 Selecting Dropdown Values/cypress/support/commands.js b/code/02 Basics/08 Selecting Dropdown Values/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/02 Basics/08 Selecting Dropdown Values/cypress/support/e2e.js b/code/02 Basics/08 Selecting Dropdown Values/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/02 Basics/08 Selecting Dropdown Values/index.html b/code/02 Basics/08 Selecting Dropdown Values/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/02 Basics/08 Selecting Dropdown Values/package.json b/code/02 Basics/08 Selecting Dropdown Values/package.json
new file mode 100644
index 0000000..d6193b3
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "cypress-basics",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.27",
+ "@types/react-dom": "^18.0.10",
+ "@vitejs/plugin-react": "^3.1.0",
+ "vite": "^4.1.0"
+ }
+}
diff --git a/code/02 Basics/08 Selecting Dropdown Values/public/vite.svg b/code/02 Basics/08 Selecting Dropdown Values/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/App.jsx b/code/02 Basics/08 Selecting Dropdown Values/src/App.jsx
new file mode 100644
index 0000000..43955a9
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/App.jsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import Header from './components/Header';
+import Modal from './components/Modal';
+
+import NewTask from './components/NewTask';
+import TaskControl from './components/TaskControl';
+import TaskList from './components/TaskList';
+
+function App() {
+ const [isAddingTask, setIsAddingTask] = useState(false);
+ const [tasks, setTasks] = useState([]);
+ const [appliedFilter, setAppliedFilter] = useState('all');
+
+ const displayedTasks = tasks.filter((task) => {
+ if (appliedFilter === 'all') {
+ return true;
+ }
+ return task.category === appliedFilter;
+ });
+
+ function startAddTaskHandler() {
+ setIsAddingTask(true);
+ }
+
+ function cancelAddTaskHandler() {
+ setIsAddingTask(false);
+ }
+
+ function addTaskHandler(taskData) {
+ setTasks((prevTasks) => {
+ return [
+ ...prevTasks,
+ {
+ id: Math.random().toString(),
+ ...taskData,
+ },
+ ];
+ });
+ setIsAddingTask(false);
+ }
+
+ function setFilterHandler(category) {
+ setAppliedFilter(category);
+ }
+
+ return (
+ <>
+ {isAddingTask && (
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/assets/logo.png b/code/02 Basics/08 Selecting Dropdown Values/src/assets/logo.png
new file mode 100644
index 0000000..2a8d015
Binary files /dev/null and b/code/02 Basics/08 Selecting Dropdown Values/src/assets/logo.png differ
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/Filter.jsx b/code/02 Basics/08 Selecting Dropdown Values/src/components/Filter.jsx
new file mode 100644
index 0000000..b0510e6
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/Filter.jsx
@@ -0,0 +1,17 @@
+function Filter({ onFilterChange }) {
+ function filterChangeHandler(event) {
+ onFilterChange(event.target.value);
+ }
+
+ return (
+
+ All
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+ );
+}
+
+export default Filter;
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/Header.css b/code/02 Basics/08 Selecting Dropdown Values/src/components/Header.css
new file mode 100644
index 0000000..2ba4a37
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/Header.css
@@ -0,0 +1,12 @@
+.main-header {
+ margin: 3rem auto;
+ text-align: center;
+ color: var(--color-gray-400);
+}
+
+.main-header img {
+ width: 7rem;
+ height: 7rem;
+ object-fit: contain;
+ transform: rotateZ(10deg);
+}
\ No newline at end of file
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/Header.jsx b/code/02 Basics/08 Selecting Dropdown Values/src/components/Header.jsx
new file mode 100644
index 0000000..d3bc6d9
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/Header.jsx
@@ -0,0 +1,13 @@
+import './Header.css';
+import logo from '../assets/logo.png';
+
+function Header() {
+ return (
+
+
+ My Cypress Course Tasks
+
+ );
+}
+
+export default Header;
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/Modal.css b/code/02 Basics/08 Selecting Dropdown Values/src/components/Modal.css
new file mode 100644
index 0000000..4c57acd
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/Modal.css
@@ -0,0 +1,24 @@
+.backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 1;
+}
+
+.modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ margin: 0;
+ padding: 2rem;
+ transform: translate(-50%, -50%);
+ width: 40rem;
+ background-color: var(--color-gray-800);
+ border: none;
+ border-radius: 4px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+}
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/Modal.jsx b/code/02 Basics/08 Selecting Dropdown Values/src/components/Modal.jsx
new file mode 100644
index 0000000..7347c66
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/Modal.jsx
@@ -0,0 +1,14 @@
+import './Modal.css';
+
+function Modal({ children, onClose }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/NewTask.css b/code/02 Basics/08 Selecting Dropdown Values/src/components/NewTask.css
new file mode 100644
index 0000000..e9a37e7
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/NewTask.css
@@ -0,0 +1,62 @@
+#new-task-form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+#new-task-form input,
+#new-task-form textarea {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+#new-task-form select {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+#new-task-form button {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-primary-500);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#new-task-form button:hover {
+ background-color: var(--color-primary-600);
+}
+
+#new-task-form button[type="button"] {
+ background-color: transparent;
+ color: var(--color-gray-200);
+}
+
+#new-task-form button[type="button"]:hover {
+ background-color: var(--color-gray-700);
+}
+
+
+
+.error-message {
+ color: var(--color-primary-300);
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+}
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/NewTask.jsx b/code/02 Basics/08 Selecting Dropdown Values/src/components/NewTask.jsx
new file mode 100644
index 0000000..17f1540
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/NewTask.jsx
@@ -0,0 +1,67 @@
+import { useRef, useState } from 'react';
+
+import './NewTask.css';
+
+function NewTask({ onAddTask, onCancel }) {
+ const titleRef = useRef();
+ const summaryRef = useRef();
+ const categoryRef = useRef();
+
+ const [formInvalid, setFormInvalid] = useState(false);
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ const enteredTitle = titleRef.current.value;
+ const enteredSummary = summaryRef.current.value;
+ const chosenCategory = categoryRef.current.value;
+
+ if (
+ enteredTitle.trim().length === 0 ||
+ enteredSummary.trim().length === 0
+ ) {
+ setFormInvalid(true);
+ return;
+ }
+
+ const taskData = {
+ title: enteredTitle,
+ summary: enteredSummary,
+ category: chosenCategory,
+ };
+ onAddTask(taskData);
+ }
+
+ return (
+
+
+ Title
+
+
+
+ Summary
+
+
+
+ Category
+
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+
+ {formInvalid && (
+
+ Please provide values for task title, summary and category!
+
+ )}
+
+ Cancel
+ Add Task
+
+
+ );
+}
+
+export default NewTask;
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/Task.css b/code/02 Basics/08 Selecting Dropdown Values/src/components/Task.css
new file mode 100644
index 0000000..ee3c6f3
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/Task.css
@@ -0,0 +1,26 @@
+.task {
+ display: flex;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 1rem;
+ border: 1px solid var(--color-gray-600);
+ background-color: var(--color-gray-700);
+ border-radius: 4px;
+}
+
+.task-category {
+ font-size: 1.25rem;
+}
+
+.task h2 {
+ margin: 0;
+ color: var(--color-gray-300);
+ font-size: 1rem;
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.task p {
+ margin: 0;
+ color: var(--color-gray-200);
+}
\ No newline at end of file
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/Task.jsx b/code/02 Basics/08 Selecting Dropdown Values/src/components/Task.jsx
new file mode 100644
index 0000000..9867dab
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/Task.jsx
@@ -0,0 +1,22 @@
+import './Task.css';
+
+const CATEGORY_ICONS = {
+ urgent: '🚨',
+ important: '🔴',
+ moderate: '🔵',
+ low: '🟢',
+};
+
+function Task({ category, title, summary }) {
+ return (
+
+ {CATEGORY_ICONS[category]}
+
+
+ );
+}
+
+export default Task;
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/TaskControl.css b/code/02 Basics/08 Selecting Dropdown Values/src/components/TaskControl.css
new file mode 100644
index 0000000..c334096
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/TaskControl.css
@@ -0,0 +1,28 @@
+#task-control {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+#task-control button {
+ font: inherit;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-gray-800);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#task-control button:hover {
+ background-color: var(--color-gray-700);
+}
+
+#task-control select {
+ font: inherit;
+ padding: 0.5rem;
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
\ No newline at end of file
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/TaskControl.jsx b/code/02 Basics/08 Selecting Dropdown Values/src/components/TaskControl.jsx
new file mode 100644
index 0000000..65f3cfd
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/TaskControl.jsx
@@ -0,0 +1,14 @@
+import Filter from './Filter';
+
+import './TaskControl.css';
+
+function TaskControl({ onStartAddTask, onSetFilter }) {
+ return (
+
+ Add Task
+
+
+ );
+}
+
+export default TaskControl;
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/TaskList.css b/code/02 Basics/08 Selecting Dropdown Values/src/components/TaskList.css
new file mode 100644
index 0000000..111d52b
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/TaskList.css
@@ -0,0 +1,13 @@
+.task-list {
+ list-style: none;
+ margin: 2rem 0;
+ padding: 0;
+}
+
+.no-tasks {
+ text-align: center;
+ font-weight: bold;
+ color: var(--color-gray-400);
+ font-size: 2rem;
+ margin: 3rem auto;
+}
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/components/TaskList.jsx b/code/02 Basics/08 Selecting Dropdown Values/src/components/TaskList.jsx
new file mode 100644
index 0000000..007b7ed
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/components/TaskList.jsx
@@ -0,0 +1,18 @@
+import Task from './Task';
+import './TaskList.css';
+
+function TaskList({ tasks }) {
+ if (!tasks || tasks.length === 0) {
+ return No tasks found!
;
+ }
+
+ return (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ );
+}
+
+export default TaskList;
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/index.css b/code/02 Basics/08 Selecting Dropdown Values/src/index.css
new file mode 100644
index 0000000..572e24b
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/index.css
@@ -0,0 +1,61 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+
+ --color-gray-100: #f9f7fc;
+ --color-gray-200: #f3eefc;
+ --color-gray-300: #c7c0da;
+ --color-gray-400: #a396bf;
+ --color-gray-500: #8172a2;
+ --color-gray-600: #6b5f8a;
+ --color-gray-700: #5a4f73;
+ --color-gray-800: #4c4160;
+ --color-gray-900: #3c334d;
+
+ --color-primary-100: #f4ebff;
+ --color-primary-200: #e1d0ff;
+ --color-primary-300: #c9aaff;
+ --color-primary-400: #b085f5;
+ --color-primary-500: #9755f5;
+ --color-primary-600: #7442c8;
+ --color-primary-700: #5a32a3;
+ --color-primary-800: #4c2889;
+ --color-primary-900: #3c1d6b;
+}
+
+html {
+ /* background-color: var(--color-gray-900); */
+ height: 100%;
+ background: linear-gradient(
+ 160deg,
+ #241b33,
+ #281b42
+ );
+ color: var(--color-gray-100);
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ max-width: 40rem;
+ margin: 2rem auto;
+ padding: 3rem;
+ border-radius: 8px;
+ background-color: var(--color-gray-900);
+ border: 2px solid var(--color-gray-300);
+}
diff --git a/code/02 Basics/08 Selecting Dropdown Values/src/main.jsx b/code/02 Basics/08 Selecting Dropdown Values/src/main.jsx
new file mode 100644
index 0000000..5cc5991
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/code/02 Basics/08 Selecting Dropdown Values/vite.config.js b/code/02 Basics/08 Selecting Dropdown Values/vite.config.js
new file mode 100644
index 0000000..5a33944
--- /dev/null
+++ b/code/02 Basics/08 Selecting Dropdown Values/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/code/02 Basics/09 Time For More Queries/cypress.config.js b/code/02 Basics/09 Time For More Queries/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/02 Basics/09 Time For More Queries/cypress/e2e/basics.cy.js b/code/02 Basics/09 Time For More Queries/cypress/e2e/basics.cy.js
new file mode 100644
index 0000000..7a8ee21
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/cypress/e2e/basics.cy.js
@@ -0,0 +1,16 @@
+///
+
+describe('tasks page', () => {
+ it('should render the main image', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('.main-header').find('img');
+ // cy.get('.main-header img'); // => also works!
+ });
+
+ it('should display the page title', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('h1').should('have.length', 1);
+ cy.get('h1').contains('My Cypress Course Tasks');
+ // cy.contains('My Cypress Course Tasks');
+ });
+});
diff --git a/code/02 Basics/09 Time For More Queries/cypress/e2e/tasks.cy.js b/code/02 Basics/09 Time For More Queries/cypress/e2e/tasks.cy.js
new file mode 100644
index 0000000..3653bfc
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/cypress/e2e/tasks.cy.js
@@ -0,0 +1,69 @@
+///
+
+describe('tasks management', () => {
+ it('should open and close the new task modal', () => {
+ cy.visit('http://localhost:5173/');
+ cy.contains('Add Task').click();
+ cy.get('.backdrop').click({ force: true });
+ cy.get('.backdrop').should('not.exist');
+ cy.get('.modal').should('not.exist');
+
+ cy.contains('Add Task').click();
+ cy.contains('Cancel').click();
+ cy.get('.backdrop').should('not.exist');
+ cy.get('.modal').should('not.exist');
+ });
+
+ it('should create a new task', () => {
+ cy.visit('http://localhost:5173/');
+ cy.contains('Add Task').click();
+ cy.get('#title').type('New Task');
+ cy.get('#summary').type('Some description');
+ cy.get('.modal').contains('Add Task').click();
+ cy.get('.backdrop').should('not.exist');
+ cy.get('.modal').should('not.exist');
+ cy.get('.task').should('have.length', 1);
+ cy.get('.task h2').contains('New Task');
+ cy.get('.task p').contains('Some description');
+ });
+
+ it('should validate user input', () => {
+ cy.visit('http://localhost:5173/');
+ cy.contains('Add Task').click();
+ cy.get('.modal').contains('Add Task').click();
+ cy.contains('Please provide values');
+ });
+
+ it('should filter tasks', () => {
+ cy.visit('http://localhost:5173/');
+ cy.contains('Add Task').click();
+ cy.get('#title').type('New Task');
+ cy.get('#summary').type('Some description');
+ cy.get('#category').select('urgent');
+ cy.get('.modal').contains('Add Task').click();
+ cy.get('.task').should('have.length', 1);
+ cy.get('#filter').select('moderate');
+ cy.get('.task').should('have.length', 0);
+ cy.get('#filter').select('urgent');
+ cy.get('.task').should('have.length', 1);
+ cy.get('#filter').select('all');
+ cy.get('.task').should('have.length', 1);
+ });
+
+ it('should add multiple tasks', () => {
+ cy.visit('http://localhost:5173/');
+ cy.contains('Add Task').click();
+ cy.get('#title').type('Task 1');
+ cy.get('#summary').type('First task');
+ cy.get('.modal').contains('Add Task').click();
+ cy.get('.task').should('have.length', 1);
+
+ cy.contains('Add Task').click();
+ cy.get('#title').type('Task 2');
+ cy.get('#summary').type('Second task');
+ cy.get('.modal').contains('Add Task').click();
+ cy.get('.task').should('have.length', 2);
+ cy.get('.task').eq(0).contains('First task'); // first()
+ cy.get('.task').eq(1).contains('Second task'); // last()
+ });
+});
diff --git a/code/02 Basics/09 Time For More Queries/cypress/fixtures/example.json b/code/02 Basics/09 Time For More Queries/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/02 Basics/09 Time For More Queries/cypress/support/commands.js b/code/02 Basics/09 Time For More Queries/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/02 Basics/09 Time For More Queries/cypress/support/e2e.js b/code/02 Basics/09 Time For More Queries/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/02 Basics/09 Time For More Queries/index.html b/code/02 Basics/09 Time For More Queries/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/02 Basics/09 Time For More Queries/package.json b/code/02 Basics/09 Time For More Queries/package.json
new file mode 100644
index 0000000..d6193b3
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "cypress-basics",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.27",
+ "@types/react-dom": "^18.0.10",
+ "@vitejs/plugin-react": "^3.1.0",
+ "vite": "^4.1.0"
+ }
+}
diff --git a/code/02 Basics/09 Time For More Queries/public/vite.svg b/code/02 Basics/09 Time For More Queries/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/02 Basics/09 Time For More Queries/src/App.jsx b/code/02 Basics/09 Time For More Queries/src/App.jsx
new file mode 100644
index 0000000..43955a9
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/App.jsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import Header from './components/Header';
+import Modal from './components/Modal';
+
+import NewTask from './components/NewTask';
+import TaskControl from './components/TaskControl';
+import TaskList from './components/TaskList';
+
+function App() {
+ const [isAddingTask, setIsAddingTask] = useState(false);
+ const [tasks, setTasks] = useState([]);
+ const [appliedFilter, setAppliedFilter] = useState('all');
+
+ const displayedTasks = tasks.filter((task) => {
+ if (appliedFilter === 'all') {
+ return true;
+ }
+ return task.category === appliedFilter;
+ });
+
+ function startAddTaskHandler() {
+ setIsAddingTask(true);
+ }
+
+ function cancelAddTaskHandler() {
+ setIsAddingTask(false);
+ }
+
+ function addTaskHandler(taskData) {
+ setTasks((prevTasks) => {
+ return [
+ ...prevTasks,
+ {
+ id: Math.random().toString(),
+ ...taskData,
+ },
+ ];
+ });
+ setIsAddingTask(false);
+ }
+
+ function setFilterHandler(category) {
+ setAppliedFilter(category);
+ }
+
+ return (
+ <>
+ {isAddingTask && (
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/02 Basics/09 Time For More Queries/src/assets/logo.png b/code/02 Basics/09 Time For More Queries/src/assets/logo.png
new file mode 100644
index 0000000..2a8d015
Binary files /dev/null and b/code/02 Basics/09 Time For More Queries/src/assets/logo.png differ
diff --git a/code/02 Basics/09 Time For More Queries/src/components/Filter.jsx b/code/02 Basics/09 Time For More Queries/src/components/Filter.jsx
new file mode 100644
index 0000000..b0510e6
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/Filter.jsx
@@ -0,0 +1,17 @@
+function Filter({ onFilterChange }) {
+ function filterChangeHandler(event) {
+ onFilterChange(event.target.value);
+ }
+
+ return (
+
+ All
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+ );
+}
+
+export default Filter;
diff --git a/code/02 Basics/09 Time For More Queries/src/components/Header.css b/code/02 Basics/09 Time For More Queries/src/components/Header.css
new file mode 100644
index 0000000..2ba4a37
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/Header.css
@@ -0,0 +1,12 @@
+.main-header {
+ margin: 3rem auto;
+ text-align: center;
+ color: var(--color-gray-400);
+}
+
+.main-header img {
+ width: 7rem;
+ height: 7rem;
+ object-fit: contain;
+ transform: rotateZ(10deg);
+}
\ No newline at end of file
diff --git a/code/02 Basics/09 Time For More Queries/src/components/Header.jsx b/code/02 Basics/09 Time For More Queries/src/components/Header.jsx
new file mode 100644
index 0000000..d3bc6d9
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/Header.jsx
@@ -0,0 +1,13 @@
+import './Header.css';
+import logo from '../assets/logo.png';
+
+function Header() {
+ return (
+
+
+ My Cypress Course Tasks
+
+ );
+}
+
+export default Header;
diff --git a/code/02 Basics/09 Time For More Queries/src/components/Modal.css b/code/02 Basics/09 Time For More Queries/src/components/Modal.css
new file mode 100644
index 0000000..4c57acd
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/Modal.css
@@ -0,0 +1,24 @@
+.backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 1;
+}
+
+.modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ margin: 0;
+ padding: 2rem;
+ transform: translate(-50%, -50%);
+ width: 40rem;
+ background-color: var(--color-gray-800);
+ border: none;
+ border-radius: 4px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+}
diff --git a/code/02 Basics/09 Time For More Queries/src/components/Modal.jsx b/code/02 Basics/09 Time For More Queries/src/components/Modal.jsx
new file mode 100644
index 0000000..7347c66
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/Modal.jsx
@@ -0,0 +1,14 @@
+import './Modal.css';
+
+function Modal({ children, onClose }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/02 Basics/09 Time For More Queries/src/components/NewTask.css b/code/02 Basics/09 Time For More Queries/src/components/NewTask.css
new file mode 100644
index 0000000..e9a37e7
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/NewTask.css
@@ -0,0 +1,62 @@
+#new-task-form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+#new-task-form input,
+#new-task-form textarea {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+#new-task-form select {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--color-gray-300);
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+#new-task-form button {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-primary-500);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#new-task-form button:hover {
+ background-color: var(--color-primary-600);
+}
+
+#new-task-form button[type="button"] {
+ background-color: transparent;
+ color: var(--color-gray-200);
+}
+
+#new-task-form button[type="button"]:hover {
+ background-color: var(--color-gray-700);
+}
+
+
+
+.error-message {
+ color: var(--color-primary-300);
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+}
diff --git a/code/02 Basics/09 Time For More Queries/src/components/NewTask.jsx b/code/02 Basics/09 Time For More Queries/src/components/NewTask.jsx
new file mode 100644
index 0000000..17f1540
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/NewTask.jsx
@@ -0,0 +1,67 @@
+import { useRef, useState } from 'react';
+
+import './NewTask.css';
+
+function NewTask({ onAddTask, onCancel }) {
+ const titleRef = useRef();
+ const summaryRef = useRef();
+ const categoryRef = useRef();
+
+ const [formInvalid, setFormInvalid] = useState(false);
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ const enteredTitle = titleRef.current.value;
+ const enteredSummary = summaryRef.current.value;
+ const chosenCategory = categoryRef.current.value;
+
+ if (
+ enteredTitle.trim().length === 0 ||
+ enteredSummary.trim().length === 0
+ ) {
+ setFormInvalid(true);
+ return;
+ }
+
+ const taskData = {
+ title: enteredTitle,
+ summary: enteredSummary,
+ category: chosenCategory,
+ };
+ onAddTask(taskData);
+ }
+
+ return (
+
+
+ Title
+
+
+
+ Summary
+
+
+
+ Category
+
+ 🚨 Urgent
+ 🔴 Important
+ 🔵 Moderate
+ 🟢 Low
+
+
+ {formInvalid && (
+
+ Please provide values for task title, summary and category!
+
+ )}
+
+ Cancel
+ Add Task
+
+
+ );
+}
+
+export default NewTask;
diff --git a/code/02 Basics/09 Time For More Queries/src/components/Task.css b/code/02 Basics/09 Time For More Queries/src/components/Task.css
new file mode 100644
index 0000000..ee3c6f3
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/Task.css
@@ -0,0 +1,26 @@
+.task {
+ display: flex;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 1rem;
+ border: 1px solid var(--color-gray-600);
+ background-color: var(--color-gray-700);
+ border-radius: 4px;
+}
+
+.task-category {
+ font-size: 1.25rem;
+}
+
+.task h2 {
+ margin: 0;
+ color: var(--color-gray-300);
+ font-size: 1rem;
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.task p {
+ margin: 0;
+ color: var(--color-gray-200);
+}
\ No newline at end of file
diff --git a/code/02 Basics/09 Time For More Queries/src/components/Task.jsx b/code/02 Basics/09 Time For More Queries/src/components/Task.jsx
new file mode 100644
index 0000000..9867dab
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/Task.jsx
@@ -0,0 +1,22 @@
+import './Task.css';
+
+const CATEGORY_ICONS = {
+ urgent: '🚨',
+ important: '🔴',
+ moderate: '🔵',
+ low: '🟢',
+};
+
+function Task({ category, title, summary }) {
+ return (
+
+ {CATEGORY_ICONS[category]}
+
+
+ );
+}
+
+export default Task;
diff --git a/code/02 Basics/09 Time For More Queries/src/components/TaskControl.css b/code/02 Basics/09 Time For More Queries/src/components/TaskControl.css
new file mode 100644
index 0000000..c334096
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/TaskControl.css
@@ -0,0 +1,28 @@
+#task-control {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+#task-control button {
+ font: inherit;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background-color: var(--color-gray-800);
+ color: var(--color-gray-100);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#task-control button:hover {
+ background-color: var(--color-gray-700);
+}
+
+#task-control select {
+ font: inherit;
+ padding: 0.5rem;
+ background-color: var(--color-gray-400);
+ color: var(--color-gray-900);
+ border-radius: 4px;
+}
\ No newline at end of file
diff --git a/code/02 Basics/09 Time For More Queries/src/components/TaskControl.jsx b/code/02 Basics/09 Time For More Queries/src/components/TaskControl.jsx
new file mode 100644
index 0000000..65f3cfd
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/TaskControl.jsx
@@ -0,0 +1,14 @@
+import Filter from './Filter';
+
+import './TaskControl.css';
+
+function TaskControl({ onStartAddTask, onSetFilter }) {
+ return (
+
+ Add Task
+
+
+ );
+}
+
+export default TaskControl;
diff --git a/code/02 Basics/09 Time For More Queries/src/components/TaskList.css b/code/02 Basics/09 Time For More Queries/src/components/TaskList.css
new file mode 100644
index 0000000..111d52b
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/TaskList.css
@@ -0,0 +1,13 @@
+.task-list {
+ list-style: none;
+ margin: 2rem 0;
+ padding: 0;
+}
+
+.no-tasks {
+ text-align: center;
+ font-weight: bold;
+ color: var(--color-gray-400);
+ font-size: 2rem;
+ margin: 3rem auto;
+}
diff --git a/code/02 Basics/09 Time For More Queries/src/components/TaskList.jsx b/code/02 Basics/09 Time For More Queries/src/components/TaskList.jsx
new file mode 100644
index 0000000..007b7ed
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/components/TaskList.jsx
@@ -0,0 +1,18 @@
+import Task from './Task';
+import './TaskList.css';
+
+function TaskList({ tasks }) {
+ if (!tasks || tasks.length === 0) {
+ return No tasks found!
;
+ }
+
+ return (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ );
+}
+
+export default TaskList;
diff --git a/code/02 Basics/09 Time For More Queries/src/index.css b/code/02 Basics/09 Time For More Queries/src/index.css
new file mode 100644
index 0000000..572e24b
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/index.css
@@ -0,0 +1,61 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+
+ --color-gray-100: #f9f7fc;
+ --color-gray-200: #f3eefc;
+ --color-gray-300: #c7c0da;
+ --color-gray-400: #a396bf;
+ --color-gray-500: #8172a2;
+ --color-gray-600: #6b5f8a;
+ --color-gray-700: #5a4f73;
+ --color-gray-800: #4c4160;
+ --color-gray-900: #3c334d;
+
+ --color-primary-100: #f4ebff;
+ --color-primary-200: #e1d0ff;
+ --color-primary-300: #c9aaff;
+ --color-primary-400: #b085f5;
+ --color-primary-500: #9755f5;
+ --color-primary-600: #7442c8;
+ --color-primary-700: #5a32a3;
+ --color-primary-800: #4c2889;
+ --color-primary-900: #3c1d6b;
+}
+
+html {
+ /* background-color: var(--color-gray-900); */
+ height: 100%;
+ background: linear-gradient(
+ 160deg,
+ #241b33,
+ #281b42
+ );
+ color: var(--color-gray-100);
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ max-width: 40rem;
+ margin: 2rem auto;
+ padding: 3rem;
+ border-radius: 8px;
+ background-color: var(--color-gray-900);
+ border: 2px solid var(--color-gray-300);
+}
diff --git a/code/02 Basics/09 Time For More Queries/src/main.jsx b/code/02 Basics/09 Time For More Queries/src/main.jsx
new file mode 100644
index 0000000..5cc5991
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/code/02 Basics/09 Time For More Queries/vite.config.js b/code/02 Basics/09 Time For More Queries/vite.config.js
new file mode 100644
index 0000000..5a33944
--- /dev/null
+++ b/code/02 Basics/09 Time For More Queries/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/code/03 Diving Deeper/01 Starting Project/cypress.config.js b/code/03 Diving Deeper/01 Starting Project/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/03 Diving Deeper/01 Starting Project/cypress/fixtures/example.json b/code/03 Diving Deeper/01 Starting Project/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/03 Diving Deeper/01 Starting Project/cypress/support/commands.js b/code/03 Diving Deeper/01 Starting Project/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/03 Diving Deeper/01 Starting Project/cypress/support/e2e.js b/code/03 Diving Deeper/01 Starting Project/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/03 Diving Deeper/01 Starting Project/index.html b/code/03 Diving Deeper/01 Starting Project/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/03 Diving Deeper/01 Starting Project/package.json b/code/03 Diving Deeper/01 Starting Project/package.json
new file mode 100644
index 0000000..eaa90c5
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypress-adv",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.17",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.1.0",
+ "vite": "^3.1.0"
+ }
+}
diff --git a/code/03 Diving Deeper/01 Starting Project/public/vite.svg b/code/03 Diving Deeper/01 Starting Project/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/03 Diving Deeper/01 Starting Project/src/App.jsx b/code/03 Diving Deeper/01 Starting Project/src/App.jsx
new file mode 100644
index 0000000..a7eba87
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/src/App.jsx
@@ -0,0 +1,21 @@
+import { Routes, Route } from 'react-router-dom';
+
+import HomePage from './pages/Home';
+import AboutPage from './pages/about';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+ } />
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/03 Diving Deeper/01 Starting Project/src/components/ContactForm.jsx b/code/03 Diving Deeper/01 Starting Project/src/components/ContactForm.jsx
new file mode 100644
index 0000000..aeb95d1
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/src/components/ContactForm.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useReducer, useState } from 'react';
+import classes from './ContactForm.module.css';
+
+const initialState = {
+ name: {
+ value: '',
+ blurred: false,
+ },
+ email: {
+ value: '',
+ blurred: false,
+ },
+ message: {
+ value: '',
+ blurred: false,
+ },
+};
+
+const formReducer = (state, action) => {
+ if (action.type === 'INPUT_CHANGE') {
+ return {
+ ...state,
+ [action.input]: {
+ value: action.value,
+ blurred: false,
+ },
+ };
+ }
+
+ if (action.type === 'INPUT_BLUR') {
+ return {
+ ...state,
+ [action.input]: {
+ ...state[action.input],
+ blurred: true,
+ },
+ };
+ }
+
+ return initialState;
+};
+
+function ContactForm() {
+ const [formState, dispatch] = useReducer(formReducer, initialState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { name, email, message } = formState;
+ const nameIsValid = name.value.trim() !== '';
+ const emailIsValid = email.value.trim() !== '' && email.value.includes('@');
+ const messageIsValid = message.value.trim() !== '';
+
+ const nameIsInvalid = !nameIsValid && name.blurred;
+ const emailIsInvalid = !emailIsValid && email.blurred;
+ const messageIsInvalid = !messageIsValid && message.blurred;
+
+ useEffect(() => {
+ if (isSubmitting) {
+ console.log('Sending message...');
+ const timer = setTimeout(() => {
+ setIsSubmitting(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isSubmitting]);
+
+ function changeInputHandler(event) {
+ dispatch({
+ type: 'INPUT_CHANGE',
+ input: event.target.id,
+ value: event.target.value,
+ });
+ }
+
+ function blurInputHandler(event) {
+ dispatch({
+ type: 'INPUT_BLUR',
+ input: event.target.id,
+ });
+ }
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ if (!nameIsValid || !emailIsValid || !messageIsValid) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ }
+
+ return (
+ <>
+ Contact Us
+
+
+ Your Message
+
+
+
+
+
+ {isSubmitting ? 'Sending...' : 'Send Message'}
+
+
+
+ >
+ );
+}
+
+export default ContactForm;
diff --git a/code/03 Diving Deeper/01 Starting Project/src/components/ContactForm.module.css b/code/03 Diving Deeper/01 Starting Project/src/components/ContactForm.module.css
new file mode 100644
index 0000000..468cd7b
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/src/components/ContactForm.module.css
@@ -0,0 +1,69 @@
+.form {
+ max-width: 40rem;
+ margin: 2rem auto;
+}
+
+.form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25;
+ color: var(--color-gray-400);
+ font-weight: 600;
+}
+
+.form input,
+.form textarea {
+ font: inherit;
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--color-gray-500);
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-800);
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 1rem;
+}
+
+.row p {
+ width: 100%;
+}
+
+.actions {
+ text-align: center;
+}
+
+.form button {
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ background-color: var(--color-primary-800);
+ font-size: 1rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.form button:hover {
+ background-color: var(--color-primary-700);
+}
+
+.form button:disabled {
+ background-color: var(--color-gray-700);
+ cursor: not-allowed;
+}
+
+.invalid label {
+ color: var(--color-red-300);
+}
+
+.invalid input,
+.invalid textarea {
+ border-color: var(--color-red-300);
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/01 Starting Project/src/components/Header.jsx b/code/03 Diving Deeper/01 Starting Project/src/components/Header.jsx
new file mode 100644
index 0000000..f3337a7
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/src/components/Header.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+import classes from './Header.module.css';
+
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/code/03 Diving Deeper/01 Starting Project/src/components/Header.module.css b/code/03 Diving Deeper/01 Starting Project/src/components/Header.module.css
new file mode 100644
index 0000000..94193af
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/src/components/Header.module.css
@@ -0,0 +1,12 @@
+.header {
+ max-width: 60rem;
+ margin: 2rem auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header ul {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/code/03 Diving Deeper/01 Starting Project/src/index.css b/code/03 Diving Deeper/01 Starting Project/src/index.css
new file mode 100644
index 0000000..534f68d
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/src/index.css
@@ -0,0 +1,81 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --color-gray-100: #f2f2f7;
+ --color-gray-200: #d9d9e3;
+ --color-gray-300: #b3b3c6;
+ --color-gray-400: #8e8ea9;
+ --color-gray-500: #6b6c80;
+ --color-gray-600: #4f505c;
+ --color-gray-700: #3a3b4e;
+ --color-gray-800: #2a2b41;
+ --color-gray-900: #1c1d2b;
+ --color-gray-1000: #12121e;
+
+ --color-primary-100: #cfcfff;
+ --color-primary-200: #b3b3ff;
+ --color-primary-300: #8e8eff;
+ --color-primary-400: #7a7aff;
+ --color-primary-500: #646cff;
+ --color-primary-600: #535bf2;
+ --color-primary-700: #454ad6;
+ --color-primary-800: #3a3bb8;
+ --color-primary-900: #2a2a8e;
+ --color-primary-1000: #1c1c6b;
+
+ --color-red-100: #ffccf0;
+ --color-red-200: #ff99e0;
+ --color-red-300: #ff66d0;
+ --color-red-400: #ff33c0;
+ --color-red-500: #ff00b0;
+ --color-red-600: #e600a3;
+ --color-red-700: #cc0099;
+ --color-red-800: #b3008c;
+ --color-red-900: #990080;
+ --color-red-1000: #800073;
+}
+
+html {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: var(--color-gray-100);
+ background-color: var(--color-gray-1000);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ font-weight: 500;
+ color: var(--color-primary-400);
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--color-primary-500);
+}
+
+.center {
+ text-align: center;
+ max-width: 60ch;
+ margin: 2rem auto;
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/01 Starting Project/src/main.jsx b/code/03 Diving Deeper/01 Starting Project/src/main.jsx
new file mode 100644
index 0000000..05421fb
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
diff --git a/code/03 Diving Deeper/01 Starting Project/src/pages/About.jsx b/code/03 Diving Deeper/01 Starting Project/src/pages/About.jsx
new file mode 100644
index 0000000..3b2d8ad
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/src/pages/About.jsx
@@ -0,0 +1,22 @@
+import ContactForm from '../components/ContactForm';
+
+function AboutPage() {
+ return (
+ <>
+
+ About Us
+
+ We are a small team of developers who are passionate about testing. We
+ have created this demo to help you learn how to use Cypress.
+
+
+ Also follow us on our{' '}
+ YouTube channel .
+
+
+
+ >
+ );
+}
+
+export default AboutPage;
diff --git a/code/03 Diving Deeper/01 Starting Project/src/pages/Home.jsx b/code/03 Diving Deeper/01 Starting Project/src/pages/Home.jsx
new file mode 100644
index 0000000..ca9afdc
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/src/pages/Home.jsx
@@ -0,0 +1,11 @@
+function HomePage() {
+ return (
+ <>
+
+
Home Page
+
+ >
+ );
+}
+
+export default HomePage;
diff --git a/code/03 Diving Deeper/01 Starting Project/vite.config.js b/code/03 Diving Deeper/01 Starting Project/vite.config.js
new file mode 100644
index 0000000..b1b5f91
--- /dev/null
+++ b/code/03 Diving Deeper/01 Starting Project/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/cypress.config.js b/code/03 Diving Deeper/02 More on Selecting Elements/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/cypress/e2e/navigation.cy.js b/code/03 Diving Deeper/02 More on Selecting Elements/cypress/e2e/navigation.cy.js
new file mode 100644
index 0000000..dec8cfb
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/cypress/e2e/navigation.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('page navigation', () => {
+ it('should navigate between pages', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.location('pathname').should('eq', '/about'); // /about => About page
+ cy.go('back');
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.get('[data-cy="header-home-link"]').click();
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ });
+});
\ No newline at end of file
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/cypress/fixtures/example.json b/code/03 Diving Deeper/02 More on Selecting Elements/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/cypress/support/commands.js b/code/03 Diving Deeper/02 More on Selecting Elements/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/cypress/support/e2e.js b/code/03 Diving Deeper/02 More on Selecting Elements/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/index.html b/code/03 Diving Deeper/02 More on Selecting Elements/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/package.json b/code/03 Diving Deeper/02 More on Selecting Elements/package.json
new file mode 100644
index 0000000..eaa90c5
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypress-adv",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.17",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.1.0",
+ "vite": "^3.1.0"
+ }
+}
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/public/vite.svg b/code/03 Diving Deeper/02 More on Selecting Elements/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/src/App.jsx b/code/03 Diving Deeper/02 More on Selecting Elements/src/App.jsx
new file mode 100644
index 0000000..a7eba87
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/src/App.jsx
@@ -0,0 +1,21 @@
+import { Routes, Route } from 'react-router-dom';
+
+import HomePage from './pages/Home';
+import AboutPage from './pages/about';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+ } />
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/src/components/ContactForm.jsx b/code/03 Diving Deeper/02 More on Selecting Elements/src/components/ContactForm.jsx
new file mode 100644
index 0000000..aeb95d1
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/src/components/ContactForm.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useReducer, useState } from 'react';
+import classes from './ContactForm.module.css';
+
+const initialState = {
+ name: {
+ value: '',
+ blurred: false,
+ },
+ email: {
+ value: '',
+ blurred: false,
+ },
+ message: {
+ value: '',
+ blurred: false,
+ },
+};
+
+const formReducer = (state, action) => {
+ if (action.type === 'INPUT_CHANGE') {
+ return {
+ ...state,
+ [action.input]: {
+ value: action.value,
+ blurred: false,
+ },
+ };
+ }
+
+ if (action.type === 'INPUT_BLUR') {
+ return {
+ ...state,
+ [action.input]: {
+ ...state[action.input],
+ blurred: true,
+ },
+ };
+ }
+
+ return initialState;
+};
+
+function ContactForm() {
+ const [formState, dispatch] = useReducer(formReducer, initialState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { name, email, message } = formState;
+ const nameIsValid = name.value.trim() !== '';
+ const emailIsValid = email.value.trim() !== '' && email.value.includes('@');
+ const messageIsValid = message.value.trim() !== '';
+
+ const nameIsInvalid = !nameIsValid && name.blurred;
+ const emailIsInvalid = !emailIsValid && email.blurred;
+ const messageIsInvalid = !messageIsValid && message.blurred;
+
+ useEffect(() => {
+ if (isSubmitting) {
+ console.log('Sending message...');
+ const timer = setTimeout(() => {
+ setIsSubmitting(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isSubmitting]);
+
+ function changeInputHandler(event) {
+ dispatch({
+ type: 'INPUT_CHANGE',
+ input: event.target.id,
+ value: event.target.value,
+ });
+ }
+
+ function blurInputHandler(event) {
+ dispatch({
+ type: 'INPUT_BLUR',
+ input: event.target.id,
+ });
+ }
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ if (!nameIsValid || !emailIsValid || !messageIsValid) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ }
+
+ return (
+ <>
+ Contact Us
+
+
+ Your Message
+
+
+
+
+
+ {isSubmitting ? 'Sending...' : 'Send Message'}
+
+
+
+ >
+ );
+}
+
+export default ContactForm;
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/src/components/ContactForm.module.css b/code/03 Diving Deeper/02 More on Selecting Elements/src/components/ContactForm.module.css
new file mode 100644
index 0000000..468cd7b
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/src/components/ContactForm.module.css
@@ -0,0 +1,69 @@
+.form {
+ max-width: 40rem;
+ margin: 2rem auto;
+}
+
+.form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25;
+ color: var(--color-gray-400);
+ font-weight: 600;
+}
+
+.form input,
+.form textarea {
+ font: inherit;
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--color-gray-500);
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-800);
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 1rem;
+}
+
+.row p {
+ width: 100%;
+}
+
+.actions {
+ text-align: center;
+}
+
+.form button {
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ background-color: var(--color-primary-800);
+ font-size: 1rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.form button:hover {
+ background-color: var(--color-primary-700);
+}
+
+.form button:disabled {
+ background-color: var(--color-gray-700);
+ cursor: not-allowed;
+}
+
+.invalid label {
+ color: var(--color-red-300);
+}
+
+.invalid input,
+.invalid textarea {
+ border-color: var(--color-red-300);
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/src/components/Header.jsx b/code/03 Diving Deeper/02 More on Selecting Elements/src/components/Header.jsx
new file mode 100644
index 0000000..f3337a7
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/src/components/Header.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+import classes from './Header.module.css';
+
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/src/components/Header.module.css b/code/03 Diving Deeper/02 More on Selecting Elements/src/components/Header.module.css
new file mode 100644
index 0000000..94193af
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/src/components/Header.module.css
@@ -0,0 +1,12 @@
+.header {
+ max-width: 60rem;
+ margin: 2rem auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header ul {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/src/index.css b/code/03 Diving Deeper/02 More on Selecting Elements/src/index.css
new file mode 100644
index 0000000..534f68d
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/src/index.css
@@ -0,0 +1,81 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --color-gray-100: #f2f2f7;
+ --color-gray-200: #d9d9e3;
+ --color-gray-300: #b3b3c6;
+ --color-gray-400: #8e8ea9;
+ --color-gray-500: #6b6c80;
+ --color-gray-600: #4f505c;
+ --color-gray-700: #3a3b4e;
+ --color-gray-800: #2a2b41;
+ --color-gray-900: #1c1d2b;
+ --color-gray-1000: #12121e;
+
+ --color-primary-100: #cfcfff;
+ --color-primary-200: #b3b3ff;
+ --color-primary-300: #8e8eff;
+ --color-primary-400: #7a7aff;
+ --color-primary-500: #646cff;
+ --color-primary-600: #535bf2;
+ --color-primary-700: #454ad6;
+ --color-primary-800: #3a3bb8;
+ --color-primary-900: #2a2a8e;
+ --color-primary-1000: #1c1c6b;
+
+ --color-red-100: #ffccf0;
+ --color-red-200: #ff99e0;
+ --color-red-300: #ff66d0;
+ --color-red-400: #ff33c0;
+ --color-red-500: #ff00b0;
+ --color-red-600: #e600a3;
+ --color-red-700: #cc0099;
+ --color-red-800: #b3008c;
+ --color-red-900: #990080;
+ --color-red-1000: #800073;
+}
+
+html {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: var(--color-gray-100);
+ background-color: var(--color-gray-1000);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ font-weight: 500;
+ color: var(--color-primary-400);
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--color-primary-500);
+}
+
+.center {
+ text-align: center;
+ max-width: 60ch;
+ margin: 2rem auto;
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/src/main.jsx b/code/03 Diving Deeper/02 More on Selecting Elements/src/main.jsx
new file mode 100644
index 0000000..05421fb
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/src/pages/About.jsx b/code/03 Diving Deeper/02 More on Selecting Elements/src/pages/About.jsx
new file mode 100644
index 0000000..3b2d8ad
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/src/pages/About.jsx
@@ -0,0 +1,22 @@
+import ContactForm from '../components/ContactForm';
+
+function AboutPage() {
+ return (
+ <>
+
+ About Us
+
+ We are a small team of developers who are passionate about testing. We
+ have created this demo to help you learn how to use Cypress.
+
+
+ Also follow us on our{' '}
+ YouTube channel .
+
+
+
+ >
+ );
+}
+
+export default AboutPage;
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/src/pages/Home.jsx b/code/03 Diving Deeper/02 More on Selecting Elements/src/pages/Home.jsx
new file mode 100644
index 0000000..ca9afdc
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/src/pages/Home.jsx
@@ -0,0 +1,11 @@
+function HomePage() {
+ return (
+ <>
+
+
Home Page
+
+ >
+ );
+}
+
+export default HomePage;
diff --git a/code/03 Diving Deeper/02 More on Selecting Elements/vite.config.js b/code/03 Diving Deeper/02 More on Selecting Elements/vite.config.js
new file mode 100644
index 0000000..b1b5f91
--- /dev/null
+++ b/code/03 Diving Deeper/02 More on Selecting Elements/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/code/03 Diving Deeper/03 More Assertions/cypress.config.js b/code/03 Diving Deeper/03 More Assertions/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/03 Diving Deeper/03 More Assertions/cypress/e2e/contact.cy.js b/code/03 Diving Deeper/03 More Assertions/cypress/e2e/contact.cy.js
new file mode 100644
index 0000000..328f4b4
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/cypress/e2e/contact.cy.js
@@ -0,0 +1,15 @@
+///
+
+describe('contact form', () => {
+ it('should submit the form', () => {
+ cy.visit('http://localhost:5173/about');
+ cy.get('[data-cy="contact-input-message"]').type('Hello world!');
+ cy.get('[data-cy="contact-input-name"]').type('John Doe');
+ cy.get('[data-cy="contact-input-email"]').type('test@example.com');
+ cy.get('[data-cy="contact-btn-submit"]').contains('Send Message');
+ cy.get('[data-cy="contact-btn-submit"]').should('not.have.attr', 'disabled');
+ cy.get('[data-cy="contact-btn-submit"]').click();
+ cy.get('[data-cy="contact-btn-submit"]').contains('Sending...');
+ cy.get('[data-cy="contact-btn-submit"]').should('have.attr', 'disabled');
+ });
+});
diff --git a/code/03 Diving Deeper/03 More Assertions/cypress/e2e/navigation.cy.js b/code/03 Diving Deeper/03 More Assertions/cypress/e2e/navigation.cy.js
new file mode 100644
index 0000000..dec8cfb
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/cypress/e2e/navigation.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('page navigation', () => {
+ it('should navigate between pages', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.location('pathname').should('eq', '/about'); // /about => About page
+ cy.go('back');
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.get('[data-cy="header-home-link"]').click();
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ });
+});
\ No newline at end of file
diff --git a/code/03 Diving Deeper/03 More Assertions/cypress/fixtures/example.json b/code/03 Diving Deeper/03 More Assertions/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/03 Diving Deeper/03 More Assertions/cypress/support/commands.js b/code/03 Diving Deeper/03 More Assertions/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/03 Diving Deeper/03 More Assertions/cypress/support/e2e.js b/code/03 Diving Deeper/03 More Assertions/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/03 Diving Deeper/03 More Assertions/index.html b/code/03 Diving Deeper/03 More Assertions/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/03 Diving Deeper/03 More Assertions/package.json b/code/03 Diving Deeper/03 More Assertions/package.json
new file mode 100644
index 0000000..eaa90c5
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypress-adv",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.17",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.1.0",
+ "vite": "^3.1.0"
+ }
+}
diff --git a/code/03 Diving Deeper/03 More Assertions/public/vite.svg b/code/03 Diving Deeper/03 More Assertions/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/03 Diving Deeper/03 More Assertions/src/App.jsx b/code/03 Diving Deeper/03 More Assertions/src/App.jsx
new file mode 100644
index 0000000..a7eba87
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/src/App.jsx
@@ -0,0 +1,21 @@
+import { Routes, Route } from 'react-router-dom';
+
+import HomePage from './pages/Home';
+import AboutPage from './pages/about';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+ } />
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/03 Diving Deeper/03 More Assertions/src/components/ContactForm.jsx b/code/03 Diving Deeper/03 More Assertions/src/components/ContactForm.jsx
new file mode 100644
index 0000000..aeb95d1
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/src/components/ContactForm.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useReducer, useState } from 'react';
+import classes from './ContactForm.module.css';
+
+const initialState = {
+ name: {
+ value: '',
+ blurred: false,
+ },
+ email: {
+ value: '',
+ blurred: false,
+ },
+ message: {
+ value: '',
+ blurred: false,
+ },
+};
+
+const formReducer = (state, action) => {
+ if (action.type === 'INPUT_CHANGE') {
+ return {
+ ...state,
+ [action.input]: {
+ value: action.value,
+ blurred: false,
+ },
+ };
+ }
+
+ if (action.type === 'INPUT_BLUR') {
+ return {
+ ...state,
+ [action.input]: {
+ ...state[action.input],
+ blurred: true,
+ },
+ };
+ }
+
+ return initialState;
+};
+
+function ContactForm() {
+ const [formState, dispatch] = useReducer(formReducer, initialState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { name, email, message } = formState;
+ const nameIsValid = name.value.trim() !== '';
+ const emailIsValid = email.value.trim() !== '' && email.value.includes('@');
+ const messageIsValid = message.value.trim() !== '';
+
+ const nameIsInvalid = !nameIsValid && name.blurred;
+ const emailIsInvalid = !emailIsValid && email.blurred;
+ const messageIsInvalid = !messageIsValid && message.blurred;
+
+ useEffect(() => {
+ if (isSubmitting) {
+ console.log('Sending message...');
+ const timer = setTimeout(() => {
+ setIsSubmitting(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isSubmitting]);
+
+ function changeInputHandler(event) {
+ dispatch({
+ type: 'INPUT_CHANGE',
+ input: event.target.id,
+ value: event.target.value,
+ });
+ }
+
+ function blurInputHandler(event) {
+ dispatch({
+ type: 'INPUT_BLUR',
+ input: event.target.id,
+ });
+ }
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ if (!nameIsValid || !emailIsValid || !messageIsValid) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ }
+
+ return (
+ <>
+ Contact Us
+
+
+ Your Message
+
+
+
+
+
+ {isSubmitting ? 'Sending...' : 'Send Message'}
+
+
+
+ >
+ );
+}
+
+export default ContactForm;
diff --git a/code/03 Diving Deeper/03 More Assertions/src/components/ContactForm.module.css b/code/03 Diving Deeper/03 More Assertions/src/components/ContactForm.module.css
new file mode 100644
index 0000000..468cd7b
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/src/components/ContactForm.module.css
@@ -0,0 +1,69 @@
+.form {
+ max-width: 40rem;
+ margin: 2rem auto;
+}
+
+.form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25;
+ color: var(--color-gray-400);
+ font-weight: 600;
+}
+
+.form input,
+.form textarea {
+ font: inherit;
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--color-gray-500);
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-800);
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 1rem;
+}
+
+.row p {
+ width: 100%;
+}
+
+.actions {
+ text-align: center;
+}
+
+.form button {
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ background-color: var(--color-primary-800);
+ font-size: 1rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.form button:hover {
+ background-color: var(--color-primary-700);
+}
+
+.form button:disabled {
+ background-color: var(--color-gray-700);
+ cursor: not-allowed;
+}
+
+.invalid label {
+ color: var(--color-red-300);
+}
+
+.invalid input,
+.invalid textarea {
+ border-color: var(--color-red-300);
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/03 More Assertions/src/components/Header.jsx b/code/03 Diving Deeper/03 More Assertions/src/components/Header.jsx
new file mode 100644
index 0000000..f3337a7
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/src/components/Header.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+import classes from './Header.module.css';
+
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/code/03 Diving Deeper/03 More Assertions/src/components/Header.module.css b/code/03 Diving Deeper/03 More Assertions/src/components/Header.module.css
new file mode 100644
index 0000000..94193af
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/src/components/Header.module.css
@@ -0,0 +1,12 @@
+.header {
+ max-width: 60rem;
+ margin: 2rem auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header ul {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/code/03 Diving Deeper/03 More Assertions/src/index.css b/code/03 Diving Deeper/03 More Assertions/src/index.css
new file mode 100644
index 0000000..534f68d
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/src/index.css
@@ -0,0 +1,81 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --color-gray-100: #f2f2f7;
+ --color-gray-200: #d9d9e3;
+ --color-gray-300: #b3b3c6;
+ --color-gray-400: #8e8ea9;
+ --color-gray-500: #6b6c80;
+ --color-gray-600: #4f505c;
+ --color-gray-700: #3a3b4e;
+ --color-gray-800: #2a2b41;
+ --color-gray-900: #1c1d2b;
+ --color-gray-1000: #12121e;
+
+ --color-primary-100: #cfcfff;
+ --color-primary-200: #b3b3ff;
+ --color-primary-300: #8e8eff;
+ --color-primary-400: #7a7aff;
+ --color-primary-500: #646cff;
+ --color-primary-600: #535bf2;
+ --color-primary-700: #454ad6;
+ --color-primary-800: #3a3bb8;
+ --color-primary-900: #2a2a8e;
+ --color-primary-1000: #1c1c6b;
+
+ --color-red-100: #ffccf0;
+ --color-red-200: #ff99e0;
+ --color-red-300: #ff66d0;
+ --color-red-400: #ff33c0;
+ --color-red-500: #ff00b0;
+ --color-red-600: #e600a3;
+ --color-red-700: #cc0099;
+ --color-red-800: #b3008c;
+ --color-red-900: #990080;
+ --color-red-1000: #800073;
+}
+
+html {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: var(--color-gray-100);
+ background-color: var(--color-gray-1000);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ font-weight: 500;
+ color: var(--color-primary-400);
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--color-primary-500);
+}
+
+.center {
+ text-align: center;
+ max-width: 60ch;
+ margin: 2rem auto;
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/03 More Assertions/src/main.jsx b/code/03 Diving Deeper/03 More Assertions/src/main.jsx
new file mode 100644
index 0000000..05421fb
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
diff --git a/code/03 Diving Deeper/03 More Assertions/src/pages/About.jsx b/code/03 Diving Deeper/03 More Assertions/src/pages/About.jsx
new file mode 100644
index 0000000..3b2d8ad
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/src/pages/About.jsx
@@ -0,0 +1,22 @@
+import ContactForm from '../components/ContactForm';
+
+function AboutPage() {
+ return (
+ <>
+
+ About Us
+
+ We are a small team of developers who are passionate about testing. We
+ have created this demo to help you learn how to use Cypress.
+
+
+ Also follow us on our{' '}
+ YouTube channel .
+
+
+
+ >
+ );
+}
+
+export default AboutPage;
diff --git a/code/03 Diving Deeper/03 More Assertions/src/pages/Home.jsx b/code/03 Diving Deeper/03 More Assertions/src/pages/Home.jsx
new file mode 100644
index 0000000..ca9afdc
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/src/pages/Home.jsx
@@ -0,0 +1,11 @@
+function HomePage() {
+ return (
+ <>
+
+
Home Page
+
+ >
+ );
+}
+
+export default HomePage;
diff --git a/code/03 Diving Deeper/03 More Assertions/vite.config.js b/code/03 Diving Deeper/03 More Assertions/vite.config.js
new file mode 100644
index 0000000..b1b5f91
--- /dev/null
+++ b/code/03 Diving Deeper/03 More Assertions/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/cypress.config.js b/code/03 Diving Deeper/04 Working with Values & Aliases/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/e2e/contact.cy.js b/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/e2e/contact.cy.js
new file mode 100644
index 0000000..4a34266
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/e2e/contact.cy.js
@@ -0,0 +1,17 @@
+///
+
+describe('contact form', () => {
+ it('should submit the form', () => {
+ cy.visit('http://localhost:5173/about');
+ cy.get('[data-cy="contact-input-message"]').type('Hello world!');
+ cy.get('[data-cy="contact-input-name"]').type('John Doe');
+ cy.get('[data-cy="contact-input-email"]').type('test@example.com');
+ cy.get('[data-cy="contact-btn-submit"]')
+ .contains('Send Message')
+ .should('not.have.attr', 'disabled');
+ cy.get('[data-cy="contact-btn-submit"]').as('submitBtn');
+ cy.get('@submitBtn').click();
+ cy.get('@submitBtn').contains('Sending...');
+ cy.get('@submitBtn').should('have.attr', 'disabled');
+ });
+});
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/e2e/navigation.cy.js b/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/e2e/navigation.cy.js
new file mode 100644
index 0000000..dec8cfb
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/e2e/navigation.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('page navigation', () => {
+ it('should navigate between pages', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.location('pathname').should('eq', '/about'); // /about => About page
+ cy.go('back');
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.get('[data-cy="header-home-link"]').click();
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ });
+});
\ No newline at end of file
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/fixtures/example.json b/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/support/commands.js b/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/support/e2e.js b/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/index.html b/code/03 Diving Deeper/04 Working with Values & Aliases/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/package.json b/code/03 Diving Deeper/04 Working with Values & Aliases/package.json
new file mode 100644
index 0000000..eaa90c5
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypress-adv",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.17",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.1.0",
+ "vite": "^3.1.0"
+ }
+}
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/public/vite.svg b/code/03 Diving Deeper/04 Working with Values & Aliases/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/src/App.jsx b/code/03 Diving Deeper/04 Working with Values & Aliases/src/App.jsx
new file mode 100644
index 0000000..a7eba87
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/src/App.jsx
@@ -0,0 +1,21 @@
+import { Routes, Route } from 'react-router-dom';
+
+import HomePage from './pages/Home';
+import AboutPage from './pages/about';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+ } />
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/src/components/ContactForm.jsx b/code/03 Diving Deeper/04 Working with Values & Aliases/src/components/ContactForm.jsx
new file mode 100644
index 0000000..aeb95d1
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/src/components/ContactForm.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useReducer, useState } from 'react';
+import classes from './ContactForm.module.css';
+
+const initialState = {
+ name: {
+ value: '',
+ blurred: false,
+ },
+ email: {
+ value: '',
+ blurred: false,
+ },
+ message: {
+ value: '',
+ blurred: false,
+ },
+};
+
+const formReducer = (state, action) => {
+ if (action.type === 'INPUT_CHANGE') {
+ return {
+ ...state,
+ [action.input]: {
+ value: action.value,
+ blurred: false,
+ },
+ };
+ }
+
+ if (action.type === 'INPUT_BLUR') {
+ return {
+ ...state,
+ [action.input]: {
+ ...state[action.input],
+ blurred: true,
+ },
+ };
+ }
+
+ return initialState;
+};
+
+function ContactForm() {
+ const [formState, dispatch] = useReducer(formReducer, initialState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { name, email, message } = formState;
+ const nameIsValid = name.value.trim() !== '';
+ const emailIsValid = email.value.trim() !== '' && email.value.includes('@');
+ const messageIsValid = message.value.trim() !== '';
+
+ const nameIsInvalid = !nameIsValid && name.blurred;
+ const emailIsInvalid = !emailIsValid && email.blurred;
+ const messageIsInvalid = !messageIsValid && message.blurred;
+
+ useEffect(() => {
+ if (isSubmitting) {
+ console.log('Sending message...');
+ const timer = setTimeout(() => {
+ setIsSubmitting(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isSubmitting]);
+
+ function changeInputHandler(event) {
+ dispatch({
+ type: 'INPUT_CHANGE',
+ input: event.target.id,
+ value: event.target.value,
+ });
+ }
+
+ function blurInputHandler(event) {
+ dispatch({
+ type: 'INPUT_BLUR',
+ input: event.target.id,
+ });
+ }
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ if (!nameIsValid || !emailIsValid || !messageIsValid) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ }
+
+ return (
+ <>
+ Contact Us
+
+
+ Your Message
+
+
+
+
+
+ {isSubmitting ? 'Sending...' : 'Send Message'}
+
+
+
+ >
+ );
+}
+
+export default ContactForm;
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/src/components/ContactForm.module.css b/code/03 Diving Deeper/04 Working with Values & Aliases/src/components/ContactForm.module.css
new file mode 100644
index 0000000..468cd7b
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/src/components/ContactForm.module.css
@@ -0,0 +1,69 @@
+.form {
+ max-width: 40rem;
+ margin: 2rem auto;
+}
+
+.form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25;
+ color: var(--color-gray-400);
+ font-weight: 600;
+}
+
+.form input,
+.form textarea {
+ font: inherit;
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--color-gray-500);
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-800);
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 1rem;
+}
+
+.row p {
+ width: 100%;
+}
+
+.actions {
+ text-align: center;
+}
+
+.form button {
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ background-color: var(--color-primary-800);
+ font-size: 1rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.form button:hover {
+ background-color: var(--color-primary-700);
+}
+
+.form button:disabled {
+ background-color: var(--color-gray-700);
+ cursor: not-allowed;
+}
+
+.invalid label {
+ color: var(--color-red-300);
+}
+
+.invalid input,
+.invalid textarea {
+ border-color: var(--color-red-300);
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/src/components/Header.jsx b/code/03 Diving Deeper/04 Working with Values & Aliases/src/components/Header.jsx
new file mode 100644
index 0000000..f3337a7
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/src/components/Header.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+import classes from './Header.module.css';
+
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/src/components/Header.module.css b/code/03 Diving Deeper/04 Working with Values & Aliases/src/components/Header.module.css
new file mode 100644
index 0000000..94193af
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/src/components/Header.module.css
@@ -0,0 +1,12 @@
+.header {
+ max-width: 60rem;
+ margin: 2rem auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header ul {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/src/index.css b/code/03 Diving Deeper/04 Working with Values & Aliases/src/index.css
new file mode 100644
index 0000000..534f68d
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/src/index.css
@@ -0,0 +1,81 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --color-gray-100: #f2f2f7;
+ --color-gray-200: #d9d9e3;
+ --color-gray-300: #b3b3c6;
+ --color-gray-400: #8e8ea9;
+ --color-gray-500: #6b6c80;
+ --color-gray-600: #4f505c;
+ --color-gray-700: #3a3b4e;
+ --color-gray-800: #2a2b41;
+ --color-gray-900: #1c1d2b;
+ --color-gray-1000: #12121e;
+
+ --color-primary-100: #cfcfff;
+ --color-primary-200: #b3b3ff;
+ --color-primary-300: #8e8eff;
+ --color-primary-400: #7a7aff;
+ --color-primary-500: #646cff;
+ --color-primary-600: #535bf2;
+ --color-primary-700: #454ad6;
+ --color-primary-800: #3a3bb8;
+ --color-primary-900: #2a2a8e;
+ --color-primary-1000: #1c1c6b;
+
+ --color-red-100: #ffccf0;
+ --color-red-200: #ff99e0;
+ --color-red-300: #ff66d0;
+ --color-red-400: #ff33c0;
+ --color-red-500: #ff00b0;
+ --color-red-600: #e600a3;
+ --color-red-700: #cc0099;
+ --color-red-800: #b3008c;
+ --color-red-900: #990080;
+ --color-red-1000: #800073;
+}
+
+html {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: var(--color-gray-100);
+ background-color: var(--color-gray-1000);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ font-weight: 500;
+ color: var(--color-primary-400);
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--color-primary-500);
+}
+
+.center {
+ text-align: center;
+ max-width: 60ch;
+ margin: 2rem auto;
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/src/main.jsx b/code/03 Diving Deeper/04 Working with Values & Aliases/src/main.jsx
new file mode 100644
index 0000000..05421fb
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/src/pages/About.jsx b/code/03 Diving Deeper/04 Working with Values & Aliases/src/pages/About.jsx
new file mode 100644
index 0000000..3b2d8ad
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/src/pages/About.jsx
@@ -0,0 +1,22 @@
+import ContactForm from '../components/ContactForm';
+
+function AboutPage() {
+ return (
+ <>
+
+ About Us
+
+ We are a small team of developers who are passionate about testing. We
+ have created this demo to help you learn how to use Cypress.
+
+
+ Also follow us on our{' '}
+ YouTube channel .
+
+
+
+ >
+ );
+}
+
+export default AboutPage;
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/src/pages/Home.jsx b/code/03 Diving Deeper/04 Working with Values & Aliases/src/pages/Home.jsx
new file mode 100644
index 0000000..ca9afdc
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/src/pages/Home.jsx
@@ -0,0 +1,11 @@
+function HomePage() {
+ return (
+ <>
+
+
Home Page
+
+ >
+ );
+}
+
+export default HomePage;
diff --git a/code/03 Diving Deeper/04 Working with Values & Aliases/vite.config.js b/code/03 Diving Deeper/04 Working with Values & Aliases/vite.config.js
new file mode 100644
index 0000000..b1b5f91
--- /dev/null
+++ b/code/03 Diving Deeper/04 Working with Values & Aliases/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress.config.js b/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/e2e/contact.cy.js b/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/e2e/contact.cy.js
new file mode 100644
index 0000000..0bf56f4
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/e2e/contact.cy.js
@@ -0,0 +1,21 @@
+///
+
+describe('contact form', () => {
+ it('should submit the form', () => {
+ cy.visit('http://localhost:5173/about');
+ cy.get('[data-cy="contact-input-message"]').type('Hello world!');
+ cy.get('[data-cy="contact-input-name"]').type('John Doe');
+ cy.get('[data-cy="contact-input-email"]').type('test@example.com');
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el.attr('disabled')).to.be.undefined;
+ expect(el.text()).to.eq('Send Message');
+ });
+ // cy.get('[data-cy="contact-btn-submit"]')
+ // .contains('Send Message')
+ // .should('not.have.attr', 'disabled');
+ cy.get('[data-cy="contact-btn-submit"]').as('submitBtn');
+ cy.get('@submitBtn').click();
+ cy.get('@submitBtn').contains('Sending...');
+ cy.get('@submitBtn').should('have.attr', 'disabled');
+ });
+});
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/e2e/navigation.cy.js b/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/e2e/navigation.cy.js
new file mode 100644
index 0000000..dec8cfb
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/e2e/navigation.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('page navigation', () => {
+ it('should navigate between pages', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.location('pathname').should('eq', '/about'); // /about => About page
+ cy.go('back');
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.get('[data-cy="header-home-link"]').click();
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ });
+});
\ No newline at end of file
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/fixtures/example.json b/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/support/commands.js b/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/support/e2e.js b/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/index.html b/code/03 Diving Deeper/05 Getting More Direct Element Access/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/package.json b/code/03 Diving Deeper/05 Getting More Direct Element Access/package.json
new file mode 100644
index 0000000..eaa90c5
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypress-adv",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.17",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.1.0",
+ "vite": "^3.1.0"
+ }
+}
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/public/vite.svg b/code/03 Diving Deeper/05 Getting More Direct Element Access/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/src/App.jsx b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/App.jsx
new file mode 100644
index 0000000..a7eba87
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/App.jsx
@@ -0,0 +1,21 @@
+import { Routes, Route } from 'react-router-dom';
+
+import HomePage from './pages/Home';
+import AboutPage from './pages/about';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+ } />
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/src/components/ContactForm.jsx b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/components/ContactForm.jsx
new file mode 100644
index 0000000..aeb95d1
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/components/ContactForm.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useReducer, useState } from 'react';
+import classes from './ContactForm.module.css';
+
+const initialState = {
+ name: {
+ value: '',
+ blurred: false,
+ },
+ email: {
+ value: '',
+ blurred: false,
+ },
+ message: {
+ value: '',
+ blurred: false,
+ },
+};
+
+const formReducer = (state, action) => {
+ if (action.type === 'INPUT_CHANGE') {
+ return {
+ ...state,
+ [action.input]: {
+ value: action.value,
+ blurred: false,
+ },
+ };
+ }
+
+ if (action.type === 'INPUT_BLUR') {
+ return {
+ ...state,
+ [action.input]: {
+ ...state[action.input],
+ blurred: true,
+ },
+ };
+ }
+
+ return initialState;
+};
+
+function ContactForm() {
+ const [formState, dispatch] = useReducer(formReducer, initialState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { name, email, message } = formState;
+ const nameIsValid = name.value.trim() !== '';
+ const emailIsValid = email.value.trim() !== '' && email.value.includes('@');
+ const messageIsValid = message.value.trim() !== '';
+
+ const nameIsInvalid = !nameIsValid && name.blurred;
+ const emailIsInvalid = !emailIsValid && email.blurred;
+ const messageIsInvalid = !messageIsValid && message.blurred;
+
+ useEffect(() => {
+ if (isSubmitting) {
+ console.log('Sending message...');
+ const timer = setTimeout(() => {
+ setIsSubmitting(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isSubmitting]);
+
+ function changeInputHandler(event) {
+ dispatch({
+ type: 'INPUT_CHANGE',
+ input: event.target.id,
+ value: event.target.value,
+ });
+ }
+
+ function blurInputHandler(event) {
+ dispatch({
+ type: 'INPUT_BLUR',
+ input: event.target.id,
+ });
+ }
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ if (!nameIsValid || !emailIsValid || !messageIsValid) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ }
+
+ return (
+ <>
+ Contact Us
+
+
+ Your Message
+
+
+
+
+
+ {isSubmitting ? 'Sending...' : 'Send Message'}
+
+
+
+ >
+ );
+}
+
+export default ContactForm;
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/src/components/ContactForm.module.css b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/components/ContactForm.module.css
new file mode 100644
index 0000000..468cd7b
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/components/ContactForm.module.css
@@ -0,0 +1,69 @@
+.form {
+ max-width: 40rem;
+ margin: 2rem auto;
+}
+
+.form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25;
+ color: var(--color-gray-400);
+ font-weight: 600;
+}
+
+.form input,
+.form textarea {
+ font: inherit;
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--color-gray-500);
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-800);
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 1rem;
+}
+
+.row p {
+ width: 100%;
+}
+
+.actions {
+ text-align: center;
+}
+
+.form button {
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ background-color: var(--color-primary-800);
+ font-size: 1rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.form button:hover {
+ background-color: var(--color-primary-700);
+}
+
+.form button:disabled {
+ background-color: var(--color-gray-700);
+ cursor: not-allowed;
+}
+
+.invalid label {
+ color: var(--color-red-300);
+}
+
+.invalid input,
+.invalid textarea {
+ border-color: var(--color-red-300);
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/src/components/Header.jsx b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/components/Header.jsx
new file mode 100644
index 0000000..f3337a7
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/components/Header.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+import classes from './Header.module.css';
+
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/src/components/Header.module.css b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/components/Header.module.css
new file mode 100644
index 0000000..94193af
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/components/Header.module.css
@@ -0,0 +1,12 @@
+.header {
+ max-width: 60rem;
+ margin: 2rem auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header ul {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/src/index.css b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/index.css
new file mode 100644
index 0000000..534f68d
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/index.css
@@ -0,0 +1,81 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --color-gray-100: #f2f2f7;
+ --color-gray-200: #d9d9e3;
+ --color-gray-300: #b3b3c6;
+ --color-gray-400: #8e8ea9;
+ --color-gray-500: #6b6c80;
+ --color-gray-600: #4f505c;
+ --color-gray-700: #3a3b4e;
+ --color-gray-800: #2a2b41;
+ --color-gray-900: #1c1d2b;
+ --color-gray-1000: #12121e;
+
+ --color-primary-100: #cfcfff;
+ --color-primary-200: #b3b3ff;
+ --color-primary-300: #8e8eff;
+ --color-primary-400: #7a7aff;
+ --color-primary-500: #646cff;
+ --color-primary-600: #535bf2;
+ --color-primary-700: #454ad6;
+ --color-primary-800: #3a3bb8;
+ --color-primary-900: #2a2a8e;
+ --color-primary-1000: #1c1c6b;
+
+ --color-red-100: #ffccf0;
+ --color-red-200: #ff99e0;
+ --color-red-300: #ff66d0;
+ --color-red-400: #ff33c0;
+ --color-red-500: #ff00b0;
+ --color-red-600: #e600a3;
+ --color-red-700: #cc0099;
+ --color-red-800: #b3008c;
+ --color-red-900: #990080;
+ --color-red-1000: #800073;
+}
+
+html {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: var(--color-gray-100);
+ background-color: var(--color-gray-1000);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ font-weight: 500;
+ color: var(--color-primary-400);
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--color-primary-500);
+}
+
+.center {
+ text-align: center;
+ max-width: 60ch;
+ margin: 2rem auto;
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/src/main.jsx b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/main.jsx
new file mode 100644
index 0000000..05421fb
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/src/pages/About.jsx b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/pages/About.jsx
new file mode 100644
index 0000000..3b2d8ad
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/pages/About.jsx
@@ -0,0 +1,22 @@
+import ContactForm from '../components/ContactForm';
+
+function AboutPage() {
+ return (
+ <>
+
+ About Us
+
+ We are a small team of developers who are passionate about testing. We
+ have created this demo to help you learn how to use Cypress.
+
+
+ Also follow us on our{' '}
+ YouTube channel .
+
+
+
+ >
+ );
+}
+
+export default AboutPage;
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/src/pages/Home.jsx b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/pages/Home.jsx
new file mode 100644
index 0000000..ca9afdc
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/src/pages/Home.jsx
@@ -0,0 +1,11 @@
+function HomePage() {
+ return (
+ <>
+
+
Home Page
+
+ >
+ );
+}
+
+export default HomePage;
diff --git a/code/03 Diving Deeper/05 Getting More Direct Element Access/vite.config.js b/code/03 Diving Deeper/05 Getting More Direct Element Access/vite.config.js
new file mode 100644
index 0000000..b1b5f91
--- /dev/null
+++ b/code/03 Diving Deeper/05 Getting More Direct Element Access/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress.config.js b/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/e2e/contact.cy.js b/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/e2e/contact.cy.js
new file mode 100644
index 0000000..bc3691c
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/e2e/contact.cy.js
@@ -0,0 +1,21 @@
+///
+
+describe('contact form', () => {
+ it('should submit the form', () => {
+ cy.visit('http://localhost:5173/about');
+ cy.get('[data-cy="contact-input-message"]').type('Hello world!');
+ cy.get('[data-cy="contact-input-name"]').type('John Doe');
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el.attr('disabled')).to.be.undefined;
+ expect(el.text()).to.eq('Send Message');
+ });
+ cy.get('[data-cy="contact-input-email"]').type('test@example.com{enter}');
+ // cy.get('[data-cy="contact-btn-submit"]')
+ // .contains('Send Message')
+ // .should('not.have.attr', 'disabled');
+ cy.get('[data-cy="contact-btn-submit"]').as('submitBtn');
+ // cy.get('@submitBtn').click();
+ cy.get('@submitBtn').contains('Sending...');
+ cy.get('@submitBtn').should('have.attr', 'disabled');
+ });
+});
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/e2e/navigation.cy.js b/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/e2e/navigation.cy.js
new file mode 100644
index 0000000..dec8cfb
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/e2e/navigation.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('page navigation', () => {
+ it('should navigate between pages', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.location('pathname').should('eq', '/about'); // /about => About page
+ cy.go('back');
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.get('[data-cy="header-home-link"]').click();
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ });
+});
\ No newline at end of file
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/fixtures/example.json b/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/support/commands.js b/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/support/e2e.js b/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/index.html b/code/03 Diving Deeper/06 Simulating Special Key Presses/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/package.json b/code/03 Diving Deeper/06 Simulating Special Key Presses/package.json
new file mode 100644
index 0000000..eaa90c5
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypress-adv",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.17",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.1.0",
+ "vite": "^3.1.0"
+ }
+}
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/public/vite.svg b/code/03 Diving Deeper/06 Simulating Special Key Presses/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/src/App.jsx b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/App.jsx
new file mode 100644
index 0000000..a7eba87
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/App.jsx
@@ -0,0 +1,21 @@
+import { Routes, Route } from 'react-router-dom';
+
+import HomePage from './pages/Home';
+import AboutPage from './pages/about';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+ } />
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/src/components/ContactForm.jsx b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/components/ContactForm.jsx
new file mode 100644
index 0000000..aeb95d1
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/components/ContactForm.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useReducer, useState } from 'react';
+import classes from './ContactForm.module.css';
+
+const initialState = {
+ name: {
+ value: '',
+ blurred: false,
+ },
+ email: {
+ value: '',
+ blurred: false,
+ },
+ message: {
+ value: '',
+ blurred: false,
+ },
+};
+
+const formReducer = (state, action) => {
+ if (action.type === 'INPUT_CHANGE') {
+ return {
+ ...state,
+ [action.input]: {
+ value: action.value,
+ blurred: false,
+ },
+ };
+ }
+
+ if (action.type === 'INPUT_BLUR') {
+ return {
+ ...state,
+ [action.input]: {
+ ...state[action.input],
+ blurred: true,
+ },
+ };
+ }
+
+ return initialState;
+};
+
+function ContactForm() {
+ const [formState, dispatch] = useReducer(formReducer, initialState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { name, email, message } = formState;
+ const nameIsValid = name.value.trim() !== '';
+ const emailIsValid = email.value.trim() !== '' && email.value.includes('@');
+ const messageIsValid = message.value.trim() !== '';
+
+ const nameIsInvalid = !nameIsValid && name.blurred;
+ const emailIsInvalid = !emailIsValid && email.blurred;
+ const messageIsInvalid = !messageIsValid && message.blurred;
+
+ useEffect(() => {
+ if (isSubmitting) {
+ console.log('Sending message...');
+ const timer = setTimeout(() => {
+ setIsSubmitting(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isSubmitting]);
+
+ function changeInputHandler(event) {
+ dispatch({
+ type: 'INPUT_CHANGE',
+ input: event.target.id,
+ value: event.target.value,
+ });
+ }
+
+ function blurInputHandler(event) {
+ dispatch({
+ type: 'INPUT_BLUR',
+ input: event.target.id,
+ });
+ }
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ if (!nameIsValid || !emailIsValid || !messageIsValid) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ }
+
+ return (
+ <>
+ Contact Us
+
+
+ Your Message
+
+
+
+
+
+ {isSubmitting ? 'Sending...' : 'Send Message'}
+
+
+
+ >
+ );
+}
+
+export default ContactForm;
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/src/components/ContactForm.module.css b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/components/ContactForm.module.css
new file mode 100644
index 0000000..468cd7b
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/components/ContactForm.module.css
@@ -0,0 +1,69 @@
+.form {
+ max-width: 40rem;
+ margin: 2rem auto;
+}
+
+.form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25;
+ color: var(--color-gray-400);
+ font-weight: 600;
+}
+
+.form input,
+.form textarea {
+ font: inherit;
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--color-gray-500);
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-800);
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 1rem;
+}
+
+.row p {
+ width: 100%;
+}
+
+.actions {
+ text-align: center;
+}
+
+.form button {
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ background-color: var(--color-primary-800);
+ font-size: 1rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.form button:hover {
+ background-color: var(--color-primary-700);
+}
+
+.form button:disabled {
+ background-color: var(--color-gray-700);
+ cursor: not-allowed;
+}
+
+.invalid label {
+ color: var(--color-red-300);
+}
+
+.invalid input,
+.invalid textarea {
+ border-color: var(--color-red-300);
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/src/components/Header.jsx b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/components/Header.jsx
new file mode 100644
index 0000000..f3337a7
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/components/Header.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+import classes from './Header.module.css';
+
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/src/components/Header.module.css b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/components/Header.module.css
new file mode 100644
index 0000000..94193af
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/components/Header.module.css
@@ -0,0 +1,12 @@
+.header {
+ max-width: 60rem;
+ margin: 2rem auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header ul {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/src/index.css b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/index.css
new file mode 100644
index 0000000..534f68d
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/index.css
@@ -0,0 +1,81 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --color-gray-100: #f2f2f7;
+ --color-gray-200: #d9d9e3;
+ --color-gray-300: #b3b3c6;
+ --color-gray-400: #8e8ea9;
+ --color-gray-500: #6b6c80;
+ --color-gray-600: #4f505c;
+ --color-gray-700: #3a3b4e;
+ --color-gray-800: #2a2b41;
+ --color-gray-900: #1c1d2b;
+ --color-gray-1000: #12121e;
+
+ --color-primary-100: #cfcfff;
+ --color-primary-200: #b3b3ff;
+ --color-primary-300: #8e8eff;
+ --color-primary-400: #7a7aff;
+ --color-primary-500: #646cff;
+ --color-primary-600: #535bf2;
+ --color-primary-700: #454ad6;
+ --color-primary-800: #3a3bb8;
+ --color-primary-900: #2a2a8e;
+ --color-primary-1000: #1c1c6b;
+
+ --color-red-100: #ffccf0;
+ --color-red-200: #ff99e0;
+ --color-red-300: #ff66d0;
+ --color-red-400: #ff33c0;
+ --color-red-500: #ff00b0;
+ --color-red-600: #e600a3;
+ --color-red-700: #cc0099;
+ --color-red-800: #b3008c;
+ --color-red-900: #990080;
+ --color-red-1000: #800073;
+}
+
+html {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: var(--color-gray-100);
+ background-color: var(--color-gray-1000);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ font-weight: 500;
+ color: var(--color-primary-400);
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--color-primary-500);
+}
+
+.center {
+ text-align: center;
+ max-width: 60ch;
+ margin: 2rem auto;
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/src/main.jsx b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/main.jsx
new file mode 100644
index 0000000..05421fb
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/src/pages/About.jsx b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/pages/About.jsx
new file mode 100644
index 0000000..3b2d8ad
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/pages/About.jsx
@@ -0,0 +1,22 @@
+import ContactForm from '../components/ContactForm';
+
+function AboutPage() {
+ return (
+ <>
+
+ About Us
+
+ We are a small team of developers who are passionate about testing. We
+ have created this demo to help you learn how to use Cypress.
+
+
+ Also follow us on our{' '}
+ YouTube channel .
+
+
+
+ >
+ );
+}
+
+export default AboutPage;
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/src/pages/Home.jsx b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/pages/Home.jsx
new file mode 100644
index 0000000..ca9afdc
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/src/pages/Home.jsx
@@ -0,0 +1,11 @@
+function HomePage() {
+ return (
+ <>
+
+
Home Page
+
+ >
+ );
+}
+
+export default HomePage;
diff --git a/code/03 Diving Deeper/06 Simulating Special Key Presses/vite.config.js b/code/03 Diving Deeper/06 Simulating Special Key Presses/vite.config.js
new file mode 100644
index 0000000..b1b5f91
--- /dev/null
+++ b/code/03 Diving Deeper/06 Simulating Special Key Presses/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/code/03 Diving Deeper/07 Changing Subjects/cypress.config.js b/code/03 Diving Deeper/07 Changing Subjects/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/03 Diving Deeper/07 Changing Subjects/cypress/e2e/contact.cy.js b/code/03 Diving Deeper/07 Changing Subjects/cypress/e2e/contact.cy.js
new file mode 100644
index 0000000..84a70d2
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/cypress/e2e/contact.cy.js
@@ -0,0 +1,52 @@
+///
+
+describe('contact form', () => {
+ it('should submit the form', () => {
+ cy.visit('http://localhost:5173/about');
+ cy.get('[data-cy="contact-input-message"]').type('Hello world!');
+ cy.get('[data-cy="contact-input-name"]').type('John Doe');
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el.attr('disabled')).to.be.undefined;
+ expect(el.text()).to.eq('Send Message');
+ });
+ cy.get('[data-cy="contact-input-email"]').type('test@example.com{enter}');
+ // cy.get('[data-cy="contact-btn-submit"]')
+ // .contains('Send Message')
+ // .should('not.have.attr', 'disabled');
+ cy.get('[data-cy="contact-btn-submit"]').as('submitBtn');
+ // cy.get('@submitBtn').click();
+ cy.get('@submitBtn').contains('Sending...');
+ cy.get('@submitBtn').should('have.attr', 'disabled');
+ });
+
+ it('should validate the form input', () => {
+ cy.visit('http://localhost:5173/about');
+ cy.get('[data-cy="contact-btn-submit"]').click();
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el).to.not.have.attr('disabled');
+ expect(el.text()).to.not.equal('Sending...');
+ });
+ cy.get('[data-cy="contact-btn-submit"]').contains('Send Message');
+ cy.get('[data-cy="contact-input-message"]').as('msgInput');
+ cy.get('@msgInput').focus().blur();
+ cy.get('@msgInput')
+ .parent()
+ .then((el) => {
+ expect(el.attr('class')).to.contains('invalid');
+ });
+
+ cy.get('[data-cy="contact-input-name"]').focus().blur();
+ cy.get('[data-cy="contact-input-name"]')
+ .parent()
+ .then((el) => {
+ expect(el.attr('class')).to.contains('invalid');
+ });
+
+ cy.get('[data-cy="contact-input-email"]').focus().blur();
+ cy.get('[data-cy="contact-input-email"]')
+ .parent()
+ .then((el) => {
+ expect(el.attr('class')).to.contains('invalid');
+ });
+ });
+});
diff --git a/code/03 Diving Deeper/07 Changing Subjects/cypress/e2e/navigation.cy.js b/code/03 Diving Deeper/07 Changing Subjects/cypress/e2e/navigation.cy.js
new file mode 100644
index 0000000..dec8cfb
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/cypress/e2e/navigation.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('page navigation', () => {
+ it('should navigate between pages', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.location('pathname').should('eq', '/about'); // /about => About page
+ cy.go('back');
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.get('[data-cy="header-home-link"]').click();
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ });
+});
\ No newline at end of file
diff --git a/code/03 Diving Deeper/07 Changing Subjects/cypress/fixtures/example.json b/code/03 Diving Deeper/07 Changing Subjects/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/03 Diving Deeper/07 Changing Subjects/cypress/support/commands.js b/code/03 Diving Deeper/07 Changing Subjects/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/03 Diving Deeper/07 Changing Subjects/cypress/support/e2e.js b/code/03 Diving Deeper/07 Changing Subjects/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/03 Diving Deeper/07 Changing Subjects/index.html b/code/03 Diving Deeper/07 Changing Subjects/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/03 Diving Deeper/07 Changing Subjects/package.json b/code/03 Diving Deeper/07 Changing Subjects/package.json
new file mode 100644
index 0000000..eaa90c5
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypress-adv",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.17",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.1.0",
+ "vite": "^3.1.0"
+ }
+}
diff --git a/code/03 Diving Deeper/07 Changing Subjects/public/vite.svg b/code/03 Diving Deeper/07 Changing Subjects/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/03 Diving Deeper/07 Changing Subjects/src/App.jsx b/code/03 Diving Deeper/07 Changing Subjects/src/App.jsx
new file mode 100644
index 0000000..a7eba87
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/src/App.jsx
@@ -0,0 +1,21 @@
+import { Routes, Route } from 'react-router-dom';
+
+import HomePage from './pages/Home';
+import AboutPage from './pages/about';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+ } />
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/03 Diving Deeper/07 Changing Subjects/src/components/ContactForm.jsx b/code/03 Diving Deeper/07 Changing Subjects/src/components/ContactForm.jsx
new file mode 100644
index 0000000..aeb95d1
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/src/components/ContactForm.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useReducer, useState } from 'react';
+import classes from './ContactForm.module.css';
+
+const initialState = {
+ name: {
+ value: '',
+ blurred: false,
+ },
+ email: {
+ value: '',
+ blurred: false,
+ },
+ message: {
+ value: '',
+ blurred: false,
+ },
+};
+
+const formReducer = (state, action) => {
+ if (action.type === 'INPUT_CHANGE') {
+ return {
+ ...state,
+ [action.input]: {
+ value: action.value,
+ blurred: false,
+ },
+ };
+ }
+
+ if (action.type === 'INPUT_BLUR') {
+ return {
+ ...state,
+ [action.input]: {
+ ...state[action.input],
+ blurred: true,
+ },
+ };
+ }
+
+ return initialState;
+};
+
+function ContactForm() {
+ const [formState, dispatch] = useReducer(formReducer, initialState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { name, email, message } = formState;
+ const nameIsValid = name.value.trim() !== '';
+ const emailIsValid = email.value.trim() !== '' && email.value.includes('@');
+ const messageIsValid = message.value.trim() !== '';
+
+ const nameIsInvalid = !nameIsValid && name.blurred;
+ const emailIsInvalid = !emailIsValid && email.blurred;
+ const messageIsInvalid = !messageIsValid && message.blurred;
+
+ useEffect(() => {
+ if (isSubmitting) {
+ console.log('Sending message...');
+ const timer = setTimeout(() => {
+ setIsSubmitting(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isSubmitting]);
+
+ function changeInputHandler(event) {
+ dispatch({
+ type: 'INPUT_CHANGE',
+ input: event.target.id,
+ value: event.target.value,
+ });
+ }
+
+ function blurInputHandler(event) {
+ dispatch({
+ type: 'INPUT_BLUR',
+ input: event.target.id,
+ });
+ }
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ if (!nameIsValid || !emailIsValid || !messageIsValid) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ }
+
+ return (
+ <>
+ Contact Us
+
+
+ Your Message
+
+
+
+
+
+ {isSubmitting ? 'Sending...' : 'Send Message'}
+
+
+
+ >
+ );
+}
+
+export default ContactForm;
diff --git a/code/03 Diving Deeper/07 Changing Subjects/src/components/ContactForm.module.css b/code/03 Diving Deeper/07 Changing Subjects/src/components/ContactForm.module.css
new file mode 100644
index 0000000..468cd7b
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/src/components/ContactForm.module.css
@@ -0,0 +1,69 @@
+.form {
+ max-width: 40rem;
+ margin: 2rem auto;
+}
+
+.form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25;
+ color: var(--color-gray-400);
+ font-weight: 600;
+}
+
+.form input,
+.form textarea {
+ font: inherit;
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--color-gray-500);
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-800);
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 1rem;
+}
+
+.row p {
+ width: 100%;
+}
+
+.actions {
+ text-align: center;
+}
+
+.form button {
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ background-color: var(--color-primary-800);
+ font-size: 1rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.form button:hover {
+ background-color: var(--color-primary-700);
+}
+
+.form button:disabled {
+ background-color: var(--color-gray-700);
+ cursor: not-allowed;
+}
+
+.invalid label {
+ color: var(--color-red-300);
+}
+
+.invalid input,
+.invalid textarea {
+ border-color: var(--color-red-300);
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/07 Changing Subjects/src/components/Header.jsx b/code/03 Diving Deeper/07 Changing Subjects/src/components/Header.jsx
new file mode 100644
index 0000000..f3337a7
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/src/components/Header.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+import classes from './Header.module.css';
+
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/code/03 Diving Deeper/07 Changing Subjects/src/components/Header.module.css b/code/03 Diving Deeper/07 Changing Subjects/src/components/Header.module.css
new file mode 100644
index 0000000..94193af
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/src/components/Header.module.css
@@ -0,0 +1,12 @@
+.header {
+ max-width: 60rem;
+ margin: 2rem auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header ul {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/code/03 Diving Deeper/07 Changing Subjects/src/index.css b/code/03 Diving Deeper/07 Changing Subjects/src/index.css
new file mode 100644
index 0000000..534f68d
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/src/index.css
@@ -0,0 +1,81 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --color-gray-100: #f2f2f7;
+ --color-gray-200: #d9d9e3;
+ --color-gray-300: #b3b3c6;
+ --color-gray-400: #8e8ea9;
+ --color-gray-500: #6b6c80;
+ --color-gray-600: #4f505c;
+ --color-gray-700: #3a3b4e;
+ --color-gray-800: #2a2b41;
+ --color-gray-900: #1c1d2b;
+ --color-gray-1000: #12121e;
+
+ --color-primary-100: #cfcfff;
+ --color-primary-200: #b3b3ff;
+ --color-primary-300: #8e8eff;
+ --color-primary-400: #7a7aff;
+ --color-primary-500: #646cff;
+ --color-primary-600: #535bf2;
+ --color-primary-700: #454ad6;
+ --color-primary-800: #3a3bb8;
+ --color-primary-900: #2a2a8e;
+ --color-primary-1000: #1c1c6b;
+
+ --color-red-100: #ffccf0;
+ --color-red-200: #ff99e0;
+ --color-red-300: #ff66d0;
+ --color-red-400: #ff33c0;
+ --color-red-500: #ff00b0;
+ --color-red-600: #e600a3;
+ --color-red-700: #cc0099;
+ --color-red-800: #b3008c;
+ --color-red-900: #990080;
+ --color-red-1000: #800073;
+}
+
+html {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: var(--color-gray-100);
+ background-color: var(--color-gray-1000);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ font-weight: 500;
+ color: var(--color-primary-400);
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--color-primary-500);
+}
+
+.center {
+ text-align: center;
+ max-width: 60ch;
+ margin: 2rem auto;
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/07 Changing Subjects/src/main.jsx b/code/03 Diving Deeper/07 Changing Subjects/src/main.jsx
new file mode 100644
index 0000000..05421fb
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
diff --git a/code/03 Diving Deeper/07 Changing Subjects/src/pages/About.jsx b/code/03 Diving Deeper/07 Changing Subjects/src/pages/About.jsx
new file mode 100644
index 0000000..3b2d8ad
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/src/pages/About.jsx
@@ -0,0 +1,22 @@
+import ContactForm from '../components/ContactForm';
+
+function AboutPage() {
+ return (
+ <>
+
+ About Us
+
+ We are a small team of developers who are passionate about testing. We
+ have created this demo to help you learn how to use Cypress.
+
+
+ Also follow us on our{' '}
+ YouTube channel .
+
+
+
+ >
+ );
+}
+
+export default AboutPage;
diff --git a/code/03 Diving Deeper/07 Changing Subjects/src/pages/Home.jsx b/code/03 Diving Deeper/07 Changing Subjects/src/pages/Home.jsx
new file mode 100644
index 0000000..ca9afdc
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/src/pages/Home.jsx
@@ -0,0 +1,11 @@
+function HomePage() {
+ return (
+ <>
+
+
Home Page
+
+ >
+ );
+}
+
+export default HomePage;
diff --git a/code/03 Diving Deeper/07 Changing Subjects/vite.config.js b/code/03 Diving Deeper/07 Changing Subjects/vite.config.js
new file mode 100644
index 0000000..b1b5f91
--- /dev/null
+++ b/code/03 Diving Deeper/07 Changing Subjects/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/code/03 Diving Deeper/08 Screenshots/cypress.config.js b/code/03 Diving Deeper/08 Screenshots/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/03 Diving Deeper/08 Screenshots/cypress/e2e/contact.cy.js b/code/03 Diving Deeper/08 Screenshots/cypress/e2e/contact.cy.js
new file mode 100644
index 0000000..9575c89
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/cypress/e2e/contact.cy.js
@@ -0,0 +1,51 @@
+///
+
+describe('contact form', () => {
+ it('should submit the form', () => {
+ cy.visit('http://localhost:5173/about');
+ cy.get('[data-cy="contact-input-message"]').type('Hello world!');
+ cy.get('[data-cy="contact-input-name"]').type('John Doe');
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el.attr('disabled')).to.be.undefined;
+ expect(el.text()).to.eq('Send Message');
+ });
+ cy.screenshot();
+ cy.get('[data-cy="contact-input-email"]').type('test@example.com{enter}');
+ // cy.get('[data-cy="contact-btn-submit"]')
+ // .contains('Send Message')
+ // .should('not.have.attr', 'disabled');
+ cy.screenshot();
+ cy.get('[data-cy="contact-btn-submit"]').as('submitBtn');
+ // cy.get('@submitBtn').click();
+ cy.get('@submitBtn').contains('Sending...');
+ cy.get('@submitBtn').should('have.attr', 'disabled');
+ });
+
+ it('should validate the form input', () => {
+ cy.visit('http://localhost:5173/about');
+ cy.get('[data-cy="contact-btn-submit"]').click();
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el).to.not.have.attr('disabled');
+ expect(el.text()).to.not.equal('Sending...');
+ });
+ cy.get('[data-cy="contact-btn-submit"]').contains('Send Message');
+ cy.get('[data-cy="contact-input-message"]').as('msgInput');
+ cy.get('@msgInput').focus().blur();
+ cy.get('@msgInput')
+ .parent()
+ .should('have.attr', 'class')
+ .and('match', /invalid/);
+
+ cy.get('[data-cy="contact-input-name"]').focus().blur();
+ cy.get('[data-cy="contact-input-name"]')
+ .parent()
+ .should('have.attr', 'class')
+ .and('match', /invalid/);
+
+ cy.get('[data-cy="contact-input-email"]').focus().blur();
+ cy.get('[data-cy="contact-input-email"]')
+ .parent()
+ .should('have.attr', 'class')
+ .and('match', /invalid/);
+ });
+});
diff --git a/code/03 Diving Deeper/08 Screenshots/cypress/e2e/navigation.cy.js b/code/03 Diving Deeper/08 Screenshots/cypress/e2e/navigation.cy.js
new file mode 100644
index 0000000..dec8cfb
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/cypress/e2e/navigation.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('page navigation', () => {
+ it('should navigate between pages', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.location('pathname').should('eq', '/about'); // /about => About page
+ cy.go('back');
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.get('[data-cy="header-home-link"]').click();
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ });
+});
\ No newline at end of file
diff --git a/code/03 Diving Deeper/08 Screenshots/cypress/fixtures/example.json b/code/03 Diving Deeper/08 Screenshots/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/03 Diving Deeper/08 Screenshots/cypress/support/commands.js b/code/03 Diving Deeper/08 Screenshots/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/03 Diving Deeper/08 Screenshots/cypress/support/e2e.js b/code/03 Diving Deeper/08 Screenshots/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/03 Diving Deeper/08 Screenshots/index.html b/code/03 Diving Deeper/08 Screenshots/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/03 Diving Deeper/08 Screenshots/package.json b/code/03 Diving Deeper/08 Screenshots/package.json
new file mode 100644
index 0000000..eaa90c5
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypress-adv",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.17",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.1.0",
+ "vite": "^3.1.0"
+ }
+}
diff --git a/code/03 Diving Deeper/08 Screenshots/public/vite.svg b/code/03 Diving Deeper/08 Screenshots/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/03 Diving Deeper/08 Screenshots/src/App.jsx b/code/03 Diving Deeper/08 Screenshots/src/App.jsx
new file mode 100644
index 0000000..a7eba87
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/src/App.jsx
@@ -0,0 +1,21 @@
+import { Routes, Route } from 'react-router-dom';
+
+import HomePage from './pages/Home';
+import AboutPage from './pages/about';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+ } />
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/03 Diving Deeper/08 Screenshots/src/components/ContactForm.jsx b/code/03 Diving Deeper/08 Screenshots/src/components/ContactForm.jsx
new file mode 100644
index 0000000..aeb95d1
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/src/components/ContactForm.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useReducer, useState } from 'react';
+import classes from './ContactForm.module.css';
+
+const initialState = {
+ name: {
+ value: '',
+ blurred: false,
+ },
+ email: {
+ value: '',
+ blurred: false,
+ },
+ message: {
+ value: '',
+ blurred: false,
+ },
+};
+
+const formReducer = (state, action) => {
+ if (action.type === 'INPUT_CHANGE') {
+ return {
+ ...state,
+ [action.input]: {
+ value: action.value,
+ blurred: false,
+ },
+ };
+ }
+
+ if (action.type === 'INPUT_BLUR') {
+ return {
+ ...state,
+ [action.input]: {
+ ...state[action.input],
+ blurred: true,
+ },
+ };
+ }
+
+ return initialState;
+};
+
+function ContactForm() {
+ const [formState, dispatch] = useReducer(formReducer, initialState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { name, email, message } = formState;
+ const nameIsValid = name.value.trim() !== '';
+ const emailIsValid = email.value.trim() !== '' && email.value.includes('@');
+ const messageIsValid = message.value.trim() !== '';
+
+ const nameIsInvalid = !nameIsValid && name.blurred;
+ const emailIsInvalid = !emailIsValid && email.blurred;
+ const messageIsInvalid = !messageIsValid && message.blurred;
+
+ useEffect(() => {
+ if (isSubmitting) {
+ console.log('Sending message...');
+ const timer = setTimeout(() => {
+ setIsSubmitting(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isSubmitting]);
+
+ function changeInputHandler(event) {
+ dispatch({
+ type: 'INPUT_CHANGE',
+ input: event.target.id,
+ value: event.target.value,
+ });
+ }
+
+ function blurInputHandler(event) {
+ dispatch({
+ type: 'INPUT_BLUR',
+ input: event.target.id,
+ });
+ }
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ if (!nameIsValid || !emailIsValid || !messageIsValid) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ }
+
+ return (
+ <>
+ Contact Us
+
+
+ Your Message
+
+
+
+
+
+ {isSubmitting ? 'Sending...' : 'Send Message'}
+
+
+
+ >
+ );
+}
+
+export default ContactForm;
diff --git a/code/03 Diving Deeper/08 Screenshots/src/components/ContactForm.module.css b/code/03 Diving Deeper/08 Screenshots/src/components/ContactForm.module.css
new file mode 100644
index 0000000..468cd7b
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/src/components/ContactForm.module.css
@@ -0,0 +1,69 @@
+.form {
+ max-width: 40rem;
+ margin: 2rem auto;
+}
+
+.form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25;
+ color: var(--color-gray-400);
+ font-weight: 600;
+}
+
+.form input,
+.form textarea {
+ font: inherit;
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--color-gray-500);
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-800);
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 1rem;
+}
+
+.row p {
+ width: 100%;
+}
+
+.actions {
+ text-align: center;
+}
+
+.form button {
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ background-color: var(--color-primary-800);
+ font-size: 1rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.form button:hover {
+ background-color: var(--color-primary-700);
+}
+
+.form button:disabled {
+ background-color: var(--color-gray-700);
+ cursor: not-allowed;
+}
+
+.invalid label {
+ color: var(--color-red-300);
+}
+
+.invalid input,
+.invalid textarea {
+ border-color: var(--color-red-300);
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/08 Screenshots/src/components/Header.jsx b/code/03 Diving Deeper/08 Screenshots/src/components/Header.jsx
new file mode 100644
index 0000000..f3337a7
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/src/components/Header.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+import classes from './Header.module.css';
+
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/code/03 Diving Deeper/08 Screenshots/src/components/Header.module.css b/code/03 Diving Deeper/08 Screenshots/src/components/Header.module.css
new file mode 100644
index 0000000..94193af
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/src/components/Header.module.css
@@ -0,0 +1,12 @@
+.header {
+ max-width: 60rem;
+ margin: 2rem auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header ul {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/code/03 Diving Deeper/08 Screenshots/src/index.css b/code/03 Diving Deeper/08 Screenshots/src/index.css
new file mode 100644
index 0000000..534f68d
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/src/index.css
@@ -0,0 +1,81 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --color-gray-100: #f2f2f7;
+ --color-gray-200: #d9d9e3;
+ --color-gray-300: #b3b3c6;
+ --color-gray-400: #8e8ea9;
+ --color-gray-500: #6b6c80;
+ --color-gray-600: #4f505c;
+ --color-gray-700: #3a3b4e;
+ --color-gray-800: #2a2b41;
+ --color-gray-900: #1c1d2b;
+ --color-gray-1000: #12121e;
+
+ --color-primary-100: #cfcfff;
+ --color-primary-200: #b3b3ff;
+ --color-primary-300: #8e8eff;
+ --color-primary-400: #7a7aff;
+ --color-primary-500: #646cff;
+ --color-primary-600: #535bf2;
+ --color-primary-700: #454ad6;
+ --color-primary-800: #3a3bb8;
+ --color-primary-900: #2a2a8e;
+ --color-primary-1000: #1c1c6b;
+
+ --color-red-100: #ffccf0;
+ --color-red-200: #ff99e0;
+ --color-red-300: #ff66d0;
+ --color-red-400: #ff33c0;
+ --color-red-500: #ff00b0;
+ --color-red-600: #e600a3;
+ --color-red-700: #cc0099;
+ --color-red-800: #b3008c;
+ --color-red-900: #990080;
+ --color-red-1000: #800073;
+}
+
+html {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: var(--color-gray-100);
+ background-color: var(--color-gray-1000);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ font-weight: 500;
+ color: var(--color-primary-400);
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--color-primary-500);
+}
+
+.center {
+ text-align: center;
+ max-width: 60ch;
+ margin: 2rem auto;
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/08 Screenshots/src/main.jsx b/code/03 Diving Deeper/08 Screenshots/src/main.jsx
new file mode 100644
index 0000000..05421fb
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
diff --git a/code/03 Diving Deeper/08 Screenshots/src/pages/About.jsx b/code/03 Diving Deeper/08 Screenshots/src/pages/About.jsx
new file mode 100644
index 0000000..3b2d8ad
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/src/pages/About.jsx
@@ -0,0 +1,22 @@
+import ContactForm from '../components/ContactForm';
+
+function AboutPage() {
+ return (
+ <>
+
+ About Us
+
+ We are a small team of developers who are passionate about testing. We
+ have created this demo to help you learn how to use Cypress.
+
+
+ Also follow us on our{' '}
+ YouTube channel .
+
+
+
+ >
+ );
+}
+
+export default AboutPage;
diff --git a/code/03 Diving Deeper/08 Screenshots/src/pages/Home.jsx b/code/03 Diving Deeper/08 Screenshots/src/pages/Home.jsx
new file mode 100644
index 0000000..ca9afdc
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/src/pages/Home.jsx
@@ -0,0 +1,11 @@
+function HomePage() {
+ return (
+ <>
+
+
Home Page
+
+ >
+ );
+}
+
+export default HomePage;
diff --git a/code/03 Diving Deeper/08 Screenshots/vite.config.js b/code/03 Diving Deeper/08 Screenshots/vite.config.js
new file mode 100644
index 0000000..b1b5f91
--- /dev/null
+++ b/code/03 Diving Deeper/08 Screenshots/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/code/03 Diving Deeper/09 should instead of then/cypress.config.js b/code/03 Diving Deeper/09 should instead of then/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/03 Diving Deeper/09 should instead of then/cypress/e2e/contact.cy.js b/code/03 Diving Deeper/09 should instead of then/cypress/e2e/contact.cy.js
new file mode 100644
index 0000000..3a110f0
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/cypress/e2e/contact.cy.js
@@ -0,0 +1,57 @@
+///
+
+describe('contact form', () => {
+ it('should submit the form', () => {
+ cy.visit('http://localhost:5173/about');
+ cy.get('[data-cy="contact-input-message"]').type('Hello world!');
+ cy.get('[data-cy="contact-input-name"]').type('John Doe');
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el.attr('disabled')).to.be.undefined;
+ expect(el.text()).to.eq('Send Message');
+ });
+ cy.screenshot();
+ cy.get('[data-cy="contact-input-email"]').type('test@example.com{enter}');
+ // cy.get('[data-cy="contact-btn-submit"]')
+ // .contains('Send Message')
+ // .should('not.have.attr', 'disabled');
+ cy.screenshot();
+ cy.get('[data-cy="contact-btn-submit"]').as('submitBtn');
+ // cy.get('@submitBtn').click();
+ cy.get('@submitBtn').contains('Sending...');
+ cy.get('@submitBtn').should('have.attr', 'disabled');
+ });
+
+ it('should validate the form input', () => {
+ cy.visit('http://localhost:5173/about');
+ cy.get('[data-cy="contact-btn-submit"]').click();
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el).to.not.have.attr('disabled');
+ expect(el.text()).to.not.equal('Sending...');
+ });
+ cy.get('[data-cy="contact-btn-submit"]').contains('Send Message');
+ cy.get('[data-cy="contact-input-message"]').as('msgInput');
+ cy.get('@msgInput').focus().blur();
+ cy.get('@msgInput')
+ .parent()
+ .should((el) => {
+ expect(el.attr('class')).not.to.be.undefined;
+ expect(el.attr('class')).contains('invalid');
+ })
+
+ cy.get('[data-cy="contact-input-name"]').focus().blur();
+ cy.get('[data-cy="contact-input-name"]')
+ .parent()
+ .should((el) => {
+ expect(el.attr('class')).not.to.be.undefined;
+ expect(el.attr('class')).contains('invalid');
+ })
+
+ cy.get('[data-cy="contact-input-email"]').focus().blur();
+ cy.get('[data-cy="contact-input-email"]')
+ .parent()
+ .should((el) => {
+ expect(el.attr('class')).not.to.be.undefined;
+ expect(el.attr('class')).contains('invalid');
+ })
+ });
+});
diff --git a/code/03 Diving Deeper/09 should instead of then/cypress/e2e/navigation.cy.js b/code/03 Diving Deeper/09 should instead of then/cypress/e2e/navigation.cy.js
new file mode 100644
index 0000000..dec8cfb
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/cypress/e2e/navigation.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('page navigation', () => {
+ it('should navigate between pages', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.location('pathname').should('eq', '/about'); // /about => About page
+ cy.go('back');
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.get('[data-cy="header-home-link"]').click();
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ });
+});
\ No newline at end of file
diff --git a/code/03 Diving Deeper/09 should instead of then/cypress/fixtures/example.json b/code/03 Diving Deeper/09 should instead of then/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/03 Diving Deeper/09 should instead of then/cypress/screenshots/contact.cy.js/contact form -- should submit the form (1).png b/code/03 Diving Deeper/09 should instead of then/cypress/screenshots/contact.cy.js/contact form -- should submit the form (1).png
new file mode 100644
index 0000000..e5ae395
Binary files /dev/null and b/code/03 Diving Deeper/09 should instead of then/cypress/screenshots/contact.cy.js/contact form -- should submit the form (1).png differ
diff --git a/code/03 Diving Deeper/09 should instead of then/cypress/screenshots/contact.cy.js/contact form -- should submit the form.png b/code/03 Diving Deeper/09 should instead of then/cypress/screenshots/contact.cy.js/contact form -- should submit the form.png
new file mode 100644
index 0000000..54daa8d
Binary files /dev/null and b/code/03 Diving Deeper/09 should instead of then/cypress/screenshots/contact.cy.js/contact form -- should submit the form.png differ
diff --git a/code/03 Diving Deeper/09 should instead of then/cypress/support/commands.js b/code/03 Diving Deeper/09 should instead of then/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/03 Diving Deeper/09 should instead of then/cypress/support/e2e.js b/code/03 Diving Deeper/09 should instead of then/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/03 Diving Deeper/09 should instead of then/cypress/videos/contact.cy.js.mp4 b/code/03 Diving Deeper/09 should instead of then/cypress/videos/contact.cy.js.mp4
new file mode 100644
index 0000000..06f39e3
Binary files /dev/null and b/code/03 Diving Deeper/09 should instead of then/cypress/videos/contact.cy.js.mp4 differ
diff --git a/code/03 Diving Deeper/09 should instead of then/cypress/videos/navigation.cy.js.mp4 b/code/03 Diving Deeper/09 should instead of then/cypress/videos/navigation.cy.js.mp4
new file mode 100644
index 0000000..93c7fbf
Binary files /dev/null and b/code/03 Diving Deeper/09 should instead of then/cypress/videos/navigation.cy.js.mp4 differ
diff --git a/code/03 Diving Deeper/09 should instead of then/index.html b/code/03 Diving Deeper/09 should instead of then/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/03 Diving Deeper/09 should instead of then/package.json b/code/03 Diving Deeper/09 should instead of then/package.json
new file mode 100644
index 0000000..eaa90c5
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypress-adv",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.17",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.1.0",
+ "vite": "^3.1.0"
+ }
+}
diff --git a/code/03 Diving Deeper/09 should instead of then/public/vite.svg b/code/03 Diving Deeper/09 should instead of then/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/03 Diving Deeper/09 should instead of then/src/App.jsx b/code/03 Diving Deeper/09 should instead of then/src/App.jsx
new file mode 100644
index 0000000..a7eba87
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/src/App.jsx
@@ -0,0 +1,21 @@
+import { Routes, Route } from 'react-router-dom';
+
+import HomePage from './pages/Home';
+import AboutPage from './pages/about';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+ } />
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/03 Diving Deeper/09 should instead of then/src/components/ContactForm.jsx b/code/03 Diving Deeper/09 should instead of then/src/components/ContactForm.jsx
new file mode 100644
index 0000000..aeb95d1
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/src/components/ContactForm.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useReducer, useState } from 'react';
+import classes from './ContactForm.module.css';
+
+const initialState = {
+ name: {
+ value: '',
+ blurred: false,
+ },
+ email: {
+ value: '',
+ blurred: false,
+ },
+ message: {
+ value: '',
+ blurred: false,
+ },
+};
+
+const formReducer = (state, action) => {
+ if (action.type === 'INPUT_CHANGE') {
+ return {
+ ...state,
+ [action.input]: {
+ value: action.value,
+ blurred: false,
+ },
+ };
+ }
+
+ if (action.type === 'INPUT_BLUR') {
+ return {
+ ...state,
+ [action.input]: {
+ ...state[action.input],
+ blurred: true,
+ },
+ };
+ }
+
+ return initialState;
+};
+
+function ContactForm() {
+ const [formState, dispatch] = useReducer(formReducer, initialState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { name, email, message } = formState;
+ const nameIsValid = name.value.trim() !== '';
+ const emailIsValid = email.value.trim() !== '' && email.value.includes('@');
+ const messageIsValid = message.value.trim() !== '';
+
+ const nameIsInvalid = !nameIsValid && name.blurred;
+ const emailIsInvalid = !emailIsValid && email.blurred;
+ const messageIsInvalid = !messageIsValid && message.blurred;
+
+ useEffect(() => {
+ if (isSubmitting) {
+ console.log('Sending message...');
+ const timer = setTimeout(() => {
+ setIsSubmitting(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isSubmitting]);
+
+ function changeInputHandler(event) {
+ dispatch({
+ type: 'INPUT_CHANGE',
+ input: event.target.id,
+ value: event.target.value,
+ });
+ }
+
+ function blurInputHandler(event) {
+ dispatch({
+ type: 'INPUT_BLUR',
+ input: event.target.id,
+ });
+ }
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ if (!nameIsValid || !emailIsValid || !messageIsValid) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ }
+
+ return (
+ <>
+ Contact Us
+
+
+ Your Message
+
+
+
+
+
+ {isSubmitting ? 'Sending...' : 'Send Message'}
+
+
+
+ >
+ );
+}
+
+export default ContactForm;
diff --git a/code/03 Diving Deeper/09 should instead of then/src/components/ContactForm.module.css b/code/03 Diving Deeper/09 should instead of then/src/components/ContactForm.module.css
new file mode 100644
index 0000000..468cd7b
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/src/components/ContactForm.module.css
@@ -0,0 +1,69 @@
+.form {
+ max-width: 40rem;
+ margin: 2rem auto;
+}
+
+.form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25;
+ color: var(--color-gray-400);
+ font-weight: 600;
+}
+
+.form input,
+.form textarea {
+ font: inherit;
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--color-gray-500);
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-800);
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 1rem;
+}
+
+.row p {
+ width: 100%;
+}
+
+.actions {
+ text-align: center;
+}
+
+.form button {
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ background-color: var(--color-primary-800);
+ font-size: 1rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.form button:hover {
+ background-color: var(--color-primary-700);
+}
+
+.form button:disabled {
+ background-color: var(--color-gray-700);
+ cursor: not-allowed;
+}
+
+.invalid label {
+ color: var(--color-red-300);
+}
+
+.invalid input,
+.invalid textarea {
+ border-color: var(--color-red-300);
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/09 should instead of then/src/components/Header.jsx b/code/03 Diving Deeper/09 should instead of then/src/components/Header.jsx
new file mode 100644
index 0000000..f3337a7
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/src/components/Header.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+import classes from './Header.module.css';
+
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/code/03 Diving Deeper/09 should instead of then/src/components/Header.module.css b/code/03 Diving Deeper/09 should instead of then/src/components/Header.module.css
new file mode 100644
index 0000000..94193af
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/src/components/Header.module.css
@@ -0,0 +1,12 @@
+.header {
+ max-width: 60rem;
+ margin: 2rem auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header ul {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/code/03 Diving Deeper/09 should instead of then/src/index.css b/code/03 Diving Deeper/09 should instead of then/src/index.css
new file mode 100644
index 0000000..534f68d
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/src/index.css
@@ -0,0 +1,81 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --color-gray-100: #f2f2f7;
+ --color-gray-200: #d9d9e3;
+ --color-gray-300: #b3b3c6;
+ --color-gray-400: #8e8ea9;
+ --color-gray-500: #6b6c80;
+ --color-gray-600: #4f505c;
+ --color-gray-700: #3a3b4e;
+ --color-gray-800: #2a2b41;
+ --color-gray-900: #1c1d2b;
+ --color-gray-1000: #12121e;
+
+ --color-primary-100: #cfcfff;
+ --color-primary-200: #b3b3ff;
+ --color-primary-300: #8e8eff;
+ --color-primary-400: #7a7aff;
+ --color-primary-500: #646cff;
+ --color-primary-600: #535bf2;
+ --color-primary-700: #454ad6;
+ --color-primary-800: #3a3bb8;
+ --color-primary-900: #2a2a8e;
+ --color-primary-1000: #1c1c6b;
+
+ --color-red-100: #ffccf0;
+ --color-red-200: #ff99e0;
+ --color-red-300: #ff66d0;
+ --color-red-400: #ff33c0;
+ --color-red-500: #ff00b0;
+ --color-red-600: #e600a3;
+ --color-red-700: #cc0099;
+ --color-red-800: #b3008c;
+ --color-red-900: #990080;
+ --color-red-1000: #800073;
+}
+
+html {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: var(--color-gray-100);
+ background-color: var(--color-gray-1000);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ font-weight: 500;
+ color: var(--color-primary-400);
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--color-primary-500);
+}
+
+.center {
+ text-align: center;
+ max-width: 60ch;
+ margin: 2rem auto;
+}
\ No newline at end of file
diff --git a/code/03 Diving Deeper/09 should instead of then/src/main.jsx b/code/03 Diving Deeper/09 should instead of then/src/main.jsx
new file mode 100644
index 0000000..05421fb
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
diff --git a/code/03 Diving Deeper/09 should instead of then/src/pages/About.jsx b/code/03 Diving Deeper/09 should instead of then/src/pages/About.jsx
new file mode 100644
index 0000000..3b2d8ad
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/src/pages/About.jsx
@@ -0,0 +1,22 @@
+import ContactForm from '../components/ContactForm';
+
+function AboutPage() {
+ return (
+ <>
+
+ About Us
+
+ We are a small team of developers who are passionate about testing. We
+ have created this demo to help you learn how to use Cypress.
+
+
+ Also follow us on our{' '}
+ YouTube channel .
+
+
+
+ >
+ );
+}
+
+export default AboutPage;
diff --git a/code/03 Diving Deeper/09 should instead of then/src/pages/Home.jsx b/code/03 Diving Deeper/09 should instead of then/src/pages/Home.jsx
new file mode 100644
index 0000000..ca9afdc
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/src/pages/Home.jsx
@@ -0,0 +1,11 @@
+function HomePage() {
+ return (
+ <>
+
+
Home Page
+
+ >
+ );
+}
+
+export default HomePage;
diff --git a/code/03 Diving Deeper/09 should instead of then/vite.config.js b/code/03 Diving Deeper/09 should instead of then/vite.config.js
new file mode 100644
index 0000000..b1b5f91
--- /dev/null
+++ b/code/03 Diving Deeper/09 should instead of then/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/code/04 Configuration/01 Starting Project/cypress.config.js b/code/04 Configuration/01 Starting Project/cypress.config.js
new file mode 100644
index 0000000..17161e3
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/cypress.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/04 Configuration/01 Starting Project/cypress/e2e/contact.cy.js b/code/04 Configuration/01 Starting Project/cypress/e2e/contact.cy.js
new file mode 100644
index 0000000..3a110f0
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/cypress/e2e/contact.cy.js
@@ -0,0 +1,57 @@
+///
+
+describe('contact form', () => {
+ it('should submit the form', () => {
+ cy.visit('http://localhost:5173/about');
+ cy.get('[data-cy="contact-input-message"]').type('Hello world!');
+ cy.get('[data-cy="contact-input-name"]').type('John Doe');
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el.attr('disabled')).to.be.undefined;
+ expect(el.text()).to.eq('Send Message');
+ });
+ cy.screenshot();
+ cy.get('[data-cy="contact-input-email"]').type('test@example.com{enter}');
+ // cy.get('[data-cy="contact-btn-submit"]')
+ // .contains('Send Message')
+ // .should('not.have.attr', 'disabled');
+ cy.screenshot();
+ cy.get('[data-cy="contact-btn-submit"]').as('submitBtn');
+ // cy.get('@submitBtn').click();
+ cy.get('@submitBtn').contains('Sending...');
+ cy.get('@submitBtn').should('have.attr', 'disabled');
+ });
+
+ it('should validate the form input', () => {
+ cy.visit('http://localhost:5173/about');
+ cy.get('[data-cy="contact-btn-submit"]').click();
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el).to.not.have.attr('disabled');
+ expect(el.text()).to.not.equal('Sending...');
+ });
+ cy.get('[data-cy="contact-btn-submit"]').contains('Send Message');
+ cy.get('[data-cy="contact-input-message"]').as('msgInput');
+ cy.get('@msgInput').focus().blur();
+ cy.get('@msgInput')
+ .parent()
+ .should((el) => {
+ expect(el.attr('class')).not.to.be.undefined;
+ expect(el.attr('class')).contains('invalid');
+ })
+
+ cy.get('[data-cy="contact-input-name"]').focus().blur();
+ cy.get('[data-cy="contact-input-name"]')
+ .parent()
+ .should((el) => {
+ expect(el.attr('class')).not.to.be.undefined;
+ expect(el.attr('class')).contains('invalid');
+ })
+
+ cy.get('[data-cy="contact-input-email"]').focus().blur();
+ cy.get('[data-cy="contact-input-email"]')
+ .parent()
+ .should((el) => {
+ expect(el.attr('class')).not.to.be.undefined;
+ expect(el.attr('class')).contains('invalid');
+ })
+ });
+});
diff --git a/code/04 Configuration/01 Starting Project/cypress/e2e/navigation.cy.js b/code/04 Configuration/01 Starting Project/cypress/e2e/navigation.cy.js
new file mode 100644
index 0000000..dec8cfb
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/cypress/e2e/navigation.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('page navigation', () => {
+ it('should navigate between pages', () => {
+ cy.visit('http://localhost:5173/');
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.location('pathname').should('eq', '/about'); // /about => About page
+ cy.go('back');
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.get('[data-cy="header-home-link"]').click();
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ });
+});
\ No newline at end of file
diff --git a/code/04 Configuration/01 Starting Project/cypress/fixtures/example.json b/code/04 Configuration/01 Starting Project/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/04 Configuration/01 Starting Project/cypress/screenshots/contact.cy.js/contact form -- should submit the form (1).png b/code/04 Configuration/01 Starting Project/cypress/screenshots/contact.cy.js/contact form -- should submit the form (1).png
new file mode 100644
index 0000000..e5ae395
Binary files /dev/null and b/code/04 Configuration/01 Starting Project/cypress/screenshots/contact.cy.js/contact form -- should submit the form (1).png differ
diff --git a/code/04 Configuration/01 Starting Project/cypress/screenshots/contact.cy.js/contact form -- should submit the form.png b/code/04 Configuration/01 Starting Project/cypress/screenshots/contact.cy.js/contact form -- should submit the form.png
new file mode 100644
index 0000000..54daa8d
Binary files /dev/null and b/code/04 Configuration/01 Starting Project/cypress/screenshots/contact.cy.js/contact form -- should submit the form.png differ
diff --git a/code/04 Configuration/01 Starting Project/cypress/support/commands.js b/code/04 Configuration/01 Starting Project/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/04 Configuration/01 Starting Project/cypress/support/e2e.js b/code/04 Configuration/01 Starting Project/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/04 Configuration/01 Starting Project/cypress/videos/contact.cy.js.mp4 b/code/04 Configuration/01 Starting Project/cypress/videos/contact.cy.js.mp4
new file mode 100644
index 0000000..06f39e3
Binary files /dev/null and b/code/04 Configuration/01 Starting Project/cypress/videos/contact.cy.js.mp4 differ
diff --git a/code/04 Configuration/01 Starting Project/cypress/videos/navigation.cy.js.mp4 b/code/04 Configuration/01 Starting Project/cypress/videos/navigation.cy.js.mp4
new file mode 100644
index 0000000..93c7fbf
Binary files /dev/null and b/code/04 Configuration/01 Starting Project/cypress/videos/navigation.cy.js.mp4 differ
diff --git a/code/04 Configuration/01 Starting Project/index.html b/code/04 Configuration/01 Starting Project/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/04 Configuration/01 Starting Project/package.json b/code/04 Configuration/01 Starting Project/package.json
new file mode 100644
index 0000000..eaa90c5
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypress-adv",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.17",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.1.0",
+ "vite": "^3.1.0"
+ }
+}
diff --git a/code/04 Configuration/01 Starting Project/public/vite.svg b/code/04 Configuration/01 Starting Project/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/04 Configuration/01 Starting Project/src/App.jsx b/code/04 Configuration/01 Starting Project/src/App.jsx
new file mode 100644
index 0000000..a7eba87
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/src/App.jsx
@@ -0,0 +1,21 @@
+import { Routes, Route } from 'react-router-dom';
+
+import HomePage from './pages/Home';
+import AboutPage from './pages/about';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+ } />
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/04 Configuration/01 Starting Project/src/components/ContactForm.jsx b/code/04 Configuration/01 Starting Project/src/components/ContactForm.jsx
new file mode 100644
index 0000000..aeb95d1
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/src/components/ContactForm.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useReducer, useState } from 'react';
+import classes from './ContactForm.module.css';
+
+const initialState = {
+ name: {
+ value: '',
+ blurred: false,
+ },
+ email: {
+ value: '',
+ blurred: false,
+ },
+ message: {
+ value: '',
+ blurred: false,
+ },
+};
+
+const formReducer = (state, action) => {
+ if (action.type === 'INPUT_CHANGE') {
+ return {
+ ...state,
+ [action.input]: {
+ value: action.value,
+ blurred: false,
+ },
+ };
+ }
+
+ if (action.type === 'INPUT_BLUR') {
+ return {
+ ...state,
+ [action.input]: {
+ ...state[action.input],
+ blurred: true,
+ },
+ };
+ }
+
+ return initialState;
+};
+
+function ContactForm() {
+ const [formState, dispatch] = useReducer(formReducer, initialState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { name, email, message } = formState;
+ const nameIsValid = name.value.trim() !== '';
+ const emailIsValid = email.value.trim() !== '' && email.value.includes('@');
+ const messageIsValid = message.value.trim() !== '';
+
+ const nameIsInvalid = !nameIsValid && name.blurred;
+ const emailIsInvalid = !emailIsValid && email.blurred;
+ const messageIsInvalid = !messageIsValid && message.blurred;
+
+ useEffect(() => {
+ if (isSubmitting) {
+ console.log('Sending message...');
+ const timer = setTimeout(() => {
+ setIsSubmitting(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isSubmitting]);
+
+ function changeInputHandler(event) {
+ dispatch({
+ type: 'INPUT_CHANGE',
+ input: event.target.id,
+ value: event.target.value,
+ });
+ }
+
+ function blurInputHandler(event) {
+ dispatch({
+ type: 'INPUT_BLUR',
+ input: event.target.id,
+ });
+ }
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ if (!nameIsValid || !emailIsValid || !messageIsValid) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ }
+
+ return (
+ <>
+ Contact Us
+
+
+ Your Message
+
+
+
+
+
+ {isSubmitting ? 'Sending...' : 'Send Message'}
+
+
+
+ >
+ );
+}
+
+export default ContactForm;
diff --git a/code/04 Configuration/01 Starting Project/src/components/ContactForm.module.css b/code/04 Configuration/01 Starting Project/src/components/ContactForm.module.css
new file mode 100644
index 0000000..468cd7b
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/src/components/ContactForm.module.css
@@ -0,0 +1,69 @@
+.form {
+ max-width: 40rem;
+ margin: 2rem auto;
+}
+
+.form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25;
+ color: var(--color-gray-400);
+ font-weight: 600;
+}
+
+.form input,
+.form textarea {
+ font: inherit;
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--color-gray-500);
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-800);
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 1rem;
+}
+
+.row p {
+ width: 100%;
+}
+
+.actions {
+ text-align: center;
+}
+
+.form button {
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ background-color: var(--color-primary-800);
+ font-size: 1rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.form button:hover {
+ background-color: var(--color-primary-700);
+}
+
+.form button:disabled {
+ background-color: var(--color-gray-700);
+ cursor: not-allowed;
+}
+
+.invalid label {
+ color: var(--color-red-300);
+}
+
+.invalid input,
+.invalid textarea {
+ border-color: var(--color-red-300);
+}
\ No newline at end of file
diff --git a/code/04 Configuration/01 Starting Project/src/components/Header.jsx b/code/04 Configuration/01 Starting Project/src/components/Header.jsx
new file mode 100644
index 0000000..f3337a7
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/src/components/Header.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+import classes from './Header.module.css';
+
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/code/04 Configuration/01 Starting Project/src/components/Header.module.css b/code/04 Configuration/01 Starting Project/src/components/Header.module.css
new file mode 100644
index 0000000..94193af
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/src/components/Header.module.css
@@ -0,0 +1,12 @@
+.header {
+ max-width: 60rem;
+ margin: 2rem auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header ul {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/code/04 Configuration/01 Starting Project/src/index.css b/code/04 Configuration/01 Starting Project/src/index.css
new file mode 100644
index 0000000..534f68d
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/src/index.css
@@ -0,0 +1,81 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --color-gray-100: #f2f2f7;
+ --color-gray-200: #d9d9e3;
+ --color-gray-300: #b3b3c6;
+ --color-gray-400: #8e8ea9;
+ --color-gray-500: #6b6c80;
+ --color-gray-600: #4f505c;
+ --color-gray-700: #3a3b4e;
+ --color-gray-800: #2a2b41;
+ --color-gray-900: #1c1d2b;
+ --color-gray-1000: #12121e;
+
+ --color-primary-100: #cfcfff;
+ --color-primary-200: #b3b3ff;
+ --color-primary-300: #8e8eff;
+ --color-primary-400: #7a7aff;
+ --color-primary-500: #646cff;
+ --color-primary-600: #535bf2;
+ --color-primary-700: #454ad6;
+ --color-primary-800: #3a3bb8;
+ --color-primary-900: #2a2a8e;
+ --color-primary-1000: #1c1c6b;
+
+ --color-red-100: #ffccf0;
+ --color-red-200: #ff99e0;
+ --color-red-300: #ff66d0;
+ --color-red-400: #ff33c0;
+ --color-red-500: #ff00b0;
+ --color-red-600: #e600a3;
+ --color-red-700: #cc0099;
+ --color-red-800: #b3008c;
+ --color-red-900: #990080;
+ --color-red-1000: #800073;
+}
+
+html {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: var(--color-gray-100);
+ background-color: var(--color-gray-1000);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ font-weight: 500;
+ color: var(--color-primary-400);
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--color-primary-500);
+}
+
+.center {
+ text-align: center;
+ max-width: 60ch;
+ margin: 2rem auto;
+}
\ No newline at end of file
diff --git a/code/04 Configuration/01 Starting Project/src/main.jsx b/code/04 Configuration/01 Starting Project/src/main.jsx
new file mode 100644
index 0000000..05421fb
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
diff --git a/code/04 Configuration/01 Starting Project/src/pages/About.jsx b/code/04 Configuration/01 Starting Project/src/pages/About.jsx
new file mode 100644
index 0000000..3b2d8ad
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/src/pages/About.jsx
@@ -0,0 +1,22 @@
+import ContactForm from '../components/ContactForm';
+
+function AboutPage() {
+ return (
+ <>
+
+ About Us
+
+ We are a small team of developers who are passionate about testing. We
+ have created this demo to help you learn how to use Cypress.
+
+
+ Also follow us on our{' '}
+ YouTube channel .
+
+
+
+ >
+ );
+}
+
+export default AboutPage;
diff --git a/code/04 Configuration/01 Starting Project/src/pages/Home.jsx b/code/04 Configuration/01 Starting Project/src/pages/Home.jsx
new file mode 100644
index 0000000..ca9afdc
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/src/pages/Home.jsx
@@ -0,0 +1,11 @@
+function HomePage() {
+ return (
+ <>
+
+
Home Page
+
+ >
+ );
+}
+
+export default HomePage;
diff --git a/code/04 Configuration/01 Starting Project/vite.config.js b/code/04 Configuration/01 Starting Project/vite.config.js
new file mode 100644
index 0000000..b1b5f91
--- /dev/null
+++ b/code/04 Configuration/01 Starting Project/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/code/04 Configuration/02 Hooks/cypress.config.js b/code/04 Configuration/02 Hooks/cypress.config.js
new file mode 100644
index 0000000..ddc8d98
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/cypress.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:5173',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/04 Configuration/02 Hooks/cypress/e2e/contact.cy.js b/code/04 Configuration/02 Hooks/cypress/e2e/contact.cy.js
new file mode 100644
index 0000000..cba0513
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/cypress/e2e/contact.cy.js
@@ -0,0 +1,70 @@
+///
+
+describe('contact form', () => {
+ before(() => {
+ // Runs only once, before all tests
+ });
+ beforeEach(() => {
+ // Runs before every test (i.e., it's repeated)
+ cy.visit('/about'); // http://localhost:5173/about
+ // Seeding a database
+ });
+ afterEach(() => {
+ // Runs after every test
+ });
+ after(() => {
+ // Runs after all tests (i.e., only once)
+ });
+
+ it('should submit the form', () => {
+ cy.get('[data-cy="contact-input-message"]').type('Hello world!');
+ cy.get('[data-cy="contact-input-name"]').type('John Doe');
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el.attr('disabled')).to.be.undefined;
+ expect(el.text()).to.eq('Send Message');
+ });
+ cy.screenshot();
+ cy.get('[data-cy="contact-input-email"]').type('test@example.com{enter}');
+ // cy.get('[data-cy="contact-btn-submit"]')
+ // .contains('Send Message')
+ // .should('not.have.attr', 'disabled');
+ cy.screenshot();
+ cy.get('[data-cy="contact-btn-submit"]').as('submitBtn');
+ // cy.get('@submitBtn').click();
+ cy.get('@submitBtn').contains('Sending...');
+ cy.get('@submitBtn').should('have.attr', 'disabled');
+ });
+
+ it('should validate the form input', () => {
+ cy.get('[data-cy="contact-btn-submit"]').click();
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el).to.not.have.attr('disabled');
+ expect(el.text()).to.not.equal('Sending...');
+ });
+ cy.get('[data-cy="contact-btn-submit"]').contains('Send Message');
+ cy.get('[data-cy="contact-input-message"]').as('msgInput');
+ cy.get('@msgInput').focus().blur();
+ cy.get('@msgInput')
+ .parent()
+ .should((el) => {
+ expect(el.attr('class')).not.to.be.undefined;
+ expect(el.attr('class')).contains('invalid');
+ });
+
+ cy.get('[data-cy="contact-input-name"]').focus().blur();
+ cy.get('[data-cy="contact-input-name"]')
+ .parent()
+ .should((el) => {
+ expect(el.attr('class')).not.to.be.undefined;
+ expect(el.attr('class')).contains('invalid');
+ });
+
+ cy.get('[data-cy="contact-input-email"]').focus().blur();
+ cy.get('[data-cy="contact-input-email"]')
+ .parent()
+ .should((el) => {
+ expect(el.attr('class')).not.to.be.undefined;
+ expect(el.attr('class')).contains('invalid');
+ });
+ });
+});
diff --git a/code/04 Configuration/02 Hooks/cypress/e2e/navigation.cy.js b/code/04 Configuration/02 Hooks/cypress/e2e/navigation.cy.js
new file mode 100644
index 0000000..a807198
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/cypress/e2e/navigation.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('page navigation', () => {
+ it('should navigate between pages', () => {
+ cy.visit('/');
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.location('pathname').should('eq', '/about'); // /about => About page
+ cy.go('back');
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.get('[data-cy="header-home-link"]').click();
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ });
+});
diff --git a/code/04 Configuration/02 Hooks/cypress/fixtures/example.json b/code/04 Configuration/02 Hooks/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/04 Configuration/02 Hooks/cypress/screenshots/contact.cy.js/contact form -- should submit the form (1).png b/code/04 Configuration/02 Hooks/cypress/screenshots/contact.cy.js/contact form -- should submit the form (1).png
new file mode 100644
index 0000000..e5ae395
Binary files /dev/null and b/code/04 Configuration/02 Hooks/cypress/screenshots/contact.cy.js/contact form -- should submit the form (1).png differ
diff --git a/code/04 Configuration/02 Hooks/cypress/screenshots/contact.cy.js/contact form -- should submit the form.png b/code/04 Configuration/02 Hooks/cypress/screenshots/contact.cy.js/contact form -- should submit the form.png
new file mode 100644
index 0000000..54daa8d
Binary files /dev/null and b/code/04 Configuration/02 Hooks/cypress/screenshots/contact.cy.js/contact form -- should submit the form.png differ
diff --git a/code/04 Configuration/02 Hooks/cypress/support/commands.js b/code/04 Configuration/02 Hooks/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/04 Configuration/02 Hooks/cypress/support/e2e.js b/code/04 Configuration/02 Hooks/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/04 Configuration/02 Hooks/cypress/videos/contact.cy.js.mp4 b/code/04 Configuration/02 Hooks/cypress/videos/contact.cy.js.mp4
new file mode 100644
index 0000000..68f4c22
Binary files /dev/null and b/code/04 Configuration/02 Hooks/cypress/videos/contact.cy.js.mp4 differ
diff --git a/code/04 Configuration/02 Hooks/cypress/videos/navigation.cy.js.mp4 b/code/04 Configuration/02 Hooks/cypress/videos/navigation.cy.js.mp4
new file mode 100644
index 0000000..282c7a7
Binary files /dev/null and b/code/04 Configuration/02 Hooks/cypress/videos/navigation.cy.js.mp4 differ
diff --git a/code/04 Configuration/02 Hooks/index.html b/code/04 Configuration/02 Hooks/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/04 Configuration/02 Hooks/package.json b/code/04 Configuration/02 Hooks/package.json
new file mode 100644
index 0000000..eaa90c5
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypress-adv",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.17",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.1.0",
+ "vite": "^3.1.0"
+ }
+}
diff --git a/code/04 Configuration/02 Hooks/public/vite.svg b/code/04 Configuration/02 Hooks/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/04 Configuration/02 Hooks/src/App.jsx b/code/04 Configuration/02 Hooks/src/App.jsx
new file mode 100644
index 0000000..a7eba87
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/src/App.jsx
@@ -0,0 +1,21 @@
+import { Routes, Route } from 'react-router-dom';
+
+import HomePage from './pages/Home';
+import AboutPage from './pages/about';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+ } />
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/04 Configuration/02 Hooks/src/components/ContactForm.jsx b/code/04 Configuration/02 Hooks/src/components/ContactForm.jsx
new file mode 100644
index 0000000..aeb95d1
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/src/components/ContactForm.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useReducer, useState } from 'react';
+import classes from './ContactForm.module.css';
+
+const initialState = {
+ name: {
+ value: '',
+ blurred: false,
+ },
+ email: {
+ value: '',
+ blurred: false,
+ },
+ message: {
+ value: '',
+ blurred: false,
+ },
+};
+
+const formReducer = (state, action) => {
+ if (action.type === 'INPUT_CHANGE') {
+ return {
+ ...state,
+ [action.input]: {
+ value: action.value,
+ blurred: false,
+ },
+ };
+ }
+
+ if (action.type === 'INPUT_BLUR') {
+ return {
+ ...state,
+ [action.input]: {
+ ...state[action.input],
+ blurred: true,
+ },
+ };
+ }
+
+ return initialState;
+};
+
+function ContactForm() {
+ const [formState, dispatch] = useReducer(formReducer, initialState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { name, email, message } = formState;
+ const nameIsValid = name.value.trim() !== '';
+ const emailIsValid = email.value.trim() !== '' && email.value.includes('@');
+ const messageIsValid = message.value.trim() !== '';
+
+ const nameIsInvalid = !nameIsValid && name.blurred;
+ const emailIsInvalid = !emailIsValid && email.blurred;
+ const messageIsInvalid = !messageIsValid && message.blurred;
+
+ useEffect(() => {
+ if (isSubmitting) {
+ console.log('Sending message...');
+ const timer = setTimeout(() => {
+ setIsSubmitting(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isSubmitting]);
+
+ function changeInputHandler(event) {
+ dispatch({
+ type: 'INPUT_CHANGE',
+ input: event.target.id,
+ value: event.target.value,
+ });
+ }
+
+ function blurInputHandler(event) {
+ dispatch({
+ type: 'INPUT_BLUR',
+ input: event.target.id,
+ });
+ }
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ if (!nameIsValid || !emailIsValid || !messageIsValid) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ }
+
+ return (
+ <>
+ Contact Us
+
+
+ Your Message
+
+
+
+
+
+ {isSubmitting ? 'Sending...' : 'Send Message'}
+
+
+
+ >
+ );
+}
+
+export default ContactForm;
diff --git a/code/04 Configuration/02 Hooks/src/components/ContactForm.module.css b/code/04 Configuration/02 Hooks/src/components/ContactForm.module.css
new file mode 100644
index 0000000..468cd7b
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/src/components/ContactForm.module.css
@@ -0,0 +1,69 @@
+.form {
+ max-width: 40rem;
+ margin: 2rem auto;
+}
+
+.form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25;
+ color: var(--color-gray-400);
+ font-weight: 600;
+}
+
+.form input,
+.form textarea {
+ font: inherit;
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--color-gray-500);
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-800);
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 1rem;
+}
+
+.row p {
+ width: 100%;
+}
+
+.actions {
+ text-align: center;
+}
+
+.form button {
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ background-color: var(--color-primary-800);
+ font-size: 1rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.form button:hover {
+ background-color: var(--color-primary-700);
+}
+
+.form button:disabled {
+ background-color: var(--color-gray-700);
+ cursor: not-allowed;
+}
+
+.invalid label {
+ color: var(--color-red-300);
+}
+
+.invalid input,
+.invalid textarea {
+ border-color: var(--color-red-300);
+}
\ No newline at end of file
diff --git a/code/04 Configuration/02 Hooks/src/components/Header.jsx b/code/04 Configuration/02 Hooks/src/components/Header.jsx
new file mode 100644
index 0000000..f3337a7
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/src/components/Header.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+import classes from './Header.module.css';
+
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/code/04 Configuration/02 Hooks/src/components/Header.module.css b/code/04 Configuration/02 Hooks/src/components/Header.module.css
new file mode 100644
index 0000000..94193af
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/src/components/Header.module.css
@@ -0,0 +1,12 @@
+.header {
+ max-width: 60rem;
+ margin: 2rem auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header ul {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/code/04 Configuration/02 Hooks/src/index.css b/code/04 Configuration/02 Hooks/src/index.css
new file mode 100644
index 0000000..534f68d
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/src/index.css
@@ -0,0 +1,81 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --color-gray-100: #f2f2f7;
+ --color-gray-200: #d9d9e3;
+ --color-gray-300: #b3b3c6;
+ --color-gray-400: #8e8ea9;
+ --color-gray-500: #6b6c80;
+ --color-gray-600: #4f505c;
+ --color-gray-700: #3a3b4e;
+ --color-gray-800: #2a2b41;
+ --color-gray-900: #1c1d2b;
+ --color-gray-1000: #12121e;
+
+ --color-primary-100: #cfcfff;
+ --color-primary-200: #b3b3ff;
+ --color-primary-300: #8e8eff;
+ --color-primary-400: #7a7aff;
+ --color-primary-500: #646cff;
+ --color-primary-600: #535bf2;
+ --color-primary-700: #454ad6;
+ --color-primary-800: #3a3bb8;
+ --color-primary-900: #2a2a8e;
+ --color-primary-1000: #1c1c6b;
+
+ --color-red-100: #ffccf0;
+ --color-red-200: #ff99e0;
+ --color-red-300: #ff66d0;
+ --color-red-400: #ff33c0;
+ --color-red-500: #ff00b0;
+ --color-red-600: #e600a3;
+ --color-red-700: #cc0099;
+ --color-red-800: #b3008c;
+ --color-red-900: #990080;
+ --color-red-1000: #800073;
+}
+
+html {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: var(--color-gray-100);
+ background-color: var(--color-gray-1000);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ font-weight: 500;
+ color: var(--color-primary-400);
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--color-primary-500);
+}
+
+.center {
+ text-align: center;
+ max-width: 60ch;
+ margin: 2rem auto;
+}
\ No newline at end of file
diff --git a/code/04 Configuration/02 Hooks/src/main.jsx b/code/04 Configuration/02 Hooks/src/main.jsx
new file mode 100644
index 0000000..05421fb
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
diff --git a/code/04 Configuration/02 Hooks/src/pages/About.jsx b/code/04 Configuration/02 Hooks/src/pages/About.jsx
new file mode 100644
index 0000000..3b2d8ad
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/src/pages/About.jsx
@@ -0,0 +1,22 @@
+import ContactForm from '../components/ContactForm';
+
+function AboutPage() {
+ return (
+ <>
+
+ About Us
+
+ We are a small team of developers who are passionate about testing. We
+ have created this demo to help you learn how to use Cypress.
+
+
+ Also follow us on our{' '}
+ YouTube channel .
+
+
+
+ >
+ );
+}
+
+export default AboutPage;
diff --git a/code/04 Configuration/02 Hooks/src/pages/Home.jsx b/code/04 Configuration/02 Hooks/src/pages/Home.jsx
new file mode 100644
index 0000000..ca9afdc
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/src/pages/Home.jsx
@@ -0,0 +1,11 @@
+function HomePage() {
+ return (
+ <>
+
+
Home Page
+
+ >
+ );
+}
+
+export default HomePage;
diff --git a/code/04 Configuration/02 Hooks/vite.config.js b/code/04 Configuration/02 Hooks/vite.config.js
new file mode 100644
index 0000000..b1b5f91
--- /dev/null
+++ b/code/04 Configuration/02 Hooks/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/code/04 Configuration/03 Finished/cypress.config.js b/code/04 Configuration/03 Finished/cypress.config.js
new file mode 100644
index 0000000..12f5f00
--- /dev/null
+++ b/code/04 Configuration/03 Finished/cypress.config.js
@@ -0,0 +1,17 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:5173',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ on('task', {
+ seedDatabase(filename) {
+ // Run your NodeJS code
+ // e.g., edit a file here
+ return filename;
+ }
+ });
+ },
+ },
+});
diff --git a/code/04 Configuration/03 Finished/cypress/e2e/contact.cy.js b/code/04 Configuration/03 Finished/cypress/e2e/contact.cy.js
new file mode 100644
index 0000000..a97d422
--- /dev/null
+++ b/code/04 Configuration/03 Finished/cypress/e2e/contact.cy.js
@@ -0,0 +1,74 @@
+///
+
+describe('contact form', () => {
+ before(() => {
+ // Runs only once, before all tests
+ });
+ beforeEach(() => {
+ // Runs before every test (i.e., it's repeated)
+ cy.visit('/about'); // http://localhost:5173/about
+ // Seeding a database
+ });
+ afterEach(() => {
+ // Runs after every test
+ });
+ after(() => {
+ // Runs after all tests (i.e., only once)
+ });
+
+ it('should submit the form', () => {
+ cy.task('seedDatabase', 'filename.csv').then(returnValue => {
+ // ... use returnValue
+ });
+ cy.getById('contact-input-message').type('Hello world!');
+ cy.getById('contact-input-name').type('John Doe');
+ cy.getById('contact-btn-submit').then((el) => {
+ expect(el.attr('disabled')).to.be.undefined;
+ expect(el.text()).to.eq('Send Message');
+ });
+ cy.screenshot();
+ cy.get('[data-cy="contact-input-email"]').type('test@example.com');
+ cy.submitForm();
+ // cy.get('[data-cy="contact-btn-submit"]')
+ // .contains('Send Message')
+ // .should('not.have.attr', 'disabled');
+ cy.screenshot();
+ cy.get('[data-cy="contact-btn-submit"]').as('submitBtn');
+ // cy.get('@submitBtn').click();
+ cy.get('@submitBtn').contains('Sending...');
+ cy.get('@submitBtn').should('have.attr', 'disabled');
+ });
+
+ it('should validate the form input', () => {
+ cy.submitForm();
+ cy.get('[data-cy="contact-btn-submit"]').then((el) => {
+ expect(el).to.not.have.attr('disabled');
+ expect(el.text()).to.not.equal('Sending...');
+ });
+ cy.get('[data-cy="contact-btn-submit"]').contains('Send Message');
+ cy.get('[data-cy="contact-input-message"]').as('msgInput');
+ cy.get('@msgInput').focus().blur();
+ cy.get('@msgInput')
+ .parent()
+ .should((el) => {
+ expect(el.attr('class')).not.to.be.undefined;
+ expect(el.attr('class')).contains('invalid');
+ });
+
+ cy.get('[data-cy="contact-input-name"]').focus().blur();
+ cy.get('[data-cy="contact-input-name"]')
+ .parent()
+ .should((el) => {
+ expect(el.attr('class')).not.to.be.undefined;
+ expect(el.attr('class')).contains('invalid');
+ });
+
+ cy.get('[data-cy="contact-input-email"]').focus().blur();
+ cy.get('[data-cy="contact-input-email"]')
+ .parent()
+ .should((el) => {
+ expect(el.attr('class')).not.to.be.undefined;
+ expect(el.attr('class')).contains('invalid');
+ });
+ });
+});
diff --git a/code/04 Configuration/03 Finished/cypress/e2e/navigation.cy.js b/code/04 Configuration/03 Finished/cypress/e2e/navigation.cy.js
new file mode 100644
index 0000000..a807198
--- /dev/null
+++ b/code/04 Configuration/03 Finished/cypress/e2e/navigation.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('page navigation', () => {
+ it('should navigate between pages', () => {
+ cy.visit('/');
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.location('pathname').should('eq', '/about'); // /about => About page
+ cy.go('back');
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ cy.get('[data-cy="header-about-link"]').click();
+ cy.get('[data-cy="header-home-link"]').click();
+ cy.location('pathname').should('eq', '/'); // / => Home page
+ });
+});
diff --git a/code/04 Configuration/03 Finished/cypress/fixtures/example.json b/code/04 Configuration/03 Finished/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/code/04 Configuration/03 Finished/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/code/04 Configuration/03 Finished/cypress/screenshots/contact.cy.js/contact form -- should submit the form (1).png b/code/04 Configuration/03 Finished/cypress/screenshots/contact.cy.js/contact form -- should submit the form (1).png
new file mode 100644
index 0000000..106a9fc
Binary files /dev/null and b/code/04 Configuration/03 Finished/cypress/screenshots/contact.cy.js/contact form -- should submit the form (1).png differ
diff --git a/code/04 Configuration/03 Finished/cypress/screenshots/contact.cy.js/contact form -- should submit the form.png b/code/04 Configuration/03 Finished/cypress/screenshots/contact.cy.js/contact form -- should submit the form.png
new file mode 100644
index 0000000..54daa8d
Binary files /dev/null and b/code/04 Configuration/03 Finished/cypress/screenshots/contact.cy.js/contact form -- should submit the form.png differ
diff --git a/code/04 Configuration/03 Finished/cypress/support/commands.js b/code/04 Configuration/03 Finished/cypress/support/commands.js
new file mode 100644
index 0000000..1f8040e
--- /dev/null
+++ b/code/04 Configuration/03 Finished/cypress/support/commands.js
@@ -0,0 +1,36 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+
+Cypress.Commands.add('submitForm', () => {
+ cy.get('form button[type="submit"]').click();
+});
+
+Cypress.Commands.addQuery('getById', (id) => {
+ const getFn = cy.now('get', `[data-cy="${id}"]`);
+ return () => {
+ return getFn();
+ };
+});
diff --git a/code/04 Configuration/03 Finished/cypress/support/e2e.js b/code/04 Configuration/03 Finished/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/04 Configuration/03 Finished/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/04 Configuration/03 Finished/cypress/videos/contact.cy.js.mp4 b/code/04 Configuration/03 Finished/cypress/videos/contact.cy.js.mp4
new file mode 100644
index 0000000..f8cd73c
Binary files /dev/null and b/code/04 Configuration/03 Finished/cypress/videos/contact.cy.js.mp4 differ
diff --git a/code/04 Configuration/03 Finished/cypress/videos/navigation.cy.js.mp4 b/code/04 Configuration/03 Finished/cypress/videos/navigation.cy.js.mp4
new file mode 100644
index 0000000..e06b6c2
Binary files /dev/null and b/code/04 Configuration/03 Finished/cypress/videos/navigation.cy.js.mp4 differ
diff --git a/code/04 Configuration/03 Finished/index.html b/code/04 Configuration/03 Finished/index.html
new file mode 100644
index 0000000..79c4701
--- /dev/null
+++ b/code/04 Configuration/03 Finished/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React
+
+
+
+
+
+
diff --git a/code/04 Configuration/03 Finished/package.json b/code/04 Configuration/03 Finished/package.json
new file mode 100644
index 0000000..eaa90c5
--- /dev/null
+++ b/code/04 Configuration/03 Finished/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypress-adv",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.17",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.1.0",
+ "vite": "^3.1.0"
+ }
+}
diff --git a/code/04 Configuration/03 Finished/public/vite.svg b/code/04 Configuration/03 Finished/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/04 Configuration/03 Finished/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/04 Configuration/03 Finished/src/App.jsx b/code/04 Configuration/03 Finished/src/App.jsx
new file mode 100644
index 0000000..a7eba87
--- /dev/null
+++ b/code/04 Configuration/03 Finished/src/App.jsx
@@ -0,0 +1,21 @@
+import { Routes, Route } from 'react-router-dom';
+
+import HomePage from './pages/Home';
+import AboutPage from './pages/about';
+import Header from './components/Header';
+
+function App() {
+ return (
+ <>
+
+
+
+ } />
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/code/04 Configuration/03 Finished/src/components/ContactForm.jsx b/code/04 Configuration/03 Finished/src/components/ContactForm.jsx
new file mode 100644
index 0000000..aeb95d1
--- /dev/null
+++ b/code/04 Configuration/03 Finished/src/components/ContactForm.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useReducer, useState } from 'react';
+import classes from './ContactForm.module.css';
+
+const initialState = {
+ name: {
+ value: '',
+ blurred: false,
+ },
+ email: {
+ value: '',
+ blurred: false,
+ },
+ message: {
+ value: '',
+ blurred: false,
+ },
+};
+
+const formReducer = (state, action) => {
+ if (action.type === 'INPUT_CHANGE') {
+ return {
+ ...state,
+ [action.input]: {
+ value: action.value,
+ blurred: false,
+ },
+ };
+ }
+
+ if (action.type === 'INPUT_BLUR') {
+ return {
+ ...state,
+ [action.input]: {
+ ...state[action.input],
+ blurred: true,
+ },
+ };
+ }
+
+ return initialState;
+};
+
+function ContactForm() {
+ const [formState, dispatch] = useReducer(formReducer, initialState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { name, email, message } = formState;
+ const nameIsValid = name.value.trim() !== '';
+ const emailIsValid = email.value.trim() !== '' && email.value.includes('@');
+ const messageIsValid = message.value.trim() !== '';
+
+ const nameIsInvalid = !nameIsValid && name.blurred;
+ const emailIsInvalid = !emailIsValid && email.blurred;
+ const messageIsInvalid = !messageIsValid && message.blurred;
+
+ useEffect(() => {
+ if (isSubmitting) {
+ console.log('Sending message...');
+ const timer = setTimeout(() => {
+ setIsSubmitting(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isSubmitting]);
+
+ function changeInputHandler(event) {
+ dispatch({
+ type: 'INPUT_CHANGE',
+ input: event.target.id,
+ value: event.target.value,
+ });
+ }
+
+ function blurInputHandler(event) {
+ dispatch({
+ type: 'INPUT_BLUR',
+ input: event.target.id,
+ });
+ }
+
+ function submitHandler(event) {
+ event.preventDefault();
+
+ if (!nameIsValid || !emailIsValid || !messageIsValid) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ }
+
+ return (
+ <>
+ Contact Us
+
+
+ Your Message
+
+
+
+
+
+ {isSubmitting ? 'Sending...' : 'Send Message'}
+
+
+
+ >
+ );
+}
+
+export default ContactForm;
diff --git a/code/04 Configuration/03 Finished/src/components/ContactForm.module.css b/code/04 Configuration/03 Finished/src/components/ContactForm.module.css
new file mode 100644
index 0000000..468cd7b
--- /dev/null
+++ b/code/04 Configuration/03 Finished/src/components/ContactForm.module.css
@@ -0,0 +1,69 @@
+.form {
+ max-width: 40rem;
+ margin: 2rem auto;
+}
+
+.form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25;
+ color: var(--color-gray-400);
+ font-weight: 600;
+}
+
+.form input,
+.form textarea {
+ font: inherit;
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--color-gray-500);
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-800);
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 1rem;
+}
+
+.row p {
+ width: 100%;
+}
+
+.actions {
+ text-align: center;
+}
+
+.form button {
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ background-color: var(--color-primary-800);
+ font-size: 1rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.form button:hover {
+ background-color: var(--color-primary-700);
+}
+
+.form button:disabled {
+ background-color: var(--color-gray-700);
+ cursor: not-allowed;
+}
+
+.invalid label {
+ color: var(--color-red-300);
+}
+
+.invalid input,
+.invalid textarea {
+ border-color: var(--color-red-300);
+}
\ No newline at end of file
diff --git a/code/04 Configuration/03 Finished/src/components/Header.jsx b/code/04 Configuration/03 Finished/src/components/Header.jsx
new file mode 100644
index 0000000..f3337a7
--- /dev/null
+++ b/code/04 Configuration/03 Finished/src/components/Header.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+import classes from './Header.module.css';
+
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/code/04 Configuration/03 Finished/src/components/Header.module.css b/code/04 Configuration/03 Finished/src/components/Header.module.css
new file mode 100644
index 0000000..94193af
--- /dev/null
+++ b/code/04 Configuration/03 Finished/src/components/Header.module.css
@@ -0,0 +1,12 @@
+.header {
+ max-width: 60rem;
+ margin: 2rem auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header ul {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/code/04 Configuration/03 Finished/src/index.css b/code/04 Configuration/03 Finished/src/index.css
new file mode 100644
index 0000000..534f68d
--- /dev/null
+++ b/code/04 Configuration/03 Finished/src/index.css
@@ -0,0 +1,81 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --color-gray-100: #f2f2f7;
+ --color-gray-200: #d9d9e3;
+ --color-gray-300: #b3b3c6;
+ --color-gray-400: #8e8ea9;
+ --color-gray-500: #6b6c80;
+ --color-gray-600: #4f505c;
+ --color-gray-700: #3a3b4e;
+ --color-gray-800: #2a2b41;
+ --color-gray-900: #1c1d2b;
+ --color-gray-1000: #12121e;
+
+ --color-primary-100: #cfcfff;
+ --color-primary-200: #b3b3ff;
+ --color-primary-300: #8e8eff;
+ --color-primary-400: #7a7aff;
+ --color-primary-500: #646cff;
+ --color-primary-600: #535bf2;
+ --color-primary-700: #454ad6;
+ --color-primary-800: #3a3bb8;
+ --color-primary-900: #2a2a8e;
+ --color-primary-1000: #1c1c6b;
+
+ --color-red-100: #ffccf0;
+ --color-red-200: #ff99e0;
+ --color-red-300: #ff66d0;
+ --color-red-400: #ff33c0;
+ --color-red-500: #ff00b0;
+ --color-red-600: #e600a3;
+ --color-red-700: #cc0099;
+ --color-red-800: #b3008c;
+ --color-red-900: #990080;
+ --color-red-1000: #800073;
+}
+
+html {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: var(--color-gray-100);
+ background-color: var(--color-gray-1000);
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ font-weight: 500;
+ color: var(--color-primary-400);
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--color-primary-500);
+}
+
+.center {
+ text-align: center;
+ max-width: 60ch;
+ margin: 2rem auto;
+}
\ No newline at end of file
diff --git a/code/04 Configuration/03 Finished/src/main.jsx b/code/04 Configuration/03 Finished/src/main.jsx
new file mode 100644
index 0000000..05421fb
--- /dev/null
+++ b/code/04 Configuration/03 Finished/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
diff --git a/code/04 Configuration/03 Finished/src/pages/About.jsx b/code/04 Configuration/03 Finished/src/pages/About.jsx
new file mode 100644
index 0000000..3b2d8ad
--- /dev/null
+++ b/code/04 Configuration/03 Finished/src/pages/About.jsx
@@ -0,0 +1,22 @@
+import ContactForm from '../components/ContactForm';
+
+function AboutPage() {
+ return (
+ <>
+
+ About Us
+
+ We are a small team of developers who are passionate about testing. We
+ have created this demo to help you learn how to use Cypress.
+
+
+ Also follow us on our{' '}
+ YouTube channel .
+
+
+
+ >
+ );
+}
+
+export default AboutPage;
diff --git a/code/04 Configuration/03 Finished/src/pages/Home.jsx b/code/04 Configuration/03 Finished/src/pages/Home.jsx
new file mode 100644
index 0000000..ca9afdc
--- /dev/null
+++ b/code/04 Configuration/03 Finished/src/pages/Home.jsx
@@ -0,0 +1,11 @@
+function HomePage() {
+ return (
+ <>
+
+
Home Page
+
+ >
+ );
+}
+
+export default HomePage;
diff --git a/code/04 Configuration/03 Finished/vite.config.js b/code/04 Configuration/03 Finished/vite.config.js
new file mode 100644
index 0000000..b1b5f91
--- /dev/null
+++ b/code/04 Configuration/03 Finished/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/code/05 Stubs, Spies/01 Starting Project/cypress.config.js b/code/05 Stubs, Spies/01 Starting Project/cypress.config.js
new file mode 100644
index 0000000..a0c7e4a
--- /dev/null
+++ b/code/05 Stubs, Spies/01 Starting Project/cypress.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'cypress';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:5173',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/05 Stubs, Spies/01 Starting Project/cypress/e2e/location.cy.js b/code/05 Stubs, Spies/01 Starting Project/cypress/e2e/location.cy.js
new file mode 100644
index 0000000..a5fea10
--- /dev/null
+++ b/code/05 Stubs, Spies/01 Starting Project/cypress/e2e/location.cy.js
@@ -0,0 +1,8 @@
+///
+
+describe('share location', () => {
+ it('should fetch the user location', () => {
+ cy.visit('/');
+ cy.get('[data-cy="get-loc-btn"]').click();
+ });
+});
diff --git a/code/05 Stubs, Spies/01 Starting Project/cypress/support/commands.js b/code/05 Stubs, Spies/01 Starting Project/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/05 Stubs, Spies/01 Starting Project/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/01 Starting Project/cypress/support/e2e.js b/code/05 Stubs, Spies/01 Starting Project/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/05 Stubs, Spies/01 Starting Project/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/01 Starting Project/index.html b/code/05 Stubs, Spies/01 Starting Project/index.html
new file mode 100644
index 0000000..6832363
--- /dev/null
+++ b/code/05 Stubs, Spies/01 Starting Project/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ Cypress Stubs
+
+
+
+
+ SnapLocation
+
+
+ Your name
+
+
+
+
+ Get Location
+
+
+
+
+
+
+ Share Link
+
+
+
+
+
+
+
+
diff --git a/code/05 Stubs, Spies/01 Starting Project/main.js b/code/05 Stubs, Spies/01 Starting Project/main.js
new file mode 100644
index 0000000..09b6501
--- /dev/null
+++ b/code/05 Stubs, Spies/01 Starting Project/main.js
@@ -0,0 +1,102 @@
+const user = {
+ location: {
+ lat: 0,
+ lng: 0,
+ url: '',
+ },
+};
+
+function getUserLocation(event) {
+ const clickedBtn = event.target;
+ const container = clickedBtn.parentNode;
+ if ('geolocation' in navigator) {
+ clickedBtn.disabled = true;
+ clickedBtn.innerHTML = ' ';
+ navigator.geolocation.getCurrentPosition(function (position) {
+ user.location.lat = position.coords.latitude;
+ user.location.lng = position.coords.longitude;
+ user.location.url = `https://www.bing.com/maps?cp=${user.location.lat}~${user.location.lng}&lvl=15&style=r`;
+ container.insertBefore(
+ document.createTextNode('Location fetched!'),
+ clickedBtn
+ );
+ container.querySelector('svg').classList.add('active');
+ container.removeChild(clickedBtn);
+ container.querySelector('button').disabled = false;
+ container.querySelector('button').classList.add('active');
+ }, () => {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ });
+ } else {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ }
+}
+
+function shareLocation(event) {
+ // Use clipboard API to copy the location to the clipboard
+ event.preventDefault();
+ const fd = new FormData(event.target);
+ const userName = fd.get('name');
+
+ if (
+ userName.trim() === '' ||
+ user.location.lat === 0 ||
+ user.location.lng === 0
+ ) {
+ document.getElementById('error').textContent =
+ 'Please enter your name and get your location first!';
+ return;
+ }
+
+ document.getElementById('error').textContent = '';
+
+ const storedUrl = localStorage.getItem(userName);
+ if (storedUrl) {
+ copyToClipboard(storedUrl, 'Stored location URL copied to clipboard.');
+ return;
+ }
+
+ user.location.url += `&sp=point.${user.location.lat}_${
+ user.location.lng
+ }_${encodeURI(userName)}`;
+
+ localStorage.setItem(userName, user.location.url);
+ copyToClipboard(user.location.url, 'Location URL copied to clipboard.');
+}
+
+function copyToClipboard(data, infoText) {
+ if ('clipboard' in navigator) {
+ navigator.clipboard.writeText(data).then(
+ function () {
+ displayInfoMessage(infoText);
+ },
+ function () {
+ displayInfoMessage('Failed to copy location URL to clipboard.');
+ }
+ );
+ }
+}
+
+let existingTimer;
+
+function displayInfoMessage(message) {
+ if (existingTimer) {
+ clearTimeout(existingTimer);
+ }
+ const infoMsg = document.getElementById('info-message');
+ infoMsg.querySelector('p').textContent = message;
+ infoMsg.classList.add('visible');
+ existingTimer = setTimeout(() => {
+ infoMsg.classList.remove('visible');
+ }, 2000);
+}
+
+const getLocBtn = document.getElementById('get-location');
+const form = document.querySelector('form');
+
+getLocBtn.addEventListener('click', getUserLocation);
+form.addEventListener('submit', shareLocation);
diff --git a/code/05 Stubs, Spies/01 Starting Project/package.json b/code/05 Stubs, Spies/01 Starting Project/package.json
new file mode 100644
index 0000000..2ea1447
--- /dev/null
+++ b/code/05 Stubs, Spies/01 Starting Project/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "cypress-stubs",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "vite": "^4.1.0"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1"
+ }
+}
diff --git a/code/05 Stubs, Spies/01 Starting Project/public/vite.svg b/code/05 Stubs, Spies/01 Starting Project/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/05 Stubs, Spies/01 Starting Project/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/01 Starting Project/styles.css b/code/05 Stubs, Spies/01 Starting Project/styles.css
new file mode 100644
index 0000000..71f49d7
--- /dev/null
+++ b/code/05 Stubs, Spies/01 Starting Project/styles.css
@@ -0,0 +1,172 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+html {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+}
+
+body {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+}
+
+h1 {
+ text-align: center;
+ color: #8892a4;
+}
+
+main {
+ max-width: 42rem;
+ margin: 3rem auto;
+ padding: 0 1rem;
+}
+
+form p {
+ margin: 0 0 0.5rem 0;
+}
+
+form {
+ width: 90%;
+ margin: auto;
+ padding: 2rem 5rem;
+ background-color: #1a1919;
+ border-radius: 8px;
+}
+
+form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #8f8f8f;
+ font-weight: 600;
+}
+
+form input {
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 0.25rem;
+ background-color: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 255, 255, 0.87);
+}
+
+.actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+ align-items: center;
+ color: #b7c4fe;
+}
+
+.icon {
+ height: 2.5rem;
+ width: 2.5rem;
+}
+
+.actions svg {
+ height: 2.5rem;
+ width: 2.5rem;
+ stroke: #393939;
+ transition: all 0.3s ease-out;
+}
+
+.actions svg.active {
+ stroke: #4969f9;
+}
+
+.actions button {
+ width: 8rem;
+ height: 2rem;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: all 0.3s ease-out;
+}
+
+.actions button.active {
+ background-color: #4969f9;
+ color: #ffffff;
+}
+
+.actions button:hover {
+ background-color: #3a56d1;
+}
+
+.actions button.active:disabled {
+ background-color: #283365;
+}
+
+.actions button:disabled {
+ background-color: #1e1c1c;
+ color: #595656;
+ cursor: not-allowed;
+}
+
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+form #error {
+ color: #f6424b;
+ font-weight: 600;
+}
+
+#info-message {
+ position: fixed;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%) translateY(-100%);
+ background-color: #b6a8fd;
+ color: #0c153e;
+ font-weight: bold;
+ padding: 1rem 3rem;
+ border-radius: 0 0 6px 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
+ text-align: center;
+}
+
+#info-message.visible {
+ animation: slide-in 0.2s ease-out forwards;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes slide-in {
+ 0% {
+ transform: translateX(-50%) translateY(-100%);
+ }
+ 100% {
+ transform: translateX(-50%) translateY(0);
+ }
+}
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/02 Creating a Stub/cypress.config.js b/code/05 Stubs, Spies/02 Creating a Stub/cypress.config.js
new file mode 100644
index 0000000..a0c7e4a
--- /dev/null
+++ b/code/05 Stubs, Spies/02 Creating a Stub/cypress.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'cypress';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:5173',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/05 Stubs, Spies/02 Creating a Stub/cypress/e2e/location.cy.js b/code/05 Stubs, Spies/02 Creating a Stub/cypress/e2e/location.cy.js
new file mode 100644
index 0000000..3093c42
--- /dev/null
+++ b/code/05 Stubs, Spies/02 Creating a Stub/cypress/e2e/location.cy.js
@@ -0,0 +1,11 @@
+///
+
+describe('share location', () => {
+ it('should fetch the user location', () => {
+ cy.visit('/').then((win) => {
+ cy.stub(win.navigator.geolocation, 'getCurrentPosition').as('getUserPosition');
+ });
+ cy.get('[data-cy="get-loc-btn"]').click();
+ cy.get('@getUserPosition').should('have.been.called');
+ });
+});
diff --git a/code/05 Stubs, Spies/02 Creating a Stub/cypress/support/commands.js b/code/05 Stubs, Spies/02 Creating a Stub/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/05 Stubs, Spies/02 Creating a Stub/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/02 Creating a Stub/cypress/support/e2e.js b/code/05 Stubs, Spies/02 Creating a Stub/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/05 Stubs, Spies/02 Creating a Stub/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/02 Creating a Stub/index.html b/code/05 Stubs, Spies/02 Creating a Stub/index.html
new file mode 100644
index 0000000..6832363
--- /dev/null
+++ b/code/05 Stubs, Spies/02 Creating a Stub/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ Cypress Stubs
+
+
+
+
+ SnapLocation
+
+
+ Your name
+
+
+
+
+ Get Location
+
+
+
+
+
+
+ Share Link
+
+
+
+
+
+
+
+
diff --git a/code/05 Stubs, Spies/02 Creating a Stub/main.js b/code/05 Stubs, Spies/02 Creating a Stub/main.js
new file mode 100644
index 0000000..09b6501
--- /dev/null
+++ b/code/05 Stubs, Spies/02 Creating a Stub/main.js
@@ -0,0 +1,102 @@
+const user = {
+ location: {
+ lat: 0,
+ lng: 0,
+ url: '',
+ },
+};
+
+function getUserLocation(event) {
+ const clickedBtn = event.target;
+ const container = clickedBtn.parentNode;
+ if ('geolocation' in navigator) {
+ clickedBtn.disabled = true;
+ clickedBtn.innerHTML = ' ';
+ navigator.geolocation.getCurrentPosition(function (position) {
+ user.location.lat = position.coords.latitude;
+ user.location.lng = position.coords.longitude;
+ user.location.url = `https://www.bing.com/maps?cp=${user.location.lat}~${user.location.lng}&lvl=15&style=r`;
+ container.insertBefore(
+ document.createTextNode('Location fetched!'),
+ clickedBtn
+ );
+ container.querySelector('svg').classList.add('active');
+ container.removeChild(clickedBtn);
+ container.querySelector('button').disabled = false;
+ container.querySelector('button').classList.add('active');
+ }, () => {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ });
+ } else {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ }
+}
+
+function shareLocation(event) {
+ // Use clipboard API to copy the location to the clipboard
+ event.preventDefault();
+ const fd = new FormData(event.target);
+ const userName = fd.get('name');
+
+ if (
+ userName.trim() === '' ||
+ user.location.lat === 0 ||
+ user.location.lng === 0
+ ) {
+ document.getElementById('error').textContent =
+ 'Please enter your name and get your location first!';
+ return;
+ }
+
+ document.getElementById('error').textContent = '';
+
+ const storedUrl = localStorage.getItem(userName);
+ if (storedUrl) {
+ copyToClipboard(storedUrl, 'Stored location URL copied to clipboard.');
+ return;
+ }
+
+ user.location.url += `&sp=point.${user.location.lat}_${
+ user.location.lng
+ }_${encodeURI(userName)}`;
+
+ localStorage.setItem(userName, user.location.url);
+ copyToClipboard(user.location.url, 'Location URL copied to clipboard.');
+}
+
+function copyToClipboard(data, infoText) {
+ if ('clipboard' in navigator) {
+ navigator.clipboard.writeText(data).then(
+ function () {
+ displayInfoMessage(infoText);
+ },
+ function () {
+ displayInfoMessage('Failed to copy location URL to clipboard.');
+ }
+ );
+ }
+}
+
+let existingTimer;
+
+function displayInfoMessage(message) {
+ if (existingTimer) {
+ clearTimeout(existingTimer);
+ }
+ const infoMsg = document.getElementById('info-message');
+ infoMsg.querySelector('p').textContent = message;
+ infoMsg.classList.add('visible');
+ existingTimer = setTimeout(() => {
+ infoMsg.classList.remove('visible');
+ }, 2000);
+}
+
+const getLocBtn = document.getElementById('get-location');
+const form = document.querySelector('form');
+
+getLocBtn.addEventListener('click', getUserLocation);
+form.addEventListener('submit', shareLocation);
diff --git a/code/05 Stubs, Spies/02 Creating a Stub/package.json b/code/05 Stubs, Spies/02 Creating a Stub/package.json
new file mode 100644
index 0000000..2ea1447
--- /dev/null
+++ b/code/05 Stubs, Spies/02 Creating a Stub/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "cypress-stubs",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "vite": "^4.1.0"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1"
+ }
+}
diff --git a/code/05 Stubs, Spies/02 Creating a Stub/public/vite.svg b/code/05 Stubs, Spies/02 Creating a Stub/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/05 Stubs, Spies/02 Creating a Stub/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/02 Creating a Stub/styles.css b/code/05 Stubs, Spies/02 Creating a Stub/styles.css
new file mode 100644
index 0000000..71f49d7
--- /dev/null
+++ b/code/05 Stubs, Spies/02 Creating a Stub/styles.css
@@ -0,0 +1,172 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+html {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+}
+
+body {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+}
+
+h1 {
+ text-align: center;
+ color: #8892a4;
+}
+
+main {
+ max-width: 42rem;
+ margin: 3rem auto;
+ padding: 0 1rem;
+}
+
+form p {
+ margin: 0 0 0.5rem 0;
+}
+
+form {
+ width: 90%;
+ margin: auto;
+ padding: 2rem 5rem;
+ background-color: #1a1919;
+ border-radius: 8px;
+}
+
+form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #8f8f8f;
+ font-weight: 600;
+}
+
+form input {
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 0.25rem;
+ background-color: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 255, 255, 0.87);
+}
+
+.actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+ align-items: center;
+ color: #b7c4fe;
+}
+
+.icon {
+ height: 2.5rem;
+ width: 2.5rem;
+}
+
+.actions svg {
+ height: 2.5rem;
+ width: 2.5rem;
+ stroke: #393939;
+ transition: all 0.3s ease-out;
+}
+
+.actions svg.active {
+ stroke: #4969f9;
+}
+
+.actions button {
+ width: 8rem;
+ height: 2rem;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: all 0.3s ease-out;
+}
+
+.actions button.active {
+ background-color: #4969f9;
+ color: #ffffff;
+}
+
+.actions button:hover {
+ background-color: #3a56d1;
+}
+
+.actions button.active:disabled {
+ background-color: #283365;
+}
+
+.actions button:disabled {
+ background-color: #1e1c1c;
+ color: #595656;
+ cursor: not-allowed;
+}
+
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+form #error {
+ color: #f6424b;
+ font-weight: 600;
+}
+
+#info-message {
+ position: fixed;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%) translateY(-100%);
+ background-color: #b6a8fd;
+ color: #0c153e;
+ font-weight: bold;
+ padding: 1rem 3rem;
+ border-radius: 0 0 6px 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
+ text-align: center;
+}
+
+#info-message.visible {
+ animation: slide-in 0.2s ease-out forwards;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes slide-in {
+ 0% {
+ transform: translateX(-50%) translateY(-100%);
+ }
+ 100% {
+ transform: translateX(-50%) translateY(0);
+ }
+}
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/03 Fake Stub Implementation/cypress.config.js b/code/05 Stubs, Spies/03 Fake Stub Implementation/cypress.config.js
new file mode 100644
index 0000000..a0c7e4a
--- /dev/null
+++ b/code/05 Stubs, Spies/03 Fake Stub Implementation/cypress.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'cypress';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:5173',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/05 Stubs, Spies/03 Fake Stub Implementation/cypress/e2e/location.cy.js b/code/05 Stubs, Spies/03 Fake Stub Implementation/cypress/e2e/location.cy.js
new file mode 100644
index 0000000..dae6be3
--- /dev/null
+++ b/code/05 Stubs, Spies/03 Fake Stub Implementation/cypress/e2e/location.cy.js
@@ -0,0 +1,24 @@
+///
+
+describe('share location', () => {
+ it('should fetch the user location', () => {
+ cy.visit('/').then((win) => {
+ cy.stub(win.navigator.geolocation, 'getCurrentPosition')
+ .as('getUserPosition')
+ .callsFake((cb) => {
+ setTimeout(() => {
+ cb({
+ coords: {
+ latitude: 37.5,
+ longitude: 48.01,
+ },
+ });
+ }, 100);
+ });
+ });
+ cy.get('[data-cy="get-loc-btn"]').click();
+ cy.get('@getUserPosition').should('have.been.called');
+ cy.get('[data-cy="get-loc-btn"]').should('be.disabled');
+ cy.get('[data-cy="actions"]').should('contain', 'Location fetched'); // contains()
+ });
+});
diff --git a/code/05 Stubs, Spies/03 Fake Stub Implementation/cypress/support/commands.js b/code/05 Stubs, Spies/03 Fake Stub Implementation/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/05 Stubs, Spies/03 Fake Stub Implementation/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/03 Fake Stub Implementation/cypress/support/e2e.js b/code/05 Stubs, Spies/03 Fake Stub Implementation/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/05 Stubs, Spies/03 Fake Stub Implementation/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/03 Fake Stub Implementation/index.html b/code/05 Stubs, Spies/03 Fake Stub Implementation/index.html
new file mode 100644
index 0000000..6832363
--- /dev/null
+++ b/code/05 Stubs, Spies/03 Fake Stub Implementation/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ Cypress Stubs
+
+
+
+
+ SnapLocation
+
+
+ Your name
+
+
+
+
+ Get Location
+
+
+
+
+
+
+ Share Link
+
+
+
+
+
+
+
+
diff --git a/code/05 Stubs, Spies/03 Fake Stub Implementation/main.js b/code/05 Stubs, Spies/03 Fake Stub Implementation/main.js
new file mode 100644
index 0000000..09b6501
--- /dev/null
+++ b/code/05 Stubs, Spies/03 Fake Stub Implementation/main.js
@@ -0,0 +1,102 @@
+const user = {
+ location: {
+ lat: 0,
+ lng: 0,
+ url: '',
+ },
+};
+
+function getUserLocation(event) {
+ const clickedBtn = event.target;
+ const container = clickedBtn.parentNode;
+ if ('geolocation' in navigator) {
+ clickedBtn.disabled = true;
+ clickedBtn.innerHTML = ' ';
+ navigator.geolocation.getCurrentPosition(function (position) {
+ user.location.lat = position.coords.latitude;
+ user.location.lng = position.coords.longitude;
+ user.location.url = `https://www.bing.com/maps?cp=${user.location.lat}~${user.location.lng}&lvl=15&style=r`;
+ container.insertBefore(
+ document.createTextNode('Location fetched!'),
+ clickedBtn
+ );
+ container.querySelector('svg').classList.add('active');
+ container.removeChild(clickedBtn);
+ container.querySelector('button').disabled = false;
+ container.querySelector('button').classList.add('active');
+ }, () => {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ });
+ } else {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ }
+}
+
+function shareLocation(event) {
+ // Use clipboard API to copy the location to the clipboard
+ event.preventDefault();
+ const fd = new FormData(event.target);
+ const userName = fd.get('name');
+
+ if (
+ userName.trim() === '' ||
+ user.location.lat === 0 ||
+ user.location.lng === 0
+ ) {
+ document.getElementById('error').textContent =
+ 'Please enter your name and get your location first!';
+ return;
+ }
+
+ document.getElementById('error').textContent = '';
+
+ const storedUrl = localStorage.getItem(userName);
+ if (storedUrl) {
+ copyToClipboard(storedUrl, 'Stored location URL copied to clipboard.');
+ return;
+ }
+
+ user.location.url += `&sp=point.${user.location.lat}_${
+ user.location.lng
+ }_${encodeURI(userName)}`;
+
+ localStorage.setItem(userName, user.location.url);
+ copyToClipboard(user.location.url, 'Location URL copied to clipboard.');
+}
+
+function copyToClipboard(data, infoText) {
+ if ('clipboard' in navigator) {
+ navigator.clipboard.writeText(data).then(
+ function () {
+ displayInfoMessage(infoText);
+ },
+ function () {
+ displayInfoMessage('Failed to copy location URL to clipboard.');
+ }
+ );
+ }
+}
+
+let existingTimer;
+
+function displayInfoMessage(message) {
+ if (existingTimer) {
+ clearTimeout(existingTimer);
+ }
+ const infoMsg = document.getElementById('info-message');
+ infoMsg.querySelector('p').textContent = message;
+ infoMsg.classList.add('visible');
+ existingTimer = setTimeout(() => {
+ infoMsg.classList.remove('visible');
+ }, 2000);
+}
+
+const getLocBtn = document.getElementById('get-location');
+const form = document.querySelector('form');
+
+getLocBtn.addEventListener('click', getUserLocation);
+form.addEventListener('submit', shareLocation);
diff --git a/code/05 Stubs, Spies/03 Fake Stub Implementation/package.json b/code/05 Stubs, Spies/03 Fake Stub Implementation/package.json
new file mode 100644
index 0000000..2ea1447
--- /dev/null
+++ b/code/05 Stubs, Spies/03 Fake Stub Implementation/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "cypress-stubs",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "vite": "^4.1.0"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1"
+ }
+}
diff --git a/code/05 Stubs, Spies/03 Fake Stub Implementation/public/vite.svg b/code/05 Stubs, Spies/03 Fake Stub Implementation/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/05 Stubs, Spies/03 Fake Stub Implementation/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/03 Fake Stub Implementation/styles.css b/code/05 Stubs, Spies/03 Fake Stub Implementation/styles.css
new file mode 100644
index 0000000..71f49d7
--- /dev/null
+++ b/code/05 Stubs, Spies/03 Fake Stub Implementation/styles.css
@@ -0,0 +1,172 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+html {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+}
+
+body {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+}
+
+h1 {
+ text-align: center;
+ color: #8892a4;
+}
+
+main {
+ max-width: 42rem;
+ margin: 3rem auto;
+ padding: 0 1rem;
+}
+
+form p {
+ margin: 0 0 0.5rem 0;
+}
+
+form {
+ width: 90%;
+ margin: auto;
+ padding: 2rem 5rem;
+ background-color: #1a1919;
+ border-radius: 8px;
+}
+
+form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #8f8f8f;
+ font-weight: 600;
+}
+
+form input {
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 0.25rem;
+ background-color: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 255, 255, 0.87);
+}
+
+.actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+ align-items: center;
+ color: #b7c4fe;
+}
+
+.icon {
+ height: 2.5rem;
+ width: 2.5rem;
+}
+
+.actions svg {
+ height: 2.5rem;
+ width: 2.5rem;
+ stroke: #393939;
+ transition: all 0.3s ease-out;
+}
+
+.actions svg.active {
+ stroke: #4969f9;
+}
+
+.actions button {
+ width: 8rem;
+ height: 2rem;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: all 0.3s ease-out;
+}
+
+.actions button.active {
+ background-color: #4969f9;
+ color: #ffffff;
+}
+
+.actions button:hover {
+ background-color: #3a56d1;
+}
+
+.actions button.active:disabled {
+ background-color: #283365;
+}
+
+.actions button:disabled {
+ background-color: #1e1c1c;
+ color: #595656;
+ cursor: not-allowed;
+}
+
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+form #error {
+ color: #f6424b;
+ font-weight: 600;
+}
+
+#info-message {
+ position: fixed;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%) translateY(-100%);
+ background-color: #b6a8fd;
+ color: #0c153e;
+ font-weight: bold;
+ padding: 1rem 3rem;
+ border-radius: 0 0 6px 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
+ text-align: center;
+}
+
+#info-message.visible {
+ animation: slide-in 0.2s ease-out forwards;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes slide-in {
+ 0% {
+ transform: translateX(-50%) translateY(-100%);
+ }
+ 100% {
+ transform: translateX(-50%) translateY(0);
+ }
+}
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/04 Evaluating Stub Arguments/cypress.config.js b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/cypress.config.js
new file mode 100644
index 0000000..a0c7e4a
--- /dev/null
+++ b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/cypress.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'cypress';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:5173',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/05 Stubs, Spies/04 Evaluating Stub Arguments/cypress/e2e/location.cy.js b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/cypress/e2e/location.cy.js
new file mode 100644
index 0000000..eee3279
--- /dev/null
+++ b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/cypress/e2e/location.cy.js
@@ -0,0 +1,40 @@
+///
+
+describe('share location', () => {
+ beforeEach(() => {
+ cy.visit('/').then((win) => {
+ cy.stub(win.navigator.geolocation, 'getCurrentPosition')
+ .as('getUserPosition')
+ .callsFake((cb) => {
+ setTimeout(() => {
+ cb({
+ coords: {
+ latitude: 37.5,
+ longitude: 48.01,
+ },
+ });
+ }, 100);
+ });
+ cy.stub(win.navigator.clipboard, 'writeText')
+ .as('saveToClipboard')
+ .resolves();
+ });
+ });
+ it('should fetch the user location', () => {
+ cy.get('[data-cy="get-loc-btn"]').click();
+ cy.get('@getUserPosition').should('have.been.called');
+ cy.get('[data-cy="get-loc-btn"]').should('be.disabled');
+ cy.get('[data-cy="actions"]').should('contain', 'Location fetched'); // contains()
+ });
+
+ it('should share a location URL', () => {
+ cy.get('[data-cy="name-input"]').type('John Doe');
+ cy.get('[data-cy="get-loc-btn"]').click();
+ cy.get('[data-cy="share-loc-btn"]').click();
+ cy.get('@saveToClipboard').should('have.been.called');
+ cy.get('@saveToClipboard').should(
+ 'have.been.calledWithMatch',
+ new RegExp(`${37.5}.*${48.01}.*${encodeURI('John Doe')}`)
+ );
+ });
+});
diff --git a/code/05 Stubs, Spies/04 Evaluating Stub Arguments/cypress/support/commands.js b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/04 Evaluating Stub Arguments/cypress/support/e2e.js b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/04 Evaluating Stub Arguments/index.html b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/index.html
new file mode 100644
index 0000000..6832363
--- /dev/null
+++ b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ Cypress Stubs
+
+
+
+
+ SnapLocation
+
+
+ Your name
+
+
+
+
+ Get Location
+
+
+
+
+
+
+ Share Link
+
+
+
+
+
+
+
+
diff --git a/code/05 Stubs, Spies/04 Evaluating Stub Arguments/main.js b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/main.js
new file mode 100644
index 0000000..09b6501
--- /dev/null
+++ b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/main.js
@@ -0,0 +1,102 @@
+const user = {
+ location: {
+ lat: 0,
+ lng: 0,
+ url: '',
+ },
+};
+
+function getUserLocation(event) {
+ const clickedBtn = event.target;
+ const container = clickedBtn.parentNode;
+ if ('geolocation' in navigator) {
+ clickedBtn.disabled = true;
+ clickedBtn.innerHTML = ' ';
+ navigator.geolocation.getCurrentPosition(function (position) {
+ user.location.lat = position.coords.latitude;
+ user.location.lng = position.coords.longitude;
+ user.location.url = `https://www.bing.com/maps?cp=${user.location.lat}~${user.location.lng}&lvl=15&style=r`;
+ container.insertBefore(
+ document.createTextNode('Location fetched!'),
+ clickedBtn
+ );
+ container.querySelector('svg').classList.add('active');
+ container.removeChild(clickedBtn);
+ container.querySelector('button').disabled = false;
+ container.querySelector('button').classList.add('active');
+ }, () => {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ });
+ } else {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ }
+}
+
+function shareLocation(event) {
+ // Use clipboard API to copy the location to the clipboard
+ event.preventDefault();
+ const fd = new FormData(event.target);
+ const userName = fd.get('name');
+
+ if (
+ userName.trim() === '' ||
+ user.location.lat === 0 ||
+ user.location.lng === 0
+ ) {
+ document.getElementById('error').textContent =
+ 'Please enter your name and get your location first!';
+ return;
+ }
+
+ document.getElementById('error').textContent = '';
+
+ const storedUrl = localStorage.getItem(userName);
+ if (storedUrl) {
+ copyToClipboard(storedUrl, 'Stored location URL copied to clipboard.');
+ return;
+ }
+
+ user.location.url += `&sp=point.${user.location.lat}_${
+ user.location.lng
+ }_${encodeURI(userName)}`;
+
+ localStorage.setItem(userName, user.location.url);
+ copyToClipboard(user.location.url, 'Location URL copied to clipboard.');
+}
+
+function copyToClipboard(data, infoText) {
+ if ('clipboard' in navigator) {
+ navigator.clipboard.writeText(data).then(
+ function () {
+ displayInfoMessage(infoText);
+ },
+ function () {
+ displayInfoMessage('Failed to copy location URL to clipboard.');
+ }
+ );
+ }
+}
+
+let existingTimer;
+
+function displayInfoMessage(message) {
+ if (existingTimer) {
+ clearTimeout(existingTimer);
+ }
+ const infoMsg = document.getElementById('info-message');
+ infoMsg.querySelector('p').textContent = message;
+ infoMsg.classList.add('visible');
+ existingTimer = setTimeout(() => {
+ infoMsg.classList.remove('visible');
+ }, 2000);
+}
+
+const getLocBtn = document.getElementById('get-location');
+const form = document.querySelector('form');
+
+getLocBtn.addEventListener('click', getUserLocation);
+form.addEventListener('submit', shareLocation);
diff --git a/code/05 Stubs, Spies/04 Evaluating Stub Arguments/package.json b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/package.json
new file mode 100644
index 0000000..2ea1447
--- /dev/null
+++ b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "cypress-stubs",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "vite": "^4.1.0"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1"
+ }
+}
diff --git a/code/05 Stubs, Spies/04 Evaluating Stub Arguments/public/vite.svg b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/04 Evaluating Stub Arguments/styles.css b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/styles.css
new file mode 100644
index 0000000..71f49d7
--- /dev/null
+++ b/code/05 Stubs, Spies/04 Evaluating Stub Arguments/styles.css
@@ -0,0 +1,172 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+html {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+}
+
+body {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+}
+
+h1 {
+ text-align: center;
+ color: #8892a4;
+}
+
+main {
+ max-width: 42rem;
+ margin: 3rem auto;
+ padding: 0 1rem;
+}
+
+form p {
+ margin: 0 0 0.5rem 0;
+}
+
+form {
+ width: 90%;
+ margin: auto;
+ padding: 2rem 5rem;
+ background-color: #1a1919;
+ border-radius: 8px;
+}
+
+form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #8f8f8f;
+ font-weight: 600;
+}
+
+form input {
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 0.25rem;
+ background-color: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 255, 255, 0.87);
+}
+
+.actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+ align-items: center;
+ color: #b7c4fe;
+}
+
+.icon {
+ height: 2.5rem;
+ width: 2.5rem;
+}
+
+.actions svg {
+ height: 2.5rem;
+ width: 2.5rem;
+ stroke: #393939;
+ transition: all 0.3s ease-out;
+}
+
+.actions svg.active {
+ stroke: #4969f9;
+}
+
+.actions button {
+ width: 8rem;
+ height: 2rem;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: all 0.3s ease-out;
+}
+
+.actions button.active {
+ background-color: #4969f9;
+ color: #ffffff;
+}
+
+.actions button:hover {
+ background-color: #3a56d1;
+}
+
+.actions button.active:disabled {
+ background-color: #283365;
+}
+
+.actions button:disabled {
+ background-color: #1e1c1c;
+ color: #595656;
+ cursor: not-allowed;
+}
+
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+form #error {
+ color: #f6424b;
+ font-weight: 600;
+}
+
+#info-message {
+ position: fixed;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%) translateY(-100%);
+ background-color: #b6a8fd;
+ color: #0c153e;
+ font-weight: bold;
+ padding: 1rem 3rem;
+ border-radius: 0 0 6px 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
+ text-align: center;
+}
+
+#info-message.visible {
+ animation: slide-in 0.2s ease-out forwards;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes slide-in {
+ 0% {
+ transform: translateX(-50%) translateY(-100%);
+ }
+ 100% {
+ transform: translateX(-50%) translateY(0);
+ }
+}
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/05 Fixtures/cypress.config.js b/code/05 Stubs, Spies/05 Fixtures/cypress.config.js
new file mode 100644
index 0000000..a0c7e4a
--- /dev/null
+++ b/code/05 Stubs, Spies/05 Fixtures/cypress.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'cypress';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:5173',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/05 Stubs, Spies/05 Fixtures/cypress/e2e/location.cy.js b/code/05 Stubs, Spies/05 Fixtures/cypress/e2e/location.cy.js
new file mode 100644
index 0000000..55d89bd
--- /dev/null
+++ b/code/05 Stubs, Spies/05 Fixtures/cypress/e2e/location.cy.js
@@ -0,0 +1,41 @@
+///
+
+describe('share location', () => {
+ beforeEach(() => {
+ cy.fixture('user-location.json').as('userLocation');
+ cy.visit('/').then((win) => {
+ cy.get('@userLocation').then((fakePosition) => {
+ cy.stub(win.navigator.geolocation, 'getCurrentPosition')
+ .as('getUserPosition')
+ .callsFake((cb) => {
+ setTimeout(() => {
+ cb(fakePosition);
+ }, 100);
+ });
+ });
+ cy.stub(win.navigator.clipboard, 'writeText')
+ .as('saveToClipboard')
+ .resolves();
+ });
+ });
+ it('should fetch the user location', () => {
+ cy.get('[data-cy="get-loc-btn"]').click();
+ cy.get('@getUserPosition').should('have.been.called');
+ cy.get('[data-cy="get-loc-btn"]').should('be.disabled');
+ cy.get('[data-cy="actions"]').should('contain', 'Location fetched'); // contains()
+ });
+
+ it('should share a location URL', () => {
+ cy.get('[data-cy="name-input"]').type('John Doe');
+ cy.get('[data-cy="get-loc-btn"]').click();
+ cy.get('[data-cy="share-loc-btn"]').click();
+ cy.get('@saveToClipboard').should('have.been.called');
+ cy.get('@userLocation').then((fakePosition) => {
+ const { latitude, longitude } = fakePosition.coords;
+ cy.get('@saveToClipboard').should(
+ 'have.been.calledWithMatch',
+ new RegExp(`${latitude}.*${longitude}.*${encodeURI('John Doe')}`)
+ );
+ });
+ });
+});
diff --git a/code/05 Stubs, Spies/05 Fixtures/cypress/fixtures/user-location.json b/code/05 Stubs, Spies/05 Fixtures/cypress/fixtures/user-location.json
new file mode 100644
index 0000000..b9e6c27
--- /dev/null
+++ b/code/05 Stubs, Spies/05 Fixtures/cypress/fixtures/user-location.json
@@ -0,0 +1,6 @@
+{
+ "coords": {
+ "latitude": 37.5,
+ "longitude": 48.01
+ }
+}
diff --git a/code/05 Stubs, Spies/05 Fixtures/cypress/support/commands.js b/code/05 Stubs, Spies/05 Fixtures/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/05 Stubs, Spies/05 Fixtures/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/05 Fixtures/cypress/support/e2e.js b/code/05 Stubs, Spies/05 Fixtures/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/05 Stubs, Spies/05 Fixtures/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/05 Fixtures/index.html b/code/05 Stubs, Spies/05 Fixtures/index.html
new file mode 100644
index 0000000..6832363
--- /dev/null
+++ b/code/05 Stubs, Spies/05 Fixtures/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ Cypress Stubs
+
+
+
+
+ SnapLocation
+
+
+ Your name
+
+
+
+
+ Get Location
+
+
+
+
+
+
+ Share Link
+
+
+
+
+
+
+
+
diff --git a/code/05 Stubs, Spies/05 Fixtures/main.js b/code/05 Stubs, Spies/05 Fixtures/main.js
new file mode 100644
index 0000000..09b6501
--- /dev/null
+++ b/code/05 Stubs, Spies/05 Fixtures/main.js
@@ -0,0 +1,102 @@
+const user = {
+ location: {
+ lat: 0,
+ lng: 0,
+ url: '',
+ },
+};
+
+function getUserLocation(event) {
+ const clickedBtn = event.target;
+ const container = clickedBtn.parentNode;
+ if ('geolocation' in navigator) {
+ clickedBtn.disabled = true;
+ clickedBtn.innerHTML = ' ';
+ navigator.geolocation.getCurrentPosition(function (position) {
+ user.location.lat = position.coords.latitude;
+ user.location.lng = position.coords.longitude;
+ user.location.url = `https://www.bing.com/maps?cp=${user.location.lat}~${user.location.lng}&lvl=15&style=r`;
+ container.insertBefore(
+ document.createTextNode('Location fetched!'),
+ clickedBtn
+ );
+ container.querySelector('svg').classList.add('active');
+ container.removeChild(clickedBtn);
+ container.querySelector('button').disabled = false;
+ container.querySelector('button').classList.add('active');
+ }, () => {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ });
+ } else {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ }
+}
+
+function shareLocation(event) {
+ // Use clipboard API to copy the location to the clipboard
+ event.preventDefault();
+ const fd = new FormData(event.target);
+ const userName = fd.get('name');
+
+ if (
+ userName.trim() === '' ||
+ user.location.lat === 0 ||
+ user.location.lng === 0
+ ) {
+ document.getElementById('error').textContent =
+ 'Please enter your name and get your location first!';
+ return;
+ }
+
+ document.getElementById('error').textContent = '';
+
+ const storedUrl = localStorage.getItem(userName);
+ if (storedUrl) {
+ copyToClipboard(storedUrl, 'Stored location URL copied to clipboard.');
+ return;
+ }
+
+ user.location.url += `&sp=point.${user.location.lat}_${
+ user.location.lng
+ }_${encodeURI(userName)}`;
+
+ localStorage.setItem(userName, user.location.url);
+ copyToClipboard(user.location.url, 'Location URL copied to clipboard.');
+}
+
+function copyToClipboard(data, infoText) {
+ if ('clipboard' in navigator) {
+ navigator.clipboard.writeText(data).then(
+ function () {
+ displayInfoMessage(infoText);
+ },
+ function () {
+ displayInfoMessage('Failed to copy location URL to clipboard.');
+ }
+ );
+ }
+}
+
+let existingTimer;
+
+function displayInfoMessage(message) {
+ if (existingTimer) {
+ clearTimeout(existingTimer);
+ }
+ const infoMsg = document.getElementById('info-message');
+ infoMsg.querySelector('p').textContent = message;
+ infoMsg.classList.add('visible');
+ existingTimer = setTimeout(() => {
+ infoMsg.classList.remove('visible');
+ }, 2000);
+}
+
+const getLocBtn = document.getElementById('get-location');
+const form = document.querySelector('form');
+
+getLocBtn.addEventListener('click', getUserLocation);
+form.addEventListener('submit', shareLocation);
diff --git a/code/05 Stubs, Spies/05 Fixtures/package.json b/code/05 Stubs, Spies/05 Fixtures/package.json
new file mode 100644
index 0000000..2ea1447
--- /dev/null
+++ b/code/05 Stubs, Spies/05 Fixtures/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "cypress-stubs",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "vite": "^4.1.0"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1"
+ }
+}
diff --git a/code/05 Stubs, Spies/05 Fixtures/public/vite.svg b/code/05 Stubs, Spies/05 Fixtures/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/05 Stubs, Spies/05 Fixtures/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/05 Fixtures/styles.css b/code/05 Stubs, Spies/05 Fixtures/styles.css
new file mode 100644
index 0000000..71f49d7
--- /dev/null
+++ b/code/05 Stubs, Spies/05 Fixtures/styles.css
@@ -0,0 +1,172 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+html {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+}
+
+body {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+}
+
+h1 {
+ text-align: center;
+ color: #8892a4;
+}
+
+main {
+ max-width: 42rem;
+ margin: 3rem auto;
+ padding: 0 1rem;
+}
+
+form p {
+ margin: 0 0 0.5rem 0;
+}
+
+form {
+ width: 90%;
+ margin: auto;
+ padding: 2rem 5rem;
+ background-color: #1a1919;
+ border-radius: 8px;
+}
+
+form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #8f8f8f;
+ font-weight: 600;
+}
+
+form input {
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 0.25rem;
+ background-color: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 255, 255, 0.87);
+}
+
+.actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+ align-items: center;
+ color: #b7c4fe;
+}
+
+.icon {
+ height: 2.5rem;
+ width: 2.5rem;
+}
+
+.actions svg {
+ height: 2.5rem;
+ width: 2.5rem;
+ stroke: #393939;
+ transition: all 0.3s ease-out;
+}
+
+.actions svg.active {
+ stroke: #4969f9;
+}
+
+.actions button {
+ width: 8rem;
+ height: 2rem;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: all 0.3s ease-out;
+}
+
+.actions button.active {
+ background-color: #4969f9;
+ color: #ffffff;
+}
+
+.actions button:hover {
+ background-color: #3a56d1;
+}
+
+.actions button.active:disabled {
+ background-color: #283365;
+}
+
+.actions button:disabled {
+ background-color: #1e1c1c;
+ color: #595656;
+ cursor: not-allowed;
+}
+
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+form #error {
+ color: #f6424b;
+ font-weight: 600;
+}
+
+#info-message {
+ position: fixed;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%) translateY(-100%);
+ background-color: #b6a8fd;
+ color: #0c153e;
+ font-weight: bold;
+ padding: 1rem 3rem;
+ border-radius: 0 0 6px 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
+ text-align: center;
+}
+
+#info-message.visible {
+ animation: slide-in 0.2s ease-out forwards;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes slide-in {
+ 0% {
+ transform: translateX(-50%) translateY(-100%);
+ }
+ 100% {
+ transform: translateX(-50%) translateY(0);
+ }
+}
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/06 Creating Spies/cypress.config.js b/code/05 Stubs, Spies/06 Creating Spies/cypress.config.js
new file mode 100644
index 0000000..a0c7e4a
--- /dev/null
+++ b/code/05 Stubs, Spies/06 Creating Spies/cypress.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'cypress';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:5173',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/05 Stubs, Spies/06 Creating Spies/cypress/e2e/location.cy.js b/code/05 Stubs, Spies/06 Creating Spies/cypress/e2e/location.cy.js
new file mode 100644
index 0000000..2841495
--- /dev/null
+++ b/code/05 Stubs, Spies/06 Creating Spies/cypress/e2e/location.cy.js
@@ -0,0 +1,51 @@
+///
+
+describe('share location', () => {
+ beforeEach(() => {
+ cy.fixture('user-location.json').as('userLocation');
+ cy.visit('/').then((win) => {
+ cy.get('@userLocation').then((fakePosition) => {
+ cy.stub(win.navigator.geolocation, 'getCurrentPosition')
+ .as('getUserPosition')
+ .callsFake((cb) => {
+ setTimeout(() => {
+ cb(fakePosition);
+ }, 100);
+ });
+ });
+ cy.stub(win.navigator.clipboard, 'writeText')
+ .as('saveToClipboard')
+ .resolves();
+ cy.spy(win.localStorage, 'setItem').as('storeLocation');
+ cy.spy(win.localStorage, 'getItem').as('getStoredLocation');
+ });
+ });
+ it('should fetch the user location', () => {
+ cy.get('[data-cy="get-loc-btn"]').click();
+ cy.get('@getUserPosition').should('have.been.called');
+ cy.get('[data-cy="get-loc-btn"]').should('be.disabled');
+ cy.get('[data-cy="actions"]').should('contain', 'Location fetched'); // contains()
+ });
+
+ it('should share a location URL', () => {
+ cy.get('[data-cy="name-input"]').type('John Doe');
+ cy.get('[data-cy="get-loc-btn"]').click();
+ cy.get('[data-cy="share-loc-btn"]').click();
+ cy.get('@saveToClipboard').should('have.been.called');
+ cy.get('@userLocation').then((fakePosition) => {
+ const { latitude, longitude } = fakePosition.coords;
+ cy.get('@saveToClipboard').should(
+ 'have.been.calledWithMatch',
+ new RegExp(`${latitude}.*${longitude}.*${encodeURI('John Doe')}`)
+ );
+ cy.get('@storeLocation').should(
+ 'have.been.calledWithMatch',
+ /John Doe/,
+ new RegExp(`${latitude}.*${longitude}.*${encodeURI('John Doe')}`)
+ );
+ });
+ cy.get('@storeLocation').should('have.been.called');
+ cy.get('[data-cy="share-loc-btn"]').click();
+ cy.get('@getStoredLocation').should('have.been.called');
+ });
+});
diff --git a/code/05 Stubs, Spies/06 Creating Spies/cypress/fixtures/user-location.json b/code/05 Stubs, Spies/06 Creating Spies/cypress/fixtures/user-location.json
new file mode 100644
index 0000000..b9e6c27
--- /dev/null
+++ b/code/05 Stubs, Spies/06 Creating Spies/cypress/fixtures/user-location.json
@@ -0,0 +1,6 @@
+{
+ "coords": {
+ "latitude": 37.5,
+ "longitude": 48.01
+ }
+}
diff --git a/code/05 Stubs, Spies/06 Creating Spies/cypress/support/commands.js b/code/05 Stubs, Spies/06 Creating Spies/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/05 Stubs, Spies/06 Creating Spies/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/06 Creating Spies/cypress/support/e2e.js b/code/05 Stubs, Spies/06 Creating Spies/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/05 Stubs, Spies/06 Creating Spies/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/06 Creating Spies/index.html b/code/05 Stubs, Spies/06 Creating Spies/index.html
new file mode 100644
index 0000000..6832363
--- /dev/null
+++ b/code/05 Stubs, Spies/06 Creating Spies/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ Cypress Stubs
+
+
+
+
+ SnapLocation
+
+
+ Your name
+
+
+
+
+ Get Location
+
+
+
+
+
+
+ Share Link
+
+
+
+
+
+
+
+
diff --git a/code/05 Stubs, Spies/06 Creating Spies/main.js b/code/05 Stubs, Spies/06 Creating Spies/main.js
new file mode 100644
index 0000000..09b6501
--- /dev/null
+++ b/code/05 Stubs, Spies/06 Creating Spies/main.js
@@ -0,0 +1,102 @@
+const user = {
+ location: {
+ lat: 0,
+ lng: 0,
+ url: '',
+ },
+};
+
+function getUserLocation(event) {
+ const clickedBtn = event.target;
+ const container = clickedBtn.parentNode;
+ if ('geolocation' in navigator) {
+ clickedBtn.disabled = true;
+ clickedBtn.innerHTML = ' ';
+ navigator.geolocation.getCurrentPosition(function (position) {
+ user.location.lat = position.coords.latitude;
+ user.location.lng = position.coords.longitude;
+ user.location.url = `https://www.bing.com/maps?cp=${user.location.lat}~${user.location.lng}&lvl=15&style=r`;
+ container.insertBefore(
+ document.createTextNode('Location fetched!'),
+ clickedBtn
+ );
+ container.querySelector('svg').classList.add('active');
+ container.removeChild(clickedBtn);
+ container.querySelector('button').disabled = false;
+ container.querySelector('button').classList.add('active');
+ }, () => {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ });
+ } else {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ }
+}
+
+function shareLocation(event) {
+ // Use clipboard API to copy the location to the clipboard
+ event.preventDefault();
+ const fd = new FormData(event.target);
+ const userName = fd.get('name');
+
+ if (
+ userName.trim() === '' ||
+ user.location.lat === 0 ||
+ user.location.lng === 0
+ ) {
+ document.getElementById('error').textContent =
+ 'Please enter your name and get your location first!';
+ return;
+ }
+
+ document.getElementById('error').textContent = '';
+
+ const storedUrl = localStorage.getItem(userName);
+ if (storedUrl) {
+ copyToClipboard(storedUrl, 'Stored location URL copied to clipboard.');
+ return;
+ }
+
+ user.location.url += `&sp=point.${user.location.lat}_${
+ user.location.lng
+ }_${encodeURI(userName)}`;
+
+ localStorage.setItem(userName, user.location.url);
+ copyToClipboard(user.location.url, 'Location URL copied to clipboard.');
+}
+
+function copyToClipboard(data, infoText) {
+ if ('clipboard' in navigator) {
+ navigator.clipboard.writeText(data).then(
+ function () {
+ displayInfoMessage(infoText);
+ },
+ function () {
+ displayInfoMessage('Failed to copy location URL to clipboard.');
+ }
+ );
+ }
+}
+
+let existingTimer;
+
+function displayInfoMessage(message) {
+ if (existingTimer) {
+ clearTimeout(existingTimer);
+ }
+ const infoMsg = document.getElementById('info-message');
+ infoMsg.querySelector('p').textContent = message;
+ infoMsg.classList.add('visible');
+ existingTimer = setTimeout(() => {
+ infoMsg.classList.remove('visible');
+ }, 2000);
+}
+
+const getLocBtn = document.getElementById('get-location');
+const form = document.querySelector('form');
+
+getLocBtn.addEventListener('click', getUserLocation);
+form.addEventListener('submit', shareLocation);
diff --git a/code/05 Stubs, Spies/06 Creating Spies/package.json b/code/05 Stubs, Spies/06 Creating Spies/package.json
new file mode 100644
index 0000000..2ea1447
--- /dev/null
+++ b/code/05 Stubs, Spies/06 Creating Spies/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "cypress-stubs",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "vite": "^4.1.0"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1"
+ }
+}
diff --git a/code/05 Stubs, Spies/06 Creating Spies/public/vite.svg b/code/05 Stubs, Spies/06 Creating Spies/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/05 Stubs, Spies/06 Creating Spies/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/06 Creating Spies/styles.css b/code/05 Stubs, Spies/06 Creating Spies/styles.css
new file mode 100644
index 0000000..71f49d7
--- /dev/null
+++ b/code/05 Stubs, Spies/06 Creating Spies/styles.css
@@ -0,0 +1,172 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+html {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+}
+
+body {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+}
+
+h1 {
+ text-align: center;
+ color: #8892a4;
+}
+
+main {
+ max-width: 42rem;
+ margin: 3rem auto;
+ padding: 0 1rem;
+}
+
+form p {
+ margin: 0 0 0.5rem 0;
+}
+
+form {
+ width: 90%;
+ margin: auto;
+ padding: 2rem 5rem;
+ background-color: #1a1919;
+ border-radius: 8px;
+}
+
+form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #8f8f8f;
+ font-weight: 600;
+}
+
+form input {
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 0.25rem;
+ background-color: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 255, 255, 0.87);
+}
+
+.actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+ align-items: center;
+ color: #b7c4fe;
+}
+
+.icon {
+ height: 2.5rem;
+ width: 2.5rem;
+}
+
+.actions svg {
+ height: 2.5rem;
+ width: 2.5rem;
+ stroke: #393939;
+ transition: all 0.3s ease-out;
+}
+
+.actions svg.active {
+ stroke: #4969f9;
+}
+
+.actions button {
+ width: 8rem;
+ height: 2rem;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: all 0.3s ease-out;
+}
+
+.actions button.active {
+ background-color: #4969f9;
+ color: #ffffff;
+}
+
+.actions button:hover {
+ background-color: #3a56d1;
+}
+
+.actions button.active:disabled {
+ background-color: #283365;
+}
+
+.actions button:disabled {
+ background-color: #1e1c1c;
+ color: #595656;
+ cursor: not-allowed;
+}
+
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+form #error {
+ color: #f6424b;
+ font-weight: 600;
+}
+
+#info-message {
+ position: fixed;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%) translateY(-100%);
+ background-color: #b6a8fd;
+ color: #0c153e;
+ font-weight: bold;
+ padding: 1rem 3rem;
+ border-radius: 0 0 6px 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
+ text-align: center;
+}
+
+#info-message.visible {
+ animation: slide-in 0.2s ease-out forwards;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes slide-in {
+ 0% {
+ transform: translateX(-50%) translateY(-100%);
+ }
+ 100% {
+ transform: translateX(-50%) translateY(0);
+ }
+}
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/07 Clock/cypress.config.js b/code/05 Stubs, Spies/07 Clock/cypress.config.js
new file mode 100644
index 0000000..a0c7e4a
--- /dev/null
+++ b/code/05 Stubs, Spies/07 Clock/cypress.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'cypress';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:5173',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ },
+ },
+});
diff --git a/code/05 Stubs, Spies/07 Clock/cypress/e2e/location.cy.js b/code/05 Stubs, Spies/07 Clock/cypress/e2e/location.cy.js
new file mode 100644
index 0000000..b98f3e8
--- /dev/null
+++ b/code/05 Stubs, Spies/07 Clock/cypress/e2e/location.cy.js
@@ -0,0 +1,56 @@
+///
+
+describe('share location', () => {
+ beforeEach(() => {
+ cy.clock();
+ cy.fixture('user-location.json').as('userLocation');
+ cy.visit('/').then((win) => {
+ cy.get('@userLocation').then((fakePosition) => {
+ cy.stub(win.navigator.geolocation, 'getCurrentPosition')
+ .as('getUserPosition')
+ .callsFake((cb) => {
+ setTimeout(() => {
+ cb(fakePosition);
+ }, 100);
+ });
+ });
+ cy.stub(win.navigator.clipboard, 'writeText')
+ .as('saveToClipboard')
+ .resolves();
+ cy.spy(win.localStorage, 'setItem').as('storeLocation');
+ cy.spy(win.localStorage, 'getItem').as('getStoredLocation');
+ });
+ });
+ it('should fetch the user location', () => {
+ cy.get('[data-cy="get-loc-btn"]').click();
+ cy.get('@getUserPosition').should('have.been.called');
+ cy.get('[data-cy="get-loc-btn"]').should('be.disabled');
+ cy.get('[data-cy="actions"]').should('contain', 'Location fetched'); // contains()
+ });
+
+ it('should share a location URL', () => {
+ cy.get('[data-cy="name-input"]').type('John Doe');
+ cy.get('[data-cy="get-loc-btn"]').click();
+ cy.get('[data-cy="share-loc-btn"]').click();
+ cy.get('@saveToClipboard').should('have.been.called');
+ cy.get('@userLocation').then((fakePosition) => {
+ const { latitude, longitude } = fakePosition.coords;
+ cy.get('@saveToClipboard').should(
+ 'have.been.calledWithMatch',
+ new RegExp(`${latitude}.*${longitude}.*${encodeURI('John Doe')}`)
+ );
+ cy.get('@storeLocation').should(
+ 'have.been.calledWithMatch',
+ /John Doe/,
+ new RegExp(`${latitude}.*${longitude}.*${encodeURI('John Doe')}`)
+ );
+ });
+ cy.get('@storeLocation').should('have.been.called');
+ cy.get('[data-cy="share-loc-btn"]').click();
+ cy.get('@getStoredLocation').should('have.been.called');
+ cy.get('[data-cy="info-message"]').should('be.visible');
+ cy.get('[data-cy="info-message"]').should('have.class', 'visible');
+ cy.tick(2000);
+ cy.get('[data-cy="info-message"]').should('not.be.visible');
+ });
+});
diff --git a/code/05 Stubs, Spies/07 Clock/cypress/fixtures/user-location.json b/code/05 Stubs, Spies/07 Clock/cypress/fixtures/user-location.json
new file mode 100644
index 0000000..b9e6c27
--- /dev/null
+++ b/code/05 Stubs, Spies/07 Clock/cypress/fixtures/user-location.json
@@ -0,0 +1,6 @@
+{
+ "coords": {
+ "latitude": 37.5,
+ "longitude": 48.01
+ }
+}
diff --git a/code/05 Stubs, Spies/07 Clock/cypress/support/commands.js b/code/05 Stubs, Spies/07 Clock/cypress/support/commands.js
new file mode 100644
index 0000000..66ea16e
--- /dev/null
+++ b/code/05 Stubs, Spies/07 Clock/cypress/support/commands.js
@@ -0,0 +1,25 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/07 Clock/cypress/support/e2e.js b/code/05 Stubs, Spies/07 Clock/cypress/support/e2e.js
new file mode 100644
index 0000000..0e7290a
--- /dev/null
+++ b/code/05 Stubs, Spies/07 Clock/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/07 Clock/index.html b/code/05 Stubs, Spies/07 Clock/index.html
new file mode 100644
index 0000000..6832363
--- /dev/null
+++ b/code/05 Stubs, Spies/07 Clock/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ Cypress Stubs
+
+
+
+
+ SnapLocation
+
+
+ Your name
+
+
+
+
+ Get Location
+
+
+
+
+
+
+ Share Link
+
+
+
+
+
+
+
+
diff --git a/code/05 Stubs, Spies/07 Clock/main.js b/code/05 Stubs, Spies/07 Clock/main.js
new file mode 100644
index 0000000..09b6501
--- /dev/null
+++ b/code/05 Stubs, Spies/07 Clock/main.js
@@ -0,0 +1,102 @@
+const user = {
+ location: {
+ lat: 0,
+ lng: 0,
+ url: '',
+ },
+};
+
+function getUserLocation(event) {
+ const clickedBtn = event.target;
+ const container = clickedBtn.parentNode;
+ if ('geolocation' in navigator) {
+ clickedBtn.disabled = true;
+ clickedBtn.innerHTML = ' ';
+ navigator.geolocation.getCurrentPosition(function (position) {
+ user.location.lat = position.coords.latitude;
+ user.location.lng = position.coords.longitude;
+ user.location.url = `https://www.bing.com/maps?cp=${user.location.lat}~${user.location.lng}&lvl=15&style=r`;
+ container.insertBefore(
+ document.createTextNode('Location fetched!'),
+ clickedBtn
+ );
+ container.querySelector('svg').classList.add('active');
+ container.removeChild(clickedBtn);
+ container.querySelector('button').disabled = false;
+ container.querySelector('button').classList.add('active');
+ }, () => {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ });
+ } else {
+ displayInfoMessage(
+ 'Your browser or permission settings do not allow location fetching.'
+ );
+ }
+}
+
+function shareLocation(event) {
+ // Use clipboard API to copy the location to the clipboard
+ event.preventDefault();
+ const fd = new FormData(event.target);
+ const userName = fd.get('name');
+
+ if (
+ userName.trim() === '' ||
+ user.location.lat === 0 ||
+ user.location.lng === 0
+ ) {
+ document.getElementById('error').textContent =
+ 'Please enter your name and get your location first!';
+ return;
+ }
+
+ document.getElementById('error').textContent = '';
+
+ const storedUrl = localStorage.getItem(userName);
+ if (storedUrl) {
+ copyToClipboard(storedUrl, 'Stored location URL copied to clipboard.');
+ return;
+ }
+
+ user.location.url += `&sp=point.${user.location.lat}_${
+ user.location.lng
+ }_${encodeURI(userName)}`;
+
+ localStorage.setItem(userName, user.location.url);
+ copyToClipboard(user.location.url, 'Location URL copied to clipboard.');
+}
+
+function copyToClipboard(data, infoText) {
+ if ('clipboard' in navigator) {
+ navigator.clipboard.writeText(data).then(
+ function () {
+ displayInfoMessage(infoText);
+ },
+ function () {
+ displayInfoMessage('Failed to copy location URL to clipboard.');
+ }
+ );
+ }
+}
+
+let existingTimer;
+
+function displayInfoMessage(message) {
+ if (existingTimer) {
+ clearTimeout(existingTimer);
+ }
+ const infoMsg = document.getElementById('info-message');
+ infoMsg.querySelector('p').textContent = message;
+ infoMsg.classList.add('visible');
+ existingTimer = setTimeout(() => {
+ infoMsg.classList.remove('visible');
+ }, 2000);
+}
+
+const getLocBtn = document.getElementById('get-location');
+const form = document.querySelector('form');
+
+getLocBtn.addEventListener('click', getUserLocation);
+form.addEventListener('submit', shareLocation);
diff --git a/code/05 Stubs, Spies/07 Clock/package.json b/code/05 Stubs, Spies/07 Clock/package.json
new file mode 100644
index 0000000..2ea1447
--- /dev/null
+++ b/code/05 Stubs, Spies/07 Clock/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "cypress-stubs",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "vite": "^4.1.0"
+ },
+ "dependencies": {
+ "cypress": "^12.5.1"
+ }
+}
diff --git a/code/05 Stubs, Spies/07 Clock/public/vite.svg b/code/05 Stubs, Spies/07 Clock/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/code/05 Stubs, Spies/07 Clock/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/code/05 Stubs, Spies/07 Clock/styles.css b/code/05 Stubs, Spies/07 Clock/styles.css
new file mode 100644
index 0000000..71f49d7
--- /dev/null
+++ b/code/05 Stubs, Spies/07 Clock/styles.css
@@ -0,0 +1,172 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+html {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+}
+
+body {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+}
+
+h1 {
+ text-align: center;
+ color: #8892a4;
+}
+
+main {
+ max-width: 42rem;
+ margin: 3rem auto;
+ padding: 0 1rem;
+}
+
+form p {
+ margin: 0 0 0.5rem 0;
+}
+
+form {
+ width: 90%;
+ margin: auto;
+ padding: 2rem 5rem;
+ background-color: #1a1919;
+ border-radius: 8px;
+}
+
+form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #8f8f8f;
+ font-weight: 600;
+}
+
+form input {
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 0.25rem;
+ background-color: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 255, 255, 0.87);
+}
+
+.actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+ align-items: center;
+ color: #b7c4fe;
+}
+
+.icon {
+ height: 2.5rem;
+ width: 2.5rem;
+}
+
+.actions svg {
+ height: 2.5rem;
+ width: 2.5rem;
+ stroke: #393939;
+ transition: all 0.3s ease-out;
+}
+
+.actions svg.active {
+ stroke: #4969f9;
+}
+
+.actions button {
+ width: 8rem;
+ height: 2rem;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: all 0.3s ease-out;
+}
+
+.actions button.active {
+ background-color: #4969f9;
+ color: #ffffff;
+}
+
+.actions button:hover {
+ background-color: #3a56d1;
+}
+
+.actions button.active:disabled {
+ background-color: #283365;
+}
+
+.actions button:disabled {
+ background-color: #1e1c1c;
+ color: #595656;
+ cursor: not-allowed;
+}
+
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+form #error {
+ color: #f6424b;
+ font-weight: 600;
+}
+
+#info-message {
+ position: fixed;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%) translateY(-100%);
+ background-color: #b6a8fd;
+ color: #0c153e;
+ font-weight: bold;
+ padding: 1rem 3rem;
+ border-radius: 0 0 6px 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
+ text-align: center;
+}
+
+#info-message.visible {
+ animation: slide-in 0.2s ease-out forwards;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes slide-in {
+ 0% {
+ transform: translateX(-50%) translateY(-100%);
+ }
+ 100% {
+ transform: translateX(-50%) translateY(0);
+ }
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/01 Starting Project/.env b/code/06 Network, Db, Auth/01 Starting Project/.env
new file mode 100644
index 0000000..3e05cc4
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/.env
@@ -0,0 +1,8 @@
+# Environment variables declared in this file are automatically made available to Prisma.
+# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
+
+# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
+# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
+
+DATABASE_URL="file:./demo.db"
+SESSION_SECRET="supersecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/01 Starting Project/.env.test b/code/06 Network, Db, Auth/01 Starting Project/.env.test
new file mode 100644
index 0000000..53e955f
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/.env.test
@@ -0,0 +1,2 @@
+DATABASE_URL="file:./test.db"
+SESSION_SECRET="testsecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/01 Starting Project/.eslintrc.js b/code/06 Network, Db, Auth/01 Starting Project/.eslintrc.js
new file mode 100644
index 0000000..2216dd2
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/.eslintrc.js
@@ -0,0 +1,4 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node", "plugin:cypress/recommended"],
+};
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/components/Auth.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/components/Auth.jsx
new file mode 100644
index 0000000..a80b441
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/components/Auth.jsx
@@ -0,0 +1,66 @@
+import { Form, Link, useActionData } from '@remix-run/react';
+
+function Auth({ mode }) {
+ const validationData = useActionData();
+
+ return (
+
+
+
+ Email
+
+
+
+
+
+ Password
+
+
+
+ {validationData && {validationData.statusText}
}
+
+
+ {mode === 'login'
+ ? 'Create a new account'
+ : 'Log in with existing account'}
+
+
+ {mode === 'login' ? 'Login' : 'Create Account'}
+
+
+
+ );
+}
+
+export default Auth;
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/components/Layout.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/components/Layout.jsx
new file mode 100644
index 0000000..306b211
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/components/Layout.jsx
@@ -0,0 +1,51 @@
+import { Form, Link } from '@remix-run/react';
+import NewsletterSignup from './NewsletterSignup';
+
+function Layout({ isLoggedIn, children }) {
+ return (
+ <>
+
+
+ LearnCypress
+
+
+
+
+
+ Takeaways
+
+
+ {!isLoggedIn && (
+
+
+ Login
+
+
+ )}
+ {isLoggedIn && (
+
+
+
+ Logout
+
+
+
+ )}
+
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Layout;
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/components/Modal.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/components/Modal.jsx
new file mode 100644
index 0000000..1401476
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/components/Modal.jsx
@@ -0,0 +1,18 @@
+function Modal({ onClose, children }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/components/NewsletterSignup.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/components/NewsletterSignup.jsx
new file mode 100644
index 0000000..468f2c6
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/components/NewsletterSignup.jsx
@@ -0,0 +1,50 @@
+import { useFetcher } from '@remix-run/react';
+
+function NewsletterSignup() {
+ const fetcher = useFetcher();
+
+ const isSubmitting = fetcher.state === 'submitting';
+ let result;
+
+ if (fetcher.data && fetcher.data.status !== 201) {
+ result = 'error';
+ }
+
+ if (fetcher.data && fetcher.data.status === 201) {
+ result = 'success';
+ }
+
+ return (
+
+ {result !== 'success' && (
+
+
+
+
+ {isSubmitting ? : 'Sign up'}
+
+
+ {result === 'error' && (
+
+ {fetcher.data.message || 'Something went wrong'}
+
+ )}
+
+ )}
+ {result === 'success' &&
Thanks for signing up!
}
+
+ );
+}
+
+export default NewsletterSignup;
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/components/Takeaways.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/components/Takeaways.jsx
new file mode 100644
index 0000000..19cbcd6
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/components/Takeaways.jsx
@@ -0,0 +1,16 @@
+function Takeaways({ items }) {
+ return (
+
+ {items.map((item) => (
+
+
+ {item.title}
+ {item.body}
+
+
+ ))}
+
+ );
+}
+
+export default Takeaways;
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/data/auth.server.js b/code/06 Network, Db, Auth/01 Starting Project/app/data/auth.server.js
new file mode 100644
index 0000000..6ca6148
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/data/auth.server.js
@@ -0,0 +1,93 @@
+import { hash, compare } from 'bcryptjs';
+import { createCookieSessionStorage, json, redirect } from '@remix-run/node';
+
+import { prisma } from './prisma.server';
+
+const SESSION_SECRET = process.env.SESSION_SECRET;
+
+const sessionStorage = createCookieSessionStorage({
+ cookie: {
+ secure: process.env.NODE_ENV === 'production',
+ secrets: [SESSION_SECRET],
+ sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60, // 30 days
+ httpOnly: true,
+ },
+});
+
+async function createUserSession(userId, redirectPath) {
+ const session = await sessionStorage.getSession();
+ session.set('userId', userId);
+ return redirect(redirectPath, {
+ headers: {
+ 'Set-Cookie': await sessionStorage.commitSession(session),
+ },
+ });
+}
+
+export async function getUserFromSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ const userId = session.get('userId');
+
+ if (!userId) {
+ return null;
+ }
+
+ return userId;
+}
+
+export async function destroyUserSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ return redirect('/', {
+ headers: {
+ 'Set-Cookie': await sessionStorage.destroySession(session),
+ },
+ });
+}
+
+export async function requireUserSession(request) {
+ const userId = await getUserFromSession(request);
+
+ if (!userId) {
+ throw redirect('/login');
+ }
+
+ return userId;
+}
+
+export async function signup({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (existingUser) {
+ return json({ status: 409, statusText: 'User exists already.' });
+ }
+
+ const passwordHash = await hash(password, 12);
+
+ const user = await prisma.user.create({
+ data: { email: email, password: passwordHash },
+ });
+ return createUserSession(user.id, '/takeaways');
+}
+
+export async function login({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (!existingUser) {
+ return json({ status: 400, statusText: 'Invalid credentials.' });
+ }
+
+ const passwordCorrect = await compare(password, existingUser.password);
+
+ if (!passwordCorrect) {
+ return json({ status: 400, statusText: 'Invalid credentials (pw).' });
+ }
+
+ return createUserSession(existingUser.id, '/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/data/newsletter.server.js b/code/06 Network, Db, Auth/01 Starting Project/app/data/newsletter.server.js
new file mode 100644
index 0000000..f1822ea
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/data/newsletter.server.js
@@ -0,0 +1,27 @@
+import { isValidEmail } from '../util/validation.server';
+import { wait } from '../util/wait';
+import { prisma } from './prisma.server';
+
+export async function addNewsletterContact(email) {
+ if (!isValidEmail(email)) {
+ throw new Error('Invalid email address.');
+ }
+
+ const existingContact = await prisma.newsletterSignup.findUnique({
+ where: {
+ email,
+ },
+ });
+ await wait(2000);
+
+ if (existingContact) {
+ throw new Error('This email is already subscribed.');
+ }
+
+
+ await prisma.newsletterSignup.create({
+ data: {
+ email,
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/data/prisma.server.js b/code/06 Network, Db, Auth/01 Starting Project/app/data/prisma.server.js
new file mode 100644
index 0000000..cf1eaa4
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/data/prisma.server.js
@@ -0,0 +1,19 @@
+import { PrismaClient } from '@prisma/client';
+
+/**
+ * @type PrismaClient
+ */
+let prisma;
+
+if (process.env.NODE_ENV === 'production') {
+ prisma = new PrismaClient();
+ prisma.$connect();
+} else {
+ if (!global.__db) {
+ global.__db = new PrismaClient();
+ global.__db.$connect();
+ }
+ prisma = global.__db;
+}
+
+export { prisma };
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/entry.client.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/entry.client.jsx
new file mode 100644
index 0000000..8338545
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/entry.client.jsx
@@ -0,0 +1,22 @@
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/entry.server.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/entry.server.jsx
new file mode 100644
index 0000000..8e65b75
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/entry.server.jsx
@@ -0,0 +1,111 @@
+import { PassThrough } from "stream";
+
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
+
+export default function handleRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return isbot(request.headers.get("user-agent"))
+ ? handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ )
+ : handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ );
+}
+
+function handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error) {
+ reject(error);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(err) {
+ reject(err);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/root.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/root.jsx
new file mode 100644
index 0000000..98ee375
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/root.jsx
@@ -0,0 +1,52 @@
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useLoaderData,
+} from '@remix-run/react';
+
+import Layout from './components/Layout';
+import { getUserFromSession } from './data/auth.server';
+import mainStyles from './styles/main.css';
+import tailwindStyles from './styles/tailwind.css';
+
+export const meta = () => ({
+ charset: 'utf-8',
+ title: 'Cypress Requests',
+ viewport: 'width=device-width,initial-scale=1',
+});
+
+export const links = () => [
+ { rel: 'stylesheet', href: tailwindStyles },
+ { rel: 'stylesheet', href: mainStyles },
+ { rel: 'icon', href: '/favicon.ico' },
+];
+
+export default function App() {
+ const isLoggedIn = useLoaderData();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export async function loader({ request }) {
+ const userId = await getUserFromSession(request);
+ return !!userId;
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/routes/index.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/routes/index.jsx
new file mode 100644
index 0000000..f5da528
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/routes/index.jsx
@@ -0,0 +1,31 @@
+import { Link, useLoaderData } from '@remix-run/react';
+import Takeaways from '../components/Takeaways';
+import { prisma } from '../data/prisma.server';
+
+export default function Index() {
+ const takeways = useLoaderData();
+
+ return (
+ <>
+
+ Learn Cypress
+ Cypress is an amazing end-to-end testing software and framework.
+
+ Manage your key Cypress takeaways and concepts with our learning app.
+
+
+
+
+
+ + Add a new takeaway
+
+
+ >
+ );
+}
+
+export function loader() {
+ return prisma.takeaway.findMany({ take: 2 });
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/routes/login.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/routes/login.jsx
new file mode 100644
index 0000000..1ce4ea9
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/routes/login.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { login } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function LoginRoute() {
+ return ;
+}
+
+export default LoginRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return login(credentials);
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/routes/logout.js b/code/06 Network, Db, Auth/01 Starting Project/app/routes/logout.js
new file mode 100644
index 0000000..16ba683
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/routes/logout.js
@@ -0,0 +1,10 @@
+import { destroyUserSession } from '~/data/auth.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export function action({ request }) {
+ if (request.method !== 'POST') {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ return destroyUserSession(request);
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/routes/newsletter.js b/code/06 Network, Db, Auth/01 Starting Project/app/routes/newsletter.js
new file mode 100644
index 0000000..74444ab
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/routes/newsletter.js
@@ -0,0 +1,35 @@
+import { json } from '@remix-run/node';
+import { addNewsletterContact } from '../data/newsletter.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export async function action({ request }) {
+ if (request.method !== 'POST') {
+ return new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ const body = await request.formData();
+ const email = body.get('email');
+
+ try {
+ await addNewsletterContact(email);
+ } catch (error) {
+ return json(
+ { message: error.message },
+ {
+ status: 400,
+ statusText: 'Failed to create contact',
+ }
+ );
+ }
+ return json(
+ { status: 201 }, // this is required because useFetcher does not expose the response object
+ {
+ status: 201,
+ statusText: 'Added newsletter contact.',
+ }
+ );
+}
+
+export function loader() {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/routes/signup.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/routes/signup.jsx
new file mode 100644
index 0000000..823ab31
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/routes/signup.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { signup } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function SignupRoute() {
+ return ;
+}
+
+export default SignupRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return signup(credentials);
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/routes/takeaways.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/routes/takeaways.jsx
new file mode 100644
index 0000000..be2fadb
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/routes/takeaways.jsx
@@ -0,0 +1,36 @@
+import { Link, Outlet, useLoaderData } from '@remix-run/react';
+
+import Takeaways from '../components/Takeaways';
+import { requireUserSession } from '../data/auth.server';
+import { prisma } from '../data/prisma.server';
+
+function TakewaysLayoutRoute() {
+ const takeaways = useLoaderData();
+
+ return (
+ <>
+
+
+ Your key takeaways
+
+
+
+ + Add a new takeaway
+
+
+ {takeaways.length === 0 && You have no key takeaways yet!
}
+
+ >
+ );
+}
+
+export default TakewaysLayoutRoute;
+
+export async function loader({ request }) {
+ await requireUserSession(request);
+
+ return prisma.takeaway.findMany();
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/routes/takeaways/new.jsx b/code/06 Network, Db, Auth/01 Starting Project/app/routes/takeaways/new.jsx
new file mode 100644
index 0000000..0b6868d
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/routes/takeaways/new.jsx
@@ -0,0 +1,87 @@
+import { json, redirect } from '@remix-run/node';
+import { Form, Link, useNavigate } from '@remix-run/react';
+
+import Modal from '../../components/Modal';
+import { requireUserSession } from '../../data/auth.server';
+import { prisma } from '../../data/prisma.server';
+
+function NewTakewayRoute() {
+ const navigate = useNavigate();
+
+ return (
+ navigate('..', { relative: 'path' })}>
+
+
+
+ Title
+
+
+
+
+
+ Body
+
+
+
+
+
+ Cancel
+
+
+ Create
+
+
+
+
+ );
+}
+
+export default NewTakewayRoute;
+
+export function loader({ request }) {
+ return requireUserSession(request);
+}
+
+export async function action({ request }) {
+ const fd = await request.formData();
+ const title = fd.get('title');
+ const body = fd.get('body');
+
+ if (!title || !body) {
+ return json({ message: 'Title and body are required.' }, { status: 400 });
+ }
+
+ await prisma.takeaway.create({
+ data: {
+ title,
+ body,
+ },
+ });
+
+ return redirect('/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/styles/main.css b/code/06 Network, Db, Auth/01 Starting Project/app/styles/main.css
new file mode 100644
index 0000000..9ec8050
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/styles/main.css
@@ -0,0 +1,19 @@
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/styles/tailwind.css b/code/06 Network, Db, Auth/01 Starting Project/app/styles/tailwind.css
new file mode 100644
index 0000000..d433f58
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/styles/tailwind.css
@@ -0,0 +1,919 @@
+/*
+! tailwindcss v3.2.6 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ /* 1 */
+ border-width: 0;
+ /* 2 */
+ border-style: solid;
+ /* 2 */
+ border-color: #e5e7eb;
+ /* 2 */
+}
+
+::before,
+::after {
+ --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+*/
+
+html {
+ line-height: 1.5;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+ -moz-tab-size: 4;
+ /* 3 */
+ -o-tab-size: 4;
+ tab-size: 4;
+ /* 3 */
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ /* 4 */
+ font-feature-settings: normal;
+ /* 5 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+ margin: 0;
+ /* 1 */
+ line-height: inherit;
+ /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+ height: 0;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ border-top-width: 1px;
+ /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font family by default.
+2. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ /* 1 */
+ font-size: 1em;
+ /* 2 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+ font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+ text-indent: 0;
+ /* 1 */
+ border-color: inherit;
+ /* 2 */
+ border-collapse: collapse;
+ /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ font-weight: inherit;
+ /* 1 */
+ line-height: inherit;
+ /* 1 */
+ color: inherit;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+ padding: 0;
+ /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+ text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+ -webkit-appearance: button;
+ /* 1 */
+ background-color: transparent;
+ /* 2 */
+ background-image: none;
+ /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+ outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+ vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+ display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+ resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+ cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+ This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ /* 1 */
+ vertical-align: middle;
+ /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden] {
+ display: none;
+}
+
+*, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+::backdrop {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+.fixed {
+ position: fixed;
+}
+
+.relative {
+ position: relative;
+}
+
+.left-0 {
+ left: 0px;
+}
+
+.left-\[50\%\] {
+ left: 50%;
+}
+
+.top-0 {
+ top: 0px;
+}
+
+.top-10 {
+ top: 2.5rem;
+}
+
+.m-0 {
+ margin: 0px;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.my-12 {
+ margin-top: 3rem;
+ margin-bottom: 3rem;
+}
+
+.my-16 {
+ margin-top: 4rem;
+ margin-bottom: 4rem;
+}
+
+.my-4 {
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
+.my-8 {
+ margin-top: 2rem;
+ margin-bottom: 2rem;
+}
+
+.mb-1 {
+ margin-bottom: 0.25rem;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.mt-16 {
+ margin-top: 4rem;
+}
+
+.mt-2 {
+ margin-top: 0.5rem;
+}
+
+.block {
+ display: block;
+}
+
+.flex {
+ display: flex;
+}
+
+.grid {
+ display: grid;
+}
+
+.h-screen {
+ height: 100vh;
+}
+
+.w-96 {
+ width: 24rem;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.w-screen {
+ width: 100vw;
+}
+
+.max-w-2xl {
+ max-width: 42rem;
+}
+
+.max-w-5xl {
+ max-width: 64rem;
+}
+
+.max-w-lg {
+ max-width: 32rem;
+}
+
+.-translate-x-1\/2 {
+ --tw-translate-x: -50%;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+.grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-end {
+ justify-content: flex-end;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.gap-4 {
+ gap: 1rem;
+}
+
+.gap-6 {
+ gap: 1.5rem;
+}
+
+.gap-8 {
+ gap: 2rem;
+}
+
+.rounded-md {
+ border-radius: 0.375rem;
+}
+
+.rounded-sm {
+ border-radius: 0.125rem;
+}
+
+.rounded-l-sm {
+ border-top-left-radius: 0.125rem;
+ border-bottom-left-radius: 0.125rem;
+}
+
+.rounded-r-sm {
+ border-top-right-radius: 0.125rem;
+ border-bottom-right-radius: 0.125rem;
+}
+
+.border-2 {
+ border-width: 2px;
+}
+
+.border-blue-300 {
+ --tw-border-opacity: 1;
+ border-color: rgb(147 197 253 / var(--tw-border-opacity));
+}
+
+.border-blue-700 {
+ --tw-border-opacity: 1;
+ border-color: rgb(29 78 216 / var(--tw-border-opacity));
+}
+
+.bg-blue-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.bg-blue-600 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.bg-blue-700 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(29 78 216 / var(--tw-bg-opacity));
+}
+
+.bg-slate-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(226 232 240 / var(--tw-bg-opacity));
+}
+
+.bg-slate-300 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(203 213 225 / var(--tw-bg-opacity));
+}
+
+.bg-slate-400 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(148 163 184 / var(--tw-bg-opacity));
+}
+
+.bg-slate-800 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(30 41 59 / var(--tw-bg-opacity));
+}
+
+.bg-slate-900 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(15 23 42 / var(--tw-bg-opacity));
+}
+
+.bg-gradient-to-br {
+ background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
+}
+
+.from-slate-900 {
+ --tw-gradient-from: #0f172a;
+ --tw-gradient-to: rgb(15 23 42 / 0);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.to-slate-800 {
+ --tw-gradient-to: #1e293b;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.p-8 {
+ padding: 2rem;
+}
+
+.px-2 {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+
+.px-3 {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.px-5 {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+}
+
+.px-8 {
+ padding-left: 2rem;
+ padding-right: 2rem;
+}
+
+.py-1 {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+.py-3 {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+}
+
+.py-4 {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.font-mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+}
+
+.text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.font-bold {
+ font-weight: 700;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.text-blue-300 {
+ --tw-text-opacity: 1;
+ color: rgb(147 197 253 / var(--tw-text-opacity));
+}
+
+.text-blue-400 {
+ --tw-text-opacity: 1;
+ color: rgb(96 165 250 / var(--tw-text-opacity));
+}
+
+.text-blue-50 {
+ --tw-text-opacity: 1;
+ color: rgb(239 246 255 / var(--tw-text-opacity));
+}
+
+.text-pink-300 {
+ --tw-text-opacity: 1;
+ color: rgb(249 168 212 / var(--tw-text-opacity));
+}
+
+.text-slate-300 {
+ --tw-text-opacity: 1;
+ color: rgb(203 213 225 / var(--tw-text-opacity));
+}
+
+.text-slate-400 {
+ --tw-text-opacity: 1;
+ color: rgb(148 163 184 / var(--tw-text-opacity));
+}
+
+.text-slate-600 {
+ --tw-text-opacity: 1;
+ color: rgb(71 85 105 / var(--tw-text-opacity));
+}
+
+.text-slate-900 {
+ --tw-text-opacity: 1;
+ color: rgb(15 23 42 / var(--tw-text-opacity));
+}
+
+.text-white {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity));
+}
+
+.opacity-80 {
+ opacity: 0.8;
+}
+
+.hover\:border-blue-600:hover {
+ --tw-border-opacity: 1;
+ border-color: rgb(37 99 235 / var(--tw-border-opacity));
+}
+
+.hover\:bg-blue-300:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(147 197 253 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-400:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(96 165 250 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-500:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-600:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.hover\:text-blue-900:hover {
+ --tw-text-opacity: 1;
+ color: rgb(30 58 138 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-200:hover {
+ --tw-text-opacity: 1;
+ color: rgb(226 232 240 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-500:hover {
+ --tw-text-opacity: 1;
+ color: rgb(100 116 139 / var(--tw-text-opacity));
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/util/errors.js b/code/06 Network, Db, Auth/01 Starting Project/app/util/errors.js
new file mode 100644
index 0000000..4a37e53
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/util/errors.js
@@ -0,0 +1,11 @@
+export class BadRequestErrorResponse extends Response {
+ constructor(message, statusText = 'Bad request') {
+ super(JSON.stringify({ status: 400, message }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 400,
+ statusText: statusText,
+ });
+ }
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/util/validation.server.js b/code/06 Network, Db, Auth/01 Starting Project/app/util/validation.server.js
new file mode 100644
index 0000000..d7c407c
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/util/validation.server.js
@@ -0,0 +1,7 @@
+export function isValidEmail(email) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+}
+
+export function isValidPassword(password) {
+ return password.length >= 6;
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/01 Starting Project/app/util/wait.js b/code/06 Network, Db, Auth/01 Starting Project/app/util/wait.js
new file mode 100644
index 0000000..9f35c5b
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/app/util/wait.js
@@ -0,0 +1,5 @@
+export function wait(time) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, time);
+ });
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/cypress.config.js b/code/06 Network, Db, Auth/01 Starting Project/cypress.config.js
new file mode 100644
index 0000000..9623f3f
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/cypress.config.js
@@ -0,0 +1,18 @@
+import { defineConfig } from 'cypress';
+
+import { seed } from './prisma/seed-test';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:3000',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ on('task', {
+ async seedDatabase() {
+ await seed();
+ return null;
+ }
+ })
+ },
+ },
+});
diff --git a/code/06 Network, Db, Auth/01 Starting Project/cypress/e2e/takeaways.cy.js b/code/06 Network, Db, Auth/01 Starting Project/cypress/e2e/takeaways.cy.js
new file mode 100644
index 0000000..0d4a58b
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/cypress/e2e/takeaways.cy.js
@@ -0,0 +1,7 @@
+///
+
+describe('Takeaways', () => {
+ it('should display a list of fetched takeaways', () => {
+ cy.visit('/')
+ });
+});
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/01 Starting Project/cypress/support/commands.js b/code/06 Network, Db, Auth/01 Starting Project/cypress/support/commands.js
new file mode 100644
index 0000000..d342d6f
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/cypress/support/commands.js
@@ -0,0 +1,52 @@
+///
+// ***********************************************
+// This example commands.ts shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+//
+// declare global {
+// namespace Cypress {
+// interface Chainable {
+// login(email: string, password: string): Chainable
+// drag(subject: string, options?: Partial): Chainable
+// dismiss(subject: string, options?: Partial): Chainable
+// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable
+// }
+// }
+// }
+
+
+// the below code snippet is required to handle a React hydration bug that would cause tests to fail
+// it's only a workaround until this React behavior / bug is fixed
+Cypress.on('uncaught:exception', (err) => {
+ // we check if the error is
+ if (
+ err.message.includes('Minified React error #418;') ||
+ err.message.includes('Minified React error #423;') ||
+ err.message.includes('hydrating') ||
+ err.message.includes('Hydration')
+ ) {
+ return false;
+ }
+});
diff --git a/code/06 Network, Db, Auth/01 Starting Project/cypress/support/e2e.js b/code/06 Network, Db, Auth/01 Starting Project/cypress/support/e2e.js
new file mode 100644
index 0000000..f80f74f
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/01 Starting Project/package.json b/code/06 Network, Db, Auth/01 Starting Project/package.json
new file mode 100644
index 0000000..81764cf
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/package.json
@@ -0,0 +1,42 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "init": "npm install && dotenv -e .env npx prisma db push && node prisma/seed.js",
+ "build": "npm run build:css && remix build",
+ "build:css": "tailwindcss -m -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "dev": "concurrently \"npm run dev:css\" \"dotenv -e .env remix dev\"",
+ "dev:css": "tailwindcss -w -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "start": "remix-serve build",
+ "typecheck": "tsc",
+ "test": "dotenv -e .env npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env remix dev\" \"dotenv -e .env cypress run\"",
+ "test:open": "dotenv -e .env npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env remix dev\" \"dotenv -e .env cypress open\""
+ },
+ "dependencies": {
+ "@prisma/client": "^4.3.1",
+ "@remix-run/node": "^1.13.0",
+ "@remix-run/react": "^1.13.0",
+ "@remix-run/serve": "^1.13.0",
+ "bcryptjs": "^2.4.3",
+ "dotenv-cli": "^7.0.0",
+ "isbot": "^3.6.5",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@remix-run/dev": "^1.13.0",
+ "@remix-run/eslint-config": "^1.13.0",
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.8",
+ "concurrently": "^7.6.0",
+ "cypress": "^12.5.1",
+ "eslint": "^8.27.0",
+ "eslint-plugin-cypress": "^2.12.1",
+ "prisma": "^4.3.1",
+ "tailwindcss": "^3.2.6",
+ "typescript": "^4.8.4"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/prisma/schema.prisma b/code/06 Network, Db, Auth/01 Starting Project/prisma/schema.prisma
new file mode 100644
index 0000000..65c584b
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/prisma/schema.prisma
@@ -0,0 +1,28 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ password String
+}
+
+model NewsletterSignup {
+ id Int @id @default(autoincrement())
+ email String @unique
+}
+
+model Takeaway {
+ id Int @id @default(autoincrement())
+ title String
+ body String
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/prisma/seed-test.js b/code/06 Network, Db, Auth/01 Starting Project/prisma/seed-test.js
new file mode 100644
index 0000000..92317c8
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/prisma/seed-test.js
@@ -0,0 +1,39 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+const { hash } = require('bcryptjs');
+
+const prisma = new PrismaClient();
+
+export async function seed() {
+ console.log('Seeding...');
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.user.create({
+ data: {
+ email: 'test@example.com',
+ password: await hash('testpassword', 12),
+ },
+ });
+ await prisma.newsletterSignup.create({
+ data: {
+ email: 'test2@example.com',
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/01 Starting Project/prisma/seed.js b/code/06 Network, Db, Auth/01 Starting Project/prisma/seed.js
new file mode 100644
index 0000000..af901a0
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/prisma/seed.js
@@ -0,0 +1,31 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+
+const prisma = new PrismaClient();
+
+async function main() {
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
+
+main().then(() => {
+ console.log('seeded database');
+ process.exit(0);
+});
diff --git a/code/06 Network, Db, Auth/01 Starting Project/public/favicon.ico b/code/06 Network, Db, Auth/01 Starting Project/public/favicon.ico
new file mode 100644
index 0000000..8830cf6
Binary files /dev/null and b/code/06 Network, Db, Auth/01 Starting Project/public/favicon.ico differ
diff --git a/code/06 Network, Db, Auth/01 Starting Project/remix.config.js b/code/06 Network, Db, Auth/01 Starting Project/remix.config.js
new file mode 100644
index 0000000..adf2a0b
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/remix.config.js
@@ -0,0 +1,8 @@
+/** @type {import('@remix-run/dev').AppConfig} */
+module.exports = {
+ ignoredRouteFiles: ["**/.*"],
+ // appDirectory: "app",
+ // assetsBuildDirectory: "public/build",
+ // serverBuildPath: "build/index.js",
+ // publicPath: "/build/",
+};
diff --git a/code/06 Network, Db, Auth/01 Starting Project/styles/tailwind.css b/code/06 Network, Db, Auth/01 Starting Project/styles/tailwind.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/styles/tailwind.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/code/06 Network, Db, Auth/01 Starting Project/tailwind.config.js b/code/06 Network, Db, Auth/01 Starting Project/tailwind.config.js
new file mode 100644
index 0000000..c7c50e0
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/tailwind.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/01 Starting Project/tsconfig.json b/code/06 Network, Db, Auth/01 Starting Project/tsconfig.json
new file mode 100644
index 0000000..28951d6
--- /dev/null
+++ b/code/06 Network, Db, Auth/01 Starting Project/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "cypress.config.js", "cypress/e2e/newsletter.cy.js"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "target": "ES2019",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true
+ }
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/.env b/code/06 Network, Db, Auth/02 Test Database/.env
new file mode 100644
index 0000000..3e05cc4
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/.env
@@ -0,0 +1,8 @@
+# Environment variables declared in this file are automatically made available to Prisma.
+# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
+
+# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
+# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
+
+DATABASE_URL="file:./demo.db"
+SESSION_SECRET="supersecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/02 Test Database/.env.test b/code/06 Network, Db, Auth/02 Test Database/.env.test
new file mode 100644
index 0000000..53e955f
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/.env.test
@@ -0,0 +1,2 @@
+DATABASE_URL="file:./test.db"
+SESSION_SECRET="testsecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/02 Test Database/.eslintrc.js b/code/06 Network, Db, Auth/02 Test Database/.eslintrc.js
new file mode 100644
index 0000000..2216dd2
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/.eslintrc.js
@@ -0,0 +1,4 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node", "plugin:cypress/recommended"],
+};
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/components/Auth.jsx b/code/06 Network, Db, Auth/02 Test Database/app/components/Auth.jsx
new file mode 100644
index 0000000..a80b441
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/components/Auth.jsx
@@ -0,0 +1,66 @@
+import { Form, Link, useActionData } from '@remix-run/react';
+
+function Auth({ mode }) {
+ const validationData = useActionData();
+
+ return (
+
+
+
+ Email
+
+
+
+
+
+ Password
+
+
+
+ {validationData && {validationData.statusText}
}
+
+
+ {mode === 'login'
+ ? 'Create a new account'
+ : 'Log in with existing account'}
+
+
+ {mode === 'login' ? 'Login' : 'Create Account'}
+
+
+
+ );
+}
+
+export default Auth;
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/components/Layout.jsx b/code/06 Network, Db, Auth/02 Test Database/app/components/Layout.jsx
new file mode 100644
index 0000000..306b211
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/components/Layout.jsx
@@ -0,0 +1,51 @@
+import { Form, Link } from '@remix-run/react';
+import NewsletterSignup from './NewsletterSignup';
+
+function Layout({ isLoggedIn, children }) {
+ return (
+ <>
+
+
+ LearnCypress
+
+
+
+
+
+ Takeaways
+
+
+ {!isLoggedIn && (
+
+
+ Login
+
+
+ )}
+ {isLoggedIn && (
+
+
+
+ Logout
+
+
+
+ )}
+
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Layout;
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/components/Modal.jsx b/code/06 Network, Db, Auth/02 Test Database/app/components/Modal.jsx
new file mode 100644
index 0000000..1401476
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/components/Modal.jsx
@@ -0,0 +1,18 @@
+function Modal({ onClose, children }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/components/NewsletterSignup.jsx b/code/06 Network, Db, Auth/02 Test Database/app/components/NewsletterSignup.jsx
new file mode 100644
index 0000000..468f2c6
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/components/NewsletterSignup.jsx
@@ -0,0 +1,50 @@
+import { useFetcher } from '@remix-run/react';
+
+function NewsletterSignup() {
+ const fetcher = useFetcher();
+
+ const isSubmitting = fetcher.state === 'submitting';
+ let result;
+
+ if (fetcher.data && fetcher.data.status !== 201) {
+ result = 'error';
+ }
+
+ if (fetcher.data && fetcher.data.status === 201) {
+ result = 'success';
+ }
+
+ return (
+
+ {result !== 'success' && (
+
+
+
+
+ {isSubmitting ? : 'Sign up'}
+
+
+ {result === 'error' && (
+
+ {fetcher.data.message || 'Something went wrong'}
+
+ )}
+
+ )}
+ {result === 'success' &&
Thanks for signing up!
}
+
+ );
+}
+
+export default NewsletterSignup;
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/components/Takeaways.jsx b/code/06 Network, Db, Auth/02 Test Database/app/components/Takeaways.jsx
new file mode 100644
index 0000000..19cbcd6
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/components/Takeaways.jsx
@@ -0,0 +1,16 @@
+function Takeaways({ items }) {
+ return (
+
+ {items.map((item) => (
+
+
+ {item.title}
+ {item.body}
+
+
+ ))}
+
+ );
+}
+
+export default Takeaways;
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/data/auth.server.js b/code/06 Network, Db, Auth/02 Test Database/app/data/auth.server.js
new file mode 100644
index 0000000..6ca6148
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/data/auth.server.js
@@ -0,0 +1,93 @@
+import { hash, compare } from 'bcryptjs';
+import { createCookieSessionStorage, json, redirect } from '@remix-run/node';
+
+import { prisma } from './prisma.server';
+
+const SESSION_SECRET = process.env.SESSION_SECRET;
+
+const sessionStorage = createCookieSessionStorage({
+ cookie: {
+ secure: process.env.NODE_ENV === 'production',
+ secrets: [SESSION_SECRET],
+ sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60, // 30 days
+ httpOnly: true,
+ },
+});
+
+async function createUserSession(userId, redirectPath) {
+ const session = await sessionStorage.getSession();
+ session.set('userId', userId);
+ return redirect(redirectPath, {
+ headers: {
+ 'Set-Cookie': await sessionStorage.commitSession(session),
+ },
+ });
+}
+
+export async function getUserFromSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ const userId = session.get('userId');
+
+ if (!userId) {
+ return null;
+ }
+
+ return userId;
+}
+
+export async function destroyUserSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ return redirect('/', {
+ headers: {
+ 'Set-Cookie': await sessionStorage.destroySession(session),
+ },
+ });
+}
+
+export async function requireUserSession(request) {
+ const userId = await getUserFromSession(request);
+
+ if (!userId) {
+ throw redirect('/login');
+ }
+
+ return userId;
+}
+
+export async function signup({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (existingUser) {
+ return json({ status: 409, statusText: 'User exists already.' });
+ }
+
+ const passwordHash = await hash(password, 12);
+
+ const user = await prisma.user.create({
+ data: { email: email, password: passwordHash },
+ });
+ return createUserSession(user.id, '/takeaways');
+}
+
+export async function login({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (!existingUser) {
+ return json({ status: 400, statusText: 'Invalid credentials.' });
+ }
+
+ const passwordCorrect = await compare(password, existingUser.password);
+
+ if (!passwordCorrect) {
+ return json({ status: 400, statusText: 'Invalid credentials (pw).' });
+ }
+
+ return createUserSession(existingUser.id, '/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/data/newsletter.server.js b/code/06 Network, Db, Auth/02 Test Database/app/data/newsletter.server.js
new file mode 100644
index 0000000..f1822ea
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/data/newsletter.server.js
@@ -0,0 +1,27 @@
+import { isValidEmail } from '../util/validation.server';
+import { wait } from '../util/wait';
+import { prisma } from './prisma.server';
+
+export async function addNewsletterContact(email) {
+ if (!isValidEmail(email)) {
+ throw new Error('Invalid email address.');
+ }
+
+ const existingContact = await prisma.newsletterSignup.findUnique({
+ where: {
+ email,
+ },
+ });
+ await wait(2000);
+
+ if (existingContact) {
+ throw new Error('This email is already subscribed.');
+ }
+
+
+ await prisma.newsletterSignup.create({
+ data: {
+ email,
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/data/prisma.server.js b/code/06 Network, Db, Auth/02 Test Database/app/data/prisma.server.js
new file mode 100644
index 0000000..cf1eaa4
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/data/prisma.server.js
@@ -0,0 +1,19 @@
+import { PrismaClient } from '@prisma/client';
+
+/**
+ * @type PrismaClient
+ */
+let prisma;
+
+if (process.env.NODE_ENV === 'production') {
+ prisma = new PrismaClient();
+ prisma.$connect();
+} else {
+ if (!global.__db) {
+ global.__db = new PrismaClient();
+ global.__db.$connect();
+ }
+ prisma = global.__db;
+}
+
+export { prisma };
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/entry.client.jsx b/code/06 Network, Db, Auth/02 Test Database/app/entry.client.jsx
new file mode 100644
index 0000000..8338545
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/entry.client.jsx
@@ -0,0 +1,22 @@
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/entry.server.jsx b/code/06 Network, Db, Auth/02 Test Database/app/entry.server.jsx
new file mode 100644
index 0000000..8e65b75
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/entry.server.jsx
@@ -0,0 +1,111 @@
+import { PassThrough } from "stream";
+
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
+
+export default function handleRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return isbot(request.headers.get("user-agent"))
+ ? handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ )
+ : handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ );
+}
+
+function handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error) {
+ reject(error);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(err) {
+ reject(err);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/root.jsx b/code/06 Network, Db, Auth/02 Test Database/app/root.jsx
new file mode 100644
index 0000000..98ee375
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/root.jsx
@@ -0,0 +1,52 @@
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useLoaderData,
+} from '@remix-run/react';
+
+import Layout from './components/Layout';
+import { getUserFromSession } from './data/auth.server';
+import mainStyles from './styles/main.css';
+import tailwindStyles from './styles/tailwind.css';
+
+export const meta = () => ({
+ charset: 'utf-8',
+ title: 'Cypress Requests',
+ viewport: 'width=device-width,initial-scale=1',
+});
+
+export const links = () => [
+ { rel: 'stylesheet', href: tailwindStyles },
+ { rel: 'stylesheet', href: mainStyles },
+ { rel: 'icon', href: '/favicon.ico' },
+];
+
+export default function App() {
+ const isLoggedIn = useLoaderData();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export async function loader({ request }) {
+ const userId = await getUserFromSession(request);
+ return !!userId;
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/routes/index.jsx b/code/06 Network, Db, Auth/02 Test Database/app/routes/index.jsx
new file mode 100644
index 0000000..f5da528
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/routes/index.jsx
@@ -0,0 +1,31 @@
+import { Link, useLoaderData } from '@remix-run/react';
+import Takeaways from '../components/Takeaways';
+import { prisma } from '../data/prisma.server';
+
+export default function Index() {
+ const takeways = useLoaderData();
+
+ return (
+ <>
+
+ Learn Cypress
+ Cypress is an amazing end-to-end testing software and framework.
+
+ Manage your key Cypress takeaways and concepts with our learning app.
+
+
+
+
+
+ + Add a new takeaway
+
+
+ >
+ );
+}
+
+export function loader() {
+ return prisma.takeaway.findMany({ take: 2 });
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/routes/login.jsx b/code/06 Network, Db, Auth/02 Test Database/app/routes/login.jsx
new file mode 100644
index 0000000..1ce4ea9
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/routes/login.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { login } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function LoginRoute() {
+ return ;
+}
+
+export default LoginRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return login(credentials);
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/routes/logout.js b/code/06 Network, Db, Auth/02 Test Database/app/routes/logout.js
new file mode 100644
index 0000000..16ba683
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/routes/logout.js
@@ -0,0 +1,10 @@
+import { destroyUserSession } from '~/data/auth.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export function action({ request }) {
+ if (request.method !== 'POST') {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ return destroyUserSession(request);
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/routes/newsletter.js b/code/06 Network, Db, Auth/02 Test Database/app/routes/newsletter.js
new file mode 100644
index 0000000..74444ab
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/routes/newsletter.js
@@ -0,0 +1,35 @@
+import { json } from '@remix-run/node';
+import { addNewsletterContact } from '../data/newsletter.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export async function action({ request }) {
+ if (request.method !== 'POST') {
+ return new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ const body = await request.formData();
+ const email = body.get('email');
+
+ try {
+ await addNewsletterContact(email);
+ } catch (error) {
+ return json(
+ { message: error.message },
+ {
+ status: 400,
+ statusText: 'Failed to create contact',
+ }
+ );
+ }
+ return json(
+ { status: 201 }, // this is required because useFetcher does not expose the response object
+ {
+ status: 201,
+ statusText: 'Added newsletter contact.',
+ }
+ );
+}
+
+export function loader() {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/routes/signup.jsx b/code/06 Network, Db, Auth/02 Test Database/app/routes/signup.jsx
new file mode 100644
index 0000000..823ab31
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/routes/signup.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { signup } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function SignupRoute() {
+ return ;
+}
+
+export default SignupRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return signup(credentials);
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/routes/takeaways.jsx b/code/06 Network, Db, Auth/02 Test Database/app/routes/takeaways.jsx
new file mode 100644
index 0000000..be2fadb
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/routes/takeaways.jsx
@@ -0,0 +1,36 @@
+import { Link, Outlet, useLoaderData } from '@remix-run/react';
+
+import Takeaways from '../components/Takeaways';
+import { requireUserSession } from '../data/auth.server';
+import { prisma } from '../data/prisma.server';
+
+function TakewaysLayoutRoute() {
+ const takeaways = useLoaderData();
+
+ return (
+ <>
+
+
+ Your key takeaways
+
+
+
+ + Add a new takeaway
+
+
+ {takeaways.length === 0 && You have no key takeaways yet!
}
+
+ >
+ );
+}
+
+export default TakewaysLayoutRoute;
+
+export async function loader({ request }) {
+ await requireUserSession(request);
+
+ return prisma.takeaway.findMany();
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/routes/takeaways/new.jsx b/code/06 Network, Db, Auth/02 Test Database/app/routes/takeaways/new.jsx
new file mode 100644
index 0000000..0b6868d
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/routes/takeaways/new.jsx
@@ -0,0 +1,87 @@
+import { json, redirect } from '@remix-run/node';
+import { Form, Link, useNavigate } from '@remix-run/react';
+
+import Modal from '../../components/Modal';
+import { requireUserSession } from '../../data/auth.server';
+import { prisma } from '../../data/prisma.server';
+
+function NewTakewayRoute() {
+ const navigate = useNavigate();
+
+ return (
+ navigate('..', { relative: 'path' })}>
+
+
+
+ Title
+
+
+
+
+
+ Body
+
+
+
+
+
+ Cancel
+
+
+ Create
+
+
+
+
+ );
+}
+
+export default NewTakewayRoute;
+
+export function loader({ request }) {
+ return requireUserSession(request);
+}
+
+export async function action({ request }) {
+ const fd = await request.formData();
+ const title = fd.get('title');
+ const body = fd.get('body');
+
+ if (!title || !body) {
+ return json({ message: 'Title and body are required.' }, { status: 400 });
+ }
+
+ await prisma.takeaway.create({
+ data: {
+ title,
+ body,
+ },
+ });
+
+ return redirect('/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/styles/main.css b/code/06 Network, Db, Auth/02 Test Database/app/styles/main.css
new file mode 100644
index 0000000..9ec8050
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/styles/main.css
@@ -0,0 +1,19 @@
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/styles/tailwind.css b/code/06 Network, Db, Auth/02 Test Database/app/styles/tailwind.css
new file mode 100644
index 0000000..d433f58
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/styles/tailwind.css
@@ -0,0 +1,919 @@
+/*
+! tailwindcss v3.2.6 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ /* 1 */
+ border-width: 0;
+ /* 2 */
+ border-style: solid;
+ /* 2 */
+ border-color: #e5e7eb;
+ /* 2 */
+}
+
+::before,
+::after {
+ --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+*/
+
+html {
+ line-height: 1.5;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+ -moz-tab-size: 4;
+ /* 3 */
+ -o-tab-size: 4;
+ tab-size: 4;
+ /* 3 */
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ /* 4 */
+ font-feature-settings: normal;
+ /* 5 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+ margin: 0;
+ /* 1 */
+ line-height: inherit;
+ /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+ height: 0;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ border-top-width: 1px;
+ /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font family by default.
+2. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ /* 1 */
+ font-size: 1em;
+ /* 2 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+ font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+ text-indent: 0;
+ /* 1 */
+ border-color: inherit;
+ /* 2 */
+ border-collapse: collapse;
+ /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ font-weight: inherit;
+ /* 1 */
+ line-height: inherit;
+ /* 1 */
+ color: inherit;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+ padding: 0;
+ /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+ text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+ -webkit-appearance: button;
+ /* 1 */
+ background-color: transparent;
+ /* 2 */
+ background-image: none;
+ /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+ outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+ vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+ display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+ resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+ cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+ This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ /* 1 */
+ vertical-align: middle;
+ /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden] {
+ display: none;
+}
+
+*, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+::backdrop {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+.fixed {
+ position: fixed;
+}
+
+.relative {
+ position: relative;
+}
+
+.left-0 {
+ left: 0px;
+}
+
+.left-\[50\%\] {
+ left: 50%;
+}
+
+.top-0 {
+ top: 0px;
+}
+
+.top-10 {
+ top: 2.5rem;
+}
+
+.m-0 {
+ margin: 0px;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.my-12 {
+ margin-top: 3rem;
+ margin-bottom: 3rem;
+}
+
+.my-16 {
+ margin-top: 4rem;
+ margin-bottom: 4rem;
+}
+
+.my-4 {
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
+.my-8 {
+ margin-top: 2rem;
+ margin-bottom: 2rem;
+}
+
+.mb-1 {
+ margin-bottom: 0.25rem;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.mt-16 {
+ margin-top: 4rem;
+}
+
+.mt-2 {
+ margin-top: 0.5rem;
+}
+
+.block {
+ display: block;
+}
+
+.flex {
+ display: flex;
+}
+
+.grid {
+ display: grid;
+}
+
+.h-screen {
+ height: 100vh;
+}
+
+.w-96 {
+ width: 24rem;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.w-screen {
+ width: 100vw;
+}
+
+.max-w-2xl {
+ max-width: 42rem;
+}
+
+.max-w-5xl {
+ max-width: 64rem;
+}
+
+.max-w-lg {
+ max-width: 32rem;
+}
+
+.-translate-x-1\/2 {
+ --tw-translate-x: -50%;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+.grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-end {
+ justify-content: flex-end;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.gap-4 {
+ gap: 1rem;
+}
+
+.gap-6 {
+ gap: 1.5rem;
+}
+
+.gap-8 {
+ gap: 2rem;
+}
+
+.rounded-md {
+ border-radius: 0.375rem;
+}
+
+.rounded-sm {
+ border-radius: 0.125rem;
+}
+
+.rounded-l-sm {
+ border-top-left-radius: 0.125rem;
+ border-bottom-left-radius: 0.125rem;
+}
+
+.rounded-r-sm {
+ border-top-right-radius: 0.125rem;
+ border-bottom-right-radius: 0.125rem;
+}
+
+.border-2 {
+ border-width: 2px;
+}
+
+.border-blue-300 {
+ --tw-border-opacity: 1;
+ border-color: rgb(147 197 253 / var(--tw-border-opacity));
+}
+
+.border-blue-700 {
+ --tw-border-opacity: 1;
+ border-color: rgb(29 78 216 / var(--tw-border-opacity));
+}
+
+.bg-blue-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.bg-blue-600 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.bg-blue-700 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(29 78 216 / var(--tw-bg-opacity));
+}
+
+.bg-slate-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(226 232 240 / var(--tw-bg-opacity));
+}
+
+.bg-slate-300 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(203 213 225 / var(--tw-bg-opacity));
+}
+
+.bg-slate-400 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(148 163 184 / var(--tw-bg-opacity));
+}
+
+.bg-slate-800 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(30 41 59 / var(--tw-bg-opacity));
+}
+
+.bg-slate-900 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(15 23 42 / var(--tw-bg-opacity));
+}
+
+.bg-gradient-to-br {
+ background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
+}
+
+.from-slate-900 {
+ --tw-gradient-from: #0f172a;
+ --tw-gradient-to: rgb(15 23 42 / 0);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.to-slate-800 {
+ --tw-gradient-to: #1e293b;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.p-8 {
+ padding: 2rem;
+}
+
+.px-2 {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+
+.px-3 {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.px-5 {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+}
+
+.px-8 {
+ padding-left: 2rem;
+ padding-right: 2rem;
+}
+
+.py-1 {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+.py-3 {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+}
+
+.py-4 {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.font-mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+}
+
+.text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.font-bold {
+ font-weight: 700;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.text-blue-300 {
+ --tw-text-opacity: 1;
+ color: rgb(147 197 253 / var(--tw-text-opacity));
+}
+
+.text-blue-400 {
+ --tw-text-opacity: 1;
+ color: rgb(96 165 250 / var(--tw-text-opacity));
+}
+
+.text-blue-50 {
+ --tw-text-opacity: 1;
+ color: rgb(239 246 255 / var(--tw-text-opacity));
+}
+
+.text-pink-300 {
+ --tw-text-opacity: 1;
+ color: rgb(249 168 212 / var(--tw-text-opacity));
+}
+
+.text-slate-300 {
+ --tw-text-opacity: 1;
+ color: rgb(203 213 225 / var(--tw-text-opacity));
+}
+
+.text-slate-400 {
+ --tw-text-opacity: 1;
+ color: rgb(148 163 184 / var(--tw-text-opacity));
+}
+
+.text-slate-600 {
+ --tw-text-opacity: 1;
+ color: rgb(71 85 105 / var(--tw-text-opacity));
+}
+
+.text-slate-900 {
+ --tw-text-opacity: 1;
+ color: rgb(15 23 42 / var(--tw-text-opacity));
+}
+
+.text-white {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity));
+}
+
+.opacity-80 {
+ opacity: 0.8;
+}
+
+.hover\:border-blue-600:hover {
+ --tw-border-opacity: 1;
+ border-color: rgb(37 99 235 / var(--tw-border-opacity));
+}
+
+.hover\:bg-blue-300:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(147 197 253 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-400:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(96 165 250 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-500:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-600:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.hover\:text-blue-900:hover {
+ --tw-text-opacity: 1;
+ color: rgb(30 58 138 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-200:hover {
+ --tw-text-opacity: 1;
+ color: rgb(226 232 240 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-500:hover {
+ --tw-text-opacity: 1;
+ color: rgb(100 116 139 / var(--tw-text-opacity));
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/util/errors.js b/code/06 Network, Db, Auth/02 Test Database/app/util/errors.js
new file mode 100644
index 0000000..4a37e53
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/util/errors.js
@@ -0,0 +1,11 @@
+export class BadRequestErrorResponse extends Response {
+ constructor(message, statusText = 'Bad request') {
+ super(JSON.stringify({ status: 400, message }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 400,
+ statusText: statusText,
+ });
+ }
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/util/validation.server.js b/code/06 Network, Db, Auth/02 Test Database/app/util/validation.server.js
new file mode 100644
index 0000000..d7c407c
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/util/validation.server.js
@@ -0,0 +1,7 @@
+export function isValidEmail(email) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+}
+
+export function isValidPassword(password) {
+ return password.length >= 6;
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/02 Test Database/app/util/wait.js b/code/06 Network, Db, Auth/02 Test Database/app/util/wait.js
new file mode 100644
index 0000000..9f35c5b
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/app/util/wait.js
@@ -0,0 +1,5 @@
+export function wait(time) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, time);
+ });
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/cypress.config.js b/code/06 Network, Db, Auth/02 Test Database/cypress.config.js
new file mode 100644
index 0000000..9623f3f
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/cypress.config.js
@@ -0,0 +1,18 @@
+import { defineConfig } from 'cypress';
+
+import { seed } from './prisma/seed-test';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:3000',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ on('task', {
+ async seedDatabase() {
+ await seed();
+ return null;
+ }
+ })
+ },
+ },
+});
diff --git a/code/06 Network, Db, Auth/02 Test Database/cypress/e2e/takeaways.cy.js b/code/06 Network, Db, Auth/02 Test Database/cypress/e2e/takeaways.cy.js
new file mode 100644
index 0000000..b0ef018
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/cypress/e2e/takeaways.cy.js
@@ -0,0 +1,11 @@
+///
+
+describe('Takeaways', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a list of fetched takeaways', () => {
+ cy.visit('/');
+ cy.get('[data-cy="takeaway-item"]').should('have.length', 2);
+ });
+});
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/02 Test Database/cypress/support/commands.js b/code/06 Network, Db, Auth/02 Test Database/cypress/support/commands.js
new file mode 100644
index 0000000..d342d6f
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/cypress/support/commands.js
@@ -0,0 +1,52 @@
+///
+// ***********************************************
+// This example commands.ts shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+//
+// declare global {
+// namespace Cypress {
+// interface Chainable {
+// login(email: string, password: string): Chainable
+// drag(subject: string, options?: Partial): Chainable
+// dismiss(subject: string, options?: Partial): Chainable
+// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable
+// }
+// }
+// }
+
+
+// the below code snippet is required to handle a React hydration bug that would cause tests to fail
+// it's only a workaround until this React behavior / bug is fixed
+Cypress.on('uncaught:exception', (err) => {
+ // we check if the error is
+ if (
+ err.message.includes('Minified React error #418;') ||
+ err.message.includes('Minified React error #423;') ||
+ err.message.includes('hydrating') ||
+ err.message.includes('Hydration')
+ ) {
+ return false;
+ }
+});
diff --git a/code/06 Network, Db, Auth/02 Test Database/cypress/support/e2e.js b/code/06 Network, Db, Auth/02 Test Database/cypress/support/e2e.js
new file mode 100644
index 0000000..f80f74f
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/02 Test Database/package.json b/code/06 Network, Db, Auth/02 Test Database/package.json
new file mode 100644
index 0000000..ceff0da
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/package.json
@@ -0,0 +1,42 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "init": "npm install && dotenv -e .env npx prisma db push && node prisma/seed.js",
+ "build": "npm run build:css && remix build",
+ "build:css": "tailwindcss -m -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "dev": "concurrently \"npm run dev:css\" \"dotenv -e .env remix dev\"",
+ "dev:css": "tailwindcss -w -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "start": "remix-serve build",
+ "typecheck": "tsc",
+ "test": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress run\"",
+ "test:open": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress open\""
+ },
+ "dependencies": {
+ "@prisma/client": "^4.3.1",
+ "@remix-run/node": "^1.13.0",
+ "@remix-run/react": "^1.13.0",
+ "@remix-run/serve": "^1.13.0",
+ "bcryptjs": "^2.4.3",
+ "dotenv-cli": "^7.0.0",
+ "isbot": "^3.6.5",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@remix-run/dev": "^1.13.0",
+ "@remix-run/eslint-config": "^1.13.0",
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.8",
+ "concurrently": "^7.6.0",
+ "cypress": "^12.5.1",
+ "eslint": "^8.27.0",
+ "eslint-plugin-cypress": "^2.12.1",
+ "prisma": "^4.3.1",
+ "tailwindcss": "^3.2.6",
+ "typescript": "^4.8.4"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/prisma/schema.prisma b/code/06 Network, Db, Auth/02 Test Database/prisma/schema.prisma
new file mode 100644
index 0000000..65c584b
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/prisma/schema.prisma
@@ -0,0 +1,28 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ password String
+}
+
+model NewsletterSignup {
+ id Int @id @default(autoincrement())
+ email String @unique
+}
+
+model Takeaway {
+ id Int @id @default(autoincrement())
+ title String
+ body String
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/prisma/seed-test.js b/code/06 Network, Db, Auth/02 Test Database/prisma/seed-test.js
new file mode 100644
index 0000000..92317c8
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/prisma/seed-test.js
@@ -0,0 +1,39 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+const { hash } = require('bcryptjs');
+
+const prisma = new PrismaClient();
+
+export async function seed() {
+ console.log('Seeding...');
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.user.create({
+ data: {
+ email: 'test@example.com',
+ password: await hash('testpassword', 12),
+ },
+ });
+ await prisma.newsletterSignup.create({
+ data: {
+ email: 'test2@example.com',
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/02 Test Database/prisma/seed.js b/code/06 Network, Db, Auth/02 Test Database/prisma/seed.js
new file mode 100644
index 0000000..af901a0
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/prisma/seed.js
@@ -0,0 +1,31 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+
+const prisma = new PrismaClient();
+
+async function main() {
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
+
+main().then(() => {
+ console.log('seeded database');
+ process.exit(0);
+});
diff --git a/code/06 Network, Db, Auth/02 Test Database/public/favicon.ico b/code/06 Network, Db, Auth/02 Test Database/public/favicon.ico
new file mode 100644
index 0000000..8830cf6
Binary files /dev/null and b/code/06 Network, Db, Auth/02 Test Database/public/favicon.ico differ
diff --git a/code/06 Network, Db, Auth/02 Test Database/remix.config.js b/code/06 Network, Db, Auth/02 Test Database/remix.config.js
new file mode 100644
index 0000000..adf2a0b
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/remix.config.js
@@ -0,0 +1,8 @@
+/** @type {import('@remix-run/dev').AppConfig} */
+module.exports = {
+ ignoredRouteFiles: ["**/.*"],
+ // appDirectory: "app",
+ // assetsBuildDirectory: "public/build",
+ // serverBuildPath: "build/index.js",
+ // publicPath: "/build/",
+};
diff --git a/code/06 Network, Db, Auth/02 Test Database/styles/tailwind.css b/code/06 Network, Db, Auth/02 Test Database/styles/tailwind.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/styles/tailwind.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/code/06 Network, Db, Auth/02 Test Database/tailwind.config.js b/code/06 Network, Db, Auth/02 Test Database/tailwind.config.js
new file mode 100644
index 0000000..c7c50e0
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/tailwind.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/02 Test Database/tsconfig.json b/code/06 Network, Db, Auth/02 Test Database/tsconfig.json
new file mode 100644
index 0000000..28951d6
--- /dev/null
+++ b/code/06 Network, Db, Auth/02 Test Database/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "cypress.config.js", "cypress/e2e/newsletter.cy.js"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "target": "ES2019",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true
+ }
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/.env b/code/06 Network, Db, Auth/03 More Intercepting/.env
new file mode 100644
index 0000000..3e05cc4
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/.env
@@ -0,0 +1,8 @@
+# Environment variables declared in this file are automatically made available to Prisma.
+# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
+
+# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
+# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
+
+DATABASE_URL="file:./demo.db"
+SESSION_SECRET="supersecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/.env.test b/code/06 Network, Db, Auth/03 More Intercepting/.env.test
new file mode 100644
index 0000000..53e955f
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/.env.test
@@ -0,0 +1,2 @@
+DATABASE_URL="file:./test.db"
+SESSION_SECRET="testsecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/.eslintrc.js b/code/06 Network, Db, Auth/03 More Intercepting/.eslintrc.js
new file mode 100644
index 0000000..2216dd2
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/.eslintrc.js
@@ -0,0 +1,4 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node", "plugin:cypress/recommended"],
+};
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/components/Auth.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/components/Auth.jsx
new file mode 100644
index 0000000..a80b441
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/components/Auth.jsx
@@ -0,0 +1,66 @@
+import { Form, Link, useActionData } from '@remix-run/react';
+
+function Auth({ mode }) {
+ const validationData = useActionData();
+
+ return (
+
+
+
+ Email
+
+
+
+
+
+ Password
+
+
+
+ {validationData && {validationData.statusText}
}
+
+
+ {mode === 'login'
+ ? 'Create a new account'
+ : 'Log in with existing account'}
+
+
+ {mode === 'login' ? 'Login' : 'Create Account'}
+
+
+
+ );
+}
+
+export default Auth;
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/components/Layout.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/components/Layout.jsx
new file mode 100644
index 0000000..306b211
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/components/Layout.jsx
@@ -0,0 +1,51 @@
+import { Form, Link } from '@remix-run/react';
+import NewsletterSignup from './NewsletterSignup';
+
+function Layout({ isLoggedIn, children }) {
+ return (
+ <>
+
+
+ LearnCypress
+
+
+
+
+
+ Takeaways
+
+
+ {!isLoggedIn && (
+
+
+ Login
+
+
+ )}
+ {isLoggedIn && (
+
+
+
+ Logout
+
+
+
+ )}
+
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Layout;
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/components/Modal.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/components/Modal.jsx
new file mode 100644
index 0000000..1401476
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/components/Modal.jsx
@@ -0,0 +1,18 @@
+function Modal({ onClose, children }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/components/NewsletterSignup.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/components/NewsletterSignup.jsx
new file mode 100644
index 0000000..468f2c6
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/components/NewsletterSignup.jsx
@@ -0,0 +1,50 @@
+import { useFetcher } from '@remix-run/react';
+
+function NewsletterSignup() {
+ const fetcher = useFetcher();
+
+ const isSubmitting = fetcher.state === 'submitting';
+ let result;
+
+ if (fetcher.data && fetcher.data.status !== 201) {
+ result = 'error';
+ }
+
+ if (fetcher.data && fetcher.data.status === 201) {
+ result = 'success';
+ }
+
+ return (
+
+ {result !== 'success' && (
+
+
+
+
+ {isSubmitting ? : 'Sign up'}
+
+
+ {result === 'error' && (
+
+ {fetcher.data.message || 'Something went wrong'}
+
+ )}
+
+ )}
+ {result === 'success' &&
Thanks for signing up!
}
+
+ );
+}
+
+export default NewsletterSignup;
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/components/Takeaways.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/components/Takeaways.jsx
new file mode 100644
index 0000000..19cbcd6
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/components/Takeaways.jsx
@@ -0,0 +1,16 @@
+function Takeaways({ items }) {
+ return (
+
+ {items.map((item) => (
+
+
+ {item.title}
+ {item.body}
+
+
+ ))}
+
+ );
+}
+
+export default Takeaways;
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/data/auth.server.js b/code/06 Network, Db, Auth/03 More Intercepting/app/data/auth.server.js
new file mode 100644
index 0000000..6ca6148
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/data/auth.server.js
@@ -0,0 +1,93 @@
+import { hash, compare } from 'bcryptjs';
+import { createCookieSessionStorage, json, redirect } from '@remix-run/node';
+
+import { prisma } from './prisma.server';
+
+const SESSION_SECRET = process.env.SESSION_SECRET;
+
+const sessionStorage = createCookieSessionStorage({
+ cookie: {
+ secure: process.env.NODE_ENV === 'production',
+ secrets: [SESSION_SECRET],
+ sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60, // 30 days
+ httpOnly: true,
+ },
+});
+
+async function createUserSession(userId, redirectPath) {
+ const session = await sessionStorage.getSession();
+ session.set('userId', userId);
+ return redirect(redirectPath, {
+ headers: {
+ 'Set-Cookie': await sessionStorage.commitSession(session),
+ },
+ });
+}
+
+export async function getUserFromSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ const userId = session.get('userId');
+
+ if (!userId) {
+ return null;
+ }
+
+ return userId;
+}
+
+export async function destroyUserSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ return redirect('/', {
+ headers: {
+ 'Set-Cookie': await sessionStorage.destroySession(session),
+ },
+ });
+}
+
+export async function requireUserSession(request) {
+ const userId = await getUserFromSession(request);
+
+ if (!userId) {
+ throw redirect('/login');
+ }
+
+ return userId;
+}
+
+export async function signup({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (existingUser) {
+ return json({ status: 409, statusText: 'User exists already.' });
+ }
+
+ const passwordHash = await hash(password, 12);
+
+ const user = await prisma.user.create({
+ data: { email: email, password: passwordHash },
+ });
+ return createUserSession(user.id, '/takeaways');
+}
+
+export async function login({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (!existingUser) {
+ return json({ status: 400, statusText: 'Invalid credentials.' });
+ }
+
+ const passwordCorrect = await compare(password, existingUser.password);
+
+ if (!passwordCorrect) {
+ return json({ status: 400, statusText: 'Invalid credentials (pw).' });
+ }
+
+ return createUserSession(existingUser.id, '/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/data/newsletter.server.js b/code/06 Network, Db, Auth/03 More Intercepting/app/data/newsletter.server.js
new file mode 100644
index 0000000..f1822ea
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/data/newsletter.server.js
@@ -0,0 +1,27 @@
+import { isValidEmail } from '../util/validation.server';
+import { wait } from '../util/wait';
+import { prisma } from './prisma.server';
+
+export async function addNewsletterContact(email) {
+ if (!isValidEmail(email)) {
+ throw new Error('Invalid email address.');
+ }
+
+ const existingContact = await prisma.newsletterSignup.findUnique({
+ where: {
+ email,
+ },
+ });
+ await wait(2000);
+
+ if (existingContact) {
+ throw new Error('This email is already subscribed.');
+ }
+
+
+ await prisma.newsletterSignup.create({
+ data: {
+ email,
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/data/prisma.server.js b/code/06 Network, Db, Auth/03 More Intercepting/app/data/prisma.server.js
new file mode 100644
index 0000000..cf1eaa4
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/data/prisma.server.js
@@ -0,0 +1,19 @@
+import { PrismaClient } from '@prisma/client';
+
+/**
+ * @type PrismaClient
+ */
+let prisma;
+
+if (process.env.NODE_ENV === 'production') {
+ prisma = new PrismaClient();
+ prisma.$connect();
+} else {
+ if (!global.__db) {
+ global.__db = new PrismaClient();
+ global.__db.$connect();
+ }
+ prisma = global.__db;
+}
+
+export { prisma };
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/entry.client.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/entry.client.jsx
new file mode 100644
index 0000000..8338545
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/entry.client.jsx
@@ -0,0 +1,22 @@
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/entry.server.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/entry.server.jsx
new file mode 100644
index 0000000..8e65b75
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/entry.server.jsx
@@ -0,0 +1,111 @@
+import { PassThrough } from "stream";
+
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
+
+export default function handleRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return isbot(request.headers.get("user-agent"))
+ ? handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ )
+ : handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ );
+}
+
+function handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error) {
+ reject(error);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(err) {
+ reject(err);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/root.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/root.jsx
new file mode 100644
index 0000000..98ee375
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/root.jsx
@@ -0,0 +1,52 @@
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useLoaderData,
+} from '@remix-run/react';
+
+import Layout from './components/Layout';
+import { getUserFromSession } from './data/auth.server';
+import mainStyles from './styles/main.css';
+import tailwindStyles from './styles/tailwind.css';
+
+export const meta = () => ({
+ charset: 'utf-8',
+ title: 'Cypress Requests',
+ viewport: 'width=device-width,initial-scale=1',
+});
+
+export const links = () => [
+ { rel: 'stylesheet', href: tailwindStyles },
+ { rel: 'stylesheet', href: mainStyles },
+ { rel: 'icon', href: '/favicon.ico' },
+];
+
+export default function App() {
+ const isLoggedIn = useLoaderData();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export async function loader({ request }) {
+ const userId = await getUserFromSession(request);
+ return !!userId;
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/routes/index.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/index.jsx
new file mode 100644
index 0000000..f5da528
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/index.jsx
@@ -0,0 +1,31 @@
+import { Link, useLoaderData } from '@remix-run/react';
+import Takeaways from '../components/Takeaways';
+import { prisma } from '../data/prisma.server';
+
+export default function Index() {
+ const takeways = useLoaderData();
+
+ return (
+ <>
+
+ Learn Cypress
+ Cypress is an amazing end-to-end testing software and framework.
+
+ Manage your key Cypress takeaways and concepts with our learning app.
+
+
+
+
+
+ + Add a new takeaway
+
+
+ >
+ );
+}
+
+export function loader() {
+ return prisma.takeaway.findMany({ take: 2 });
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/routes/login.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/login.jsx
new file mode 100644
index 0000000..1ce4ea9
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/login.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { login } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function LoginRoute() {
+ return ;
+}
+
+export default LoginRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return login(credentials);
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/routes/logout.js b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/logout.js
new file mode 100644
index 0000000..16ba683
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/logout.js
@@ -0,0 +1,10 @@
+import { destroyUserSession } from '~/data/auth.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export function action({ request }) {
+ if (request.method !== 'POST') {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ return destroyUserSession(request);
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/routes/newsletter.js b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/newsletter.js
new file mode 100644
index 0000000..74444ab
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/newsletter.js
@@ -0,0 +1,35 @@
+import { json } from '@remix-run/node';
+import { addNewsletterContact } from '../data/newsletter.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export async function action({ request }) {
+ if (request.method !== 'POST') {
+ return new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ const body = await request.formData();
+ const email = body.get('email');
+
+ try {
+ await addNewsletterContact(email);
+ } catch (error) {
+ return json(
+ { message: error.message },
+ {
+ status: 400,
+ statusText: 'Failed to create contact',
+ }
+ );
+ }
+ return json(
+ { status: 201 }, // this is required because useFetcher does not expose the response object
+ {
+ status: 201,
+ statusText: 'Added newsletter contact.',
+ }
+ );
+}
+
+export function loader() {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/routes/signup.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/signup.jsx
new file mode 100644
index 0000000..823ab31
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/signup.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { signup } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function SignupRoute() {
+ return ;
+}
+
+export default SignupRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return signup(credentials);
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/routes/takeaways.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/takeaways.jsx
new file mode 100644
index 0000000..be2fadb
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/takeaways.jsx
@@ -0,0 +1,36 @@
+import { Link, Outlet, useLoaderData } from '@remix-run/react';
+
+import Takeaways from '../components/Takeaways';
+import { requireUserSession } from '../data/auth.server';
+import { prisma } from '../data/prisma.server';
+
+function TakewaysLayoutRoute() {
+ const takeaways = useLoaderData();
+
+ return (
+ <>
+
+
+ Your key takeaways
+
+
+
+ + Add a new takeaway
+
+
+ {takeaways.length === 0 && You have no key takeaways yet!
}
+
+ >
+ );
+}
+
+export default TakewaysLayoutRoute;
+
+export async function loader({ request }) {
+ await requireUserSession(request);
+
+ return prisma.takeaway.findMany();
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/routes/takeaways/new.jsx b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/takeaways/new.jsx
new file mode 100644
index 0000000..0b6868d
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/routes/takeaways/new.jsx
@@ -0,0 +1,87 @@
+import { json, redirect } from '@remix-run/node';
+import { Form, Link, useNavigate } from '@remix-run/react';
+
+import Modal from '../../components/Modal';
+import { requireUserSession } from '../../data/auth.server';
+import { prisma } from '../../data/prisma.server';
+
+function NewTakewayRoute() {
+ const navigate = useNavigate();
+
+ return (
+ navigate('..', { relative: 'path' })}>
+
+
+
+ Title
+
+
+
+
+
+ Body
+
+
+
+
+
+ Cancel
+
+
+ Create
+
+
+
+
+ );
+}
+
+export default NewTakewayRoute;
+
+export function loader({ request }) {
+ return requireUserSession(request);
+}
+
+export async function action({ request }) {
+ const fd = await request.formData();
+ const title = fd.get('title');
+ const body = fd.get('body');
+
+ if (!title || !body) {
+ return json({ message: 'Title and body are required.' }, { status: 400 });
+ }
+
+ await prisma.takeaway.create({
+ data: {
+ title,
+ body,
+ },
+ });
+
+ return redirect('/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/styles/main.css b/code/06 Network, Db, Auth/03 More Intercepting/app/styles/main.css
new file mode 100644
index 0000000..9ec8050
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/styles/main.css
@@ -0,0 +1,19 @@
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/styles/tailwind.css b/code/06 Network, Db, Auth/03 More Intercepting/app/styles/tailwind.css
new file mode 100644
index 0000000..d433f58
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/styles/tailwind.css
@@ -0,0 +1,919 @@
+/*
+! tailwindcss v3.2.6 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ /* 1 */
+ border-width: 0;
+ /* 2 */
+ border-style: solid;
+ /* 2 */
+ border-color: #e5e7eb;
+ /* 2 */
+}
+
+::before,
+::after {
+ --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+*/
+
+html {
+ line-height: 1.5;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+ -moz-tab-size: 4;
+ /* 3 */
+ -o-tab-size: 4;
+ tab-size: 4;
+ /* 3 */
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ /* 4 */
+ font-feature-settings: normal;
+ /* 5 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+ margin: 0;
+ /* 1 */
+ line-height: inherit;
+ /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+ height: 0;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ border-top-width: 1px;
+ /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font family by default.
+2. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ /* 1 */
+ font-size: 1em;
+ /* 2 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+ font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+ text-indent: 0;
+ /* 1 */
+ border-color: inherit;
+ /* 2 */
+ border-collapse: collapse;
+ /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ font-weight: inherit;
+ /* 1 */
+ line-height: inherit;
+ /* 1 */
+ color: inherit;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+ padding: 0;
+ /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+ text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+ -webkit-appearance: button;
+ /* 1 */
+ background-color: transparent;
+ /* 2 */
+ background-image: none;
+ /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+ outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+ vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+ display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+ resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+ cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+ This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ /* 1 */
+ vertical-align: middle;
+ /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden] {
+ display: none;
+}
+
+*, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+::backdrop {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+.fixed {
+ position: fixed;
+}
+
+.relative {
+ position: relative;
+}
+
+.left-0 {
+ left: 0px;
+}
+
+.left-\[50\%\] {
+ left: 50%;
+}
+
+.top-0 {
+ top: 0px;
+}
+
+.top-10 {
+ top: 2.5rem;
+}
+
+.m-0 {
+ margin: 0px;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.my-12 {
+ margin-top: 3rem;
+ margin-bottom: 3rem;
+}
+
+.my-16 {
+ margin-top: 4rem;
+ margin-bottom: 4rem;
+}
+
+.my-4 {
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
+.my-8 {
+ margin-top: 2rem;
+ margin-bottom: 2rem;
+}
+
+.mb-1 {
+ margin-bottom: 0.25rem;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.mt-16 {
+ margin-top: 4rem;
+}
+
+.mt-2 {
+ margin-top: 0.5rem;
+}
+
+.block {
+ display: block;
+}
+
+.flex {
+ display: flex;
+}
+
+.grid {
+ display: grid;
+}
+
+.h-screen {
+ height: 100vh;
+}
+
+.w-96 {
+ width: 24rem;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.w-screen {
+ width: 100vw;
+}
+
+.max-w-2xl {
+ max-width: 42rem;
+}
+
+.max-w-5xl {
+ max-width: 64rem;
+}
+
+.max-w-lg {
+ max-width: 32rem;
+}
+
+.-translate-x-1\/2 {
+ --tw-translate-x: -50%;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+.grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-end {
+ justify-content: flex-end;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.gap-4 {
+ gap: 1rem;
+}
+
+.gap-6 {
+ gap: 1.5rem;
+}
+
+.gap-8 {
+ gap: 2rem;
+}
+
+.rounded-md {
+ border-radius: 0.375rem;
+}
+
+.rounded-sm {
+ border-radius: 0.125rem;
+}
+
+.rounded-l-sm {
+ border-top-left-radius: 0.125rem;
+ border-bottom-left-radius: 0.125rem;
+}
+
+.rounded-r-sm {
+ border-top-right-radius: 0.125rem;
+ border-bottom-right-radius: 0.125rem;
+}
+
+.border-2 {
+ border-width: 2px;
+}
+
+.border-blue-300 {
+ --tw-border-opacity: 1;
+ border-color: rgb(147 197 253 / var(--tw-border-opacity));
+}
+
+.border-blue-700 {
+ --tw-border-opacity: 1;
+ border-color: rgb(29 78 216 / var(--tw-border-opacity));
+}
+
+.bg-blue-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.bg-blue-600 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.bg-blue-700 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(29 78 216 / var(--tw-bg-opacity));
+}
+
+.bg-slate-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(226 232 240 / var(--tw-bg-opacity));
+}
+
+.bg-slate-300 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(203 213 225 / var(--tw-bg-opacity));
+}
+
+.bg-slate-400 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(148 163 184 / var(--tw-bg-opacity));
+}
+
+.bg-slate-800 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(30 41 59 / var(--tw-bg-opacity));
+}
+
+.bg-slate-900 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(15 23 42 / var(--tw-bg-opacity));
+}
+
+.bg-gradient-to-br {
+ background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
+}
+
+.from-slate-900 {
+ --tw-gradient-from: #0f172a;
+ --tw-gradient-to: rgb(15 23 42 / 0);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.to-slate-800 {
+ --tw-gradient-to: #1e293b;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.p-8 {
+ padding: 2rem;
+}
+
+.px-2 {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+
+.px-3 {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.px-5 {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+}
+
+.px-8 {
+ padding-left: 2rem;
+ padding-right: 2rem;
+}
+
+.py-1 {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+.py-3 {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+}
+
+.py-4 {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.font-mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+}
+
+.text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.font-bold {
+ font-weight: 700;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.text-blue-300 {
+ --tw-text-opacity: 1;
+ color: rgb(147 197 253 / var(--tw-text-opacity));
+}
+
+.text-blue-400 {
+ --tw-text-opacity: 1;
+ color: rgb(96 165 250 / var(--tw-text-opacity));
+}
+
+.text-blue-50 {
+ --tw-text-opacity: 1;
+ color: rgb(239 246 255 / var(--tw-text-opacity));
+}
+
+.text-pink-300 {
+ --tw-text-opacity: 1;
+ color: rgb(249 168 212 / var(--tw-text-opacity));
+}
+
+.text-slate-300 {
+ --tw-text-opacity: 1;
+ color: rgb(203 213 225 / var(--tw-text-opacity));
+}
+
+.text-slate-400 {
+ --tw-text-opacity: 1;
+ color: rgb(148 163 184 / var(--tw-text-opacity));
+}
+
+.text-slate-600 {
+ --tw-text-opacity: 1;
+ color: rgb(71 85 105 / var(--tw-text-opacity));
+}
+
+.text-slate-900 {
+ --tw-text-opacity: 1;
+ color: rgb(15 23 42 / var(--tw-text-opacity));
+}
+
+.text-white {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity));
+}
+
+.opacity-80 {
+ opacity: 0.8;
+}
+
+.hover\:border-blue-600:hover {
+ --tw-border-opacity: 1;
+ border-color: rgb(37 99 235 / var(--tw-border-opacity));
+}
+
+.hover\:bg-blue-300:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(147 197 253 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-400:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(96 165 250 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-500:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-600:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.hover\:text-blue-900:hover {
+ --tw-text-opacity: 1;
+ color: rgb(30 58 138 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-200:hover {
+ --tw-text-opacity: 1;
+ color: rgb(226 232 240 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-500:hover {
+ --tw-text-opacity: 1;
+ color: rgb(100 116 139 / var(--tw-text-opacity));
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/util/errors.js b/code/06 Network, Db, Auth/03 More Intercepting/app/util/errors.js
new file mode 100644
index 0000000..4a37e53
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/util/errors.js
@@ -0,0 +1,11 @@
+export class BadRequestErrorResponse extends Response {
+ constructor(message, statusText = 'Bad request') {
+ super(JSON.stringify({ status: 400, message }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 400,
+ statusText: statusText,
+ });
+ }
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/util/validation.server.js b/code/06 Network, Db, Auth/03 More Intercepting/app/util/validation.server.js
new file mode 100644
index 0000000..d7c407c
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/util/validation.server.js
@@ -0,0 +1,7 @@
+export function isValidEmail(email) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+}
+
+export function isValidPassword(password) {
+ return password.length >= 6;
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/app/util/wait.js b/code/06 Network, Db, Auth/03 More Intercepting/app/util/wait.js
new file mode 100644
index 0000000..9f35c5b
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/app/util/wait.js
@@ -0,0 +1,5 @@
+export function wait(time) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, time);
+ });
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/cypress.config.js b/code/06 Network, Db, Auth/03 More Intercepting/cypress.config.js
new file mode 100644
index 0000000..9623f3f
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/cypress.config.js
@@ -0,0 +1,18 @@
+import { defineConfig } from 'cypress';
+
+import { seed } from './prisma/seed-test';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:3000',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ on('task', {
+ async seedDatabase() {
+ await seed();
+ return null;
+ }
+ })
+ },
+ },
+});
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/cypress/e2e/newsletter.cy.js b/code/06 Network, Db, Auth/03 More Intercepting/cypress/e2e/newsletter.cy.js
new file mode 100644
index 0000000..d315b4d
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/cypress/e2e/newsletter.cy.js
@@ -0,0 +1,21 @@
+describe('Newsletter', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a success message', () => {
+ cy.intercept('POST', '/newsletter*', { status: 201 }).as('subscribe'); // intercept any HTTP request localhost:3000/newsletter?anything
+ cy.visit('/');
+ cy.get('[data-cy="newsletter-email"]').type('test@example.com');
+ cy.get('[data-cy="newsletter-submit"]').click();
+ cy.wait('@subscribe');
+ cy.contains('Thanks for signing up');
+ });
+ it('should display validation errors', () => {
+ cy.intercept('POST', '/newsletter*', { message: 'Email exists already.' }).as('subscribe'); // intercept any HTTP request localhost:3000/newsletter?anything
+ cy.visit('/');
+ cy.get('[data-cy="newsletter-email"]').type('test@example.com');
+ cy.get('[data-cy="newsletter-submit"]').click();
+ cy.wait('@subscribe');
+ cy.contains('Email exists already.');
+ });
+});
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/cypress/e2e/takeaways.cy.js b/code/06 Network, Db, Auth/03 More Intercepting/cypress/e2e/takeaways.cy.js
new file mode 100644
index 0000000..b0ef018
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/cypress/e2e/takeaways.cy.js
@@ -0,0 +1,11 @@
+///
+
+describe('Takeaways', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a list of fetched takeaways', () => {
+ cy.visit('/');
+ cy.get('[data-cy="takeaway-item"]').should('have.length', 2);
+ });
+});
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/cypress/support/commands.js b/code/06 Network, Db, Auth/03 More Intercepting/cypress/support/commands.js
new file mode 100644
index 0000000..d342d6f
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/cypress/support/commands.js
@@ -0,0 +1,52 @@
+///
+// ***********************************************
+// This example commands.ts shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+//
+// declare global {
+// namespace Cypress {
+// interface Chainable {
+// login(email: string, password: string): Chainable
+// drag(subject: string, options?: Partial): Chainable
+// dismiss(subject: string, options?: Partial): Chainable
+// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable
+// }
+// }
+// }
+
+
+// the below code snippet is required to handle a React hydration bug that would cause tests to fail
+// it's only a workaround until this React behavior / bug is fixed
+Cypress.on('uncaught:exception', (err) => {
+ // we check if the error is
+ if (
+ err.message.includes('Minified React error #418;') ||
+ err.message.includes('Minified React error #423;') ||
+ err.message.includes('hydrating') ||
+ err.message.includes('Hydration')
+ ) {
+ return false;
+ }
+});
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/cypress/support/e2e.js b/code/06 Network, Db, Auth/03 More Intercepting/cypress/support/e2e.js
new file mode 100644
index 0000000..f80f74f
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/package.json b/code/06 Network, Db, Auth/03 More Intercepting/package.json
new file mode 100644
index 0000000..ceff0da
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/package.json
@@ -0,0 +1,42 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "init": "npm install && dotenv -e .env npx prisma db push && node prisma/seed.js",
+ "build": "npm run build:css && remix build",
+ "build:css": "tailwindcss -m -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "dev": "concurrently \"npm run dev:css\" \"dotenv -e .env remix dev\"",
+ "dev:css": "tailwindcss -w -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "start": "remix-serve build",
+ "typecheck": "tsc",
+ "test": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress run\"",
+ "test:open": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress open\""
+ },
+ "dependencies": {
+ "@prisma/client": "^4.3.1",
+ "@remix-run/node": "^1.13.0",
+ "@remix-run/react": "^1.13.0",
+ "@remix-run/serve": "^1.13.0",
+ "bcryptjs": "^2.4.3",
+ "dotenv-cli": "^7.0.0",
+ "isbot": "^3.6.5",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@remix-run/dev": "^1.13.0",
+ "@remix-run/eslint-config": "^1.13.0",
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.8",
+ "concurrently": "^7.6.0",
+ "cypress": "^12.5.1",
+ "eslint": "^8.27.0",
+ "eslint-plugin-cypress": "^2.12.1",
+ "prisma": "^4.3.1",
+ "tailwindcss": "^3.2.6",
+ "typescript": "^4.8.4"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/prisma/schema.prisma b/code/06 Network, Db, Auth/03 More Intercepting/prisma/schema.prisma
new file mode 100644
index 0000000..65c584b
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/prisma/schema.prisma
@@ -0,0 +1,28 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ password String
+}
+
+model NewsletterSignup {
+ id Int @id @default(autoincrement())
+ email String @unique
+}
+
+model Takeaway {
+ id Int @id @default(autoincrement())
+ title String
+ body String
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/prisma/seed-test.js b/code/06 Network, Db, Auth/03 More Intercepting/prisma/seed-test.js
new file mode 100644
index 0000000..92317c8
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/prisma/seed-test.js
@@ -0,0 +1,39 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+const { hash } = require('bcryptjs');
+
+const prisma = new PrismaClient();
+
+export async function seed() {
+ console.log('Seeding...');
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.user.create({
+ data: {
+ email: 'test@example.com',
+ password: await hash('testpassword', 12),
+ },
+ });
+ await prisma.newsletterSignup.create({
+ data: {
+ email: 'test2@example.com',
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/prisma/seed.js b/code/06 Network, Db, Auth/03 More Intercepting/prisma/seed.js
new file mode 100644
index 0000000..af901a0
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/prisma/seed.js
@@ -0,0 +1,31 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+
+const prisma = new PrismaClient();
+
+async function main() {
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
+
+main().then(() => {
+ console.log('seeded database');
+ process.exit(0);
+});
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/public/favicon.ico b/code/06 Network, Db, Auth/03 More Intercepting/public/favicon.ico
new file mode 100644
index 0000000..8830cf6
Binary files /dev/null and b/code/06 Network, Db, Auth/03 More Intercepting/public/favicon.ico differ
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/remix.config.js b/code/06 Network, Db, Auth/03 More Intercepting/remix.config.js
new file mode 100644
index 0000000..adf2a0b
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/remix.config.js
@@ -0,0 +1,8 @@
+/** @type {import('@remix-run/dev').AppConfig} */
+module.exports = {
+ ignoredRouteFiles: ["**/.*"],
+ // appDirectory: "app",
+ // assetsBuildDirectory: "public/build",
+ // serverBuildPath: "build/index.js",
+ // publicPath: "/build/",
+};
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/styles/tailwind.css b/code/06 Network, Db, Auth/03 More Intercepting/styles/tailwind.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/styles/tailwind.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/tailwind.config.js b/code/06 Network, Db, Auth/03 More Intercepting/tailwind.config.js
new file mode 100644
index 0000000..c7c50e0
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/tailwind.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/03 More Intercepting/tsconfig.json b/code/06 Network, Db, Auth/03 More Intercepting/tsconfig.json
new file mode 100644
index 0000000..28951d6
--- /dev/null
+++ b/code/06 Network, Db, Auth/03 More Intercepting/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "cypress.config.js", "cypress/e2e/newsletter.cy.js"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "target": "ES2019",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true
+ }
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/.env b/code/06 Network, Db, Auth/04 Testing APIs/.env
new file mode 100644
index 0000000..3e05cc4
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/.env
@@ -0,0 +1,8 @@
+# Environment variables declared in this file are automatically made available to Prisma.
+# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
+
+# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
+# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
+
+DATABASE_URL="file:./demo.db"
+SESSION_SECRET="supersecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/.env.test b/code/06 Network, Db, Auth/04 Testing APIs/.env.test
new file mode 100644
index 0000000..53e955f
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/.env.test
@@ -0,0 +1,2 @@
+DATABASE_URL="file:./test.db"
+SESSION_SECRET="testsecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/.eslintrc.js b/code/06 Network, Db, Auth/04 Testing APIs/.eslintrc.js
new file mode 100644
index 0000000..2216dd2
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/.eslintrc.js
@@ -0,0 +1,4 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node", "plugin:cypress/recommended"],
+};
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/components/Auth.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/components/Auth.jsx
new file mode 100644
index 0000000..a80b441
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/components/Auth.jsx
@@ -0,0 +1,66 @@
+import { Form, Link, useActionData } from '@remix-run/react';
+
+function Auth({ mode }) {
+ const validationData = useActionData();
+
+ return (
+
+
+
+ Email
+
+
+
+
+
+ Password
+
+
+
+ {validationData && {validationData.statusText}
}
+
+
+ {mode === 'login'
+ ? 'Create a new account'
+ : 'Log in with existing account'}
+
+
+ {mode === 'login' ? 'Login' : 'Create Account'}
+
+
+
+ );
+}
+
+export default Auth;
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/components/Layout.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/components/Layout.jsx
new file mode 100644
index 0000000..306b211
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/components/Layout.jsx
@@ -0,0 +1,51 @@
+import { Form, Link } from '@remix-run/react';
+import NewsletterSignup from './NewsletterSignup';
+
+function Layout({ isLoggedIn, children }) {
+ return (
+ <>
+
+
+ LearnCypress
+
+
+
+
+
+ Takeaways
+
+
+ {!isLoggedIn && (
+
+
+ Login
+
+
+ )}
+ {isLoggedIn && (
+
+
+
+ Logout
+
+
+
+ )}
+
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Layout;
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/components/Modal.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/components/Modal.jsx
new file mode 100644
index 0000000..1401476
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/components/Modal.jsx
@@ -0,0 +1,18 @@
+function Modal({ onClose, children }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/components/NewsletterSignup.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/components/NewsletterSignup.jsx
new file mode 100644
index 0000000..468f2c6
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/components/NewsletterSignup.jsx
@@ -0,0 +1,50 @@
+import { useFetcher } from '@remix-run/react';
+
+function NewsletterSignup() {
+ const fetcher = useFetcher();
+
+ const isSubmitting = fetcher.state === 'submitting';
+ let result;
+
+ if (fetcher.data && fetcher.data.status !== 201) {
+ result = 'error';
+ }
+
+ if (fetcher.data && fetcher.data.status === 201) {
+ result = 'success';
+ }
+
+ return (
+
+ {result !== 'success' && (
+
+
+
+
+ {isSubmitting ? : 'Sign up'}
+
+
+ {result === 'error' && (
+
+ {fetcher.data.message || 'Something went wrong'}
+
+ )}
+
+ )}
+ {result === 'success' &&
Thanks for signing up!
}
+
+ );
+}
+
+export default NewsletterSignup;
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/components/Takeaways.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/components/Takeaways.jsx
new file mode 100644
index 0000000..19cbcd6
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/components/Takeaways.jsx
@@ -0,0 +1,16 @@
+function Takeaways({ items }) {
+ return (
+
+ {items.map((item) => (
+
+
+ {item.title}
+ {item.body}
+
+
+ ))}
+
+ );
+}
+
+export default Takeaways;
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/data/auth.server.js b/code/06 Network, Db, Auth/04 Testing APIs/app/data/auth.server.js
new file mode 100644
index 0000000..6ca6148
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/data/auth.server.js
@@ -0,0 +1,93 @@
+import { hash, compare } from 'bcryptjs';
+import { createCookieSessionStorage, json, redirect } from '@remix-run/node';
+
+import { prisma } from './prisma.server';
+
+const SESSION_SECRET = process.env.SESSION_SECRET;
+
+const sessionStorage = createCookieSessionStorage({
+ cookie: {
+ secure: process.env.NODE_ENV === 'production',
+ secrets: [SESSION_SECRET],
+ sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60, // 30 days
+ httpOnly: true,
+ },
+});
+
+async function createUserSession(userId, redirectPath) {
+ const session = await sessionStorage.getSession();
+ session.set('userId', userId);
+ return redirect(redirectPath, {
+ headers: {
+ 'Set-Cookie': await sessionStorage.commitSession(session),
+ },
+ });
+}
+
+export async function getUserFromSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ const userId = session.get('userId');
+
+ if (!userId) {
+ return null;
+ }
+
+ return userId;
+}
+
+export async function destroyUserSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ return redirect('/', {
+ headers: {
+ 'Set-Cookie': await sessionStorage.destroySession(session),
+ },
+ });
+}
+
+export async function requireUserSession(request) {
+ const userId = await getUserFromSession(request);
+
+ if (!userId) {
+ throw redirect('/login');
+ }
+
+ return userId;
+}
+
+export async function signup({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (existingUser) {
+ return json({ status: 409, statusText: 'User exists already.' });
+ }
+
+ const passwordHash = await hash(password, 12);
+
+ const user = await prisma.user.create({
+ data: { email: email, password: passwordHash },
+ });
+ return createUserSession(user.id, '/takeaways');
+}
+
+export async function login({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (!existingUser) {
+ return json({ status: 400, statusText: 'Invalid credentials.' });
+ }
+
+ const passwordCorrect = await compare(password, existingUser.password);
+
+ if (!passwordCorrect) {
+ return json({ status: 400, statusText: 'Invalid credentials (pw).' });
+ }
+
+ return createUserSession(existingUser.id, '/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/data/newsletter.server.js b/code/06 Network, Db, Auth/04 Testing APIs/app/data/newsletter.server.js
new file mode 100644
index 0000000..f1822ea
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/data/newsletter.server.js
@@ -0,0 +1,27 @@
+import { isValidEmail } from '../util/validation.server';
+import { wait } from '../util/wait';
+import { prisma } from './prisma.server';
+
+export async function addNewsletterContact(email) {
+ if (!isValidEmail(email)) {
+ throw new Error('Invalid email address.');
+ }
+
+ const existingContact = await prisma.newsletterSignup.findUnique({
+ where: {
+ email,
+ },
+ });
+ await wait(2000);
+
+ if (existingContact) {
+ throw new Error('This email is already subscribed.');
+ }
+
+
+ await prisma.newsletterSignup.create({
+ data: {
+ email,
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/data/prisma.server.js b/code/06 Network, Db, Auth/04 Testing APIs/app/data/prisma.server.js
new file mode 100644
index 0000000..cf1eaa4
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/data/prisma.server.js
@@ -0,0 +1,19 @@
+import { PrismaClient } from '@prisma/client';
+
+/**
+ * @type PrismaClient
+ */
+let prisma;
+
+if (process.env.NODE_ENV === 'production') {
+ prisma = new PrismaClient();
+ prisma.$connect();
+} else {
+ if (!global.__db) {
+ global.__db = new PrismaClient();
+ global.__db.$connect();
+ }
+ prisma = global.__db;
+}
+
+export { prisma };
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/entry.client.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/entry.client.jsx
new file mode 100644
index 0000000..8338545
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/entry.client.jsx
@@ -0,0 +1,22 @@
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/entry.server.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/entry.server.jsx
new file mode 100644
index 0000000..8e65b75
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/entry.server.jsx
@@ -0,0 +1,111 @@
+import { PassThrough } from "stream";
+
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
+
+export default function handleRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return isbot(request.headers.get("user-agent"))
+ ? handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ )
+ : handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ );
+}
+
+function handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error) {
+ reject(error);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(err) {
+ reject(err);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/root.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/root.jsx
new file mode 100644
index 0000000..98ee375
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/root.jsx
@@ -0,0 +1,52 @@
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useLoaderData,
+} from '@remix-run/react';
+
+import Layout from './components/Layout';
+import { getUserFromSession } from './data/auth.server';
+import mainStyles from './styles/main.css';
+import tailwindStyles from './styles/tailwind.css';
+
+export const meta = () => ({
+ charset: 'utf-8',
+ title: 'Cypress Requests',
+ viewport: 'width=device-width,initial-scale=1',
+});
+
+export const links = () => [
+ { rel: 'stylesheet', href: tailwindStyles },
+ { rel: 'stylesheet', href: mainStyles },
+ { rel: 'icon', href: '/favicon.ico' },
+];
+
+export default function App() {
+ const isLoggedIn = useLoaderData();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export async function loader({ request }) {
+ const userId = await getUserFromSession(request);
+ return !!userId;
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/routes/index.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/index.jsx
new file mode 100644
index 0000000..f5da528
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/index.jsx
@@ -0,0 +1,31 @@
+import { Link, useLoaderData } from '@remix-run/react';
+import Takeaways from '../components/Takeaways';
+import { prisma } from '../data/prisma.server';
+
+export default function Index() {
+ const takeways = useLoaderData();
+
+ return (
+ <>
+
+ Learn Cypress
+ Cypress is an amazing end-to-end testing software and framework.
+
+ Manage your key Cypress takeaways and concepts with our learning app.
+
+
+
+
+
+ + Add a new takeaway
+
+
+ >
+ );
+}
+
+export function loader() {
+ return prisma.takeaway.findMany({ take: 2 });
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/routes/login.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/login.jsx
new file mode 100644
index 0000000..1ce4ea9
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/login.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { login } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function LoginRoute() {
+ return ;
+}
+
+export default LoginRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return login(credentials);
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/routes/logout.js b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/logout.js
new file mode 100644
index 0000000..16ba683
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/logout.js
@@ -0,0 +1,10 @@
+import { destroyUserSession } from '~/data/auth.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export function action({ request }) {
+ if (request.method !== 'POST') {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ return destroyUserSession(request);
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/routes/newsletter.js b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/newsletter.js
new file mode 100644
index 0000000..74444ab
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/newsletter.js
@@ -0,0 +1,35 @@
+import { json } from '@remix-run/node';
+import { addNewsletterContact } from '../data/newsletter.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export async function action({ request }) {
+ if (request.method !== 'POST') {
+ return new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ const body = await request.formData();
+ const email = body.get('email');
+
+ try {
+ await addNewsletterContact(email);
+ } catch (error) {
+ return json(
+ { message: error.message },
+ {
+ status: 400,
+ statusText: 'Failed to create contact',
+ }
+ );
+ }
+ return json(
+ { status: 201 }, // this is required because useFetcher does not expose the response object
+ {
+ status: 201,
+ statusText: 'Added newsletter contact.',
+ }
+ );
+}
+
+export function loader() {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/routes/signup.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/signup.jsx
new file mode 100644
index 0000000..823ab31
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/signup.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { signup } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function SignupRoute() {
+ return ;
+}
+
+export default SignupRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return signup(credentials);
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/routes/takeaways.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/takeaways.jsx
new file mode 100644
index 0000000..be2fadb
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/takeaways.jsx
@@ -0,0 +1,36 @@
+import { Link, Outlet, useLoaderData } from '@remix-run/react';
+
+import Takeaways from '../components/Takeaways';
+import { requireUserSession } from '../data/auth.server';
+import { prisma } from '../data/prisma.server';
+
+function TakewaysLayoutRoute() {
+ const takeaways = useLoaderData();
+
+ return (
+ <>
+
+
+ Your key takeaways
+
+
+
+ + Add a new takeaway
+
+
+ {takeaways.length === 0 && You have no key takeaways yet!
}
+
+ >
+ );
+}
+
+export default TakewaysLayoutRoute;
+
+export async function loader({ request }) {
+ await requireUserSession(request);
+
+ return prisma.takeaway.findMany();
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/routes/takeaways/new.jsx b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/takeaways/new.jsx
new file mode 100644
index 0000000..0b6868d
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/routes/takeaways/new.jsx
@@ -0,0 +1,87 @@
+import { json, redirect } from '@remix-run/node';
+import { Form, Link, useNavigate } from '@remix-run/react';
+
+import Modal from '../../components/Modal';
+import { requireUserSession } from '../../data/auth.server';
+import { prisma } from '../../data/prisma.server';
+
+function NewTakewayRoute() {
+ const navigate = useNavigate();
+
+ return (
+ navigate('..', { relative: 'path' })}>
+
+
+
+ Title
+
+
+
+
+
+ Body
+
+
+
+
+
+ Cancel
+
+
+ Create
+
+
+
+
+ );
+}
+
+export default NewTakewayRoute;
+
+export function loader({ request }) {
+ return requireUserSession(request);
+}
+
+export async function action({ request }) {
+ const fd = await request.formData();
+ const title = fd.get('title');
+ const body = fd.get('body');
+
+ if (!title || !body) {
+ return json({ message: 'Title and body are required.' }, { status: 400 });
+ }
+
+ await prisma.takeaway.create({
+ data: {
+ title,
+ body,
+ },
+ });
+
+ return redirect('/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/styles/main.css b/code/06 Network, Db, Auth/04 Testing APIs/app/styles/main.css
new file mode 100644
index 0000000..9ec8050
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/styles/main.css
@@ -0,0 +1,19 @@
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/styles/tailwind.css b/code/06 Network, Db, Auth/04 Testing APIs/app/styles/tailwind.css
new file mode 100644
index 0000000..d433f58
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/styles/tailwind.css
@@ -0,0 +1,919 @@
+/*
+! tailwindcss v3.2.6 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ /* 1 */
+ border-width: 0;
+ /* 2 */
+ border-style: solid;
+ /* 2 */
+ border-color: #e5e7eb;
+ /* 2 */
+}
+
+::before,
+::after {
+ --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+*/
+
+html {
+ line-height: 1.5;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+ -moz-tab-size: 4;
+ /* 3 */
+ -o-tab-size: 4;
+ tab-size: 4;
+ /* 3 */
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ /* 4 */
+ font-feature-settings: normal;
+ /* 5 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+ margin: 0;
+ /* 1 */
+ line-height: inherit;
+ /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+ height: 0;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ border-top-width: 1px;
+ /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font family by default.
+2. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ /* 1 */
+ font-size: 1em;
+ /* 2 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+ font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+ text-indent: 0;
+ /* 1 */
+ border-color: inherit;
+ /* 2 */
+ border-collapse: collapse;
+ /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ font-weight: inherit;
+ /* 1 */
+ line-height: inherit;
+ /* 1 */
+ color: inherit;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+ padding: 0;
+ /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+ text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+ -webkit-appearance: button;
+ /* 1 */
+ background-color: transparent;
+ /* 2 */
+ background-image: none;
+ /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+ outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+ vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+ display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+ resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+ cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+ This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ /* 1 */
+ vertical-align: middle;
+ /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden] {
+ display: none;
+}
+
+*, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+::backdrop {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+.fixed {
+ position: fixed;
+}
+
+.relative {
+ position: relative;
+}
+
+.left-0 {
+ left: 0px;
+}
+
+.left-\[50\%\] {
+ left: 50%;
+}
+
+.top-0 {
+ top: 0px;
+}
+
+.top-10 {
+ top: 2.5rem;
+}
+
+.m-0 {
+ margin: 0px;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.my-12 {
+ margin-top: 3rem;
+ margin-bottom: 3rem;
+}
+
+.my-16 {
+ margin-top: 4rem;
+ margin-bottom: 4rem;
+}
+
+.my-4 {
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
+.my-8 {
+ margin-top: 2rem;
+ margin-bottom: 2rem;
+}
+
+.mb-1 {
+ margin-bottom: 0.25rem;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.mt-16 {
+ margin-top: 4rem;
+}
+
+.mt-2 {
+ margin-top: 0.5rem;
+}
+
+.block {
+ display: block;
+}
+
+.flex {
+ display: flex;
+}
+
+.grid {
+ display: grid;
+}
+
+.h-screen {
+ height: 100vh;
+}
+
+.w-96 {
+ width: 24rem;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.w-screen {
+ width: 100vw;
+}
+
+.max-w-2xl {
+ max-width: 42rem;
+}
+
+.max-w-5xl {
+ max-width: 64rem;
+}
+
+.max-w-lg {
+ max-width: 32rem;
+}
+
+.-translate-x-1\/2 {
+ --tw-translate-x: -50%;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+.grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-end {
+ justify-content: flex-end;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.gap-4 {
+ gap: 1rem;
+}
+
+.gap-6 {
+ gap: 1.5rem;
+}
+
+.gap-8 {
+ gap: 2rem;
+}
+
+.rounded-md {
+ border-radius: 0.375rem;
+}
+
+.rounded-sm {
+ border-radius: 0.125rem;
+}
+
+.rounded-l-sm {
+ border-top-left-radius: 0.125rem;
+ border-bottom-left-radius: 0.125rem;
+}
+
+.rounded-r-sm {
+ border-top-right-radius: 0.125rem;
+ border-bottom-right-radius: 0.125rem;
+}
+
+.border-2 {
+ border-width: 2px;
+}
+
+.border-blue-300 {
+ --tw-border-opacity: 1;
+ border-color: rgb(147 197 253 / var(--tw-border-opacity));
+}
+
+.border-blue-700 {
+ --tw-border-opacity: 1;
+ border-color: rgb(29 78 216 / var(--tw-border-opacity));
+}
+
+.bg-blue-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.bg-blue-600 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.bg-blue-700 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(29 78 216 / var(--tw-bg-opacity));
+}
+
+.bg-slate-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(226 232 240 / var(--tw-bg-opacity));
+}
+
+.bg-slate-300 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(203 213 225 / var(--tw-bg-opacity));
+}
+
+.bg-slate-400 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(148 163 184 / var(--tw-bg-opacity));
+}
+
+.bg-slate-800 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(30 41 59 / var(--tw-bg-opacity));
+}
+
+.bg-slate-900 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(15 23 42 / var(--tw-bg-opacity));
+}
+
+.bg-gradient-to-br {
+ background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
+}
+
+.from-slate-900 {
+ --tw-gradient-from: #0f172a;
+ --tw-gradient-to: rgb(15 23 42 / 0);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.to-slate-800 {
+ --tw-gradient-to: #1e293b;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.p-8 {
+ padding: 2rem;
+}
+
+.px-2 {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+
+.px-3 {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.px-5 {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+}
+
+.px-8 {
+ padding-left: 2rem;
+ padding-right: 2rem;
+}
+
+.py-1 {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+.py-3 {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+}
+
+.py-4 {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.font-mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+}
+
+.text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.font-bold {
+ font-weight: 700;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.text-blue-300 {
+ --tw-text-opacity: 1;
+ color: rgb(147 197 253 / var(--tw-text-opacity));
+}
+
+.text-blue-400 {
+ --tw-text-opacity: 1;
+ color: rgb(96 165 250 / var(--tw-text-opacity));
+}
+
+.text-blue-50 {
+ --tw-text-opacity: 1;
+ color: rgb(239 246 255 / var(--tw-text-opacity));
+}
+
+.text-pink-300 {
+ --tw-text-opacity: 1;
+ color: rgb(249 168 212 / var(--tw-text-opacity));
+}
+
+.text-slate-300 {
+ --tw-text-opacity: 1;
+ color: rgb(203 213 225 / var(--tw-text-opacity));
+}
+
+.text-slate-400 {
+ --tw-text-opacity: 1;
+ color: rgb(148 163 184 / var(--tw-text-opacity));
+}
+
+.text-slate-600 {
+ --tw-text-opacity: 1;
+ color: rgb(71 85 105 / var(--tw-text-opacity));
+}
+
+.text-slate-900 {
+ --tw-text-opacity: 1;
+ color: rgb(15 23 42 / var(--tw-text-opacity));
+}
+
+.text-white {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity));
+}
+
+.opacity-80 {
+ opacity: 0.8;
+}
+
+.hover\:border-blue-600:hover {
+ --tw-border-opacity: 1;
+ border-color: rgb(37 99 235 / var(--tw-border-opacity));
+}
+
+.hover\:bg-blue-300:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(147 197 253 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-400:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(96 165 250 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-500:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-600:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.hover\:text-blue-900:hover {
+ --tw-text-opacity: 1;
+ color: rgb(30 58 138 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-200:hover {
+ --tw-text-opacity: 1;
+ color: rgb(226 232 240 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-500:hover {
+ --tw-text-opacity: 1;
+ color: rgb(100 116 139 / var(--tw-text-opacity));
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/util/errors.js b/code/06 Network, Db, Auth/04 Testing APIs/app/util/errors.js
new file mode 100644
index 0000000..4a37e53
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/util/errors.js
@@ -0,0 +1,11 @@
+export class BadRequestErrorResponse extends Response {
+ constructor(message, statusText = 'Bad request') {
+ super(JSON.stringify({ status: 400, message }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 400,
+ statusText: statusText,
+ });
+ }
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/util/validation.server.js b/code/06 Network, Db, Auth/04 Testing APIs/app/util/validation.server.js
new file mode 100644
index 0000000..d7c407c
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/util/validation.server.js
@@ -0,0 +1,7 @@
+export function isValidEmail(email) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+}
+
+export function isValidPassword(password) {
+ return password.length >= 6;
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/app/util/wait.js b/code/06 Network, Db, Auth/04 Testing APIs/app/util/wait.js
new file mode 100644
index 0000000..9f35c5b
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/app/util/wait.js
@@ -0,0 +1,5 @@
+export function wait(time) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, time);
+ });
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/cypress.config.js b/code/06 Network, Db, Auth/04 Testing APIs/cypress.config.js
new file mode 100644
index 0000000..9623f3f
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/cypress.config.js
@@ -0,0 +1,18 @@
+import { defineConfig } from 'cypress';
+
+import { seed } from './prisma/seed-test';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:3000',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ on('task', {
+ async seedDatabase() {
+ await seed();
+ return null;
+ }
+ })
+ },
+ },
+});
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/cypress/e2e/newsletter.cy.js b/code/06 Network, Db, Auth/04 Testing APIs/cypress/e2e/newsletter.cy.js
new file mode 100644
index 0000000..348176b
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/cypress/e2e/newsletter.cy.js
@@ -0,0 +1,33 @@
+describe('Newsletter', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a success message', () => {
+ cy.intercept('POST', '/newsletter*', { status: 201 }).as('subscribe'); // intercept any HTTP request localhost:3000/newsletter?anything
+ cy.visit('/');
+ cy.get('[data-cy="newsletter-email"]').type('test@example.com');
+ cy.get('[data-cy="newsletter-submit"]').click();
+ cy.wait('@subscribe');
+ cy.contains('Thanks for signing up');
+ });
+ it('should display validation errors', () => {
+ cy.intercept('POST', '/newsletter*', {
+ message: 'Email exists already.',
+ }).as('subscribe'); // intercept any HTTP request localhost:3000/newsletter?anything
+ cy.visit('/');
+ cy.get('[data-cy="newsletter-email"]').type('test@example.com');
+ cy.get('[data-cy="newsletter-submit"]').click();
+ cy.wait('@subscribe');
+ cy.contains('Email exists already.');
+ });
+ it('should successfully create a new contact', () => {
+ cy.request({
+ method: 'POST',
+ url: '/newsletter',
+ body: { email: 'test@example.com' },
+ form: true
+ }).then(res => {
+ expect(res.status).to.eq(201);
+ });
+ })
+});
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/cypress/e2e/takeaways.cy.js b/code/06 Network, Db, Auth/04 Testing APIs/cypress/e2e/takeaways.cy.js
new file mode 100644
index 0000000..b0ef018
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/cypress/e2e/takeaways.cy.js
@@ -0,0 +1,11 @@
+///
+
+describe('Takeaways', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a list of fetched takeaways', () => {
+ cy.visit('/');
+ cy.get('[data-cy="takeaway-item"]').should('have.length', 2);
+ });
+});
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/cypress/support/commands.js b/code/06 Network, Db, Auth/04 Testing APIs/cypress/support/commands.js
new file mode 100644
index 0000000..d342d6f
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/cypress/support/commands.js
@@ -0,0 +1,52 @@
+///
+// ***********************************************
+// This example commands.ts shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+//
+// declare global {
+// namespace Cypress {
+// interface Chainable {
+// login(email: string, password: string): Chainable
+// drag(subject: string, options?: Partial): Chainable
+// dismiss(subject: string, options?: Partial): Chainable
+// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable
+// }
+// }
+// }
+
+
+// the below code snippet is required to handle a React hydration bug that would cause tests to fail
+// it's only a workaround until this React behavior / bug is fixed
+Cypress.on('uncaught:exception', (err) => {
+ // we check if the error is
+ if (
+ err.message.includes('Minified React error #418;') ||
+ err.message.includes('Minified React error #423;') ||
+ err.message.includes('hydrating') ||
+ err.message.includes('Hydration')
+ ) {
+ return false;
+ }
+});
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/cypress/support/e2e.js b/code/06 Network, Db, Auth/04 Testing APIs/cypress/support/e2e.js
new file mode 100644
index 0000000..f80f74f
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/package.json b/code/06 Network, Db, Auth/04 Testing APIs/package.json
new file mode 100644
index 0000000..ceff0da
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/package.json
@@ -0,0 +1,42 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "init": "npm install && dotenv -e .env npx prisma db push && node prisma/seed.js",
+ "build": "npm run build:css && remix build",
+ "build:css": "tailwindcss -m -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "dev": "concurrently \"npm run dev:css\" \"dotenv -e .env remix dev\"",
+ "dev:css": "tailwindcss -w -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "start": "remix-serve build",
+ "typecheck": "tsc",
+ "test": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress run\"",
+ "test:open": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress open\""
+ },
+ "dependencies": {
+ "@prisma/client": "^4.3.1",
+ "@remix-run/node": "^1.13.0",
+ "@remix-run/react": "^1.13.0",
+ "@remix-run/serve": "^1.13.0",
+ "bcryptjs": "^2.4.3",
+ "dotenv-cli": "^7.0.0",
+ "isbot": "^3.6.5",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@remix-run/dev": "^1.13.0",
+ "@remix-run/eslint-config": "^1.13.0",
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.8",
+ "concurrently": "^7.6.0",
+ "cypress": "^12.5.1",
+ "eslint": "^8.27.0",
+ "eslint-plugin-cypress": "^2.12.1",
+ "prisma": "^4.3.1",
+ "tailwindcss": "^3.2.6",
+ "typescript": "^4.8.4"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/prisma/schema.prisma b/code/06 Network, Db, Auth/04 Testing APIs/prisma/schema.prisma
new file mode 100644
index 0000000..65c584b
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/prisma/schema.prisma
@@ -0,0 +1,28 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ password String
+}
+
+model NewsletterSignup {
+ id Int @id @default(autoincrement())
+ email String @unique
+}
+
+model Takeaway {
+ id Int @id @default(autoincrement())
+ title String
+ body String
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/prisma/seed-test.js b/code/06 Network, Db, Auth/04 Testing APIs/prisma/seed-test.js
new file mode 100644
index 0000000..92317c8
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/prisma/seed-test.js
@@ -0,0 +1,39 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+const { hash } = require('bcryptjs');
+
+const prisma = new PrismaClient();
+
+export async function seed() {
+ console.log('Seeding...');
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.user.create({
+ data: {
+ email: 'test@example.com',
+ password: await hash('testpassword', 12),
+ },
+ });
+ await prisma.newsletterSignup.create({
+ data: {
+ email: 'test2@example.com',
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/prisma/seed.js b/code/06 Network, Db, Auth/04 Testing APIs/prisma/seed.js
new file mode 100644
index 0000000..af901a0
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/prisma/seed.js
@@ -0,0 +1,31 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+
+const prisma = new PrismaClient();
+
+async function main() {
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
+
+main().then(() => {
+ console.log('seeded database');
+ process.exit(0);
+});
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/public/favicon.ico b/code/06 Network, Db, Auth/04 Testing APIs/public/favicon.ico
new file mode 100644
index 0000000..8830cf6
Binary files /dev/null and b/code/06 Network, Db, Auth/04 Testing APIs/public/favicon.ico differ
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/remix.config.js b/code/06 Network, Db, Auth/04 Testing APIs/remix.config.js
new file mode 100644
index 0000000..adf2a0b
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/remix.config.js
@@ -0,0 +1,8 @@
+/** @type {import('@remix-run/dev').AppConfig} */
+module.exports = {
+ ignoredRouteFiles: ["**/.*"],
+ // appDirectory: "app",
+ // assetsBuildDirectory: "public/build",
+ // serverBuildPath: "build/index.js",
+ // publicPath: "/build/",
+};
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/styles/tailwind.css b/code/06 Network, Db, Auth/04 Testing APIs/styles/tailwind.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/styles/tailwind.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/tailwind.config.js b/code/06 Network, Db, Auth/04 Testing APIs/tailwind.config.js
new file mode 100644
index 0000000..c7c50e0
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/tailwind.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/04 Testing APIs/tsconfig.json b/code/06 Network, Db, Auth/04 Testing APIs/tsconfig.json
new file mode 100644
index 0000000..28951d6
--- /dev/null
+++ b/code/06 Network, Db, Auth/04 Testing APIs/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "cypress.config.js", "cypress/e2e/newsletter.cy.js"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "target": "ES2019",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true
+ }
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/.env b/code/06 Network, Db, Auth/05 Testing Auth/.env
new file mode 100644
index 0000000..3e05cc4
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/.env
@@ -0,0 +1,8 @@
+# Environment variables declared in this file are automatically made available to Prisma.
+# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
+
+# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
+# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
+
+DATABASE_URL="file:./demo.db"
+SESSION_SECRET="supersecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/.env.test b/code/06 Network, Db, Auth/05 Testing Auth/.env.test
new file mode 100644
index 0000000..53e955f
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/.env.test
@@ -0,0 +1,2 @@
+DATABASE_URL="file:./test.db"
+SESSION_SECRET="testsecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/.eslintrc.js b/code/06 Network, Db, Auth/05 Testing Auth/.eslintrc.js
new file mode 100644
index 0000000..2216dd2
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/.eslintrc.js
@@ -0,0 +1,4 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node", "plugin:cypress/recommended"],
+};
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/components/Auth.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/components/Auth.jsx
new file mode 100644
index 0000000..a80b441
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/components/Auth.jsx
@@ -0,0 +1,66 @@
+import { Form, Link, useActionData } from '@remix-run/react';
+
+function Auth({ mode }) {
+ const validationData = useActionData();
+
+ return (
+
+
+
+ Email
+
+
+
+
+
+ Password
+
+
+
+ {validationData && {validationData.statusText}
}
+
+
+ {mode === 'login'
+ ? 'Create a new account'
+ : 'Log in with existing account'}
+
+
+ {mode === 'login' ? 'Login' : 'Create Account'}
+
+
+
+ );
+}
+
+export default Auth;
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/components/Layout.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/components/Layout.jsx
new file mode 100644
index 0000000..306b211
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/components/Layout.jsx
@@ -0,0 +1,51 @@
+import { Form, Link } from '@remix-run/react';
+import NewsletterSignup from './NewsletterSignup';
+
+function Layout({ isLoggedIn, children }) {
+ return (
+ <>
+
+
+ LearnCypress
+
+
+
+
+
+ Takeaways
+
+
+ {!isLoggedIn && (
+
+
+ Login
+
+
+ )}
+ {isLoggedIn && (
+
+
+
+ Logout
+
+
+
+ )}
+
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Layout;
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/components/Modal.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/components/Modal.jsx
new file mode 100644
index 0000000..1401476
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/components/Modal.jsx
@@ -0,0 +1,18 @@
+function Modal({ onClose, children }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/components/NewsletterSignup.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/components/NewsletterSignup.jsx
new file mode 100644
index 0000000..468f2c6
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/components/NewsletterSignup.jsx
@@ -0,0 +1,50 @@
+import { useFetcher } from '@remix-run/react';
+
+function NewsletterSignup() {
+ const fetcher = useFetcher();
+
+ const isSubmitting = fetcher.state === 'submitting';
+ let result;
+
+ if (fetcher.data && fetcher.data.status !== 201) {
+ result = 'error';
+ }
+
+ if (fetcher.data && fetcher.data.status === 201) {
+ result = 'success';
+ }
+
+ return (
+
+ {result !== 'success' && (
+
+
+
+
+ {isSubmitting ? : 'Sign up'}
+
+
+ {result === 'error' && (
+
+ {fetcher.data.message || 'Something went wrong'}
+
+ )}
+
+ )}
+ {result === 'success' &&
Thanks for signing up!
}
+
+ );
+}
+
+export default NewsletterSignup;
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/components/Takeaways.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/components/Takeaways.jsx
new file mode 100644
index 0000000..19cbcd6
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/components/Takeaways.jsx
@@ -0,0 +1,16 @@
+function Takeaways({ items }) {
+ return (
+
+ {items.map((item) => (
+
+
+ {item.title}
+ {item.body}
+
+
+ ))}
+
+ );
+}
+
+export default Takeaways;
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/data/auth.server.js b/code/06 Network, Db, Auth/05 Testing Auth/app/data/auth.server.js
new file mode 100644
index 0000000..6ca6148
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/data/auth.server.js
@@ -0,0 +1,93 @@
+import { hash, compare } from 'bcryptjs';
+import { createCookieSessionStorage, json, redirect } from '@remix-run/node';
+
+import { prisma } from './prisma.server';
+
+const SESSION_SECRET = process.env.SESSION_SECRET;
+
+const sessionStorage = createCookieSessionStorage({
+ cookie: {
+ secure: process.env.NODE_ENV === 'production',
+ secrets: [SESSION_SECRET],
+ sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60, // 30 days
+ httpOnly: true,
+ },
+});
+
+async function createUserSession(userId, redirectPath) {
+ const session = await sessionStorage.getSession();
+ session.set('userId', userId);
+ return redirect(redirectPath, {
+ headers: {
+ 'Set-Cookie': await sessionStorage.commitSession(session),
+ },
+ });
+}
+
+export async function getUserFromSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ const userId = session.get('userId');
+
+ if (!userId) {
+ return null;
+ }
+
+ return userId;
+}
+
+export async function destroyUserSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ return redirect('/', {
+ headers: {
+ 'Set-Cookie': await sessionStorage.destroySession(session),
+ },
+ });
+}
+
+export async function requireUserSession(request) {
+ const userId = await getUserFromSession(request);
+
+ if (!userId) {
+ throw redirect('/login');
+ }
+
+ return userId;
+}
+
+export async function signup({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (existingUser) {
+ return json({ status: 409, statusText: 'User exists already.' });
+ }
+
+ const passwordHash = await hash(password, 12);
+
+ const user = await prisma.user.create({
+ data: { email: email, password: passwordHash },
+ });
+ return createUserSession(user.id, '/takeaways');
+}
+
+export async function login({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (!existingUser) {
+ return json({ status: 400, statusText: 'Invalid credentials.' });
+ }
+
+ const passwordCorrect = await compare(password, existingUser.password);
+
+ if (!passwordCorrect) {
+ return json({ status: 400, statusText: 'Invalid credentials (pw).' });
+ }
+
+ return createUserSession(existingUser.id, '/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/data/newsletter.server.js b/code/06 Network, Db, Auth/05 Testing Auth/app/data/newsletter.server.js
new file mode 100644
index 0000000..f1822ea
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/data/newsletter.server.js
@@ -0,0 +1,27 @@
+import { isValidEmail } from '../util/validation.server';
+import { wait } from '../util/wait';
+import { prisma } from './prisma.server';
+
+export async function addNewsletterContact(email) {
+ if (!isValidEmail(email)) {
+ throw new Error('Invalid email address.');
+ }
+
+ const existingContact = await prisma.newsletterSignup.findUnique({
+ where: {
+ email,
+ },
+ });
+ await wait(2000);
+
+ if (existingContact) {
+ throw new Error('This email is already subscribed.');
+ }
+
+
+ await prisma.newsletterSignup.create({
+ data: {
+ email,
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/data/prisma.server.js b/code/06 Network, Db, Auth/05 Testing Auth/app/data/prisma.server.js
new file mode 100644
index 0000000..cf1eaa4
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/data/prisma.server.js
@@ -0,0 +1,19 @@
+import { PrismaClient } from '@prisma/client';
+
+/**
+ * @type PrismaClient
+ */
+let prisma;
+
+if (process.env.NODE_ENV === 'production') {
+ prisma = new PrismaClient();
+ prisma.$connect();
+} else {
+ if (!global.__db) {
+ global.__db = new PrismaClient();
+ global.__db.$connect();
+ }
+ prisma = global.__db;
+}
+
+export { prisma };
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/entry.client.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/entry.client.jsx
new file mode 100644
index 0000000..8338545
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/entry.client.jsx
@@ -0,0 +1,22 @@
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/entry.server.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/entry.server.jsx
new file mode 100644
index 0000000..8e65b75
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/entry.server.jsx
@@ -0,0 +1,111 @@
+import { PassThrough } from "stream";
+
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
+
+export default function handleRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return isbot(request.headers.get("user-agent"))
+ ? handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ )
+ : handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ );
+}
+
+function handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error) {
+ reject(error);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(err) {
+ reject(err);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/root.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/root.jsx
new file mode 100644
index 0000000..98ee375
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/root.jsx
@@ -0,0 +1,52 @@
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useLoaderData,
+} from '@remix-run/react';
+
+import Layout from './components/Layout';
+import { getUserFromSession } from './data/auth.server';
+import mainStyles from './styles/main.css';
+import tailwindStyles from './styles/tailwind.css';
+
+export const meta = () => ({
+ charset: 'utf-8',
+ title: 'Cypress Requests',
+ viewport: 'width=device-width,initial-scale=1',
+});
+
+export const links = () => [
+ { rel: 'stylesheet', href: tailwindStyles },
+ { rel: 'stylesheet', href: mainStyles },
+ { rel: 'icon', href: '/favicon.ico' },
+];
+
+export default function App() {
+ const isLoggedIn = useLoaderData();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export async function loader({ request }) {
+ const userId = await getUserFromSession(request);
+ return !!userId;
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/routes/index.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/index.jsx
new file mode 100644
index 0000000..f5da528
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/index.jsx
@@ -0,0 +1,31 @@
+import { Link, useLoaderData } from '@remix-run/react';
+import Takeaways from '../components/Takeaways';
+import { prisma } from '../data/prisma.server';
+
+export default function Index() {
+ const takeways = useLoaderData();
+
+ return (
+ <>
+
+ Learn Cypress
+ Cypress is an amazing end-to-end testing software and framework.
+
+ Manage your key Cypress takeaways and concepts with our learning app.
+
+
+
+
+
+ + Add a new takeaway
+
+
+ >
+ );
+}
+
+export function loader() {
+ return prisma.takeaway.findMany({ take: 2 });
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/routes/login.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/login.jsx
new file mode 100644
index 0000000..1ce4ea9
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/login.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { login } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function LoginRoute() {
+ return ;
+}
+
+export default LoginRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return login(credentials);
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/routes/logout.js b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/logout.js
new file mode 100644
index 0000000..16ba683
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/logout.js
@@ -0,0 +1,10 @@
+import { destroyUserSession } from '~/data/auth.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export function action({ request }) {
+ if (request.method !== 'POST') {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ return destroyUserSession(request);
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/routes/newsletter.js b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/newsletter.js
new file mode 100644
index 0000000..74444ab
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/newsletter.js
@@ -0,0 +1,35 @@
+import { json } from '@remix-run/node';
+import { addNewsletterContact } from '../data/newsletter.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export async function action({ request }) {
+ if (request.method !== 'POST') {
+ return new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ const body = await request.formData();
+ const email = body.get('email');
+
+ try {
+ await addNewsletterContact(email);
+ } catch (error) {
+ return json(
+ { message: error.message },
+ {
+ status: 400,
+ statusText: 'Failed to create contact',
+ }
+ );
+ }
+ return json(
+ { status: 201 }, // this is required because useFetcher does not expose the response object
+ {
+ status: 201,
+ statusText: 'Added newsletter contact.',
+ }
+ );
+}
+
+export function loader() {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/routes/signup.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/signup.jsx
new file mode 100644
index 0000000..823ab31
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/signup.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { signup } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function SignupRoute() {
+ return ;
+}
+
+export default SignupRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return signup(credentials);
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/routes/takeaways.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/takeaways.jsx
new file mode 100644
index 0000000..be2fadb
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/takeaways.jsx
@@ -0,0 +1,36 @@
+import { Link, Outlet, useLoaderData } from '@remix-run/react';
+
+import Takeaways from '../components/Takeaways';
+import { requireUserSession } from '../data/auth.server';
+import { prisma } from '../data/prisma.server';
+
+function TakewaysLayoutRoute() {
+ const takeaways = useLoaderData();
+
+ return (
+ <>
+
+
+ Your key takeaways
+
+
+
+ + Add a new takeaway
+
+
+ {takeaways.length === 0 && You have no key takeaways yet!
}
+
+ >
+ );
+}
+
+export default TakewaysLayoutRoute;
+
+export async function loader({ request }) {
+ await requireUserSession(request);
+
+ return prisma.takeaway.findMany();
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/routes/takeaways/new.jsx b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/takeaways/new.jsx
new file mode 100644
index 0000000..0b6868d
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/routes/takeaways/new.jsx
@@ -0,0 +1,87 @@
+import { json, redirect } from '@remix-run/node';
+import { Form, Link, useNavigate } from '@remix-run/react';
+
+import Modal from '../../components/Modal';
+import { requireUserSession } from '../../data/auth.server';
+import { prisma } from '../../data/prisma.server';
+
+function NewTakewayRoute() {
+ const navigate = useNavigate();
+
+ return (
+ navigate('..', { relative: 'path' })}>
+
+
+
+ Title
+
+
+
+
+
+ Body
+
+
+
+
+
+ Cancel
+
+
+ Create
+
+
+
+
+ );
+}
+
+export default NewTakewayRoute;
+
+export function loader({ request }) {
+ return requireUserSession(request);
+}
+
+export async function action({ request }) {
+ const fd = await request.formData();
+ const title = fd.get('title');
+ const body = fd.get('body');
+
+ if (!title || !body) {
+ return json({ message: 'Title and body are required.' }, { status: 400 });
+ }
+
+ await prisma.takeaway.create({
+ data: {
+ title,
+ body,
+ },
+ });
+
+ return redirect('/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/styles/main.css b/code/06 Network, Db, Auth/05 Testing Auth/app/styles/main.css
new file mode 100644
index 0000000..9ec8050
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/styles/main.css
@@ -0,0 +1,19 @@
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/styles/tailwind.css b/code/06 Network, Db, Auth/05 Testing Auth/app/styles/tailwind.css
new file mode 100644
index 0000000..d433f58
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/styles/tailwind.css
@@ -0,0 +1,919 @@
+/*
+! tailwindcss v3.2.6 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ /* 1 */
+ border-width: 0;
+ /* 2 */
+ border-style: solid;
+ /* 2 */
+ border-color: #e5e7eb;
+ /* 2 */
+}
+
+::before,
+::after {
+ --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+*/
+
+html {
+ line-height: 1.5;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+ -moz-tab-size: 4;
+ /* 3 */
+ -o-tab-size: 4;
+ tab-size: 4;
+ /* 3 */
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ /* 4 */
+ font-feature-settings: normal;
+ /* 5 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+ margin: 0;
+ /* 1 */
+ line-height: inherit;
+ /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+ height: 0;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ border-top-width: 1px;
+ /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font family by default.
+2. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ /* 1 */
+ font-size: 1em;
+ /* 2 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+ font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+ text-indent: 0;
+ /* 1 */
+ border-color: inherit;
+ /* 2 */
+ border-collapse: collapse;
+ /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ font-weight: inherit;
+ /* 1 */
+ line-height: inherit;
+ /* 1 */
+ color: inherit;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+ padding: 0;
+ /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+ text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+ -webkit-appearance: button;
+ /* 1 */
+ background-color: transparent;
+ /* 2 */
+ background-image: none;
+ /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+ outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+ vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+ display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+ resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+ cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+ This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ /* 1 */
+ vertical-align: middle;
+ /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden] {
+ display: none;
+}
+
+*, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+::backdrop {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+.fixed {
+ position: fixed;
+}
+
+.relative {
+ position: relative;
+}
+
+.left-0 {
+ left: 0px;
+}
+
+.left-\[50\%\] {
+ left: 50%;
+}
+
+.top-0 {
+ top: 0px;
+}
+
+.top-10 {
+ top: 2.5rem;
+}
+
+.m-0 {
+ margin: 0px;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.my-12 {
+ margin-top: 3rem;
+ margin-bottom: 3rem;
+}
+
+.my-16 {
+ margin-top: 4rem;
+ margin-bottom: 4rem;
+}
+
+.my-4 {
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
+.my-8 {
+ margin-top: 2rem;
+ margin-bottom: 2rem;
+}
+
+.mb-1 {
+ margin-bottom: 0.25rem;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.mt-16 {
+ margin-top: 4rem;
+}
+
+.mt-2 {
+ margin-top: 0.5rem;
+}
+
+.block {
+ display: block;
+}
+
+.flex {
+ display: flex;
+}
+
+.grid {
+ display: grid;
+}
+
+.h-screen {
+ height: 100vh;
+}
+
+.w-96 {
+ width: 24rem;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.w-screen {
+ width: 100vw;
+}
+
+.max-w-2xl {
+ max-width: 42rem;
+}
+
+.max-w-5xl {
+ max-width: 64rem;
+}
+
+.max-w-lg {
+ max-width: 32rem;
+}
+
+.-translate-x-1\/2 {
+ --tw-translate-x: -50%;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+.grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-end {
+ justify-content: flex-end;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.gap-4 {
+ gap: 1rem;
+}
+
+.gap-6 {
+ gap: 1.5rem;
+}
+
+.gap-8 {
+ gap: 2rem;
+}
+
+.rounded-md {
+ border-radius: 0.375rem;
+}
+
+.rounded-sm {
+ border-radius: 0.125rem;
+}
+
+.rounded-l-sm {
+ border-top-left-radius: 0.125rem;
+ border-bottom-left-radius: 0.125rem;
+}
+
+.rounded-r-sm {
+ border-top-right-radius: 0.125rem;
+ border-bottom-right-radius: 0.125rem;
+}
+
+.border-2 {
+ border-width: 2px;
+}
+
+.border-blue-300 {
+ --tw-border-opacity: 1;
+ border-color: rgb(147 197 253 / var(--tw-border-opacity));
+}
+
+.border-blue-700 {
+ --tw-border-opacity: 1;
+ border-color: rgb(29 78 216 / var(--tw-border-opacity));
+}
+
+.bg-blue-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.bg-blue-600 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.bg-blue-700 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(29 78 216 / var(--tw-bg-opacity));
+}
+
+.bg-slate-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(226 232 240 / var(--tw-bg-opacity));
+}
+
+.bg-slate-300 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(203 213 225 / var(--tw-bg-opacity));
+}
+
+.bg-slate-400 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(148 163 184 / var(--tw-bg-opacity));
+}
+
+.bg-slate-800 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(30 41 59 / var(--tw-bg-opacity));
+}
+
+.bg-slate-900 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(15 23 42 / var(--tw-bg-opacity));
+}
+
+.bg-gradient-to-br {
+ background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
+}
+
+.from-slate-900 {
+ --tw-gradient-from: #0f172a;
+ --tw-gradient-to: rgb(15 23 42 / 0);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.to-slate-800 {
+ --tw-gradient-to: #1e293b;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.p-8 {
+ padding: 2rem;
+}
+
+.px-2 {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+
+.px-3 {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.px-5 {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+}
+
+.px-8 {
+ padding-left: 2rem;
+ padding-right: 2rem;
+}
+
+.py-1 {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+.py-3 {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+}
+
+.py-4 {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.font-mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+}
+
+.text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.font-bold {
+ font-weight: 700;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.text-blue-300 {
+ --tw-text-opacity: 1;
+ color: rgb(147 197 253 / var(--tw-text-opacity));
+}
+
+.text-blue-400 {
+ --tw-text-opacity: 1;
+ color: rgb(96 165 250 / var(--tw-text-opacity));
+}
+
+.text-blue-50 {
+ --tw-text-opacity: 1;
+ color: rgb(239 246 255 / var(--tw-text-opacity));
+}
+
+.text-pink-300 {
+ --tw-text-opacity: 1;
+ color: rgb(249 168 212 / var(--tw-text-opacity));
+}
+
+.text-slate-300 {
+ --tw-text-opacity: 1;
+ color: rgb(203 213 225 / var(--tw-text-opacity));
+}
+
+.text-slate-400 {
+ --tw-text-opacity: 1;
+ color: rgb(148 163 184 / var(--tw-text-opacity));
+}
+
+.text-slate-600 {
+ --tw-text-opacity: 1;
+ color: rgb(71 85 105 / var(--tw-text-opacity));
+}
+
+.text-slate-900 {
+ --tw-text-opacity: 1;
+ color: rgb(15 23 42 / var(--tw-text-opacity));
+}
+
+.text-white {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity));
+}
+
+.opacity-80 {
+ opacity: 0.8;
+}
+
+.hover\:border-blue-600:hover {
+ --tw-border-opacity: 1;
+ border-color: rgb(37 99 235 / var(--tw-border-opacity));
+}
+
+.hover\:bg-blue-300:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(147 197 253 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-400:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(96 165 250 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-500:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-600:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.hover\:text-blue-900:hover {
+ --tw-text-opacity: 1;
+ color: rgb(30 58 138 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-200:hover {
+ --tw-text-opacity: 1;
+ color: rgb(226 232 240 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-500:hover {
+ --tw-text-opacity: 1;
+ color: rgb(100 116 139 / var(--tw-text-opacity));
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/util/errors.js b/code/06 Network, Db, Auth/05 Testing Auth/app/util/errors.js
new file mode 100644
index 0000000..4a37e53
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/util/errors.js
@@ -0,0 +1,11 @@
+export class BadRequestErrorResponse extends Response {
+ constructor(message, statusText = 'Bad request') {
+ super(JSON.stringify({ status: 400, message }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 400,
+ statusText: statusText,
+ });
+ }
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/util/validation.server.js b/code/06 Network, Db, Auth/05 Testing Auth/app/util/validation.server.js
new file mode 100644
index 0000000..d7c407c
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/util/validation.server.js
@@ -0,0 +1,7 @@
+export function isValidEmail(email) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+}
+
+export function isValidPassword(password) {
+ return password.length >= 6;
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/app/util/wait.js b/code/06 Network, Db, Auth/05 Testing Auth/app/util/wait.js
new file mode 100644
index 0000000..9f35c5b
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/app/util/wait.js
@@ -0,0 +1,5 @@
+export function wait(time) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, time);
+ });
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/cypress.config.js b/code/06 Network, Db, Auth/05 Testing Auth/cypress.config.js
new file mode 100644
index 0000000..9623f3f
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/cypress.config.js
@@ -0,0 +1,18 @@
+import { defineConfig } from 'cypress';
+
+import { seed } from './prisma/seed-test';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:3000',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ on('task', {
+ async seedDatabase() {
+ await seed();
+ return null;
+ }
+ })
+ },
+ },
+});
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/cypress/e2e/auth.cy.js b/code/06 Network, Db, Auth/05 Testing Auth/cypress/e2e/auth.cy.js
new file mode 100644
index 0000000..c075ceb
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/cypress/e2e/auth.cy.js
@@ -0,0 +1,16 @@
+///
+
+describe('Auth', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should signup', () => {
+ cy.visit('/signup');
+ cy.get('[data-cy="auth-email"]').click();
+ cy.get('[data-cy="auth-email"]').type('test2@example.com');
+ cy.get('[data-cy="auth-password"]').type('testpassword');
+ cy.get('[data-cy="auth-submit"]').click();
+ cy.location('pathname').should('eq', '/takeaways');
+ cy.getCookie('__session').its('value').should('not.be.empty');
+ });
+});
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/cypress/e2e/newsletter.cy.js b/code/06 Network, Db, Auth/05 Testing Auth/cypress/e2e/newsletter.cy.js
new file mode 100644
index 0000000..348176b
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/cypress/e2e/newsletter.cy.js
@@ -0,0 +1,33 @@
+describe('Newsletter', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a success message', () => {
+ cy.intercept('POST', '/newsletter*', { status: 201 }).as('subscribe'); // intercept any HTTP request localhost:3000/newsletter?anything
+ cy.visit('/');
+ cy.get('[data-cy="newsletter-email"]').type('test@example.com');
+ cy.get('[data-cy="newsletter-submit"]').click();
+ cy.wait('@subscribe');
+ cy.contains('Thanks for signing up');
+ });
+ it('should display validation errors', () => {
+ cy.intercept('POST', '/newsletter*', {
+ message: 'Email exists already.',
+ }).as('subscribe'); // intercept any HTTP request localhost:3000/newsletter?anything
+ cy.visit('/');
+ cy.get('[data-cy="newsletter-email"]').type('test@example.com');
+ cy.get('[data-cy="newsletter-submit"]').click();
+ cy.wait('@subscribe');
+ cy.contains('Email exists already.');
+ });
+ it('should successfully create a new contact', () => {
+ cy.request({
+ method: 'POST',
+ url: '/newsletter',
+ body: { email: 'test@example.com' },
+ form: true
+ }).then(res => {
+ expect(res.status).to.eq(201);
+ });
+ })
+});
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/cypress/e2e/takeaways.cy.js b/code/06 Network, Db, Auth/05 Testing Auth/cypress/e2e/takeaways.cy.js
new file mode 100644
index 0000000..b0ef018
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/cypress/e2e/takeaways.cy.js
@@ -0,0 +1,11 @@
+///
+
+describe('Takeaways', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a list of fetched takeaways', () => {
+ cy.visit('/');
+ cy.get('[data-cy="takeaway-item"]').should('have.length', 2);
+ });
+});
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/cypress/support/commands.js b/code/06 Network, Db, Auth/05 Testing Auth/cypress/support/commands.js
new file mode 100644
index 0000000..d342d6f
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/cypress/support/commands.js
@@ -0,0 +1,52 @@
+///
+// ***********************************************
+// This example commands.ts shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+//
+// declare global {
+// namespace Cypress {
+// interface Chainable {
+// login(email: string, password: string): Chainable
+// drag(subject: string, options?: Partial): Chainable
+// dismiss(subject: string, options?: Partial): Chainable
+// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable
+// }
+// }
+// }
+
+
+// the below code snippet is required to handle a React hydration bug that would cause tests to fail
+// it's only a workaround until this React behavior / bug is fixed
+Cypress.on('uncaught:exception', (err) => {
+ // we check if the error is
+ if (
+ err.message.includes('Minified React error #418;') ||
+ err.message.includes('Minified React error #423;') ||
+ err.message.includes('hydrating') ||
+ err.message.includes('Hydration')
+ ) {
+ return false;
+ }
+});
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/cypress/support/e2e.js b/code/06 Network, Db, Auth/05 Testing Auth/cypress/support/e2e.js
new file mode 100644
index 0000000..f80f74f
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/package.json b/code/06 Network, Db, Auth/05 Testing Auth/package.json
new file mode 100644
index 0000000..ceff0da
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/package.json
@@ -0,0 +1,42 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "init": "npm install && dotenv -e .env npx prisma db push && node prisma/seed.js",
+ "build": "npm run build:css && remix build",
+ "build:css": "tailwindcss -m -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "dev": "concurrently \"npm run dev:css\" \"dotenv -e .env remix dev\"",
+ "dev:css": "tailwindcss -w -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "start": "remix-serve build",
+ "typecheck": "tsc",
+ "test": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress run\"",
+ "test:open": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress open\""
+ },
+ "dependencies": {
+ "@prisma/client": "^4.3.1",
+ "@remix-run/node": "^1.13.0",
+ "@remix-run/react": "^1.13.0",
+ "@remix-run/serve": "^1.13.0",
+ "bcryptjs": "^2.4.3",
+ "dotenv-cli": "^7.0.0",
+ "isbot": "^3.6.5",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@remix-run/dev": "^1.13.0",
+ "@remix-run/eslint-config": "^1.13.0",
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.8",
+ "concurrently": "^7.6.0",
+ "cypress": "^12.5.1",
+ "eslint": "^8.27.0",
+ "eslint-plugin-cypress": "^2.12.1",
+ "prisma": "^4.3.1",
+ "tailwindcss": "^3.2.6",
+ "typescript": "^4.8.4"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/prisma/schema.prisma b/code/06 Network, Db, Auth/05 Testing Auth/prisma/schema.prisma
new file mode 100644
index 0000000..65c584b
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/prisma/schema.prisma
@@ -0,0 +1,28 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ password String
+}
+
+model NewsletterSignup {
+ id Int @id @default(autoincrement())
+ email String @unique
+}
+
+model Takeaway {
+ id Int @id @default(autoincrement())
+ title String
+ body String
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/prisma/seed-test.js b/code/06 Network, Db, Auth/05 Testing Auth/prisma/seed-test.js
new file mode 100644
index 0000000..92317c8
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/prisma/seed-test.js
@@ -0,0 +1,39 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+const { hash } = require('bcryptjs');
+
+const prisma = new PrismaClient();
+
+export async function seed() {
+ console.log('Seeding...');
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.user.create({
+ data: {
+ email: 'test@example.com',
+ password: await hash('testpassword', 12),
+ },
+ });
+ await prisma.newsletterSignup.create({
+ data: {
+ email: 'test2@example.com',
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/prisma/seed.js b/code/06 Network, Db, Auth/05 Testing Auth/prisma/seed.js
new file mode 100644
index 0000000..af901a0
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/prisma/seed.js
@@ -0,0 +1,31 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+
+const prisma = new PrismaClient();
+
+async function main() {
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
+
+main().then(() => {
+ console.log('seeded database');
+ process.exit(0);
+});
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/public/favicon.ico b/code/06 Network, Db, Auth/05 Testing Auth/public/favicon.ico
new file mode 100644
index 0000000..8830cf6
Binary files /dev/null and b/code/06 Network, Db, Auth/05 Testing Auth/public/favicon.ico differ
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/remix.config.js b/code/06 Network, Db, Auth/05 Testing Auth/remix.config.js
new file mode 100644
index 0000000..adf2a0b
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/remix.config.js
@@ -0,0 +1,8 @@
+/** @type {import('@remix-run/dev').AppConfig} */
+module.exports = {
+ ignoredRouteFiles: ["**/.*"],
+ // appDirectory: "app",
+ // assetsBuildDirectory: "public/build",
+ // serverBuildPath: "build/index.js",
+ // publicPath: "/build/",
+};
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/styles/tailwind.css b/code/06 Network, Db, Auth/05 Testing Auth/styles/tailwind.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/styles/tailwind.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/tailwind.config.js b/code/06 Network, Db, Auth/05 Testing Auth/tailwind.config.js
new file mode 100644
index 0000000..c7c50e0
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/tailwind.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/05 Testing Auth/tsconfig.json b/code/06 Network, Db, Auth/05 Testing Auth/tsconfig.json
new file mode 100644
index 0000000..28951d6
--- /dev/null
+++ b/code/06 Network, Db, Auth/05 Testing Auth/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "cypress.config.js", "cypress/e2e/newsletter.cy.js"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "target": "ES2019",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true
+ }
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/.env b/code/06 Network, Db, Auth/06 Login test/.env
new file mode 100644
index 0000000..3e05cc4
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/.env
@@ -0,0 +1,8 @@
+# Environment variables declared in this file are automatically made available to Prisma.
+# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
+
+# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
+# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
+
+DATABASE_URL="file:./demo.db"
+SESSION_SECRET="supersecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/06 Login test/.env.test b/code/06 Network, Db, Auth/06 Login test/.env.test
new file mode 100644
index 0000000..53e955f
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/.env.test
@@ -0,0 +1,2 @@
+DATABASE_URL="file:./test.db"
+SESSION_SECRET="testsecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/06 Login test/.eslintrc.js b/code/06 Network, Db, Auth/06 Login test/.eslintrc.js
new file mode 100644
index 0000000..2216dd2
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/.eslintrc.js
@@ -0,0 +1,4 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node", "plugin:cypress/recommended"],
+};
diff --git a/code/06 Network, Db, Auth/06 Login test/app/components/Auth.jsx b/code/06 Network, Db, Auth/06 Login test/app/components/Auth.jsx
new file mode 100644
index 0000000..a80b441
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/components/Auth.jsx
@@ -0,0 +1,66 @@
+import { Form, Link, useActionData } from '@remix-run/react';
+
+function Auth({ mode }) {
+ const validationData = useActionData();
+
+ return (
+
+
+
+ Email
+
+
+
+
+
+ Password
+
+
+
+ {validationData && {validationData.statusText}
}
+
+
+ {mode === 'login'
+ ? 'Create a new account'
+ : 'Log in with existing account'}
+
+
+ {mode === 'login' ? 'Login' : 'Create Account'}
+
+
+
+ );
+}
+
+export default Auth;
diff --git a/code/06 Network, Db, Auth/06 Login test/app/components/Layout.jsx b/code/06 Network, Db, Auth/06 Login test/app/components/Layout.jsx
new file mode 100644
index 0000000..306b211
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/components/Layout.jsx
@@ -0,0 +1,51 @@
+import { Form, Link } from '@remix-run/react';
+import NewsletterSignup from './NewsletterSignup';
+
+function Layout({ isLoggedIn, children }) {
+ return (
+ <>
+
+
+ LearnCypress
+
+
+
+
+
+ Takeaways
+
+
+ {!isLoggedIn && (
+
+
+ Login
+
+
+ )}
+ {isLoggedIn && (
+
+
+
+ Logout
+
+
+
+ )}
+
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Layout;
diff --git a/code/06 Network, Db, Auth/06 Login test/app/components/Modal.jsx b/code/06 Network, Db, Auth/06 Login test/app/components/Modal.jsx
new file mode 100644
index 0000000..1401476
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/components/Modal.jsx
@@ -0,0 +1,18 @@
+function Modal({ onClose, children }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/06 Network, Db, Auth/06 Login test/app/components/NewsletterSignup.jsx b/code/06 Network, Db, Auth/06 Login test/app/components/NewsletterSignup.jsx
new file mode 100644
index 0000000..468f2c6
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/components/NewsletterSignup.jsx
@@ -0,0 +1,50 @@
+import { useFetcher } from '@remix-run/react';
+
+function NewsletterSignup() {
+ const fetcher = useFetcher();
+
+ const isSubmitting = fetcher.state === 'submitting';
+ let result;
+
+ if (fetcher.data && fetcher.data.status !== 201) {
+ result = 'error';
+ }
+
+ if (fetcher.data && fetcher.data.status === 201) {
+ result = 'success';
+ }
+
+ return (
+
+ {result !== 'success' && (
+
+
+
+
+ {isSubmitting ? : 'Sign up'}
+
+
+ {result === 'error' && (
+
+ {fetcher.data.message || 'Something went wrong'}
+
+ )}
+
+ )}
+ {result === 'success' &&
Thanks for signing up!
}
+
+ );
+}
+
+export default NewsletterSignup;
diff --git a/code/06 Network, Db, Auth/06 Login test/app/components/Takeaways.jsx b/code/06 Network, Db, Auth/06 Login test/app/components/Takeaways.jsx
new file mode 100644
index 0000000..19cbcd6
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/components/Takeaways.jsx
@@ -0,0 +1,16 @@
+function Takeaways({ items }) {
+ return (
+
+ {items.map((item) => (
+
+
+ {item.title}
+ {item.body}
+
+
+ ))}
+
+ );
+}
+
+export default Takeaways;
diff --git a/code/06 Network, Db, Auth/06 Login test/app/data/auth.server.js b/code/06 Network, Db, Auth/06 Login test/app/data/auth.server.js
new file mode 100644
index 0000000..6ca6148
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/data/auth.server.js
@@ -0,0 +1,93 @@
+import { hash, compare } from 'bcryptjs';
+import { createCookieSessionStorage, json, redirect } from '@remix-run/node';
+
+import { prisma } from './prisma.server';
+
+const SESSION_SECRET = process.env.SESSION_SECRET;
+
+const sessionStorage = createCookieSessionStorage({
+ cookie: {
+ secure: process.env.NODE_ENV === 'production',
+ secrets: [SESSION_SECRET],
+ sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60, // 30 days
+ httpOnly: true,
+ },
+});
+
+async function createUserSession(userId, redirectPath) {
+ const session = await sessionStorage.getSession();
+ session.set('userId', userId);
+ return redirect(redirectPath, {
+ headers: {
+ 'Set-Cookie': await sessionStorage.commitSession(session),
+ },
+ });
+}
+
+export async function getUserFromSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ const userId = session.get('userId');
+
+ if (!userId) {
+ return null;
+ }
+
+ return userId;
+}
+
+export async function destroyUserSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ return redirect('/', {
+ headers: {
+ 'Set-Cookie': await sessionStorage.destroySession(session),
+ },
+ });
+}
+
+export async function requireUserSession(request) {
+ const userId = await getUserFromSession(request);
+
+ if (!userId) {
+ throw redirect('/login');
+ }
+
+ return userId;
+}
+
+export async function signup({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (existingUser) {
+ return json({ status: 409, statusText: 'User exists already.' });
+ }
+
+ const passwordHash = await hash(password, 12);
+
+ const user = await prisma.user.create({
+ data: { email: email, password: passwordHash },
+ });
+ return createUserSession(user.id, '/takeaways');
+}
+
+export async function login({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (!existingUser) {
+ return json({ status: 400, statusText: 'Invalid credentials.' });
+ }
+
+ const passwordCorrect = await compare(password, existingUser.password);
+
+ if (!passwordCorrect) {
+ return json({ status: 400, statusText: 'Invalid credentials (pw).' });
+ }
+
+ return createUserSession(existingUser.id, '/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/data/newsletter.server.js b/code/06 Network, Db, Auth/06 Login test/app/data/newsletter.server.js
new file mode 100644
index 0000000..f1822ea
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/data/newsletter.server.js
@@ -0,0 +1,27 @@
+import { isValidEmail } from '../util/validation.server';
+import { wait } from '../util/wait';
+import { prisma } from './prisma.server';
+
+export async function addNewsletterContact(email) {
+ if (!isValidEmail(email)) {
+ throw new Error('Invalid email address.');
+ }
+
+ const existingContact = await prisma.newsletterSignup.findUnique({
+ where: {
+ email,
+ },
+ });
+ await wait(2000);
+
+ if (existingContact) {
+ throw new Error('This email is already subscribed.');
+ }
+
+
+ await prisma.newsletterSignup.create({
+ data: {
+ email,
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/data/prisma.server.js b/code/06 Network, Db, Auth/06 Login test/app/data/prisma.server.js
new file mode 100644
index 0000000..cf1eaa4
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/data/prisma.server.js
@@ -0,0 +1,19 @@
+import { PrismaClient } from '@prisma/client';
+
+/**
+ * @type PrismaClient
+ */
+let prisma;
+
+if (process.env.NODE_ENV === 'production') {
+ prisma = new PrismaClient();
+ prisma.$connect();
+} else {
+ if (!global.__db) {
+ global.__db = new PrismaClient();
+ global.__db.$connect();
+ }
+ prisma = global.__db;
+}
+
+export { prisma };
diff --git a/code/06 Network, Db, Auth/06 Login test/app/entry.client.jsx b/code/06 Network, Db, Auth/06 Login test/app/entry.client.jsx
new file mode 100644
index 0000000..8338545
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/entry.client.jsx
@@ -0,0 +1,22 @@
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/entry.server.jsx b/code/06 Network, Db, Auth/06 Login test/app/entry.server.jsx
new file mode 100644
index 0000000..8e65b75
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/entry.server.jsx
@@ -0,0 +1,111 @@
+import { PassThrough } from "stream";
+
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
+
+export default function handleRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return isbot(request.headers.get("user-agent"))
+ ? handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ )
+ : handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ );
+}
+
+function handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error) {
+ reject(error);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(err) {
+ reject(err);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/root.jsx b/code/06 Network, Db, Auth/06 Login test/app/root.jsx
new file mode 100644
index 0000000..98ee375
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/root.jsx
@@ -0,0 +1,52 @@
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useLoaderData,
+} from '@remix-run/react';
+
+import Layout from './components/Layout';
+import { getUserFromSession } from './data/auth.server';
+import mainStyles from './styles/main.css';
+import tailwindStyles from './styles/tailwind.css';
+
+export const meta = () => ({
+ charset: 'utf-8',
+ title: 'Cypress Requests',
+ viewport: 'width=device-width,initial-scale=1',
+});
+
+export const links = () => [
+ { rel: 'stylesheet', href: tailwindStyles },
+ { rel: 'stylesheet', href: mainStyles },
+ { rel: 'icon', href: '/favicon.ico' },
+];
+
+export default function App() {
+ const isLoggedIn = useLoaderData();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export async function loader({ request }) {
+ const userId = await getUserFromSession(request);
+ return !!userId;
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/routes/index.jsx b/code/06 Network, Db, Auth/06 Login test/app/routes/index.jsx
new file mode 100644
index 0000000..f5da528
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/routes/index.jsx
@@ -0,0 +1,31 @@
+import { Link, useLoaderData } from '@remix-run/react';
+import Takeaways from '../components/Takeaways';
+import { prisma } from '../data/prisma.server';
+
+export default function Index() {
+ const takeways = useLoaderData();
+
+ return (
+ <>
+
+ Learn Cypress
+ Cypress is an amazing end-to-end testing software and framework.
+
+ Manage your key Cypress takeaways and concepts with our learning app.
+
+
+
+
+
+ + Add a new takeaway
+
+
+ >
+ );
+}
+
+export function loader() {
+ return prisma.takeaway.findMany({ take: 2 });
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/routes/login.jsx b/code/06 Network, Db, Auth/06 Login test/app/routes/login.jsx
new file mode 100644
index 0000000..1ce4ea9
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/routes/login.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { login } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function LoginRoute() {
+ return ;
+}
+
+export default LoginRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return login(credentials);
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/routes/logout.js b/code/06 Network, Db, Auth/06 Login test/app/routes/logout.js
new file mode 100644
index 0000000..16ba683
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/routes/logout.js
@@ -0,0 +1,10 @@
+import { destroyUserSession } from '~/data/auth.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export function action({ request }) {
+ if (request.method !== 'POST') {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ return destroyUserSession(request);
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/routes/newsletter.js b/code/06 Network, Db, Auth/06 Login test/app/routes/newsletter.js
new file mode 100644
index 0000000..74444ab
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/routes/newsletter.js
@@ -0,0 +1,35 @@
+import { json } from '@remix-run/node';
+import { addNewsletterContact } from '../data/newsletter.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export async function action({ request }) {
+ if (request.method !== 'POST') {
+ return new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ const body = await request.formData();
+ const email = body.get('email');
+
+ try {
+ await addNewsletterContact(email);
+ } catch (error) {
+ return json(
+ { message: error.message },
+ {
+ status: 400,
+ statusText: 'Failed to create contact',
+ }
+ );
+ }
+ return json(
+ { status: 201 }, // this is required because useFetcher does not expose the response object
+ {
+ status: 201,
+ statusText: 'Added newsletter contact.',
+ }
+ );
+}
+
+export function loader() {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/routes/signup.jsx b/code/06 Network, Db, Auth/06 Login test/app/routes/signup.jsx
new file mode 100644
index 0000000..823ab31
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/routes/signup.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { signup } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function SignupRoute() {
+ return ;
+}
+
+export default SignupRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return signup(credentials);
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/routes/takeaways.jsx b/code/06 Network, Db, Auth/06 Login test/app/routes/takeaways.jsx
new file mode 100644
index 0000000..be2fadb
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/routes/takeaways.jsx
@@ -0,0 +1,36 @@
+import { Link, Outlet, useLoaderData } from '@remix-run/react';
+
+import Takeaways from '../components/Takeaways';
+import { requireUserSession } from '../data/auth.server';
+import { prisma } from '../data/prisma.server';
+
+function TakewaysLayoutRoute() {
+ const takeaways = useLoaderData();
+
+ return (
+ <>
+
+
+ Your key takeaways
+
+
+
+ + Add a new takeaway
+
+
+ {takeaways.length === 0 && You have no key takeaways yet!
}
+
+ >
+ );
+}
+
+export default TakewaysLayoutRoute;
+
+export async function loader({ request }) {
+ await requireUserSession(request);
+
+ return prisma.takeaway.findMany();
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/routes/takeaways/new.jsx b/code/06 Network, Db, Auth/06 Login test/app/routes/takeaways/new.jsx
new file mode 100644
index 0000000..0b6868d
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/routes/takeaways/new.jsx
@@ -0,0 +1,87 @@
+import { json, redirect } from '@remix-run/node';
+import { Form, Link, useNavigate } from '@remix-run/react';
+
+import Modal from '../../components/Modal';
+import { requireUserSession } from '../../data/auth.server';
+import { prisma } from '../../data/prisma.server';
+
+function NewTakewayRoute() {
+ const navigate = useNavigate();
+
+ return (
+ navigate('..', { relative: 'path' })}>
+
+
+
+ Title
+
+
+
+
+
+ Body
+
+
+
+
+
+ Cancel
+
+
+ Create
+
+
+
+
+ );
+}
+
+export default NewTakewayRoute;
+
+export function loader({ request }) {
+ return requireUserSession(request);
+}
+
+export async function action({ request }) {
+ const fd = await request.formData();
+ const title = fd.get('title');
+ const body = fd.get('body');
+
+ if (!title || !body) {
+ return json({ message: 'Title and body are required.' }, { status: 400 });
+ }
+
+ await prisma.takeaway.create({
+ data: {
+ title,
+ body,
+ },
+ });
+
+ return redirect('/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/styles/main.css b/code/06 Network, Db, Auth/06 Login test/app/styles/main.css
new file mode 100644
index 0000000..9ec8050
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/styles/main.css
@@ -0,0 +1,19 @@
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/styles/tailwind.css b/code/06 Network, Db, Auth/06 Login test/app/styles/tailwind.css
new file mode 100644
index 0000000..d433f58
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/styles/tailwind.css
@@ -0,0 +1,919 @@
+/*
+! tailwindcss v3.2.6 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ /* 1 */
+ border-width: 0;
+ /* 2 */
+ border-style: solid;
+ /* 2 */
+ border-color: #e5e7eb;
+ /* 2 */
+}
+
+::before,
+::after {
+ --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+*/
+
+html {
+ line-height: 1.5;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+ -moz-tab-size: 4;
+ /* 3 */
+ -o-tab-size: 4;
+ tab-size: 4;
+ /* 3 */
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ /* 4 */
+ font-feature-settings: normal;
+ /* 5 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+ margin: 0;
+ /* 1 */
+ line-height: inherit;
+ /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+ height: 0;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ border-top-width: 1px;
+ /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font family by default.
+2. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ /* 1 */
+ font-size: 1em;
+ /* 2 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+ font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+ text-indent: 0;
+ /* 1 */
+ border-color: inherit;
+ /* 2 */
+ border-collapse: collapse;
+ /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ font-weight: inherit;
+ /* 1 */
+ line-height: inherit;
+ /* 1 */
+ color: inherit;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+ padding: 0;
+ /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+ text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+ -webkit-appearance: button;
+ /* 1 */
+ background-color: transparent;
+ /* 2 */
+ background-image: none;
+ /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+ outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+ vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+ display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+ resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+ cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+ This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ /* 1 */
+ vertical-align: middle;
+ /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden] {
+ display: none;
+}
+
+*, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+::backdrop {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+.fixed {
+ position: fixed;
+}
+
+.relative {
+ position: relative;
+}
+
+.left-0 {
+ left: 0px;
+}
+
+.left-\[50\%\] {
+ left: 50%;
+}
+
+.top-0 {
+ top: 0px;
+}
+
+.top-10 {
+ top: 2.5rem;
+}
+
+.m-0 {
+ margin: 0px;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.my-12 {
+ margin-top: 3rem;
+ margin-bottom: 3rem;
+}
+
+.my-16 {
+ margin-top: 4rem;
+ margin-bottom: 4rem;
+}
+
+.my-4 {
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
+.my-8 {
+ margin-top: 2rem;
+ margin-bottom: 2rem;
+}
+
+.mb-1 {
+ margin-bottom: 0.25rem;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.mt-16 {
+ margin-top: 4rem;
+}
+
+.mt-2 {
+ margin-top: 0.5rem;
+}
+
+.block {
+ display: block;
+}
+
+.flex {
+ display: flex;
+}
+
+.grid {
+ display: grid;
+}
+
+.h-screen {
+ height: 100vh;
+}
+
+.w-96 {
+ width: 24rem;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.w-screen {
+ width: 100vw;
+}
+
+.max-w-2xl {
+ max-width: 42rem;
+}
+
+.max-w-5xl {
+ max-width: 64rem;
+}
+
+.max-w-lg {
+ max-width: 32rem;
+}
+
+.-translate-x-1\/2 {
+ --tw-translate-x: -50%;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+.grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-end {
+ justify-content: flex-end;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.gap-4 {
+ gap: 1rem;
+}
+
+.gap-6 {
+ gap: 1.5rem;
+}
+
+.gap-8 {
+ gap: 2rem;
+}
+
+.rounded-md {
+ border-radius: 0.375rem;
+}
+
+.rounded-sm {
+ border-radius: 0.125rem;
+}
+
+.rounded-l-sm {
+ border-top-left-radius: 0.125rem;
+ border-bottom-left-radius: 0.125rem;
+}
+
+.rounded-r-sm {
+ border-top-right-radius: 0.125rem;
+ border-bottom-right-radius: 0.125rem;
+}
+
+.border-2 {
+ border-width: 2px;
+}
+
+.border-blue-300 {
+ --tw-border-opacity: 1;
+ border-color: rgb(147 197 253 / var(--tw-border-opacity));
+}
+
+.border-blue-700 {
+ --tw-border-opacity: 1;
+ border-color: rgb(29 78 216 / var(--tw-border-opacity));
+}
+
+.bg-blue-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.bg-blue-600 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.bg-blue-700 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(29 78 216 / var(--tw-bg-opacity));
+}
+
+.bg-slate-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(226 232 240 / var(--tw-bg-opacity));
+}
+
+.bg-slate-300 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(203 213 225 / var(--tw-bg-opacity));
+}
+
+.bg-slate-400 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(148 163 184 / var(--tw-bg-opacity));
+}
+
+.bg-slate-800 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(30 41 59 / var(--tw-bg-opacity));
+}
+
+.bg-slate-900 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(15 23 42 / var(--tw-bg-opacity));
+}
+
+.bg-gradient-to-br {
+ background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
+}
+
+.from-slate-900 {
+ --tw-gradient-from: #0f172a;
+ --tw-gradient-to: rgb(15 23 42 / 0);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.to-slate-800 {
+ --tw-gradient-to: #1e293b;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.p-8 {
+ padding: 2rem;
+}
+
+.px-2 {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+
+.px-3 {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.px-5 {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+}
+
+.px-8 {
+ padding-left: 2rem;
+ padding-right: 2rem;
+}
+
+.py-1 {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+.py-3 {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+}
+
+.py-4 {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.font-mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+}
+
+.text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.font-bold {
+ font-weight: 700;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.text-blue-300 {
+ --tw-text-opacity: 1;
+ color: rgb(147 197 253 / var(--tw-text-opacity));
+}
+
+.text-blue-400 {
+ --tw-text-opacity: 1;
+ color: rgb(96 165 250 / var(--tw-text-opacity));
+}
+
+.text-blue-50 {
+ --tw-text-opacity: 1;
+ color: rgb(239 246 255 / var(--tw-text-opacity));
+}
+
+.text-pink-300 {
+ --tw-text-opacity: 1;
+ color: rgb(249 168 212 / var(--tw-text-opacity));
+}
+
+.text-slate-300 {
+ --tw-text-opacity: 1;
+ color: rgb(203 213 225 / var(--tw-text-opacity));
+}
+
+.text-slate-400 {
+ --tw-text-opacity: 1;
+ color: rgb(148 163 184 / var(--tw-text-opacity));
+}
+
+.text-slate-600 {
+ --tw-text-opacity: 1;
+ color: rgb(71 85 105 / var(--tw-text-opacity));
+}
+
+.text-slate-900 {
+ --tw-text-opacity: 1;
+ color: rgb(15 23 42 / var(--tw-text-opacity));
+}
+
+.text-white {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity));
+}
+
+.opacity-80 {
+ opacity: 0.8;
+}
+
+.hover\:border-blue-600:hover {
+ --tw-border-opacity: 1;
+ border-color: rgb(37 99 235 / var(--tw-border-opacity));
+}
+
+.hover\:bg-blue-300:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(147 197 253 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-400:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(96 165 250 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-500:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-600:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.hover\:text-blue-900:hover {
+ --tw-text-opacity: 1;
+ color: rgb(30 58 138 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-200:hover {
+ --tw-text-opacity: 1;
+ color: rgb(226 232 240 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-500:hover {
+ --tw-text-opacity: 1;
+ color: rgb(100 116 139 / var(--tw-text-opacity));
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/util/errors.js b/code/06 Network, Db, Auth/06 Login test/app/util/errors.js
new file mode 100644
index 0000000..4a37e53
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/util/errors.js
@@ -0,0 +1,11 @@
+export class BadRequestErrorResponse extends Response {
+ constructor(message, statusText = 'Bad request') {
+ super(JSON.stringify({ status: 400, message }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 400,
+ statusText: statusText,
+ });
+ }
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/app/util/validation.server.js b/code/06 Network, Db, Auth/06 Login test/app/util/validation.server.js
new file mode 100644
index 0000000..d7c407c
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/util/validation.server.js
@@ -0,0 +1,7 @@
+export function isValidEmail(email) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+}
+
+export function isValidPassword(password) {
+ return password.length >= 6;
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/06 Login test/app/util/wait.js b/code/06 Network, Db, Auth/06 Login test/app/util/wait.js
new file mode 100644
index 0000000..9f35c5b
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/app/util/wait.js
@@ -0,0 +1,5 @@
+export function wait(time) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, time);
+ });
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/cypress.config.js b/code/06 Network, Db, Auth/06 Login test/cypress.config.js
new file mode 100644
index 0000000..9623f3f
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/cypress.config.js
@@ -0,0 +1,18 @@
+import { defineConfig } from 'cypress';
+
+import { seed } from './prisma/seed-test';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:3000',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ on('task', {
+ async seedDatabase() {
+ await seed();
+ return null;
+ }
+ })
+ },
+ },
+});
diff --git a/code/06 Network, Db, Auth/06 Login test/cypress/e2e/auth.cy.js b/code/06 Network, Db, Auth/06 Login test/cypress/e2e/auth.cy.js
new file mode 100644
index 0000000..6a08661
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/cypress/e2e/auth.cy.js
@@ -0,0 +1,25 @@
+///
+
+describe('Auth', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should signup', () => {
+ cy.visit('/signup');
+ cy.get('[data-cy="auth-email"]').click();
+ cy.get('[data-cy="auth-email"]').type('test2@example.com');
+ cy.get('[data-cy="auth-password"]').type('testpassword');
+ cy.get('[data-cy="auth-submit"]').click();
+ cy.location('pathname').should('eq', '/takeaways');
+ cy.getCookie('__session').its('value').should('not.be.empty');
+ });
+ it('should login', () => {
+ cy.visit('/login');
+ cy.get('[data-cy="auth-email"]').click();
+ cy.get('[data-cy="auth-email"]').type('test@example.com');
+ cy.get('[data-cy="auth-password"]').type('testpassword');
+ cy.get('[data-cy="auth-submit"]').click();
+ cy.location('pathname').should('eq', '/takeaways');
+ cy.getCookie('__session').its('value').should('not.be.empty');
+ })
+});
diff --git a/code/06 Network, Db, Auth/06 Login test/cypress/e2e/newsletter.cy.js b/code/06 Network, Db, Auth/06 Login test/cypress/e2e/newsletter.cy.js
new file mode 100644
index 0000000..348176b
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/cypress/e2e/newsletter.cy.js
@@ -0,0 +1,33 @@
+describe('Newsletter', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a success message', () => {
+ cy.intercept('POST', '/newsletter*', { status: 201 }).as('subscribe'); // intercept any HTTP request localhost:3000/newsletter?anything
+ cy.visit('/');
+ cy.get('[data-cy="newsletter-email"]').type('test@example.com');
+ cy.get('[data-cy="newsletter-submit"]').click();
+ cy.wait('@subscribe');
+ cy.contains('Thanks for signing up');
+ });
+ it('should display validation errors', () => {
+ cy.intercept('POST', '/newsletter*', {
+ message: 'Email exists already.',
+ }).as('subscribe'); // intercept any HTTP request localhost:3000/newsletter?anything
+ cy.visit('/');
+ cy.get('[data-cy="newsletter-email"]').type('test@example.com');
+ cy.get('[data-cy="newsletter-submit"]').click();
+ cy.wait('@subscribe');
+ cy.contains('Email exists already.');
+ });
+ it('should successfully create a new contact', () => {
+ cy.request({
+ method: 'POST',
+ url: '/newsletter',
+ body: { email: 'test@example.com' },
+ form: true
+ }).then(res => {
+ expect(res.status).to.eq(201);
+ });
+ })
+});
diff --git a/code/06 Network, Db, Auth/06 Login test/cypress/e2e/takeaways.cy.js b/code/06 Network, Db, Auth/06 Login test/cypress/e2e/takeaways.cy.js
new file mode 100644
index 0000000..b0ef018
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/cypress/e2e/takeaways.cy.js
@@ -0,0 +1,11 @@
+///
+
+describe('Takeaways', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a list of fetched takeaways', () => {
+ cy.visit('/');
+ cy.get('[data-cy="takeaway-item"]').should('have.length', 2);
+ });
+});
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/06 Login test/cypress/support/commands.js b/code/06 Network, Db, Auth/06 Login test/cypress/support/commands.js
new file mode 100644
index 0000000..d342d6f
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/cypress/support/commands.js
@@ -0,0 +1,52 @@
+///
+// ***********************************************
+// This example commands.ts shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+//
+// declare global {
+// namespace Cypress {
+// interface Chainable {
+// login(email: string, password: string): Chainable
+// drag(subject: string, options?: Partial): Chainable
+// dismiss(subject: string, options?: Partial): Chainable
+// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable
+// }
+// }
+// }
+
+
+// the below code snippet is required to handle a React hydration bug that would cause tests to fail
+// it's only a workaround until this React behavior / bug is fixed
+Cypress.on('uncaught:exception', (err) => {
+ // we check if the error is
+ if (
+ err.message.includes('Minified React error #418;') ||
+ err.message.includes('Minified React error #423;') ||
+ err.message.includes('hydrating') ||
+ err.message.includes('Hydration')
+ ) {
+ return false;
+ }
+});
diff --git a/code/06 Network, Db, Auth/06 Login test/cypress/support/e2e.js b/code/06 Network, Db, Auth/06 Login test/cypress/support/e2e.js
new file mode 100644
index 0000000..f80f74f
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/06 Login test/package.json b/code/06 Network, Db, Auth/06 Login test/package.json
new file mode 100644
index 0000000..ceff0da
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/package.json
@@ -0,0 +1,42 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "init": "npm install && dotenv -e .env npx prisma db push && node prisma/seed.js",
+ "build": "npm run build:css && remix build",
+ "build:css": "tailwindcss -m -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "dev": "concurrently \"npm run dev:css\" \"dotenv -e .env remix dev\"",
+ "dev:css": "tailwindcss -w -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "start": "remix-serve build",
+ "typecheck": "tsc",
+ "test": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress run\"",
+ "test:open": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress open\""
+ },
+ "dependencies": {
+ "@prisma/client": "^4.3.1",
+ "@remix-run/node": "^1.13.0",
+ "@remix-run/react": "^1.13.0",
+ "@remix-run/serve": "^1.13.0",
+ "bcryptjs": "^2.4.3",
+ "dotenv-cli": "^7.0.0",
+ "isbot": "^3.6.5",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@remix-run/dev": "^1.13.0",
+ "@remix-run/eslint-config": "^1.13.0",
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.8",
+ "concurrently": "^7.6.0",
+ "cypress": "^12.5.1",
+ "eslint": "^8.27.0",
+ "eslint-plugin-cypress": "^2.12.1",
+ "prisma": "^4.3.1",
+ "tailwindcss": "^3.2.6",
+ "typescript": "^4.8.4"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/prisma/schema.prisma b/code/06 Network, Db, Auth/06 Login test/prisma/schema.prisma
new file mode 100644
index 0000000..65c584b
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/prisma/schema.prisma
@@ -0,0 +1,28 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ password String
+}
+
+model NewsletterSignup {
+ id Int @id @default(autoincrement())
+ email String @unique
+}
+
+model Takeaway {
+ id Int @id @default(autoincrement())
+ title String
+ body String
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/prisma/seed-test.js b/code/06 Network, Db, Auth/06 Login test/prisma/seed-test.js
new file mode 100644
index 0000000..92317c8
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/prisma/seed-test.js
@@ -0,0 +1,39 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+const { hash } = require('bcryptjs');
+
+const prisma = new PrismaClient();
+
+export async function seed() {
+ console.log('Seeding...');
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.user.create({
+ data: {
+ email: 'test@example.com',
+ password: await hash('testpassword', 12),
+ },
+ });
+ await prisma.newsletterSignup.create({
+ data: {
+ email: 'test2@example.com',
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/06 Login test/prisma/seed.js b/code/06 Network, Db, Auth/06 Login test/prisma/seed.js
new file mode 100644
index 0000000..af901a0
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/prisma/seed.js
@@ -0,0 +1,31 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+
+const prisma = new PrismaClient();
+
+async function main() {
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
+
+main().then(() => {
+ console.log('seeded database');
+ process.exit(0);
+});
diff --git a/code/06 Network, Db, Auth/06 Login test/public/favicon.ico b/code/06 Network, Db, Auth/06 Login test/public/favicon.ico
new file mode 100644
index 0000000..8830cf6
Binary files /dev/null and b/code/06 Network, Db, Auth/06 Login test/public/favicon.ico differ
diff --git a/code/06 Network, Db, Auth/06 Login test/remix.config.js b/code/06 Network, Db, Auth/06 Login test/remix.config.js
new file mode 100644
index 0000000..adf2a0b
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/remix.config.js
@@ -0,0 +1,8 @@
+/** @type {import('@remix-run/dev').AppConfig} */
+module.exports = {
+ ignoredRouteFiles: ["**/.*"],
+ // appDirectory: "app",
+ // assetsBuildDirectory: "public/build",
+ // serverBuildPath: "build/index.js",
+ // publicPath: "/build/",
+};
diff --git a/code/06 Network, Db, Auth/06 Login test/styles/tailwind.css b/code/06 Network, Db, Auth/06 Login test/styles/tailwind.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/styles/tailwind.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/code/06 Network, Db, Auth/06 Login test/tailwind.config.js b/code/06 Network, Db, Auth/06 Login test/tailwind.config.js
new file mode 100644
index 0000000..c7c50e0
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/tailwind.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/06 Login test/tsconfig.json b/code/06 Network, Db, Auth/06 Login test/tsconfig.json
new file mode 100644
index 0000000..28951d6
--- /dev/null
+++ b/code/06 Network, Db, Auth/06 Login test/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "cypress.config.js", "cypress/e2e/newsletter.cy.js"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "target": "ES2019",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true
+ }
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/.env b/code/06 Network, Db, Auth/07 Reusable Login Command/.env
new file mode 100644
index 0000000..3e05cc4
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/.env
@@ -0,0 +1,8 @@
+# Environment variables declared in this file are automatically made available to Prisma.
+# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
+
+# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
+# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
+
+DATABASE_URL="file:./demo.db"
+SESSION_SECRET="supersecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/.env.test b/code/06 Network, Db, Auth/07 Reusable Login Command/.env.test
new file mode 100644
index 0000000..53e955f
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/.env.test
@@ -0,0 +1,2 @@
+DATABASE_URL="file:./test.db"
+SESSION_SECRET="testsecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/.eslintrc.js b/code/06 Network, Db, Auth/07 Reusable Login Command/.eslintrc.js
new file mode 100644
index 0000000..2216dd2
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/.eslintrc.js
@@ -0,0 +1,4 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node", "plugin:cypress/recommended"],
+};
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/Auth.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/Auth.jsx
new file mode 100644
index 0000000..a80b441
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/Auth.jsx
@@ -0,0 +1,66 @@
+import { Form, Link, useActionData } from '@remix-run/react';
+
+function Auth({ mode }) {
+ const validationData = useActionData();
+
+ return (
+
+
+
+ Email
+
+
+
+
+
+ Password
+
+
+
+ {validationData && {validationData.statusText}
}
+
+
+ {mode === 'login'
+ ? 'Create a new account'
+ : 'Log in with existing account'}
+
+
+ {mode === 'login' ? 'Login' : 'Create Account'}
+
+
+
+ );
+}
+
+export default Auth;
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/Layout.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/Layout.jsx
new file mode 100644
index 0000000..306b211
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/Layout.jsx
@@ -0,0 +1,51 @@
+import { Form, Link } from '@remix-run/react';
+import NewsletterSignup from './NewsletterSignup';
+
+function Layout({ isLoggedIn, children }) {
+ return (
+ <>
+
+
+ LearnCypress
+
+
+
+
+
+ Takeaways
+
+
+ {!isLoggedIn && (
+
+
+ Login
+
+
+ )}
+ {isLoggedIn && (
+
+
+
+ Logout
+
+
+
+ )}
+
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Layout;
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/Modal.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/Modal.jsx
new file mode 100644
index 0000000..1401476
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/Modal.jsx
@@ -0,0 +1,18 @@
+function Modal({ onClose, children }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/NewsletterSignup.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/NewsletterSignup.jsx
new file mode 100644
index 0000000..468f2c6
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/NewsletterSignup.jsx
@@ -0,0 +1,50 @@
+import { useFetcher } from '@remix-run/react';
+
+function NewsletterSignup() {
+ const fetcher = useFetcher();
+
+ const isSubmitting = fetcher.state === 'submitting';
+ let result;
+
+ if (fetcher.data && fetcher.data.status !== 201) {
+ result = 'error';
+ }
+
+ if (fetcher.data && fetcher.data.status === 201) {
+ result = 'success';
+ }
+
+ return (
+
+ {result !== 'success' && (
+
+
+
+
+ {isSubmitting ? : 'Sign up'}
+
+
+ {result === 'error' && (
+
+ {fetcher.data.message || 'Something went wrong'}
+
+ )}
+
+ )}
+ {result === 'success' &&
Thanks for signing up!
}
+
+ );
+}
+
+export default NewsletterSignup;
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/Takeaways.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/Takeaways.jsx
new file mode 100644
index 0000000..19cbcd6
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/components/Takeaways.jsx
@@ -0,0 +1,16 @@
+function Takeaways({ items }) {
+ return (
+
+ {items.map((item) => (
+
+
+ {item.title}
+ {item.body}
+
+
+ ))}
+
+ );
+}
+
+export default Takeaways;
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/data/auth.server.js b/code/06 Network, Db, Auth/07 Reusable Login Command/app/data/auth.server.js
new file mode 100644
index 0000000..6ca6148
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/data/auth.server.js
@@ -0,0 +1,93 @@
+import { hash, compare } from 'bcryptjs';
+import { createCookieSessionStorage, json, redirect } from '@remix-run/node';
+
+import { prisma } from './prisma.server';
+
+const SESSION_SECRET = process.env.SESSION_SECRET;
+
+const sessionStorage = createCookieSessionStorage({
+ cookie: {
+ secure: process.env.NODE_ENV === 'production',
+ secrets: [SESSION_SECRET],
+ sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60, // 30 days
+ httpOnly: true,
+ },
+});
+
+async function createUserSession(userId, redirectPath) {
+ const session = await sessionStorage.getSession();
+ session.set('userId', userId);
+ return redirect(redirectPath, {
+ headers: {
+ 'Set-Cookie': await sessionStorage.commitSession(session),
+ },
+ });
+}
+
+export async function getUserFromSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ const userId = session.get('userId');
+
+ if (!userId) {
+ return null;
+ }
+
+ return userId;
+}
+
+export async function destroyUserSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ return redirect('/', {
+ headers: {
+ 'Set-Cookie': await sessionStorage.destroySession(session),
+ },
+ });
+}
+
+export async function requireUserSession(request) {
+ const userId = await getUserFromSession(request);
+
+ if (!userId) {
+ throw redirect('/login');
+ }
+
+ return userId;
+}
+
+export async function signup({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (existingUser) {
+ return json({ status: 409, statusText: 'User exists already.' });
+ }
+
+ const passwordHash = await hash(password, 12);
+
+ const user = await prisma.user.create({
+ data: { email: email, password: passwordHash },
+ });
+ return createUserSession(user.id, '/takeaways');
+}
+
+export async function login({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (!existingUser) {
+ return json({ status: 400, statusText: 'Invalid credentials.' });
+ }
+
+ const passwordCorrect = await compare(password, existingUser.password);
+
+ if (!passwordCorrect) {
+ return json({ status: 400, statusText: 'Invalid credentials (pw).' });
+ }
+
+ return createUserSession(existingUser.id, '/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/data/newsletter.server.js b/code/06 Network, Db, Auth/07 Reusable Login Command/app/data/newsletter.server.js
new file mode 100644
index 0000000..f1822ea
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/data/newsletter.server.js
@@ -0,0 +1,27 @@
+import { isValidEmail } from '../util/validation.server';
+import { wait } from '../util/wait';
+import { prisma } from './prisma.server';
+
+export async function addNewsletterContact(email) {
+ if (!isValidEmail(email)) {
+ throw new Error('Invalid email address.');
+ }
+
+ const existingContact = await prisma.newsletterSignup.findUnique({
+ where: {
+ email,
+ },
+ });
+ await wait(2000);
+
+ if (existingContact) {
+ throw new Error('This email is already subscribed.');
+ }
+
+
+ await prisma.newsletterSignup.create({
+ data: {
+ email,
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/data/prisma.server.js b/code/06 Network, Db, Auth/07 Reusable Login Command/app/data/prisma.server.js
new file mode 100644
index 0000000..cf1eaa4
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/data/prisma.server.js
@@ -0,0 +1,19 @@
+import { PrismaClient } from '@prisma/client';
+
+/**
+ * @type PrismaClient
+ */
+let prisma;
+
+if (process.env.NODE_ENV === 'production') {
+ prisma = new PrismaClient();
+ prisma.$connect();
+} else {
+ if (!global.__db) {
+ global.__db = new PrismaClient();
+ global.__db.$connect();
+ }
+ prisma = global.__db;
+}
+
+export { prisma };
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/entry.client.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/entry.client.jsx
new file mode 100644
index 0000000..8338545
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/entry.client.jsx
@@ -0,0 +1,22 @@
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/entry.server.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/entry.server.jsx
new file mode 100644
index 0000000..8e65b75
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/entry.server.jsx
@@ -0,0 +1,111 @@
+import { PassThrough } from "stream";
+
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
+
+export default function handleRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return isbot(request.headers.get("user-agent"))
+ ? handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ )
+ : handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ );
+}
+
+function handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error) {
+ reject(error);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(err) {
+ reject(err);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/root.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/root.jsx
new file mode 100644
index 0000000..98ee375
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/root.jsx
@@ -0,0 +1,52 @@
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useLoaderData,
+} from '@remix-run/react';
+
+import Layout from './components/Layout';
+import { getUserFromSession } from './data/auth.server';
+import mainStyles from './styles/main.css';
+import tailwindStyles from './styles/tailwind.css';
+
+export const meta = () => ({
+ charset: 'utf-8',
+ title: 'Cypress Requests',
+ viewport: 'width=device-width,initial-scale=1',
+});
+
+export const links = () => [
+ { rel: 'stylesheet', href: tailwindStyles },
+ { rel: 'stylesheet', href: mainStyles },
+ { rel: 'icon', href: '/favicon.ico' },
+];
+
+export default function App() {
+ const isLoggedIn = useLoaderData();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export async function loader({ request }) {
+ const userId = await getUserFromSession(request);
+ return !!userId;
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/index.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/index.jsx
new file mode 100644
index 0000000..f5da528
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/index.jsx
@@ -0,0 +1,31 @@
+import { Link, useLoaderData } from '@remix-run/react';
+import Takeaways from '../components/Takeaways';
+import { prisma } from '../data/prisma.server';
+
+export default function Index() {
+ const takeways = useLoaderData();
+
+ return (
+ <>
+
+ Learn Cypress
+ Cypress is an amazing end-to-end testing software and framework.
+
+ Manage your key Cypress takeaways and concepts with our learning app.
+
+
+
+
+
+ + Add a new takeaway
+
+
+ >
+ );
+}
+
+export function loader() {
+ return prisma.takeaway.findMany({ take: 2 });
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/login.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/login.jsx
new file mode 100644
index 0000000..1ce4ea9
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/login.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { login } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function LoginRoute() {
+ return ;
+}
+
+export default LoginRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return login(credentials);
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/logout.js b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/logout.js
new file mode 100644
index 0000000..16ba683
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/logout.js
@@ -0,0 +1,10 @@
+import { destroyUserSession } from '~/data/auth.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export function action({ request }) {
+ if (request.method !== 'POST') {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ return destroyUserSession(request);
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/newsletter.js b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/newsletter.js
new file mode 100644
index 0000000..74444ab
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/newsletter.js
@@ -0,0 +1,35 @@
+import { json } from '@remix-run/node';
+import { addNewsletterContact } from '../data/newsletter.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export async function action({ request }) {
+ if (request.method !== 'POST') {
+ return new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ const body = await request.formData();
+ const email = body.get('email');
+
+ try {
+ await addNewsletterContact(email);
+ } catch (error) {
+ return json(
+ { message: error.message },
+ {
+ status: 400,
+ statusText: 'Failed to create contact',
+ }
+ );
+ }
+ return json(
+ { status: 201 }, // this is required because useFetcher does not expose the response object
+ {
+ status: 201,
+ statusText: 'Added newsletter contact.',
+ }
+ );
+}
+
+export function loader() {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/signup.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/signup.jsx
new file mode 100644
index 0000000..823ab31
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/signup.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { signup } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function SignupRoute() {
+ return ;
+}
+
+export default SignupRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return signup(credentials);
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/takeaways.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/takeaways.jsx
new file mode 100644
index 0000000..be2fadb
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/takeaways.jsx
@@ -0,0 +1,36 @@
+import { Link, Outlet, useLoaderData } from '@remix-run/react';
+
+import Takeaways from '../components/Takeaways';
+import { requireUserSession } from '../data/auth.server';
+import { prisma } from '../data/prisma.server';
+
+function TakewaysLayoutRoute() {
+ const takeaways = useLoaderData();
+
+ return (
+ <>
+
+
+ Your key takeaways
+
+
+
+ + Add a new takeaway
+
+
+ {takeaways.length === 0 && You have no key takeaways yet!
}
+
+ >
+ );
+}
+
+export default TakewaysLayoutRoute;
+
+export async function loader({ request }) {
+ await requireUserSession(request);
+
+ return prisma.takeaway.findMany();
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/takeaways/new.jsx b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/takeaways/new.jsx
new file mode 100644
index 0000000..0b6868d
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/routes/takeaways/new.jsx
@@ -0,0 +1,87 @@
+import { json, redirect } from '@remix-run/node';
+import { Form, Link, useNavigate } from '@remix-run/react';
+
+import Modal from '../../components/Modal';
+import { requireUserSession } from '../../data/auth.server';
+import { prisma } from '../../data/prisma.server';
+
+function NewTakewayRoute() {
+ const navigate = useNavigate();
+
+ return (
+ navigate('..', { relative: 'path' })}>
+
+
+
+ Title
+
+
+
+
+
+ Body
+
+
+
+
+
+ Cancel
+
+
+ Create
+
+
+
+
+ );
+}
+
+export default NewTakewayRoute;
+
+export function loader({ request }) {
+ return requireUserSession(request);
+}
+
+export async function action({ request }) {
+ const fd = await request.formData();
+ const title = fd.get('title');
+ const body = fd.get('body');
+
+ if (!title || !body) {
+ return json({ message: 'Title and body are required.' }, { status: 400 });
+ }
+
+ await prisma.takeaway.create({
+ data: {
+ title,
+ body,
+ },
+ });
+
+ return redirect('/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/styles/main.css b/code/06 Network, Db, Auth/07 Reusable Login Command/app/styles/main.css
new file mode 100644
index 0000000..9ec8050
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/styles/main.css
@@ -0,0 +1,19 @@
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/styles/tailwind.css b/code/06 Network, Db, Auth/07 Reusable Login Command/app/styles/tailwind.css
new file mode 100644
index 0000000..d433f58
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/styles/tailwind.css
@@ -0,0 +1,919 @@
+/*
+! tailwindcss v3.2.6 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ /* 1 */
+ border-width: 0;
+ /* 2 */
+ border-style: solid;
+ /* 2 */
+ border-color: #e5e7eb;
+ /* 2 */
+}
+
+::before,
+::after {
+ --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+*/
+
+html {
+ line-height: 1.5;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+ -moz-tab-size: 4;
+ /* 3 */
+ -o-tab-size: 4;
+ tab-size: 4;
+ /* 3 */
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ /* 4 */
+ font-feature-settings: normal;
+ /* 5 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+ margin: 0;
+ /* 1 */
+ line-height: inherit;
+ /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+ height: 0;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ border-top-width: 1px;
+ /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font family by default.
+2. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ /* 1 */
+ font-size: 1em;
+ /* 2 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+ font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+ text-indent: 0;
+ /* 1 */
+ border-color: inherit;
+ /* 2 */
+ border-collapse: collapse;
+ /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ font-weight: inherit;
+ /* 1 */
+ line-height: inherit;
+ /* 1 */
+ color: inherit;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+ padding: 0;
+ /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+ text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+ -webkit-appearance: button;
+ /* 1 */
+ background-color: transparent;
+ /* 2 */
+ background-image: none;
+ /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+ outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+ vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+ display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+ resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+ cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+ This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ /* 1 */
+ vertical-align: middle;
+ /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden] {
+ display: none;
+}
+
+*, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+::backdrop {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+.fixed {
+ position: fixed;
+}
+
+.relative {
+ position: relative;
+}
+
+.left-0 {
+ left: 0px;
+}
+
+.left-\[50\%\] {
+ left: 50%;
+}
+
+.top-0 {
+ top: 0px;
+}
+
+.top-10 {
+ top: 2.5rem;
+}
+
+.m-0 {
+ margin: 0px;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.my-12 {
+ margin-top: 3rem;
+ margin-bottom: 3rem;
+}
+
+.my-16 {
+ margin-top: 4rem;
+ margin-bottom: 4rem;
+}
+
+.my-4 {
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
+.my-8 {
+ margin-top: 2rem;
+ margin-bottom: 2rem;
+}
+
+.mb-1 {
+ margin-bottom: 0.25rem;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.mt-16 {
+ margin-top: 4rem;
+}
+
+.mt-2 {
+ margin-top: 0.5rem;
+}
+
+.block {
+ display: block;
+}
+
+.flex {
+ display: flex;
+}
+
+.grid {
+ display: grid;
+}
+
+.h-screen {
+ height: 100vh;
+}
+
+.w-96 {
+ width: 24rem;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.w-screen {
+ width: 100vw;
+}
+
+.max-w-2xl {
+ max-width: 42rem;
+}
+
+.max-w-5xl {
+ max-width: 64rem;
+}
+
+.max-w-lg {
+ max-width: 32rem;
+}
+
+.-translate-x-1\/2 {
+ --tw-translate-x: -50%;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+.grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-end {
+ justify-content: flex-end;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.gap-4 {
+ gap: 1rem;
+}
+
+.gap-6 {
+ gap: 1.5rem;
+}
+
+.gap-8 {
+ gap: 2rem;
+}
+
+.rounded-md {
+ border-radius: 0.375rem;
+}
+
+.rounded-sm {
+ border-radius: 0.125rem;
+}
+
+.rounded-l-sm {
+ border-top-left-radius: 0.125rem;
+ border-bottom-left-radius: 0.125rem;
+}
+
+.rounded-r-sm {
+ border-top-right-radius: 0.125rem;
+ border-bottom-right-radius: 0.125rem;
+}
+
+.border-2 {
+ border-width: 2px;
+}
+
+.border-blue-300 {
+ --tw-border-opacity: 1;
+ border-color: rgb(147 197 253 / var(--tw-border-opacity));
+}
+
+.border-blue-700 {
+ --tw-border-opacity: 1;
+ border-color: rgb(29 78 216 / var(--tw-border-opacity));
+}
+
+.bg-blue-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.bg-blue-600 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.bg-blue-700 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(29 78 216 / var(--tw-bg-opacity));
+}
+
+.bg-slate-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(226 232 240 / var(--tw-bg-opacity));
+}
+
+.bg-slate-300 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(203 213 225 / var(--tw-bg-opacity));
+}
+
+.bg-slate-400 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(148 163 184 / var(--tw-bg-opacity));
+}
+
+.bg-slate-800 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(30 41 59 / var(--tw-bg-opacity));
+}
+
+.bg-slate-900 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(15 23 42 / var(--tw-bg-opacity));
+}
+
+.bg-gradient-to-br {
+ background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
+}
+
+.from-slate-900 {
+ --tw-gradient-from: #0f172a;
+ --tw-gradient-to: rgb(15 23 42 / 0);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.to-slate-800 {
+ --tw-gradient-to: #1e293b;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.p-8 {
+ padding: 2rem;
+}
+
+.px-2 {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+
+.px-3 {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.px-5 {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+}
+
+.px-8 {
+ padding-left: 2rem;
+ padding-right: 2rem;
+}
+
+.py-1 {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+.py-3 {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+}
+
+.py-4 {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.font-mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+}
+
+.text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.font-bold {
+ font-weight: 700;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.text-blue-300 {
+ --tw-text-opacity: 1;
+ color: rgb(147 197 253 / var(--tw-text-opacity));
+}
+
+.text-blue-400 {
+ --tw-text-opacity: 1;
+ color: rgb(96 165 250 / var(--tw-text-opacity));
+}
+
+.text-blue-50 {
+ --tw-text-opacity: 1;
+ color: rgb(239 246 255 / var(--tw-text-opacity));
+}
+
+.text-pink-300 {
+ --tw-text-opacity: 1;
+ color: rgb(249 168 212 / var(--tw-text-opacity));
+}
+
+.text-slate-300 {
+ --tw-text-opacity: 1;
+ color: rgb(203 213 225 / var(--tw-text-opacity));
+}
+
+.text-slate-400 {
+ --tw-text-opacity: 1;
+ color: rgb(148 163 184 / var(--tw-text-opacity));
+}
+
+.text-slate-600 {
+ --tw-text-opacity: 1;
+ color: rgb(71 85 105 / var(--tw-text-opacity));
+}
+
+.text-slate-900 {
+ --tw-text-opacity: 1;
+ color: rgb(15 23 42 / var(--tw-text-opacity));
+}
+
+.text-white {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity));
+}
+
+.opacity-80 {
+ opacity: 0.8;
+}
+
+.hover\:border-blue-600:hover {
+ --tw-border-opacity: 1;
+ border-color: rgb(37 99 235 / var(--tw-border-opacity));
+}
+
+.hover\:bg-blue-300:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(147 197 253 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-400:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(96 165 250 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-500:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-600:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.hover\:text-blue-900:hover {
+ --tw-text-opacity: 1;
+ color: rgb(30 58 138 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-200:hover {
+ --tw-text-opacity: 1;
+ color: rgb(226 232 240 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-500:hover {
+ --tw-text-opacity: 1;
+ color: rgb(100 116 139 / var(--tw-text-opacity));
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/util/errors.js b/code/06 Network, Db, Auth/07 Reusable Login Command/app/util/errors.js
new file mode 100644
index 0000000..4a37e53
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/util/errors.js
@@ -0,0 +1,11 @@
+export class BadRequestErrorResponse extends Response {
+ constructor(message, statusText = 'Bad request') {
+ super(JSON.stringify({ status: 400, message }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 400,
+ statusText: statusText,
+ });
+ }
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/util/validation.server.js b/code/06 Network, Db, Auth/07 Reusable Login Command/app/util/validation.server.js
new file mode 100644
index 0000000..d7c407c
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/util/validation.server.js
@@ -0,0 +1,7 @@
+export function isValidEmail(email) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+}
+
+export function isValidPassword(password) {
+ return password.length >= 6;
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/app/util/wait.js b/code/06 Network, Db, Auth/07 Reusable Login Command/app/util/wait.js
new file mode 100644
index 0000000..9f35c5b
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/app/util/wait.js
@@ -0,0 +1,5 @@
+export function wait(time) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, time);
+ });
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/cypress.config.js b/code/06 Network, Db, Auth/07 Reusable Login Command/cypress.config.js
new file mode 100644
index 0000000..9623f3f
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/cypress.config.js
@@ -0,0 +1,18 @@
+import { defineConfig } from 'cypress';
+
+import { seed } from './prisma/seed-test';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:3000',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ on('task', {
+ async seedDatabase() {
+ await seed();
+ return null;
+ }
+ })
+ },
+ },
+});
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/e2e/auth.cy.js b/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/e2e/auth.cy.js
new file mode 100644
index 0000000..8dcbf37
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/e2e/auth.cy.js
@@ -0,0 +1,32 @@
+///
+
+describe('Auth', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should signup', () => {
+ cy.visit('/signup');
+ cy.get('[data-cy="auth-email"]').click();
+ cy.get('[data-cy="auth-email"]').type('test2@example.com');
+ cy.get('[data-cy="auth-password"]').type('testpassword');
+ cy.get('[data-cy="auth-submit"]').click();
+ cy.location('pathname').should('eq', '/takeaways');
+ cy.getCookie('__session').its('value').should('not.be.empty');
+ });
+ it('should login', () => {
+ cy.visit('/login');
+ cy.get('[data-cy="auth-email"]').click();
+ cy.get('[data-cy="auth-email"]').type('test@example.com');
+ cy.get('[data-cy="auth-password"]').type('testpassword');
+ cy.get('[data-cy="auth-submit"]').click();
+ cy.location('pathname').should('eq', '/takeaways');
+ cy.getCookie('__session').its('value').should('not.be.empty');
+ })
+ it('should logout', () => {
+ cy.login();
+
+ cy.contains('Logout').click();
+ cy.location('pathname').should('eq', '/');
+ cy.getCookie('__session').its('value').should('be.empty');
+ });
+});
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/e2e/newsletter.cy.js b/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/e2e/newsletter.cy.js
new file mode 100644
index 0000000..348176b
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/e2e/newsletter.cy.js
@@ -0,0 +1,33 @@
+describe('Newsletter', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a success message', () => {
+ cy.intercept('POST', '/newsletter*', { status: 201 }).as('subscribe'); // intercept any HTTP request localhost:3000/newsletter?anything
+ cy.visit('/');
+ cy.get('[data-cy="newsletter-email"]').type('test@example.com');
+ cy.get('[data-cy="newsletter-submit"]').click();
+ cy.wait('@subscribe');
+ cy.contains('Thanks for signing up');
+ });
+ it('should display validation errors', () => {
+ cy.intercept('POST', '/newsletter*', {
+ message: 'Email exists already.',
+ }).as('subscribe'); // intercept any HTTP request localhost:3000/newsletter?anything
+ cy.visit('/');
+ cy.get('[data-cy="newsletter-email"]').type('test@example.com');
+ cy.get('[data-cy="newsletter-submit"]').click();
+ cy.wait('@subscribe');
+ cy.contains('Email exists already.');
+ });
+ it('should successfully create a new contact', () => {
+ cy.request({
+ method: 'POST',
+ url: '/newsletter',
+ body: { email: 'test@example.com' },
+ form: true
+ }).then(res => {
+ expect(res.status).to.eq(201);
+ });
+ })
+});
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/e2e/takeaways.cy.js b/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/e2e/takeaways.cy.js
new file mode 100644
index 0000000..b0ef018
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/e2e/takeaways.cy.js
@@ -0,0 +1,11 @@
+///
+
+describe('Takeaways', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a list of fetched takeaways', () => {
+ cy.visit('/');
+ cy.get('[data-cy="takeaway-item"]').should('have.length', 2);
+ });
+});
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/support/commands.js b/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/support/commands.js
new file mode 100644
index 0000000..a35aac0
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/support/commands.js
@@ -0,0 +1,61 @@
+///
+// ***********************************************
+// This example commands.ts shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+//
+// declare global {
+// namespace Cypress {
+// interface Chainable {
+// login(email: string, password: string): Chainable
+// drag(subject: string, options?: Partial): Chainable
+// dismiss(subject: string, options?: Partial): Chainable
+// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable
+// }
+// }
+// }
+
+Cypress.Commands.add('login', () => {
+ cy.visit('/login');
+ cy.get('[data-cy="auth-email"]').click();
+ cy.get('[data-cy="auth-email"]').type('test@example.com');
+ cy.get('[data-cy="auth-password"]').type('testpassword');
+ cy.get('[data-cy="auth-submit"]').click();
+ cy.location('pathname').should('eq', '/takeaways');
+ cy.getCookie('__session').its('value').should('not.be.empty');
+});
+
+// the below code snippet is required to handle a React hydration bug that would cause tests to fail
+// it's only a workaround until this React behavior / bug is fixed
+Cypress.on('uncaught:exception', (err) => {
+ // we check if the error is
+ if (
+ err.message.includes('Minified React error #418;') ||
+ err.message.includes('Minified React error #423;') ||
+ err.message.includes('hydrating') ||
+ err.message.includes('Hydration')
+ ) {
+ return false;
+ }
+});
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/support/e2e.js b/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/support/e2e.js
new file mode 100644
index 0000000..f80f74f
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/package.json b/code/06 Network, Db, Auth/07 Reusable Login Command/package.json
new file mode 100644
index 0000000..ceff0da
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/package.json
@@ -0,0 +1,42 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "init": "npm install && dotenv -e .env npx prisma db push && node prisma/seed.js",
+ "build": "npm run build:css && remix build",
+ "build:css": "tailwindcss -m -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "dev": "concurrently \"npm run dev:css\" \"dotenv -e .env remix dev\"",
+ "dev:css": "tailwindcss -w -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "start": "remix-serve build",
+ "typecheck": "tsc",
+ "test": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress run\"",
+ "test:open": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress open\""
+ },
+ "dependencies": {
+ "@prisma/client": "^4.3.1",
+ "@remix-run/node": "^1.13.0",
+ "@remix-run/react": "^1.13.0",
+ "@remix-run/serve": "^1.13.0",
+ "bcryptjs": "^2.4.3",
+ "dotenv-cli": "^7.0.0",
+ "isbot": "^3.6.5",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@remix-run/dev": "^1.13.0",
+ "@remix-run/eslint-config": "^1.13.0",
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.8",
+ "concurrently": "^7.6.0",
+ "cypress": "^12.5.1",
+ "eslint": "^8.27.0",
+ "eslint-plugin-cypress": "^2.12.1",
+ "prisma": "^4.3.1",
+ "tailwindcss": "^3.2.6",
+ "typescript": "^4.8.4"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/prisma/schema.prisma b/code/06 Network, Db, Auth/07 Reusable Login Command/prisma/schema.prisma
new file mode 100644
index 0000000..65c584b
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/prisma/schema.prisma
@@ -0,0 +1,28 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ password String
+}
+
+model NewsletterSignup {
+ id Int @id @default(autoincrement())
+ email String @unique
+}
+
+model Takeaway {
+ id Int @id @default(autoincrement())
+ title String
+ body String
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/prisma/seed-test.js b/code/06 Network, Db, Auth/07 Reusable Login Command/prisma/seed-test.js
new file mode 100644
index 0000000..92317c8
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/prisma/seed-test.js
@@ -0,0 +1,39 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+const { hash } = require('bcryptjs');
+
+const prisma = new PrismaClient();
+
+export async function seed() {
+ console.log('Seeding...');
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.user.create({
+ data: {
+ email: 'test@example.com',
+ password: await hash('testpassword', 12),
+ },
+ });
+ await prisma.newsletterSignup.create({
+ data: {
+ email: 'test2@example.com',
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/prisma/seed.js b/code/06 Network, Db, Auth/07 Reusable Login Command/prisma/seed.js
new file mode 100644
index 0000000..af901a0
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/prisma/seed.js
@@ -0,0 +1,31 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+
+const prisma = new PrismaClient();
+
+async function main() {
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
+
+main().then(() => {
+ console.log('seeded database');
+ process.exit(0);
+});
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/public/favicon.ico b/code/06 Network, Db, Auth/07 Reusable Login Command/public/favicon.ico
new file mode 100644
index 0000000..8830cf6
Binary files /dev/null and b/code/06 Network, Db, Auth/07 Reusable Login Command/public/favicon.ico differ
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/remix.config.js b/code/06 Network, Db, Auth/07 Reusable Login Command/remix.config.js
new file mode 100644
index 0000000..adf2a0b
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/remix.config.js
@@ -0,0 +1,8 @@
+/** @type {import('@remix-run/dev').AppConfig} */
+module.exports = {
+ ignoredRouteFiles: ["**/.*"],
+ // appDirectory: "app",
+ // assetsBuildDirectory: "public/build",
+ // serverBuildPath: "build/index.js",
+ // publicPath: "/build/",
+};
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/styles/tailwind.css b/code/06 Network, Db, Auth/07 Reusable Login Command/styles/tailwind.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/styles/tailwind.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/tailwind.config.js b/code/06 Network, Db, Auth/07 Reusable Login Command/tailwind.config.js
new file mode 100644
index 0000000..c7c50e0
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/tailwind.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/07 Reusable Login Command/tsconfig.json b/code/06 Network, Db, Auth/07 Reusable Login Command/tsconfig.json
new file mode 100644
index 0000000..28951d6
--- /dev/null
+++ b/code/06 Network, Db, Auth/07 Reusable Login Command/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "cypress.config.js", "cypress/e2e/newsletter.cy.js"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "target": "ES2019",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true
+ }
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/.env b/code/06 Network, Db, Auth/08 Finished/.env
new file mode 100644
index 0000000..3e05cc4
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/.env
@@ -0,0 +1,8 @@
+# Environment variables declared in this file are automatically made available to Prisma.
+# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
+
+# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
+# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
+
+DATABASE_URL="file:./demo.db"
+SESSION_SECRET="supersecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/08 Finished/.env.test b/code/06 Network, Db, Auth/08 Finished/.env.test
new file mode 100644
index 0000000..53e955f
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/.env.test
@@ -0,0 +1,2 @@
+DATABASE_URL="file:./test.db"
+SESSION_SECRET="testsecure"
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/08 Finished/.eslintrc.js b/code/06 Network, Db, Auth/08 Finished/.eslintrc.js
new file mode 100644
index 0000000..2216dd2
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/.eslintrc.js
@@ -0,0 +1,4 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node", "plugin:cypress/recommended"],
+};
diff --git a/code/06 Network, Db, Auth/08 Finished/app/components/Auth.jsx b/code/06 Network, Db, Auth/08 Finished/app/components/Auth.jsx
new file mode 100644
index 0000000..a80b441
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/components/Auth.jsx
@@ -0,0 +1,66 @@
+import { Form, Link, useActionData } from '@remix-run/react';
+
+function Auth({ mode }) {
+ const validationData = useActionData();
+
+ return (
+
+
+
+ Email
+
+
+
+
+
+ Password
+
+
+
+ {validationData && {validationData.statusText}
}
+
+
+ {mode === 'login'
+ ? 'Create a new account'
+ : 'Log in with existing account'}
+
+
+ {mode === 'login' ? 'Login' : 'Create Account'}
+
+
+
+ );
+}
+
+export default Auth;
diff --git a/code/06 Network, Db, Auth/08 Finished/app/components/Layout.jsx b/code/06 Network, Db, Auth/08 Finished/app/components/Layout.jsx
new file mode 100644
index 0000000..306b211
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/components/Layout.jsx
@@ -0,0 +1,51 @@
+import { Form, Link } from '@remix-run/react';
+import NewsletterSignup from './NewsletterSignup';
+
+function Layout({ isLoggedIn, children }) {
+ return (
+ <>
+
+
+ LearnCypress
+
+
+
+
+
+ Takeaways
+
+
+ {!isLoggedIn && (
+
+
+ Login
+
+
+ )}
+ {isLoggedIn && (
+
+
+
+ Logout
+
+
+
+ )}
+
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Layout;
diff --git a/code/06 Network, Db, Auth/08 Finished/app/components/Modal.jsx b/code/06 Network, Db, Auth/08 Finished/app/components/Modal.jsx
new file mode 100644
index 0000000..1401476
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/components/Modal.jsx
@@ -0,0 +1,18 @@
+function Modal({ onClose, children }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+export default Modal;
diff --git a/code/06 Network, Db, Auth/08 Finished/app/components/NewsletterSignup.jsx b/code/06 Network, Db, Auth/08 Finished/app/components/NewsletterSignup.jsx
new file mode 100644
index 0000000..468f2c6
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/components/NewsletterSignup.jsx
@@ -0,0 +1,50 @@
+import { useFetcher } from '@remix-run/react';
+
+function NewsletterSignup() {
+ const fetcher = useFetcher();
+
+ const isSubmitting = fetcher.state === 'submitting';
+ let result;
+
+ if (fetcher.data && fetcher.data.status !== 201) {
+ result = 'error';
+ }
+
+ if (fetcher.data && fetcher.data.status === 201) {
+ result = 'success';
+ }
+
+ return (
+
+ {result !== 'success' && (
+
+
+
+
+ {isSubmitting ? : 'Sign up'}
+
+
+ {result === 'error' && (
+
+ {fetcher.data.message || 'Something went wrong'}
+
+ )}
+
+ )}
+ {result === 'success' &&
Thanks for signing up!
}
+
+ );
+}
+
+export default NewsletterSignup;
diff --git a/code/06 Network, Db, Auth/08 Finished/app/components/Takeaways.jsx b/code/06 Network, Db, Auth/08 Finished/app/components/Takeaways.jsx
new file mode 100644
index 0000000..19cbcd6
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/components/Takeaways.jsx
@@ -0,0 +1,16 @@
+function Takeaways({ items }) {
+ return (
+
+ {items.map((item) => (
+
+
+ {item.title}
+ {item.body}
+
+
+ ))}
+
+ );
+}
+
+export default Takeaways;
diff --git a/code/06 Network, Db, Auth/08 Finished/app/data/auth.server.js b/code/06 Network, Db, Auth/08 Finished/app/data/auth.server.js
new file mode 100644
index 0000000..6ca6148
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/data/auth.server.js
@@ -0,0 +1,93 @@
+import { hash, compare } from 'bcryptjs';
+import { createCookieSessionStorage, json, redirect } from '@remix-run/node';
+
+import { prisma } from './prisma.server';
+
+const SESSION_SECRET = process.env.SESSION_SECRET;
+
+const sessionStorage = createCookieSessionStorage({
+ cookie: {
+ secure: process.env.NODE_ENV === 'production',
+ secrets: [SESSION_SECRET],
+ sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60, // 30 days
+ httpOnly: true,
+ },
+});
+
+async function createUserSession(userId, redirectPath) {
+ const session = await sessionStorage.getSession();
+ session.set('userId', userId);
+ return redirect(redirectPath, {
+ headers: {
+ 'Set-Cookie': await sessionStorage.commitSession(session),
+ },
+ });
+}
+
+export async function getUserFromSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ const userId = session.get('userId');
+
+ if (!userId) {
+ return null;
+ }
+
+ return userId;
+}
+
+export async function destroyUserSession(request) {
+ const session = await sessionStorage.getSession(
+ request.headers.get('Cookie')
+ );
+
+ return redirect('/', {
+ headers: {
+ 'Set-Cookie': await sessionStorage.destroySession(session),
+ },
+ });
+}
+
+export async function requireUserSession(request) {
+ const userId = await getUserFromSession(request);
+
+ if (!userId) {
+ throw redirect('/login');
+ }
+
+ return userId;
+}
+
+export async function signup({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (existingUser) {
+ return json({ status: 409, statusText: 'User exists already.' });
+ }
+
+ const passwordHash = await hash(password, 12);
+
+ const user = await prisma.user.create({
+ data: { email: email, password: passwordHash },
+ });
+ return createUserSession(user.id, '/takeaways');
+}
+
+export async function login({ email, password }) {
+ const existingUser = await prisma.user.findFirst({ where: { email } });
+
+ if (!existingUser) {
+ return json({ status: 400, statusText: 'Invalid credentials.' });
+ }
+
+ const passwordCorrect = await compare(password, existingUser.password);
+
+ if (!passwordCorrect) {
+ return json({ status: 400, statusText: 'Invalid credentials (pw).' });
+ }
+
+ return createUserSession(existingUser.id, '/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/data/newsletter.server.js b/code/06 Network, Db, Auth/08 Finished/app/data/newsletter.server.js
new file mode 100644
index 0000000..f1822ea
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/data/newsletter.server.js
@@ -0,0 +1,27 @@
+import { isValidEmail } from '../util/validation.server';
+import { wait } from '../util/wait';
+import { prisma } from './prisma.server';
+
+export async function addNewsletterContact(email) {
+ if (!isValidEmail(email)) {
+ throw new Error('Invalid email address.');
+ }
+
+ const existingContact = await prisma.newsletterSignup.findUnique({
+ where: {
+ email,
+ },
+ });
+ await wait(2000);
+
+ if (existingContact) {
+ throw new Error('This email is already subscribed.');
+ }
+
+
+ await prisma.newsletterSignup.create({
+ data: {
+ email,
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/data/prisma.server.js b/code/06 Network, Db, Auth/08 Finished/app/data/prisma.server.js
new file mode 100644
index 0000000..cf1eaa4
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/data/prisma.server.js
@@ -0,0 +1,19 @@
+import { PrismaClient } from '@prisma/client';
+
+/**
+ * @type PrismaClient
+ */
+let prisma;
+
+if (process.env.NODE_ENV === 'production') {
+ prisma = new PrismaClient();
+ prisma.$connect();
+} else {
+ if (!global.__db) {
+ global.__db = new PrismaClient();
+ global.__db.$connect();
+ }
+ prisma = global.__db;
+}
+
+export { prisma };
diff --git a/code/06 Network, Db, Auth/08 Finished/app/entry.client.jsx b/code/06 Network, Db, Auth/08 Finished/app/entry.client.jsx
new file mode 100644
index 0000000..8338545
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/entry.client.jsx
@@ -0,0 +1,22 @@
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/entry.server.jsx b/code/06 Network, Db, Auth/08 Finished/app/entry.server.jsx
new file mode 100644
index 0000000..8e65b75
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/entry.server.jsx
@@ -0,0 +1,111 @@
+import { PassThrough } from "stream";
+
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
+
+export default function handleRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return isbot(request.headers.get("user-agent"))
+ ? handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ )
+ : handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ );
+}
+
+function handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error) {
+ reject(error);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(err) {
+ reject(err);
+ },
+ onError(error) {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/root.jsx b/code/06 Network, Db, Auth/08 Finished/app/root.jsx
new file mode 100644
index 0000000..98ee375
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/root.jsx
@@ -0,0 +1,52 @@
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useLoaderData,
+} from '@remix-run/react';
+
+import Layout from './components/Layout';
+import { getUserFromSession } from './data/auth.server';
+import mainStyles from './styles/main.css';
+import tailwindStyles from './styles/tailwind.css';
+
+export const meta = () => ({
+ charset: 'utf-8',
+ title: 'Cypress Requests',
+ viewport: 'width=device-width,initial-scale=1',
+});
+
+export const links = () => [
+ { rel: 'stylesheet', href: tailwindStyles },
+ { rel: 'stylesheet', href: mainStyles },
+ { rel: 'icon', href: '/favicon.ico' },
+];
+
+export default function App() {
+ const isLoggedIn = useLoaderData();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export async function loader({ request }) {
+ const userId = await getUserFromSession(request);
+ return !!userId;
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/routes/index.jsx b/code/06 Network, Db, Auth/08 Finished/app/routes/index.jsx
new file mode 100644
index 0000000..f5da528
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/routes/index.jsx
@@ -0,0 +1,31 @@
+import { Link, useLoaderData } from '@remix-run/react';
+import Takeaways from '../components/Takeaways';
+import { prisma } from '../data/prisma.server';
+
+export default function Index() {
+ const takeways = useLoaderData();
+
+ return (
+ <>
+
+ Learn Cypress
+ Cypress is an amazing end-to-end testing software and framework.
+
+ Manage your key Cypress takeaways and concepts with our learning app.
+
+
+
+
+
+ + Add a new takeaway
+
+
+ >
+ );
+}
+
+export function loader() {
+ return prisma.takeaway.findMany({ take: 2 });
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/routes/login.jsx b/code/06 Network, Db, Auth/08 Finished/app/routes/login.jsx
new file mode 100644
index 0000000..1ce4ea9
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/routes/login.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { login } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function LoginRoute() {
+ return ;
+}
+
+export default LoginRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return login(credentials);
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/routes/logout.js b/code/06 Network, Db, Auth/08 Finished/app/routes/logout.js
new file mode 100644
index 0000000..16ba683
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/routes/logout.js
@@ -0,0 +1,10 @@
+import { destroyUserSession } from '~/data/auth.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export function action({ request }) {
+ if (request.method !== 'POST') {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ return destroyUserSession(request);
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/routes/newsletter.js b/code/06 Network, Db, Auth/08 Finished/app/routes/newsletter.js
new file mode 100644
index 0000000..74444ab
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/routes/newsletter.js
@@ -0,0 +1,35 @@
+import { json } from '@remix-run/node';
+import { addNewsletterContact } from '../data/newsletter.server';
+import { BadRequestErrorResponse } from '../util/errors';
+
+export async function action({ request }) {
+ if (request.method !== 'POST') {
+ return new BadRequestErrorResponse('HTTP method not allowed.');
+ }
+
+ const body = await request.formData();
+ const email = body.get('email');
+
+ try {
+ await addNewsletterContact(email);
+ } catch (error) {
+ return json(
+ { message: error.message },
+ {
+ status: 400,
+ statusText: 'Failed to create contact',
+ }
+ );
+ }
+ return json(
+ { status: 201 }, // this is required because useFetcher does not expose the response object
+ {
+ status: 201,
+ statusText: 'Added newsletter contact.',
+ }
+ );
+}
+
+export function loader() {
+ throw new BadRequestErrorResponse('HTTP method not allowed.');
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/routes/signup.jsx b/code/06 Network, Db, Auth/08 Finished/app/routes/signup.jsx
new file mode 100644
index 0000000..823ab31
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/routes/signup.jsx
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+
+import Auth from '../components/Auth';
+import { signup } from '../data/auth.server';
+import { isValidEmail, isValidPassword } from '../util/validation.server';
+
+function SignupRoute() {
+ return ;
+}
+
+export default SignupRoute;
+
+export async function action({ request }) {
+ const formData = await request.formData();
+ const credentials = Object.fromEntries(formData);
+
+ if (
+ !isValidEmail(credentials.email) ||
+ !isValidPassword(credentials.password)
+ ) {
+ return json({ message: 'Invalid credentials entered.' }, { status: 400 });
+ }
+
+ return signup(credentials);
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/routes/takeaways.jsx b/code/06 Network, Db, Auth/08 Finished/app/routes/takeaways.jsx
new file mode 100644
index 0000000..be2fadb
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/routes/takeaways.jsx
@@ -0,0 +1,36 @@
+import { Link, Outlet, useLoaderData } from '@remix-run/react';
+
+import Takeaways from '../components/Takeaways';
+import { requireUserSession } from '../data/auth.server';
+import { prisma } from '../data/prisma.server';
+
+function TakewaysLayoutRoute() {
+ const takeaways = useLoaderData();
+
+ return (
+ <>
+
+
+ Your key takeaways
+
+
+
+ + Add a new takeaway
+
+
+ {takeaways.length === 0 && You have no key takeaways yet!
}
+
+ >
+ );
+}
+
+export default TakewaysLayoutRoute;
+
+export async function loader({ request }) {
+ await requireUserSession(request);
+
+ return prisma.takeaway.findMany();
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/routes/takeaways/new.jsx b/code/06 Network, Db, Auth/08 Finished/app/routes/takeaways/new.jsx
new file mode 100644
index 0000000..0b6868d
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/routes/takeaways/new.jsx
@@ -0,0 +1,87 @@
+import { json, redirect } from '@remix-run/node';
+import { Form, Link, useNavigate } from '@remix-run/react';
+
+import Modal from '../../components/Modal';
+import { requireUserSession } from '../../data/auth.server';
+import { prisma } from '../../data/prisma.server';
+
+function NewTakewayRoute() {
+ const navigate = useNavigate();
+
+ return (
+ navigate('..', { relative: 'path' })}>
+
+
+
+ Title
+
+
+
+
+
+ Body
+
+
+
+
+
+ Cancel
+
+
+ Create
+
+
+
+
+ );
+}
+
+export default NewTakewayRoute;
+
+export function loader({ request }) {
+ return requireUserSession(request);
+}
+
+export async function action({ request }) {
+ const fd = await request.formData();
+ const title = fd.get('title');
+ const body = fd.get('body');
+
+ if (!title || !body) {
+ return json({ message: 'Title and body are required.' }, { status: 400 });
+ }
+
+ await prisma.takeaway.create({
+ data: {
+ title,
+ body,
+ },
+ });
+
+ return redirect('/takeaways');
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/styles/main.css b/code/06 Network, Db, Auth/08 Finished/app/styles/main.css
new file mode 100644
index 0000000..9ec8050
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/styles/main.css
@@ -0,0 +1,19 @@
+.loader {
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid #fff;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/styles/tailwind.css b/code/06 Network, Db, Auth/08 Finished/app/styles/tailwind.css
new file mode 100644
index 0000000..d433f58
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/styles/tailwind.css
@@ -0,0 +1,919 @@
+/*
+! tailwindcss v3.2.6 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ /* 1 */
+ border-width: 0;
+ /* 2 */
+ border-style: solid;
+ /* 2 */
+ border-color: #e5e7eb;
+ /* 2 */
+}
+
+::before,
+::after {
+ --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+*/
+
+html {
+ line-height: 1.5;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+ -moz-tab-size: 4;
+ /* 3 */
+ -o-tab-size: 4;
+ tab-size: 4;
+ /* 3 */
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ /* 4 */
+ font-feature-settings: normal;
+ /* 5 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+ margin: 0;
+ /* 1 */
+ line-height: inherit;
+ /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+ height: 0;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ border-top-width: 1px;
+ /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font family by default.
+2. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ /* 1 */
+ font-size: 1em;
+ /* 2 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+ font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+ text-indent: 0;
+ /* 1 */
+ border-color: inherit;
+ /* 2 */
+ border-collapse: collapse;
+ /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ font-weight: inherit;
+ /* 1 */
+ line-height: inherit;
+ /* 1 */
+ color: inherit;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+ padding: 0;
+ /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+ text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+ -webkit-appearance: button;
+ /* 1 */
+ background-color: transparent;
+ /* 2 */
+ background-image: none;
+ /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+ outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+ vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+ display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+ resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+ cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+ This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ /* 1 */
+ vertical-align: middle;
+ /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden] {
+ display: none;
+}
+
+*, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+::backdrop {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+}
+
+.fixed {
+ position: fixed;
+}
+
+.relative {
+ position: relative;
+}
+
+.left-0 {
+ left: 0px;
+}
+
+.left-\[50\%\] {
+ left: 50%;
+}
+
+.top-0 {
+ top: 0px;
+}
+
+.top-10 {
+ top: 2.5rem;
+}
+
+.m-0 {
+ margin: 0px;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.my-12 {
+ margin-top: 3rem;
+ margin-bottom: 3rem;
+}
+
+.my-16 {
+ margin-top: 4rem;
+ margin-bottom: 4rem;
+}
+
+.my-4 {
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
+.my-8 {
+ margin-top: 2rem;
+ margin-bottom: 2rem;
+}
+
+.mb-1 {
+ margin-bottom: 0.25rem;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.mt-16 {
+ margin-top: 4rem;
+}
+
+.mt-2 {
+ margin-top: 0.5rem;
+}
+
+.block {
+ display: block;
+}
+
+.flex {
+ display: flex;
+}
+
+.grid {
+ display: grid;
+}
+
+.h-screen {
+ height: 100vh;
+}
+
+.w-96 {
+ width: 24rem;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.w-screen {
+ width: 100vw;
+}
+
+.max-w-2xl {
+ max-width: 42rem;
+}
+
+.max-w-5xl {
+ max-width: 64rem;
+}
+
+.max-w-lg {
+ max-width: 32rem;
+}
+
+.-translate-x-1\/2 {
+ --tw-translate-x: -50%;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+.grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-end {
+ justify-content: flex-end;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.gap-4 {
+ gap: 1rem;
+}
+
+.gap-6 {
+ gap: 1.5rem;
+}
+
+.gap-8 {
+ gap: 2rem;
+}
+
+.rounded-md {
+ border-radius: 0.375rem;
+}
+
+.rounded-sm {
+ border-radius: 0.125rem;
+}
+
+.rounded-l-sm {
+ border-top-left-radius: 0.125rem;
+ border-bottom-left-radius: 0.125rem;
+}
+
+.rounded-r-sm {
+ border-top-right-radius: 0.125rem;
+ border-bottom-right-radius: 0.125rem;
+}
+
+.border-2 {
+ border-width: 2px;
+}
+
+.border-blue-300 {
+ --tw-border-opacity: 1;
+ border-color: rgb(147 197 253 / var(--tw-border-opacity));
+}
+
+.border-blue-700 {
+ --tw-border-opacity: 1;
+ border-color: rgb(29 78 216 / var(--tw-border-opacity));
+}
+
+.bg-blue-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.bg-blue-600 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.bg-blue-700 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(29 78 216 / var(--tw-bg-opacity));
+}
+
+.bg-slate-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(226 232 240 / var(--tw-bg-opacity));
+}
+
+.bg-slate-300 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(203 213 225 / var(--tw-bg-opacity));
+}
+
+.bg-slate-400 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(148 163 184 / var(--tw-bg-opacity));
+}
+
+.bg-slate-800 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(30 41 59 / var(--tw-bg-opacity));
+}
+
+.bg-slate-900 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(15 23 42 / var(--tw-bg-opacity));
+}
+
+.bg-gradient-to-br {
+ background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
+}
+
+.from-slate-900 {
+ --tw-gradient-from: #0f172a;
+ --tw-gradient-to: rgb(15 23 42 / 0);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.to-slate-800 {
+ --tw-gradient-to: #1e293b;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.p-8 {
+ padding: 2rem;
+}
+
+.px-2 {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+
+.px-3 {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.px-5 {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+}
+
+.px-8 {
+ padding-left: 2rem;
+ padding-right: 2rem;
+}
+
+.py-1 {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+.py-3 {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+}
+
+.py-4 {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.font-mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+}
+
+.text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.font-bold {
+ font-weight: 700;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.text-blue-300 {
+ --tw-text-opacity: 1;
+ color: rgb(147 197 253 / var(--tw-text-opacity));
+}
+
+.text-blue-400 {
+ --tw-text-opacity: 1;
+ color: rgb(96 165 250 / var(--tw-text-opacity));
+}
+
+.text-blue-50 {
+ --tw-text-opacity: 1;
+ color: rgb(239 246 255 / var(--tw-text-opacity));
+}
+
+.text-pink-300 {
+ --tw-text-opacity: 1;
+ color: rgb(249 168 212 / var(--tw-text-opacity));
+}
+
+.text-slate-300 {
+ --tw-text-opacity: 1;
+ color: rgb(203 213 225 / var(--tw-text-opacity));
+}
+
+.text-slate-400 {
+ --tw-text-opacity: 1;
+ color: rgb(148 163 184 / var(--tw-text-opacity));
+}
+
+.text-slate-600 {
+ --tw-text-opacity: 1;
+ color: rgb(71 85 105 / var(--tw-text-opacity));
+}
+
+.text-slate-900 {
+ --tw-text-opacity: 1;
+ color: rgb(15 23 42 / var(--tw-text-opacity));
+}
+
+.text-white {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity));
+}
+
+.opacity-80 {
+ opacity: 0.8;
+}
+
+.hover\:border-blue-600:hover {
+ --tw-border-opacity: 1;
+ border-color: rgb(37 99 235 / var(--tw-border-opacity));
+}
+
+.hover\:bg-blue-300:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(147 197 253 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-400:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(96 165 250 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-500:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-blue-600:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+}
+
+.hover\:text-blue-900:hover {
+ --tw-text-opacity: 1;
+ color: rgb(30 58 138 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-200:hover {
+ --tw-text-opacity: 1;
+ color: rgb(226 232 240 / var(--tw-text-opacity));
+}
+
+.hover\:text-slate-500:hover {
+ --tw-text-opacity: 1;
+ color: rgb(100 116 139 / var(--tw-text-opacity));
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/util/errors.js b/code/06 Network, Db, Auth/08 Finished/app/util/errors.js
new file mode 100644
index 0000000..4a37e53
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/util/errors.js
@@ -0,0 +1,11 @@
+export class BadRequestErrorResponse extends Response {
+ constructor(message, statusText = 'Bad request') {
+ super(JSON.stringify({ status: 400, message }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 400,
+ statusText: statusText,
+ });
+ }
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/app/util/validation.server.js b/code/06 Network, Db, Auth/08 Finished/app/util/validation.server.js
new file mode 100644
index 0000000..d7c407c
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/util/validation.server.js
@@ -0,0 +1,7 @@
+export function isValidEmail(email) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+}
+
+export function isValidPassword(password) {
+ return password.length >= 6;
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/08 Finished/app/util/wait.js b/code/06 Network, Db, Auth/08 Finished/app/util/wait.js
new file mode 100644
index 0000000..9f35c5b
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/app/util/wait.js
@@ -0,0 +1,5 @@
+export function wait(time) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, time);
+ });
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/cypress.config.js b/code/06 Network, Db, Auth/08 Finished/cypress.config.js
new file mode 100644
index 0000000..9623f3f
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/cypress.config.js
@@ -0,0 +1,18 @@
+import { defineConfig } from 'cypress';
+
+import { seed } from './prisma/seed-test';
+
+export default defineConfig({
+ e2e: {
+ baseUrl: 'http://localhost:3000',
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ on('task', {
+ async seedDatabase() {
+ await seed();
+ return null;
+ }
+ })
+ },
+ },
+});
diff --git a/code/06 Network, Db, Auth/08 Finished/cypress/e2e/auth.cy.js b/code/06 Network, Db, Auth/08 Finished/cypress/e2e/auth.cy.js
new file mode 100644
index 0000000..8dcbf37
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/cypress/e2e/auth.cy.js
@@ -0,0 +1,32 @@
+///
+
+describe('Auth', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should signup', () => {
+ cy.visit('/signup');
+ cy.get('[data-cy="auth-email"]').click();
+ cy.get('[data-cy="auth-email"]').type('test2@example.com');
+ cy.get('[data-cy="auth-password"]').type('testpassword');
+ cy.get('[data-cy="auth-submit"]').click();
+ cy.location('pathname').should('eq', '/takeaways');
+ cy.getCookie('__session').its('value').should('not.be.empty');
+ });
+ it('should login', () => {
+ cy.visit('/login');
+ cy.get('[data-cy="auth-email"]').click();
+ cy.get('[data-cy="auth-email"]').type('test@example.com');
+ cy.get('[data-cy="auth-password"]').type('testpassword');
+ cy.get('[data-cy="auth-submit"]').click();
+ cy.location('pathname').should('eq', '/takeaways');
+ cy.getCookie('__session').its('value').should('not.be.empty');
+ })
+ it('should logout', () => {
+ cy.login();
+
+ cy.contains('Logout').click();
+ cy.location('pathname').should('eq', '/');
+ cy.getCookie('__session').its('value').should('be.empty');
+ });
+});
diff --git a/code/06 Network, Db, Auth/08 Finished/cypress/e2e/newsletter.cy.js b/code/06 Network, Db, Auth/08 Finished/cypress/e2e/newsletter.cy.js
new file mode 100644
index 0000000..348176b
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/cypress/e2e/newsletter.cy.js
@@ -0,0 +1,33 @@
+describe('Newsletter', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a success message', () => {
+ cy.intercept('POST', '/newsletter*', { status: 201 }).as('subscribe'); // intercept any HTTP request localhost:3000/newsletter?anything
+ cy.visit('/');
+ cy.get('[data-cy="newsletter-email"]').type('test@example.com');
+ cy.get('[data-cy="newsletter-submit"]').click();
+ cy.wait('@subscribe');
+ cy.contains('Thanks for signing up');
+ });
+ it('should display validation errors', () => {
+ cy.intercept('POST', '/newsletter*', {
+ message: 'Email exists already.',
+ }).as('subscribe'); // intercept any HTTP request localhost:3000/newsletter?anything
+ cy.visit('/');
+ cy.get('[data-cy="newsletter-email"]').type('test@example.com');
+ cy.get('[data-cy="newsletter-submit"]').click();
+ cy.wait('@subscribe');
+ cy.contains('Email exists already.');
+ });
+ it('should successfully create a new contact', () => {
+ cy.request({
+ method: 'POST',
+ url: '/newsletter',
+ body: { email: 'test@example.com' },
+ form: true
+ }).then(res => {
+ expect(res.status).to.eq(201);
+ });
+ })
+});
diff --git a/code/06 Network, Db, Auth/08 Finished/cypress/e2e/takeaways.cy.js b/code/06 Network, Db, Auth/08 Finished/cypress/e2e/takeaways.cy.js
new file mode 100644
index 0000000..408a48d
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/cypress/e2e/takeaways.cy.js
@@ -0,0 +1,23 @@
+///
+
+describe('Takeaways', () => {
+ beforeEach(() => {
+ cy.task('seedDatabase');
+ });
+ it('should display a list of fetched takeaways', () => {
+ cy.visit('/');
+ cy.get('[data-cy="takeaway-item"]').should('have.length', 2);
+ });
+ it('should add a new takeaway', () => {
+ cy.intercept('POST', '/takeaways/new*', 'success').as('createTakeaway');
+ cy.login();
+ cy.visit('/takeaways/new');
+ cy.get('[data-cy="title"]').click();
+ cy.get('[data-cy="title"]').type('TestTitle1');
+ cy.get('[data-cy="body"]').type('TestBody1');
+ cy.get('[data-cy="create-takeaway"]').click();
+ cy.wait('@createTakeaway')
+ .its('request.body')
+ .should('match', /TestTitle1.*TestBody1/);
+ });
+});
diff --git a/code/06 Network, Db, Auth/08 Finished/cypress/support/commands.js b/code/06 Network, Db, Auth/08 Finished/cypress/support/commands.js
new file mode 100644
index 0000000..a35aac0
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/cypress/support/commands.js
@@ -0,0 +1,61 @@
+///
+// ***********************************************
+// This example commands.ts shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+//
+// declare global {
+// namespace Cypress {
+// interface Chainable {
+// login(email: string, password: string): Chainable
+// drag(subject: string, options?: Partial): Chainable
+// dismiss(subject: string, options?: Partial): Chainable
+// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable
+// }
+// }
+// }
+
+Cypress.Commands.add('login', () => {
+ cy.visit('/login');
+ cy.get('[data-cy="auth-email"]').click();
+ cy.get('[data-cy="auth-email"]').type('test@example.com');
+ cy.get('[data-cy="auth-password"]').type('testpassword');
+ cy.get('[data-cy="auth-submit"]').click();
+ cy.location('pathname').should('eq', '/takeaways');
+ cy.getCookie('__session').its('value').should('not.be.empty');
+});
+
+// the below code snippet is required to handle a React hydration bug that would cause tests to fail
+// it's only a workaround until this React behavior / bug is fixed
+Cypress.on('uncaught:exception', (err) => {
+ // we check if the error is
+ if (
+ err.message.includes('Minified React error #418;') ||
+ err.message.includes('Minified React error #423;') ||
+ err.message.includes('hydrating') ||
+ err.message.includes('Hydration')
+ ) {
+ return false;
+ }
+});
diff --git a/code/06 Network, Db, Auth/08 Finished/cypress/support/e2e.js b/code/06 Network, Db, Auth/08 Finished/cypress/support/e2e.js
new file mode 100644
index 0000000..f80f74f
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/cypress/support/e2e.js
@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/e2e.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/08 Finished/package.json b/code/06 Network, Db, Auth/08 Finished/package.json
new file mode 100644
index 0000000..ceff0da
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/package.json
@@ -0,0 +1,42 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "init": "npm install && dotenv -e .env npx prisma db push && node prisma/seed.js",
+ "build": "npm run build:css && remix build",
+ "build:css": "tailwindcss -m -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "dev": "concurrently \"npm run dev:css\" \"dotenv -e .env remix dev\"",
+ "dev:css": "tailwindcss -w -i ./styles/tailwind.css -o app/styles/tailwind.css",
+ "start": "remix-serve build",
+ "typecheck": "tsc",
+ "test": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress run\"",
+ "test:open": "dotenv -e .env.test npx prisma db push && concurrently \"npm run dev:css\" \"dotenv -e .env.test remix dev\" \"dotenv -e .env.test cypress open\""
+ },
+ "dependencies": {
+ "@prisma/client": "^4.3.1",
+ "@remix-run/node": "^1.13.0",
+ "@remix-run/react": "^1.13.0",
+ "@remix-run/serve": "^1.13.0",
+ "bcryptjs": "^2.4.3",
+ "dotenv-cli": "^7.0.0",
+ "isbot": "^3.6.5",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@remix-run/dev": "^1.13.0",
+ "@remix-run/eslint-config": "^1.13.0",
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.8",
+ "concurrently": "^7.6.0",
+ "cypress": "^12.5.1",
+ "eslint": "^8.27.0",
+ "eslint-plugin-cypress": "^2.12.1",
+ "prisma": "^4.3.1",
+ "tailwindcss": "^3.2.6",
+ "typescript": "^4.8.4"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/prisma/schema.prisma b/code/06 Network, Db, Auth/08 Finished/prisma/schema.prisma
new file mode 100644
index 0000000..65c584b
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/prisma/schema.prisma
@@ -0,0 +1,28 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ password String
+}
+
+model NewsletterSignup {
+ id Int @id @default(autoincrement())
+ email String @unique
+}
+
+model Takeaway {
+ id Int @id @default(autoincrement())
+ title String
+ body String
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/prisma/seed-test.js b/code/06 Network, Db, Auth/08 Finished/prisma/seed-test.js
new file mode 100644
index 0000000..92317c8
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/prisma/seed-test.js
@@ -0,0 +1,39 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+const { hash } = require('bcryptjs');
+
+const prisma = new PrismaClient();
+
+export async function seed() {
+ console.log('Seeding...');
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.user.create({
+ data: {
+ email: 'test@example.com',
+ password: await hash('testpassword', 12),
+ },
+ });
+ await prisma.newsletterSignup.create({
+ data: {
+ email: 'test2@example.com',
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
diff --git a/code/06 Network, Db, Auth/08 Finished/prisma/seed.js b/code/06 Network, Db, Auth/08 Finished/prisma/seed.js
new file mode 100644
index 0000000..af901a0
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/prisma/seed.js
@@ -0,0 +1,31 @@
+// seed prisma database
+
+const { PrismaClient } = require('@prisma/client');
+
+const prisma = new PrismaClient();
+
+async function main() {
+ await prisma.user.deleteMany({});
+ await prisma.newsletterSignup.deleteMany({});
+ await prisma.takeaway.deleteMany({});
+
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress queues commands',
+ body:
+ "Your commands (e.g., cy.get()) don't run immediately. They are scheduled to run at some point in the future.",
+ },
+ });
+ await prisma.takeaway.create({
+ data: {
+ title: 'Cypress acts on subjects',
+ body:
+ 'You can use then() to get direct access to the subject (e.g., HTML element, stub) of the previous command.',
+ },
+ });
+}
+
+main().then(() => {
+ console.log('seeded database');
+ process.exit(0);
+});
diff --git a/code/06 Network, Db, Auth/08 Finished/public/favicon.ico b/code/06 Network, Db, Auth/08 Finished/public/favicon.ico
new file mode 100644
index 0000000..8830cf6
Binary files /dev/null and b/code/06 Network, Db, Auth/08 Finished/public/favicon.ico differ
diff --git a/code/06 Network, Db, Auth/08 Finished/remix.config.js b/code/06 Network, Db, Auth/08 Finished/remix.config.js
new file mode 100644
index 0000000..adf2a0b
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/remix.config.js
@@ -0,0 +1,8 @@
+/** @type {import('@remix-run/dev').AppConfig} */
+module.exports = {
+ ignoredRouteFiles: ["**/.*"],
+ // appDirectory: "app",
+ // assetsBuildDirectory: "public/build",
+ // serverBuildPath: "build/index.js",
+ // publicPath: "/build/",
+};
diff --git a/code/06 Network, Db, Auth/08 Finished/styles/tailwind.css b/code/06 Network, Db, Auth/08 Finished/styles/tailwind.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/styles/tailwind.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/code/06 Network, Db, Auth/08 Finished/tailwind.config.js b/code/06 Network, Db, Auth/08 Finished/tailwind.config.js
new file mode 100644
index 0000000..c7c50e0
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/tailwind.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
\ No newline at end of file
diff --git a/code/06 Network, Db, Auth/08 Finished/tsconfig.json b/code/06 Network, Db, Auth/08 Finished/tsconfig.json
new file mode 100644
index 0000000..28951d6
--- /dev/null
+++ b/code/06 Network, Db, Auth/08 Finished/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "cypress.config.js", "cypress/e2e/newsletter.cy.js"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "target": "ES2019",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true
+ }
+}
diff --git a/slides/cypress-e2e-course-slides.pdf b/slides/cypress-e2e-course-slides.pdf
new file mode 100644
index 0000000..cb8f192
Binary files /dev/null and b/slides/cypress-e2e-course-slides.pdf differ