├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── deploy.yml ├── .gitignore ├── .gitpod.Dockerfile ├── .gitpod.yml ├── .prettierignore ├── README.md ├── app.arc ├── app ├── entry.client.tsx ├── entry.server.tsx ├── root.tsx ├── routes │ ├── index.tsx │ ├── join.tsx │ ├── login.tsx │ ├── logout.tsx │ ├── notes.tsx │ └── notes │ │ ├── $noteId.tsx │ │ ├── index.tsx │ │ └── new.tsx ├── services │ ├── hyper.test.ts │ ├── hyper.ts │ ├── models │ │ ├── err.ts │ │ ├── model.test.ts │ │ ├── model.ts │ │ ├── note.test.ts │ │ ├── note.ts │ │ ├── password.test.ts │ │ ├── password.ts │ │ ├── user.test.ts │ │ └── user.ts │ ├── note.server.test.ts │ ├── note.server.ts │ ├── services.ts │ ├── types.ts │ ├── user.server.test.ts │ └── user.server.ts ├── session.server.ts ├── types.ts └── utils.ts ├── arc-dev.js ├── cypress.config.ts ├── cypress ├── .eslintrc.js ├── e2e │ └── smoke.ts ├── fixtures │ └── example.json ├── support │ ├── commands.ts │ └── index.ts └── tsconfig.json ├── docs └── alternative-stack.png ├── hyper-dev.js ├── mocks ├── README.md ├── index.js └── start.ts ├── package.json ├── prettier.config.js ├── public ├── favicon.ico └── soundgarden-live.jpeg ├── remix.config.js ├── remix.env.d.ts ├── remix.init ├── index.js ├── package-lock.json └── package.json ├── server.ts ├── tailwind.config.js ├── test ├── setup-test-env.ts └── test-routes │ └── delete-user.ts ├── tsconfig.json └── vitest.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV="development" 2 | SESSION_SECRET="super-duper-s3cret" 3 | HYPER="http://localhost:6363/notes-dev" 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /server/index.js 3 | /public/build 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').Linter.BaseConfig} 3 | */ 4 | module.exports = { 5 | extends: [ 6 | "@remix-run/eslint-config", 7 | "@remix-run/eslint-config/node", 8 | "@remix-run/eslint-config/jest-testing-library", 9 | "prettier", 10 | ], 11 | // we're using vitest which has a very similar API to jest 12 | // (so the linting plugins work nicely), but it we have to explicitly 13 | // set the jest version. 14 | settings: { 15 | jest: { 16 | version: 27, 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Something is wrong with the Stack. 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: >- 7 | Thank you for helping to improve the Alternative Stack! 8 | 9 | Our bandwidth on maintaining these stacks is limited. As a team, we're 10 | currently focusing our efforts on Hyper and Hyper Cloud itself. The good news is you can 11 | fork and adjust this stack however you'd like and start using it today 12 | as a custom stack. Learn more from 13 | [the Remix Stacks docs](https://remix.run/stacks). 14 | 15 | If you'd still like to report a bug, please fill out this form. We can't 16 | promise a timely response, but hopefully when we have the bandwidth to 17 | work on these stacks again we can take a look. Thanks! 18 | 19 | - type: input 20 | attributes: 21 | label: Have you experienced this bug with the latest version of the template? 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: Steps to Reproduce 27 | description: Steps to reproduce the behavior. 28 | validations: 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: Expected Behavior 33 | description: A concise description of what you expected to happen. 34 | validations: 35 | required: true 36 | - type: textarea 37 | attributes: 38 | label: Actual Behavior 39 | description: A concise description of what you're experiencing. 40 | validations: 41 | required: true 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Get Help 4 | url: https://hyper-chat.slack.com/archives/C023YLD15NZ 5 | about: If you can't get something to work the way you expect, open a question in 6 | the Hyper Slack support channel. 7 | - name: "#️⃣ Hyper Slack" 8 | url: https://hyper.io/slack 9 | about: Interact with other people using Hyper ⚡️ 10 | - name: 💬 New Updates (Twitter) 11 | url: https://twitter.com/_hyper_io 12 | about: Stay up to date with Hyper news on twitter 13 | - name: 🍿 Hyper YouTube Channel 14 | url: https://www.youtube.com/c/hypervideos 15 | about: Are you a tech lead or wanting to learn more about Hyper in depth? Checkout the Hyper YouTube Channel. 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for the Alternative Stack 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Thank you for helping to improve the Alternative Stack! 11 | 12 | If you have a feature idea for hyper, drop on into our 13 | [Slack](https://hyper.io/slack)! 14 | 15 | **Is your feature request related to a problem? Please describe.** A clear and 16 | concise description of what the problem is. Ex. I'm always frustrated when [...] 17 | 18 | **Describe the solution you'd like** A clear and concise description of what you 19 | want to happen. 20 | 21 | **Describe alternatives you've considered** A clear and concise description of 22 | any alternative solutions or features you've considered. 23 | 24 | **Additional context** Add any other context or screenshots about the feature 25 | request here. 26 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | pull_request: {} 9 | 10 | defaults: 11 | run: 12 | shell: bash 13 | 14 | jobs: 15 | build: 16 | name: 🏗 Build 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 🛑 Cancel Previous Runs 20 | uses: styfle/cancel-workflow-action@0.9.1 21 | 22 | - name: ⬇️ Checkout repo 23 | uses: actions/checkout@v3 24 | 25 | - name: ⎔ Setup node 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: 16 29 | 30 | - name: 📥 Download deps 31 | uses: bahmutov/npm-install@v1 32 | with: 33 | useLockFile: false 34 | 35 | - name: 🛠 Build 36 | run: npm run build 37 | 38 | lint: 39 | name: ⬣ ESLint 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: 🛑 Cancel Previous Runs 43 | uses: styfle/cancel-workflow-action@0.9.1 44 | 45 | - name: ⬇️ Checkout repo 46 | uses: actions/checkout@v3 47 | 48 | - name: ⎔ Setup node 49 | uses: actions/setup-node@v3 50 | with: 51 | node-version: 16 52 | 53 | - name: 📥 Download deps 54 | uses: bahmutov/npm-install@v1 55 | with: 56 | useLockFile: false 57 | 58 | - name: 🔬 Lint 59 | run: npm run lint 60 | 61 | typecheck: 62 | name: ʦ TypeScript 63 | runs-on: ubuntu-latest 64 | steps: 65 | - name: 🛑 Cancel Previous Runs 66 | uses: styfle/cancel-workflow-action@0.9.1 67 | 68 | - name: ⬇️ Checkout repo 69 | uses: actions/checkout@v3 70 | 71 | - name: ⎔ Setup node 72 | uses: actions/setup-node@v3 73 | with: 74 | node-version: 16 75 | 76 | - name: 📥 Download deps 77 | uses: bahmutov/npm-install@v1 78 | with: 79 | useLockFile: false 80 | 81 | - name: 🔎 Type check 82 | run: npm run typecheck --if-present 83 | 84 | vitest: 85 | name: ⚡ Vitest 86 | runs-on: ubuntu-latest 87 | steps: 88 | - name: 🛑 Cancel Previous Runs 89 | uses: styfle/cancel-workflow-action@0.9.1 90 | 91 | - name: ⬇️ Checkout repo 92 | uses: actions/checkout@v3 93 | 94 | - name: ⎔ Setup node 95 | uses: actions/setup-node@v3 96 | with: 97 | node-version: 16 98 | 99 | - name: 📥 Download deps 100 | uses: bahmutov/npm-install@v1 101 | with: 102 | useLockFile: false 103 | 104 | - name: ⚡ Run vitest 105 | run: npm run test -- --coverage 106 | 107 | deploy: 108 | if: ${{ !github.event.pull_request }} 109 | needs: [build, lint, typecheck, vitest] 110 | runs-on: ubuntu-latest 111 | 112 | steps: 113 | - name: 🛑 Cancel Previous Runs 114 | uses: styfle/cancel-workflow-action@0.9.1 115 | 116 | - name: ⬇️ Checkout repo 117 | uses: actions/checkout@v3 118 | 119 | - name: ⎔ Setup node 120 | uses: actions/setup-node@v3 121 | with: 122 | node-version: 16 123 | 124 | - name: 👀 Env 125 | run: | 126 | echo "Event name: ${{ github.event_name }}" 127 | echo "Git ref: ${{ github.ref }}" 128 | echo "GH actor: ${{ github.actor }}" 129 | echo "SHA: ${{ github.sha }}" 130 | VER=`node --version`; echo "Node ver: $VER" 131 | VER=`npm --version`; echo "npm ver: $VER" 132 | 133 | - name: 📥 Download deps 134 | uses: bahmutov/npm-install@v1 135 | with: 136 | useLockFile: false 137 | 138 | - name: 🏗 Build 139 | run: npm run build 140 | 141 | - name: 🚀 Staging Deploy 142 | if: github.ref == 'refs/heads/dev' 143 | env: 144 | CI: true 145 | # If separate Staging environment, use staging AWS creds here 146 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 147 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 148 | AWS_REGION: ${{ secrets.AWS_REGION }} 149 | run: | 150 | npx arc deploy --staging --prune 151 | 152 | - name: 🚀 Production Deploy 153 | if: github.ref == 'refs/heads/main' 154 | env: 155 | CI: true 156 | # If separate Production environment, use production AWS creds here 157 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 158 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 159 | AWS_REGION: ${{ secrets.AWS_REGION }} 160 | run: | 161 | npx arc deploy --production --prune 162 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 2 | # We don't want lockfiles in stack templates, 3 | # as people could use a different package manager 4 | # This part will be removed by `remix.init` 5 | package-lock.json 6 | yarn.lock 7 | pnpm-lock.yaml 8 | pnpm-lock.yml 9 | # 10 | node_modules 11 | coverage 12 | 13 | /server 14 | /public/build 15 | preferences.arc 16 | sam.json 17 | sam.yaml 18 | .env 19 | hyper-nano 20 | __hyper__ 21 | 22 | /cypress/screenshots 23 | /cypress/videos 24 | 25 | /app/styles/tailwind.css 26 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full 2 | 3 | RUN sudo apt-get update 4 | 5 | # Install Cypress-base dependencies 6 | RUN sudo apt-get install -y \ 7 | libgtk2.0-0 \ 8 | libgtk-3-0 9 | 10 | RUN sudo DEBIAN_FRONTEND=noninteractive apt-get install -yq \ 11 | libgbm-dev \ 12 | libnotify-dev 13 | 14 | RUN sudo apt-get install -y \ 15 | libgconf-2-4 \ 16 | libnss3 \ 17 | libxss1 18 | 19 | RUN sudo apt-get install -y \ 20 | libasound2 \ 21 | libxtst6 \ 22 | xauth \ 23 | xvfb 24 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: .gitpod.Dockerfile 3 | 4 | tasks: 5 | - init: npm install && npm run build 6 | command: npm run dev 7 | ports: 8 | # Architect 9 | - port: 3000 10 | visibility: public 11 | onOpen: ignore 12 | - port: 6363 13 | visibility: public 14 | onOpen: ignore 15 | - port: 2222 16 | visibility: public 17 | onOpen: ignore 18 | # Cypress e2e 19 | - port: 8811 20 | visibility: public 21 | onOpen: ignore 22 | - port: 8002 23 | visibility: public 24 | onOpen: ignore 25 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /server/index.js 4 | /public/build 5 | preferences.arc 6 | sam.json 7 | sam.yaml 8 | .env 9 | 10 | /app/styles/tailwind.css 11 | coverage 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alternative Stack 2 | 3 | ![The Alternative Stack](./docs/alternative-stack.png) 4 | 5 | The Alternative Stack is a [Remix Stacks](https://remix.run/stacks) using 6 | [Hyper](https://hyper.io) as a services tier 7 | 8 | Learn more about [Hyper](https://hyper.io) 9 | 10 | Learn more about [Remix Stacks](https://remix.run/stacks). 11 | 12 | ## Blog Post 13 | 14 | Check out 15 | [our blog post](https://blog.hyper.io/introducing-the-alternative-stack/) on the 16 | Alternative Stack 17 | 18 | ``` 19 | npx create-remix --template hyper63/alternative-stack 20 | ``` 21 | 22 | ## What's in the stack 23 | 24 | - [AWS deployment](https://aws.com) with [Architect](https://arc.codes/) 25 | - [Hyper Cloud](https://hyper.io) integration via 26 | [`hyper-connect`](https://www.npmjs.com/package/hyper-connect) 27 | - Zero-setup ⚡️ local development using 28 | [`hyper nano`](https://github.com/hyper63/hyper/tree/main/images/nano) 29 | - [Hyper Vision](https://docs.hyper.io/hyper-vision) support, to peer into your 30 | hyper services 31 | - [GitPod integration](https://gitpod.io/) for developing in ephermeral cloud 32 | environments 33 | - [GitHub Actions](https://github.com/features/actions) for deploy on merge to 34 | production and staging environments 35 | - Email/Password Authentication with 36 | [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) 37 | - Styling with [Tailwind](https://tailwindcss.com/) 38 | - End-to-end testing with [Cypress](https://cypress.io) 39 | - Local third party request mocking with [MSW](https://mswjs.io) 40 | - Unit testing with [Vitest](https://vitest.dev) and 41 | [Testing Library](https://testing-library.com) 42 | - Code formatting with [Prettier](https://prettier.io) 43 | - Linting with [ESLint](https://eslint.org) 44 | - Static Types with [TypeScript](https://typescriptlang.org) 45 | 46 | Not a fan of bits of the stack? Fork it, change it, and use 47 | `npx create-remix --template your/repo`! Make it your own. 48 | 49 | ## Prerequisites 50 | 51 | This Remix stack sets up a local development stack using 52 | [`hyper nano`](https://github.com/hyper63/hyper/tree/main/images/nano) ⚡️ an 53 | in-memory instance of [hyper](https://hyper.io). `hyper nano` works great for 54 | local development or short-lived, ephemeral environments like GitHub Workspaces 55 | or GitPod 56 | 57 | > At hyper, we exclusively develop using short-lived ephemeral environments 58 | 59 | If you choose **_not_** to use `hyper nano`, you will need to create a 60 | [`hyper cloud application`](https://docs.hyper.io/applications) on 61 | [hyper cloud](https://dashboard.hyper.io): 62 | 63 | - Create a free starter account on [hyper cloud](https://dashboard.hyper.io) 64 | - Create a free hyper cloud application. Learn more 65 | [here](https://docs.hyper.io/applications) 66 | - The application should at least have a 67 | [hyper data service](https://docs.hyper.io/data-api) 68 | - Take your application's connection string and use it to set your `HYPER` 69 | environment variable in your `.env`, during setup. Learn more 70 | [here](https://docs.hyper.io/app-keys) 71 | 72 | ## Development 73 | 74 | - Start dev server: 75 | 76 | ```sh 77 | npm run dev 78 | ``` 79 | 80 | This starts your app in development mode, rebuilding assets on file changes. All 81 | data will be persisted to your hyper application based on your `HYPER` 82 | connection string environment variable. 83 | 84 | ### Relevant code: 85 | 86 | This is a pretty simple note-taking app, but it's a good example of how you can 87 | build a full stack app with Hyper and Remix, and deploy it using Architect. The 88 | main functionality is creating users, logging in and out, and creating and 89 | deleting notes. 90 | 91 | - creating users, finding a user, and deleting a user and their data 92 | [./app/services/user.server.ts](./app/services/user.server.ts) 93 | - creating, and deleting notes 94 | [./app/services/note.server.ts](./app/services/note.server.ts) 95 | - user sessions, and verifying them 96 | [./app/session.server.ts](./app/session.server.ts) 97 | 98 | ### Gitpod Integration 99 | 100 | The Alternative Stack comes with support for cloud based development, using 101 | [GitPod](https://gitpod.io). Just initialize the Alternative Stack using the 102 | Remix CLI, push to Github, and then open in Gitpod by visiting: 103 | 104 | ``` 105 | https://gitpod.io/#your-repo-url 106 | ``` 107 | 108 | > You can also use 109 | > [Gitpod's browser extension](https://www.gitpod.io/docs/browser-extension) 110 | 111 | This will build a containerized cloud environment, install dependencies, and 112 | start up your services for you, a complete **sandboxed** environment. We use a 113 | [GitPod `.gitpod.yml`](https://www.gitpod.io/docs/config-gitpod-file) to set up 114 | our Cloud environment and expose our services. 115 | 116 | Botch a feature and need to wipe the data? Just open a new Gitpod! 117 | 118 | Need to fix a bug? Create an issue and then 119 | [open the issue in Gitpod](https://www.gitpod.io/docs/context-urls)! 120 | 121 | With Gitpod, you no longer have to maintain a local environment. Just spin up a 122 | new one every time! 123 | 124 | ### Hyper Sevice Vision 🕶 125 | 126 | If you'd like to see what is being stored in your hyper services, you can use 127 | [Hyper Vision](https://docs.hyper.io/hyper-vision) to peer into your services. 128 | 129 | Hyper Vision is a hosted, read-only, hyper service explorer that you can use to 130 | view your services. Just provide Hyper Vision with your application's hyper 131 | connection string (`process.env.HYPER`) and it will introspect your services! 132 | 133 | > If you're running `hyper nano` locally, you will need to use a proxy to make 134 | > it accessible on the internet. [ngrok](https://ngrok.com/) is a great tool for 135 | > this (though if you develop in ephemeral cloud environments like Gitpod, you 136 | > get this out of the box 😎). 137 | 138 | ## Clean Architecture With Hyper 139 | 140 | Hyper embraces the 141 | [Clean Architecture](https://blog.hyper.io/the-perfect-application-architecture/) 142 | approach to building software. This means separating side effects from business 143 | logic and striving to keep business logic separated from other details of the 144 | application. 145 | 146 | This has lots of benefits: 147 | 148 | - Business logic is _framework_ agnostic 149 | - Business logic is _infrastructure_ agnostic 150 | - Easier to test business logic (unit tests and TypeScript cover most of it!) 151 | - Separation of concerns 152 | 153 | **All of the business logic for this application can be found in 154 | [./app/services](./app/services)**. Each service receives its side effects via 155 | [dependency injection](https://martinfowler.com/articles/refactoring-dependencies.html#DependencyInjection) 156 | which are then easy to stub during unit testing. 157 | 158 | Our business `models` are simple schemas built on 159 | [zod](https://github.com/colinhacks/zod) used to validate the correctness of 160 | data flowing in and out of our business logic layer. 161 | 162 | > You could use anything to validate the contracts with your business logic, I 163 | > chose `zod` because it's what we use at hyper. `Joi`, `Yup`, there are tons of 164 | > options out there. 165 | 166 | Because all side effects are injected via dependency injection, the business 167 | logic is incredibly easy to test. The business logic is practically fully tested 168 | using **just unit tests**. (run `npm run test --coverage` to see for yourself) 169 | 170 | You can also see dependency injection in [./server.ts](./server.ts), which uses 171 | Remix's 172 | [`getLoadContext`](https://remix.run/docs/en/v1/other-api/adapter#createrequesthandler) 173 | to inject our business services and session handling into our `loaders` and 174 | `actions`, via `context`. 175 | 176 | Learn more 177 | [from our blog post](https://blog.hyper.io/introducing-the-alternative-stack/#cleanarchitecturewithhyper) 178 | 179 | ## Deployment 180 | 181 | This Remix Stack comes with two GitHub Actions that handle automatically 182 | deploying your app to production and staging environments. By default, Arc will 183 | deploy to the `us-west-2` region, if you wish to deploy to a different region, 184 | you'll need to change your 185 | [`app.arc`](https://arc.codes/docs/en/reference/project-manifest/aws) 186 | 187 | Alternatively, you can set `AWS_REGION` in 188 | [your GitHub repo's secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) 189 | 190 | ### Hyper Setup 191 | 192 | Each environment will need a hyper cloud application backing it. Since this 193 | stack has a `staging` and `production` environment, you will need a hyper cloud 194 | application for `staging` and then another for `production` (you get 3 hyper 195 | cloud applications for **free**, and you can always upgrade them later). Learn 196 | more [here](https://docs.hyper.io/subscriptions). 197 | 198 | Once the hyper applications have been created, set the `HYPER` environment 199 | variable in `production` and `staging` environments using Arc: 200 | 201 | ```sh 202 | npx arc env --add --env staging HYPER cloud://.... 203 | npx arc env --add --env production HYPER cloud://.... 204 | ``` 205 | 206 | > Alternatively, you can set `HYPER` for `staging` and `production` via 207 | > [your GitHub repo's secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) 208 | > as part of CI. 209 | > [See the `deploy` step in your `deploy` workflow](./.github/workflows/deploy.yml) 210 | 211 | ### Architect Setup 212 | 213 | Prior to your first deployment, you'll need to do a few things: 214 | 215 | - [Sign up](https://portal.aws.amazon.com/billing/signup#/start) and login to 216 | your AWS account 217 | 218 | - Add `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` and `AWS_REGION` to 219 | [your GitHub repo's secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets). 220 | Go to your AWS 221 | [security credentials](https://console.aws.amazon.com/iam/home?region=us-west-2#/security_credentials) 222 | and click on the "Access keys" tab, and then click "Create New Access Key", 223 | then you can copy those and add them to your repo's secrets. 224 | 225 | - Along with your AWS credentials, you'll also need to give your CloudFormation 226 | a `SESSION_SECRET` variable of its own for both staging and production 227 | environments, as well as an `ARC_APP_SECRET` for Arc itself. 228 | 229 | ```sh 230 | npx arc env --add --env staging ARC_APP_SECRET $(openssl rand -hex 32) 231 | npx arc env --add --env staging SESSION_SECRET $(openssl rand -hex 32) 232 | npx arc env --add --env production ARC_APP_SECRET $(openssl rand -hex 32) 233 | npx arc env --add --env production SESSION_SECRET $(openssl rand -hex 32) 234 | ``` 235 | 236 | > Alternatively, you can generate and set `ARC_APP_SECRET` and 237 | > `SESSION_SECRET` via 238 | > [your GitHub repo's secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) 239 | > as part of CI. 240 | > [See the `deploy` step in your `deploy` workflow](./.github/workflows/deploy.yml) 241 | 242 | If you don't have openssl installed, you can also use 243 | [1password](https://1password.com/password-generator) to generate a random 244 | secret, just replace `$(openssl rand -hex 32)` with the generated secret. 245 | 246 | ### Where do I find my CloudFormation? 247 | 248 | You can find the CloudFormation template that Architect generated for you in the 249 | sam.yaml file. 250 | 251 | To find it on AWS, you can search for 252 | [CloudFormation](https://console.aws.amazon.com/cloudformation/home) (make sure 253 | you're looking at the correct region!) and find the name of your stack (the name 254 | is a PascalCased version of what you have in `app.arc`, so by default it's 255 | AlternativeStackStaging and AlternativeStackProduction) that matches what's in 256 | `app.arc`, you can find all of your app's resources under the "Resources" tab. 257 | 258 | ## GitHub Actions 259 | 260 | We use GitHub Actions for continuous integration and deployment. Anything that 261 | gets into the `main` branch will be deployed to production after running 262 | tests/build/etc. Anything in the `dev` branch will be deployed to staging. 263 | 264 | ## Testing 265 | 266 | ### Cypress 267 | 268 | We use Cypress for our End-to-End tests in this project. You'll find those in 269 | the `cypress` directory. As you make changes, add to an existing file or create 270 | a new file in the `cypress/e2e` directory to test your changes. 271 | 272 | We use [`@testing-library/cypress`](https://testing-library.com/cypress) for 273 | selecting elements on the page semantically. 274 | 275 | To run these tests in development, run `npm run test:e2e:dev` which will start 276 | the dev server for the app as well as the Cypress client. Make sure the database 277 | is running in docker as described above. 278 | 279 | We have a utility for testing authenticated features without having to go 280 | through the login flow: 281 | 282 | ```ts 283 | cy.login(); 284 | // you are now logged in as a new user 285 | ``` 286 | 287 | We also have a utility to auto-delete the user at the end of your test. Just 288 | make sure to add this in each test file: 289 | 290 | ```ts 291 | afterEach(() => { 292 | cy.cleanupUser(); 293 | }); 294 | ``` 295 | 296 | That way, we can keep your local db clean and keep your tests isolated from one 297 | another. 298 | 299 | ### Vitest 300 | 301 | For lower level tests of utilities and individual components, we use `vitest`. 302 | We have DOM-specific assertion helpers via 303 | [`@testing-library/jest-dom`](https://testing-library.com/jest-dom). 304 | 305 | ### Type Checking 306 | 307 | This project uses TypeScript. It's recommended to get TypeScript set up for your 308 | editor to get a really great in-editor experience with type checking and 309 | auto-complete. To run type checking across the whole project, run 310 | `npm run typecheck`. 311 | 312 | ### Linting 313 | 314 | This project uses ESLint for linting. That is configured in `.eslintrc.js`. 315 | 316 | ### Formatting 317 | 318 | We use [Prettier](https://prettier.io/) for auto-formatting in this project. 319 | It's recommended to install an editor plugin (like the 320 | [VSCode Prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) 321 | to get auto-formatting on save. There's also a `npm run format` script you can 322 | run to format all files in the project. 323 | 324 | ## Thank You 325 | 326 | We at hyper are very excited about Remix and Remix stacks. A huge shout out to 327 | the [Remix team](https://remix.run/) and to 328 | [Kent C. Dodds](https://kentcdodds.com/) for showing us this cool new feature. 329 | 330 | Also thank you to the all the maintainers of open source projects that we use. 331 | In particular: 332 | 333 | - [Architect](https://arc.codes) 334 | - [Zod](https://github.com/colinhacks/zod) 335 | - [Ramda](https://ramdajs.com/) 336 | - [Vitest](https://vitest.dev/) 337 | - [Tailwind](https://tailwindcss.com/) 338 | 339 | and many many more! 340 | -------------------------------------------------------------------------------- /app.arc: -------------------------------------------------------------------------------- 1 | @app 2 | alternative-stack 3 | 4 | @http 5 | /* 6 | method any 7 | src server 8 | 9 | @static 10 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { hydrate } from "react-dom"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from "@remix-run/node"; 2 | import { RemixServer } from "@remix-run/react"; 3 | import { renderToString } from "react-dom/server"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | const markup = renderToString(); 12 | 13 | responseHeaders.set("Content-Type", "text/html"); 14 | 15 | return new Response("" + markup, { 16 | status: responseStatusCode, 17 | headers: responseHeaders, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { LinksFunction, LoaderFunction, MetaFunction } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; 4 | 5 | import tailwindStylesheetUrl from "./styles/tailwind.css"; 6 | import type { User } from "./services/models/user"; 7 | import type { LoaderContext } from "./types"; 8 | 9 | export const links: LinksFunction = () => { 10 | return [ 11 | { rel: "stylesheet", href: tailwindStylesheetUrl }, 12 | // NOTE: Architect deploys the public directory to /_static/ 13 | { rel: "icon", href: "/_static/favicon.ico" }, 14 | ]; 15 | }; 16 | 17 | export const meta: MetaFunction = () => ({ 18 | charset: "utf-8", 19 | title: "Remix Notes", 20 | viewport: "width=device-width,initial-scale=1", 21 | }); 22 | 23 | type LoaderData = { 24 | user: User; 25 | }; 26 | 27 | export const loader: LoaderFunction = async ({ request, context }) => { 28 | const { SessionServer } = context as LoaderContext; 29 | return json({ 30 | user: (await SessionServer.getUser(request)) as User, 31 | }); 32 | }; 33 | 34 | export default function App() { 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | 3 | import { useOptionalUser } from "~/utils"; 4 | 5 | export default function Index() { 6 | const user = useOptionalUser(); 7 | return ( 8 |
9 |
10 |
11 |
12 |
13 | Soundgarden playing live 18 |
19 |
20 |
21 |

22 | 23 | 24 | Alternative Stack 25 | 26 | 27 |

28 |

29 | A Remix Stack using Hyper as a services tier 30 |

31 |

32 | Check the README.md file for instructions on how to get this project deployed. 33 |

34 |
35 | {user ? ( 36 | 40 | View Notes for {user.email} 41 | 42 | ) : ( 43 |
44 | 48 | Sign up 49 | 50 | 54 | Log In 55 | 56 |
57 | )} 58 |
59 | 60 | Remix 65 | 66 | 67 | Hyper Bolt 72 | 73 |
74 |
75 |
76 | 77 |
78 |
79 | {[ 80 | { 81 | src: "https://raw.githubusercontent.com/hyper63/logos/7d426e3e53c2d8eb626923714018ff4b866c9009/hyper-logo.svg", 82 | alt: "hyper cloud", 83 | href: "https://hyper.io", 84 | }, 85 | { 86 | src: "https://raw.githubusercontent.com/hyper63/logos/7d426e3e53c2d8eb626923714018ff4b866c9009/data.svg", 87 | alt: "hyper Data", 88 | href: "https://hyper.io/product#data", 89 | }, 90 | { 91 | src: "https://user-images.githubusercontent.com/1500684/157990874-31f015c3-2af7-4669-9d61-519e5ecfdea6.svg", 92 | alt: "Architect", 93 | href: "https://arc.codes", 94 | }, 95 | { 96 | src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg", 97 | alt: "Tailwind", 98 | href: "https://tailwindcss.com", 99 | }, 100 | { 101 | src: "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg", 102 | alt: "Cypress", 103 | href: "https://www.cypress.io", 104 | }, 105 | { 106 | src: "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg", 107 | alt: "MSW", 108 | href: "https://mswjs.io", 109 | }, 110 | { 111 | src: "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg", 112 | alt: "Vitest", 113 | href: "https://vitest.dev", 114 | }, 115 | { 116 | src: "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png", 117 | alt: "Testing Library", 118 | href: "https://testing-library.com", 119 | }, 120 | { 121 | src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg", 122 | alt: "Prettier", 123 | href: "https://prettier.io", 124 | }, 125 | { 126 | src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg", 127 | alt: "ESLint", 128 | href: "https://eslint.org", 129 | }, 130 | { 131 | src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg", 132 | alt: "TypeScript", 133 | href: "https://typescriptlang.org", 134 | }, 135 | ].map((img) => ( 136 | 141 | {img.alt} 142 | 143 | ))} 144 |
145 |
146 |
147 |
148 | ); 149 | } 150 | -------------------------------------------------------------------------------- /app/routes/join.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { ActionFunction, LoaderFunction, MetaFunction } from "@remix-run/node"; 3 | import { json, redirect } from "@remix-run/node"; 4 | import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; 5 | import { z } from "zod"; 6 | 7 | import { ConflictError } from "~/services/models/err"; 8 | 9 | import type { LoaderContext } from "~/types"; 10 | 11 | export const loader: LoaderFunction = async ({ request, context }) => { 12 | const { SessionServer } = context as LoaderContext; 13 | 14 | const userId = await SessionServer.getUserId(request); 15 | if (userId) return redirect("/"); 16 | return json({}); 17 | }; 18 | 19 | interface ActionData { 20 | errors: { 21 | email?: string; 22 | password?: string; 23 | }; 24 | } 25 | 26 | const FormDataSchema = z.object({ 27 | email: z.string().email(), 28 | password: z.string().min(8), 29 | }); 30 | 31 | export const action: ActionFunction = async ({ request, context }) => { 32 | const { UserServer, SessionServer } = context as LoaderContext; 33 | 34 | const formData = await request.formData(); 35 | const redirectTo = formData.get("redirectTo"); 36 | 37 | const parsed = FormDataSchema.safeParse({ 38 | email: formData.get("email"), 39 | password: formData.get("password"), 40 | }); 41 | 42 | if (!parsed.success) { 43 | const errors = parsed.error.format(); 44 | return json( 45 | { 46 | errors: { 47 | email: errors.email?._errors.join(". "), 48 | password: errors.password?._errors.join(". "), 49 | }, 50 | }, 51 | { status: 400 } 52 | ); 53 | } 54 | 55 | const { email, password } = parsed.data; 56 | 57 | try { 58 | const user = await UserServer.createUser(email, password); 59 | return SessionServer.createUserSession({ 60 | request, 61 | userId: user.id, 62 | remember: false, 63 | redirectTo: typeof redirectTo === "string" ? redirectTo : "/", 64 | }); 65 | } catch (err) { 66 | if (err instanceof ConflictError) { 67 | return json( 68 | { errors: { email: "A user already exists with this email" } }, 69 | { status: 400 } 70 | ); 71 | } 72 | 73 | throw err; 74 | } 75 | }; 76 | 77 | export const meta: MetaFunction = () => { 78 | return { 79 | title: "Sign Up", 80 | }; 81 | }; 82 | 83 | export default function Join() { 84 | const [searchParams] = useSearchParams(); 85 | const redirectTo = searchParams.get("redirectTo") ?? undefined; 86 | const actionData = useActionData() as ActionData; 87 | const emailRef = React.useRef(null); 88 | const passwordRef = React.useRef(null); 89 | 90 | React.useEffect(() => { 91 | if (actionData?.errors?.email) { 92 | emailRef.current?.focus(); 93 | } else if (actionData?.errors?.password) { 94 | passwordRef.current?.focus(); 95 | } 96 | }, [actionData]); 97 | 98 | return ( 99 |
100 |
101 |
102 |
103 | 106 |
107 | 119 | {actionData?.errors?.email && ( 120 |
121 | {actionData.errors.email} 122 |
123 | )} 124 |
125 |
126 | 127 |
128 | 131 |
132 | 142 | {actionData?.errors?.password && ( 143 |
144 | {actionData.errors.password} 145 |
146 | )} 147 |
148 |
149 | 150 | 151 | 157 |
158 |
159 | Already have an account?{" "} 160 | 167 | Log in 168 | 169 |
170 |
171 |
172 |
173 |
174 | ); 175 | } 176 | -------------------------------------------------------------------------------- /app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { ActionFunction, LoaderFunction, MetaFunction } from "@remix-run/node"; 3 | import { json, redirect } from "@remix-run/node"; 4 | import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; 5 | import z from "zod"; 6 | 7 | import { NotFoundError, UnauthorizedError } from "~/services/models/err"; 8 | import type { LoaderContext } from "~/types"; 9 | 10 | export const loader: LoaderFunction = async ({ request, context }) => { 11 | const { SessionServer } = context as LoaderContext; 12 | 13 | const userId = await SessionServer.getUserId(request); 14 | if (userId) return redirect("/"); 15 | return json({}); 16 | }; 17 | 18 | const FormDataSchema = z.object({ 19 | email: z.string().email(), 20 | password: z.string().min(8), 21 | }); 22 | 23 | interface ActionData { 24 | errors?: { 25 | email?: string; 26 | password?: string; 27 | }; 28 | } 29 | 30 | export const action: ActionFunction = async ({ request, context }) => { 31 | const { UserServer, SessionServer } = context as LoaderContext; 32 | 33 | const formData = await request.formData(); 34 | const redirectTo = formData.get("redirectTo"); 35 | const remember = formData.get("remember"); 36 | 37 | const parsed = FormDataSchema.safeParse({ 38 | email: formData.get("email"), 39 | password: formData.get("password"), 40 | }); 41 | 42 | if (!parsed.success) { 43 | const errors = parsed.error.format(); 44 | return json( 45 | { 46 | errors: { 47 | email: errors.email?._errors.join(". "), 48 | password: errors.password?._errors.join(". "), 49 | }, 50 | }, 51 | { status: 400 } 52 | ); 53 | } 54 | 55 | const { email, password } = parsed.data; 56 | 57 | try { 58 | const user = await UserServer.verifyLogin(email, password); 59 | return SessionServer.createUserSession({ 60 | request, 61 | userId: user.id, 62 | remember: remember === "on" ? true : false, 63 | redirectTo: typeof redirectTo === "string" ? redirectTo : "/notes", 64 | }); 65 | } catch (err) { 66 | if (err instanceof UnauthorizedError || err instanceof NotFoundError) { 67 | return json({ errors: { email: "Invalid email or password" } }, { status: 400 }); 68 | } 69 | 70 | throw err; 71 | } 72 | }; 73 | 74 | export const meta: MetaFunction = () => { 75 | return { 76 | title: "Login", 77 | }; 78 | }; 79 | 80 | export default function LoginPage() { 81 | const [searchParams] = useSearchParams(); 82 | const redirectTo = searchParams.get("redirectTo") || "/notes"; 83 | const actionData = useActionData() as ActionData; 84 | const emailRef = React.useRef(null); 85 | const passwordRef = React.useRef(null); 86 | 87 | React.useEffect(() => { 88 | if (actionData?.errors?.email) { 89 | emailRef.current?.focus(); 90 | } else if (actionData?.errors?.password) { 91 | passwordRef.current?.focus(); 92 | } 93 | }, [actionData]); 94 | 95 | return ( 96 |
97 |
98 |
99 |
100 | 103 |
104 | 116 | {actionData?.errors?.email && ( 117 |
118 | {actionData.errors.email} 119 |
120 | )} 121 |
122 |
123 | 124 |
125 | 128 |
129 | 139 | {actionData?.errors?.password && ( 140 |
141 | {actionData.errors.password} 142 |
143 | )} 144 |
145 |
146 | 147 | 148 | 154 |
155 |
156 | 162 | 165 |
166 |
167 | Don't have an account?{" "} 168 | 175 | Sign up 176 | 177 |
178 |
179 |
180 |
181 |
182 | ); 183 | } 184 | -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | 4 | import type { LoaderContext } from "~/types"; 5 | 6 | export const action: ActionFunction = async ({ request, context }) => { 7 | const { SessionServer } = context as LoaderContext; 8 | 9 | return SessionServer.logout(request); 10 | }; 11 | 12 | export const loader: LoaderFunction = async () => { 13 | return redirect("/"); 14 | }; 15 | -------------------------------------------------------------------------------- /app/routes/notes.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react"; 4 | 5 | import { useUser } from "~/utils"; 6 | import type { Note } from "~/services/models/note"; 7 | import type { LoaderContext } from "~/types"; 8 | 9 | type LoaderData = { 10 | noteListItems: Array; 11 | }; 12 | 13 | export const loader: LoaderFunction = async ({ request, context }) => { 14 | const { SessionServer, NoteServer } = context as LoaderContext; 15 | 16 | const parent = await SessionServer.requireUserId(request); 17 | const noteListItems = await NoteServer.getNotesByParent({ parent }); 18 | return json({ noteListItems }); 19 | }; 20 | 21 | export default function NotesPage() { 22 | const data = useLoaderData() as LoaderData; 23 | const user = useUser(); 24 | 25 | return ( 26 |
27 |
28 |

29 | Notes 30 |

31 |

{user.email}

32 |
33 | 39 |
40 |
41 | 42 |
43 |
44 | 45 | + New Note 46 | 47 | 48 |
49 | 50 | {data.noteListItems.length === 0 ? ( 51 |

No notes yet

52 | ) : ( 53 |
    54 | {data.noteListItems.map((note) => ( 55 |
  1. 56 | 58 | `block border-b p-4 text-xl ${isActive ? "bg-white" : ""}` 59 | } 60 | to={note.id} 61 | > 62 | 📝 {note.title} 63 | 64 |
  2. 65 | ))} 66 |
67 | )} 68 |
69 | 70 |
71 | 72 |
73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /app/routes/notes/$noteId.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction } from "@remix-run/node"; 2 | import { json, redirect } from "@remix-run/node"; 3 | import { Form, useCatch, useLoaderData } from "@remix-run/react"; 4 | import { z } from "zod"; 5 | 6 | import type { Note } from "~/services/models/note"; 7 | import type { LoaderContext } from "~/types"; 8 | 9 | type LoaderData = { 10 | note: Note; 11 | }; 12 | 13 | const NoteIdSchema = z.string().min(1); 14 | 15 | function checkNote(note: Note, parent: string) { 16 | if (note.parent !== parent) { 17 | console.warn(`user ${parent} attempted to access note ${note.id} belonging to ${note.parent}`); 18 | throw new Response("Forbidden", { status: 403 }); 19 | } 20 | } 21 | 22 | export const loader: LoaderFunction = async ({ request, params, context }) => { 23 | const { SessionServer, NoteServer } = context as LoaderContext; 24 | 25 | const parent = await SessionServer.requireUserId(request); 26 | 27 | const parsed = NoteIdSchema.safeParse(params.noteId); 28 | 29 | if (!parsed.success) { 30 | throw new Response("noteId query param required", { status: 404 }); 31 | } 32 | 33 | const note = await NoteServer.getNote({ id: parsed.data }); 34 | if (!note) { 35 | throw new Response("Not Found", { status: 404 }); 36 | } 37 | 38 | checkNote(note, parent); 39 | 40 | return json({ note }); 41 | }; 42 | 43 | export const action: ActionFunction = async ({ request, params, context }) => { 44 | const { SessionServer, NoteServer } = context as LoaderContext; 45 | 46 | const parent = await SessionServer.requireUserId(request); 47 | 48 | const parsed = NoteIdSchema.safeParse(params.noteId); 49 | 50 | if (!parsed.success) { 51 | throw new Response("noteId query param required", { status: 404 }); 52 | } 53 | 54 | const note = await NoteServer.getNote({ id: parsed.data }); 55 | if (!note) { 56 | throw new Response("Not Found", { status: 404 }); 57 | } 58 | 59 | checkNote(note, parent); 60 | 61 | await NoteServer.deleteNote(note); 62 | 63 | return redirect("/notes"); 64 | }; 65 | 66 | export default function NoteDetailsPage() { 67 | const data = useLoaderData() as LoaderData; 68 | 69 | return ( 70 |
71 |

{data.note.title}

72 |

{data.note.body}

73 |
74 |
75 | 81 |
82 |
83 | ); 84 | } 85 | 86 | export function ErrorBoundary({ error }: { error: Error }) { 87 | console.error(error); 88 | 89 | return
An unexpected error occurred: {error.message}
; 90 | } 91 | 92 | export function CatchBoundary() { 93 | const caught = useCatch(); 94 | 95 | if (caught.status === 404) { 96 | return
Note not found
; 97 | } 98 | 99 | throw new Error(`Unexpected caught response with status: ${caught.status}`); 100 | } 101 | -------------------------------------------------------------------------------- /app/routes/notes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | 3 | export default function NoteIndexPage() { 4 | return ( 5 |

6 | No note selected. Select a note on the left, or{" "} 7 | 8 | create a new note. 9 | 10 |

11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/routes/notes/new.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { ActionFunction } from "@remix-run/node"; 3 | import { json, redirect } from "@remix-run/node"; 4 | import { Form, useActionData } from "@remix-run/react"; 5 | import { z } from "zod"; 6 | 7 | import type { LoaderContext } from "~/types"; 8 | 9 | type ActionData = { 10 | errors?: { 11 | title?: string; 12 | body?: string; 13 | }; 14 | }; 15 | 16 | const FormDataSchema = z.object({ 17 | title: z.string().min(1), 18 | body: z.string().min(1), 19 | }); 20 | 21 | export const action: ActionFunction = async ({ request, context }) => { 22 | const { SessionServer, NoteServer } = context as LoaderContext; 23 | 24 | const parent = await SessionServer.requireUserId(request); 25 | 26 | const formData = await request.formData(); 27 | 28 | const parsed = FormDataSchema.safeParse({ 29 | title: formData.get("title") ?? null, 30 | body: formData.get("body") ?? null, 31 | }); 32 | 33 | if (!parsed.success) { 34 | const errors = parsed.error.format(); 35 | return json( 36 | { 37 | errors: { 38 | title: errors.title?._errors.join(". "), 39 | body: errors.body?._errors.join(". "), 40 | }, 41 | }, 42 | { status: 400 } 43 | ); 44 | } 45 | 46 | const { title, body } = parsed.data; 47 | 48 | const note = await NoteServer.createNote({ title, body, parent }); 49 | 50 | return redirect(`/notes/${note.id}`); 51 | }; 52 | 53 | export default function NewNotePage() { 54 | const actionData = useActionData() as ActionData; 55 | const titleRef = React.useRef(null); 56 | const bodyRef = React.useRef(null); 57 | 58 | React.useEffect(() => { 59 | if (actionData?.errors?.title) { 60 | titleRef.current?.focus(); 61 | } else if (actionData?.errors?.body) { 62 | bodyRef.current?.focus(); 63 | } 64 | }, [actionData]); 65 | 66 | return ( 67 |
76 |
77 | 87 | {actionData?.errors?.title && ( 88 |
89 | {actionData.errors.title} 90 |
91 | )} 92 |
93 | 94 |
95 |