diff --git a/apps/docs/.env.example b/apps/docs/.env.example new file mode 100644 index 0000000..846b3b0 --- /dev/null +++ b/apps/docs/.env.example @@ -0,0 +1,51 @@ +## --- Notes ----------------------------------------------------------------------------------- */ + +## -i- The `.env.example` file can be copied into `.env.local` using `npx turbo env:local` +## -i- For development, staging & production environments, check the next.js docs: +## -i- https://nextjs.org/docs/app/building-your-application/configuring/environment-variables + +## -i- Note that you should treat environment variables as if they could be inlined in your bundle during builds & deployments +## -i- This means dynamically retrieving environment variables from e.g. `process.env[someKey]` might not work +## -i- It also means that you should never prefix with `NEXT_PUBLIC_` for sensitive / private keys + +## -i- We suggest that for each environment variable you add here, you also add an entry in `appConfig.ts` +## -i- There, you can add logic like ```envValue: process.env.NEXT_PUBLIC_ENV_KEY || process.env.EXPO_PUBLIC_ENV_KEY``` +## -i- Where you would only define the NEXT_PUBLIC_ prefixed versions here in `.env.local` locally and using Next.js UI for deployed envs +## -i- For environment variables you only be available server-side, you can omit `NEXT_PUBLIC_` + +NEXT_PUBLIC_APP_ENV=next + +## --- General --------------------------------------------------------------------------------- */ +## -i- Env vars that should always be present & the same locally, independent of the simulated environment +## --------------------------------------------------------------------------------------------- */ + +APP_SECRET="your-secret-here" # used for signing header context, generate a random string + +## --- LOCAL ----------------------------------------------------------------------------------- */ +## -i- Defaults you might want to switch out for local development by commenting / uncommenting +## --------------------------------------------------------------------------------------------- */ + +NEXT_PUBLIC_BASE_URL=http://localhost:3000 +NEXT_PUBLIC_BACKEND_URL=http://localhost:3000 +NEXT_PUBLIC_API_URL=http://localhost:3000/api +NEXT_PUBLIC_GRAPH_URL=http://localhost:3000/api/graphql + +# DB_URL= # TODO: Add DB layer connection for full local dev... + +## --- DEV ------------------------------------------------------------------------------------- */ +# -i- Uncomment while on development branch to simulate the dev environment +## --------------------------------------------------------------------------------------------- */ + +# DB_URL= # TODO: Add DB layer connection for the dev environment... + +## --- STAGE ----------------------------------------------------------------------------------- */ +# -i- Uncomment while on staging branch to simulate the stage environment +## --------------------------------------------------------------------------------------------- */ + +# DB_URL= # TODO: Add DB layer connection for the stage environment... + +## --- PROD ------------------------------------------------------------------------------------ */ +# -i- Uncomment while on main branch to simulate the production environment +## --------------------------------------------------------------------------------------------- */ + +# DB_URL= # TODO: Add DB layer connection for the production environment... diff --git a/apps/docs/babel.config.js b/apps/docs/babel.config.js new file mode 100644 index 0000000..91dfe76 --- /dev/null +++ b/apps/docs/babel.config.js @@ -0,0 +1,14 @@ +// babel.config.js +module.exports = function (api) { + api.cache(true); + return { + presets: ["babel-preset-expo"], + plugins: [ + ['@babel.plugin-transform-react-jsx', { + runtime: 'automatic', + importSource: 'nativewind', + jsxImportSource: 'nativewind', + }] + ] + } +} diff --git a/apps/docs/components/Button.docs.tsx b/apps/docs/components/Button.docs.tsx new file mode 100644 index 0000000..fc0d502 --- /dev/null +++ b/apps/docs/components/Button.docs.tsx @@ -0,0 +1,8 @@ +import * as AppButton from '@app/components/Button' + +/* --- Documentation overrides? ---------------------------------------------------------------- */ + +// -i- Optionally wrap and edit these to restyle the component for the docs + +export const Button = AppButton.Button +export const ButtonProps = AppButton.ButtonProps diff --git a/apps/docs/components/Checkbox.docs.tsx b/apps/docs/components/Checkbox.docs.tsx new file mode 100644 index 0000000..27c892d --- /dev/null +++ b/apps/docs/components/Checkbox.docs.tsx @@ -0,0 +1,8 @@ +import * as AppCheckbox from '@app/core/forms/Checkbox.styled' + +/* --- Documentation overrides? ---------------------------------------------------------------- */ + +// -i- Optionally wrap and edit these to restyle the component for the docs + +export const Checkbox = AppCheckbox.Checkbox +export const CheckboxProps = AppCheckbox.CheckboxProps diff --git a/apps/docs/components/Checklist.docs.tsx b/apps/docs/components/Checklist.docs.tsx new file mode 100644 index 0000000..8aa5c58 --- /dev/null +++ b/apps/docs/components/Checklist.docs.tsx @@ -0,0 +1,8 @@ +import * as AppCheckList from '@app/core/forms/CheckList.styled' + +/* --- Documentation overrides? ---------------------------------------------------------------- */ + +// -i- Optionally wrap and edit these to restyle the component for the docs + +export const CheckList = AppCheckList.CheckList +export const CheckListProps = AppCheckList.CheckListProps diff --git a/apps/docs/components/NumberStepper.docs.tsx b/apps/docs/components/NumberStepper.docs.tsx new file mode 100644 index 0000000..0fc07c7 --- /dev/null +++ b/apps/docs/components/NumberStepper.docs.tsx @@ -0,0 +1,12 @@ +import * as AppNumberStepper from '@app/core/forms/NumberStepper.styled' +import { styled } from '@app/primitives' + +/* --- Documentation overrides? ---------------------------------------------------------------- */ + +// -i- Optionally wrap and edit these to restyle the component for the docs + +export const NumberStepper = styled(AppNumberStepper.NumberStepper, '', { + textInputClassName: 'bg-transparent', +}) + +export const NumberStepperProps = AppNumberStepper.NumberStepperProps diff --git a/apps/docs/components/RadioGroup.docs.tsx b/apps/docs/components/RadioGroup.docs.tsx new file mode 100644 index 0000000..bb42006 --- /dev/null +++ b/apps/docs/components/RadioGroup.docs.tsx @@ -0,0 +1,14 @@ +import * as AppRadioGroup from '@app/core/forms/RadioGroup.styled' + +/* --- Documentation overrides? ---------------------------------------------------------------- */ + +// -i- Optionally wrap and edit these to restyle the component for the docs + +export const RadioGroup = AppRadioGroup.RadioGroup +export const RadioGroupProps = AppRadioGroup.RadioGroupProps + +export const RadioGroupContext = AppRadioGroup.RadioGroupContext +export const useRadioGroupContext = AppRadioGroup.useRadioGroupContext + +export const RadioButton = AppRadioGroup.RadioButton +export const RadioButtonProps = AppRadioGroup.RadioButtonProps diff --git a/apps/docs/components/Select.docs.tsx b/apps/docs/components/Select.docs.tsx new file mode 100644 index 0000000..54832b3 --- /dev/null +++ b/apps/docs/components/Select.docs.tsx @@ -0,0 +1,40 @@ +import * as AppSelect from '@app/core/forms/Select.styled' +import { styled } from '@app/primitives' + +/* --- Documentation overrides? ---------------------------------------------------------------- */ + +// -i- Optionally wrap and edit these to restyle the component for the docs + +export const SelectContext = AppSelect.SelectContext +export const useSelectContext = AppSelect.useSelectContext + +export const SelectTrigger = AppSelect.SelectTrigger +export const SelectTriggerProps = AppSelect.SelectTriggerProps + +export const SelectScrollButton = AppSelect.SelectScrollButton +export const SelectScrollButtonProps = AppSelect.SelectScrollButtonProps + +export const SelectContent = AppSelect.SelectContent +export const SelectContentProps = AppSelect.SelectContentProps + +export const SelectLabel = AppSelect.SelectLabel +export const SelectLabelProps = AppSelect.SelectLabelProps + +export const SelectItem = AppSelect.SelectItem +export const SelectItemProps = AppSelect.SelectItemProps + +export const SelectSeparator = AppSelect.SelectSeparator +export const SelectSeparatorProps = AppSelect.SelectSeparatorProps + +export const SelectProps = AppSelect.SelectProps +export const Select = Object.assign(styled(AppSelect.Select, 'bg-transparent', { + triggerClassName: 'bg-transparent', +}), { + displayName: 'Select', + Option: SelectItem, + Item: SelectItem, + Separator: SelectSeparator, + Group: AppSelect.Select.Group, + Label: SelectLabel, + Content: SelectContent, +}) diff --git a/apps/docs/components/Switch.docs.tsx b/apps/docs/components/Switch.docs.tsx new file mode 100644 index 0000000..95619c6 --- /dev/null +++ b/apps/docs/components/Switch.docs.tsx @@ -0,0 +1,8 @@ +import * as AppSwitch from '@app/core/forms/Switch.styled' + +/* --- Documentation overrides? ---------------------------------------------------------------- */ + +// -i- Optionally wrap and edit these to restyle the component for the docs + +export const Switch = AppSwitch.Switch +export const SwitchProps = AppSwitch.SwitchProps diff --git a/apps/docs/components/TextArea.docs.tsx b/apps/docs/components/TextArea.docs.tsx new file mode 100644 index 0000000..8331400 --- /dev/null +++ b/apps/docs/components/TextArea.docs.tsx @@ -0,0 +1,9 @@ +import * as AppTextArea from '@app/core/forms/TextArea.styled' +import { styled } from '@app/primitives' + +/* --- Documentation overrides? ---------------------------------------------------------------- */ + +// -i- Optionally wrap and edit these to restyle the component for the docs + +export const TextArea = styled(AppTextArea.TextArea, 'bg-transparent') +export const TextAreaProps = AppTextArea.TextAreaProps diff --git a/apps/docs/components/TextInput.docs.tsx b/apps/docs/components/TextInput.docs.tsx new file mode 100644 index 0000000..c0c3d8f --- /dev/null +++ b/apps/docs/components/TextInput.docs.tsx @@ -0,0 +1,9 @@ +import * as AppTextInput from '@app/core/forms/TextInput.styled' +import { styled } from '@app/primitives' + +/* --- Documentation overrides? ---------------------------------------------------------------- */ + +// -i- Optionally wrap and edit these to restyle the component for the docs + +export const TextInput = styled(AppTextInput.TextInput, 'bg-transparent') +export const TextInputProps = AppTextInput.TextInputProps diff --git a/apps/docs/docs.theme.jsx b/apps/docs/docs.theme.jsx new file mode 100644 index 0000000..cb22912 --- /dev/null +++ b/apps/docs/docs.theme.jsx @@ -0,0 +1,203 @@ +import { useRouter } from 'next/router' + +/* --- Theme ----------------------------------------------------------------------------------- */ + +/** @type {import('nextra-theme-docs').DocsThemeConfig} */ +export default { + logo: ( +
+ +
+ FullProduct.dev ⚡️ Universal App Starter +
+ ), + logoLink: 'https://fullproduct.dev', + project: { + link: 'https://github.com/Aetherspace/green-stack-starter-demo?tab=readme-ov-file#:Rr9ab:', + }, + navigation: true, + sidebar: { + autoCollapse: true, + defaultMenuCollapseLevel: 3, + toggleButton: true, + }, + docsRepositoryBase: 'https://github.com/Aetherspace/green-stack-starter-demo', + editLink: { + component: null, + }, + darkMode: true, + footer: { + content: ( +
+
+
+ +
+ FullProduct.dev Starterkit Logo +
+
+
+
+ FullProduct.dev 🚀 +
+
+ Universal Base Starterkit +
+
+
+
+ +
+
+
+ By +
+
+
+
+ Thorr / codinsonn's Profile Picture +
+
+
+
+ Thorr ⚡️ codinsonn.dev +
+
+
+
+
+
+ FullProduct.dev is a product of 'Aetherspace Digital' (registered in Belgium under 0757.590.784) +
+
+
+ For support or inquiries, please contact us +
+
+
+
+
+
+ The GREEN stack +
+
+ + GraphQL + +
+ + React + +
+ + Expo + +
+ + Next.js + +
+
+
+
+
+ Product +
+
+ + Starterkit Docs + +
+ + Sign-Up + +
+ + Sign-In + +
+ + Demo + +
+
+
+
+
+ Legal +
+ + ), + }, + useNextSeoProps() { + const { asPath } = useRouter() + if (asPath === '/') { + return { + title: 'FullProduct.dev ⚡️ Universal Base Starter', + } + } else if (asPath.includes('plugins')) { + return { + titleTemplate: 'FullProduct.dev ⚡️ %s Plugin - Universal Base Starter Docs', + } + } + return { + titleTemplate: 'FullProduct.dev | %s - Universal Base Starter Docs', + } + } +} diff --git a/apps/docs/next-env.d.ts b/apps/docs/next-env.d.ts new file mode 100644 index 0000000..a4a7b3f --- /dev/null +++ b/apps/docs/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs new file mode 100644 index 0000000..c824223 --- /dev/null +++ b/apps/docs/next.config.mjs @@ -0,0 +1,14 @@ +import { withExpo } from '@expo/next-adapter' +import nextra from 'nextra' + +const withNextra = nextra({ + theme: "nextra-theme-docs", + themeConfig: "./docs.theme.jsx", +}); + +import mainNextConfig from '@app/next/next.config.base.cjs' + +/** @type {import('next').NextConfig} */ +const nextConfig = withNextra(withExpo(mainNextConfig)) + +export default nextConfig diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 0000000..f221ee6 --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,21 @@ +{ + "name": "@app/docs", + "version": "1.0.0", + "private": true, + "dependencies": { + "@mdx-js/loader": "^3.1.0", + "@mdx-js/react": "^3.1.0", + "@next/mdx": "~15.0.4", + "@types/mdx": "^2.0.13", + "next": "~15.0.4", + "nextra": "^3.2.4", + "nextra-theme-docs": "^3.2.4" + }, + "scripts": { + "dev": "next -p 4000", + "build": "node -v && next build", + "start": "next start -p 4000", + "env:local": "cp .env.example .env.local", + "regenerate:docs": "npm -w @green-stack/core run run:script ../../apps/docs/scripts/regenerate-docs.ts" + } +} diff --git a/apps/docs/pages/@app-core/components/Button.mdx b/apps/docs/pages/@app-core/components/Button.mdx new file mode 100644 index 0000000..2380b91 --- /dev/null +++ b/apps/docs/pages/@app-core/components/Button.mdx @@ -0,0 +1,41 @@ + +import { Button, getDocumentationProps } from '@app/components/Button' +import { ComponentDocs } from '@app/core/mdx/ComponentDocs' +import { FileTree, Callout } from 'nextra/components' + +# Button + +```typescript copy +import { Button } from '@app/components/Button' +``` + + + +### Location + +You can find the source of the `Button` component in the following location: + + + + + + + + + + + +### Disclaimer - Automatic Docgen + + +These dynamic component docs were auto-generated with `npm run regenerate-docs`. You can hook into automatic docgen by exporting `getDocumentationProps` from a component file. You'll want to provide example props from the ComponentProps zod schema, e.g: + + +```tsx /getDocumentationProps/ /documentationProps/ copy filename="Button.tsx" +/* --- Docs ---------------------- */ + +export const getDocumentationProps = ButtonProps.documentationProps('Button') +``` diff --git a/apps/docs/pages/@app-core/components/_meta.ts b/apps/docs/pages/@app-core/components/_meta.ts new file mode 100644 index 0000000..8ecdb97 --- /dev/null +++ b/apps/docs/pages/@app-core/components/_meta.ts @@ -0,0 +1,4 @@ + +export default { + Button: 'Button', +} diff --git a/apps/docs/pages/@app-core/forms/CheckList.mdx b/apps/docs/pages/@app-core/forms/CheckList.mdx new file mode 100644 index 0000000..c45bf39 --- /dev/null +++ b/apps/docs/pages/@app-core/forms/CheckList.mdx @@ -0,0 +1,41 @@ + +import { CheckList, getDocumentationProps } from '@app/forms/CheckList.styled' +import { ComponentDocs } from '@app/core/mdx/ComponentDocs' +import { FileTree, Callout } from 'nextra/components' + +# CheckList + +```typescript copy +import { CheckList } from '@app/forms/CheckList.styled' +``` + + + +### Location + +You can find the source of the `CheckList` component in the following location: + + + + + + + + + + + +### Disclaimer - Automatic Docgen + + +These dynamic component docs were auto-generated with `npm run regenerate-docs`. You can hook into automatic docgen by exporting `getDocumentationProps` from a component file. You'll want to provide example props from the ComponentProps zod schema, e.g: + + +```tsx /getDocumentationProps/ /documentationProps/ copy filename="CheckList.styled.tsx" +/* --- Docs ---------------------- */ + +export const getDocumentationProps = CheckListProps.documentationProps('CheckList') +``` diff --git a/apps/docs/pages/@app-core/forms/Checkbox.mdx b/apps/docs/pages/@app-core/forms/Checkbox.mdx new file mode 100644 index 0000000..cad5fc4 --- /dev/null +++ b/apps/docs/pages/@app-core/forms/Checkbox.mdx @@ -0,0 +1,41 @@ + +import { Checkbox, getDocumentationProps } from '@app/forms/Checkbox.styled' +import { ComponentDocs } from '@app/core/mdx/ComponentDocs' +import { FileTree, Callout } from 'nextra/components' + +# Checkbox + +```typescript copy +import { Checkbox } from '@app/forms/Checkbox.styled' +``` + + + +### Location + +You can find the source of the `Checkbox` component in the following location: + + + + + + + + + + + +### Disclaimer - Automatic Docgen + + +These dynamic component docs were auto-generated with `npm run regenerate-docs`. You can hook into automatic docgen by exporting `getDocumentationProps` from a component file. You'll want to provide example props from the ComponentProps zod schema, e.g: + + +```tsx /getDocumentationProps/ /documentationProps/ copy filename="Checkbox.styled.tsx" +/* --- Docs ---------------------- */ + +export const getDocumentationProps = CheckboxProps.documentationProps('Checkbox') +``` diff --git a/apps/docs/pages/@app-core/forms/NumberStepper.mdx b/apps/docs/pages/@app-core/forms/NumberStepper.mdx new file mode 100644 index 0000000..18d9b94 --- /dev/null +++ b/apps/docs/pages/@app-core/forms/NumberStepper.mdx @@ -0,0 +1,41 @@ + +import { NumberStepper, getDocumentationProps } from '@app/forms/NumberStepper.styled' +import { ComponentDocs } from '@app/core/mdx/ComponentDocs' +import { FileTree, Callout } from 'nextra/components' + +# NumberStepper + +```typescript copy +import { NumberStepper } from '@app/forms/NumberStepper.styled' +``` + + + +### Location + +You can find the source of the `NumberStepper` component in the following location: + + + + + + + + + + + +### Disclaimer - Automatic Docgen + + +These dynamic component docs were auto-generated with `npm run regenerate-docs`. You can hook into automatic docgen by exporting `getDocumentationProps` from a component file. You'll want to provide example props from the ComponentProps zod schema, e.g: + + +```tsx /getDocumentationProps/ /documentationProps/ copy filename="NumberStepper.styled.tsx" +/* --- Docs ---------------------- */ + +export const getDocumentationProps = NumberStepperProps.documentationProps('NumberStepper') +``` diff --git a/apps/docs/pages/@app-core/forms/RadioGroup.mdx b/apps/docs/pages/@app-core/forms/RadioGroup.mdx new file mode 100644 index 0000000..ff02231 --- /dev/null +++ b/apps/docs/pages/@app-core/forms/RadioGroup.mdx @@ -0,0 +1,41 @@ + +import { RadioGroup, getDocumentationProps } from '@app/forms/RadioGroup.styled' +import { ComponentDocs } from '@app/core/mdx/ComponentDocs' +import { FileTree, Callout } from 'nextra/components' + +# RadioGroup + +```typescript copy +import { RadioGroup } from '@app/forms/RadioGroup.styled' +``` + + + +### Location + +You can find the source of the `RadioGroup` component in the following location: + + + + + + + + + + + +### Disclaimer - Automatic Docgen + + +These dynamic component docs were auto-generated with `npm run regenerate-docs`. You can hook into automatic docgen by exporting `getDocumentationProps` from a component file. You'll want to provide example props from the ComponentProps zod schema, e.g: + + +```tsx /getDocumentationProps/ /documentationProps/ copy filename="RadioGroup.styled.tsx" +/* --- Docs ---------------------- */ + +export const getDocumentationProps = RadioGroupProps.documentationProps('RadioGroup') +``` diff --git a/apps/docs/pages/@app-core/forms/Select.mdx b/apps/docs/pages/@app-core/forms/Select.mdx new file mode 100644 index 0000000..b3555ea --- /dev/null +++ b/apps/docs/pages/@app-core/forms/Select.mdx @@ -0,0 +1,41 @@ + +import { Select, getDocumentationProps } from '@app/forms/Select.styled' +import { ComponentDocs } from '@app/core/mdx/ComponentDocs' +import { FileTree, Callout } from 'nextra/components' + +# Select + +```typescript copy +import { Select } from '@app/forms/Select.styled' +``` + + + +### Location + +You can find the source of the `Select` component in the following location: + + + + + + + + + + + +### Disclaimer - Automatic Docgen + + +These dynamic component docs were auto-generated with `npm run regenerate-docs`. You can hook into automatic docgen by exporting `getDocumentationProps` from a component file. You'll want to provide example props from the ComponentProps zod schema, e.g: + + +```tsx /getDocumentationProps/ /documentationProps/ copy filename="Select.styled.tsx" +/* --- Docs ---------------------- */ + +export const getDocumentationProps = SelectProps.documentationProps('Select') +``` diff --git a/apps/docs/pages/@app-core/forms/Switch.mdx b/apps/docs/pages/@app-core/forms/Switch.mdx new file mode 100644 index 0000000..5249129 --- /dev/null +++ b/apps/docs/pages/@app-core/forms/Switch.mdx @@ -0,0 +1,41 @@ + +import { Switch, getDocumentationProps } from '@app/forms/Switch.styled' +import { ComponentDocs } from '@app/core/mdx/ComponentDocs' +import { FileTree, Callout } from 'nextra/components' + +# Switch + +```typescript copy +import { Switch } from '@app/forms/Switch.styled' +``` + + + +### Location + +You can find the source of the `Switch` component in the following location: + + + + + + + + + + + +### Disclaimer - Automatic Docgen + + +These dynamic component docs were auto-generated with `npm run regenerate-docs`. You can hook into automatic docgen by exporting `getDocumentationProps` from a component file. You'll want to provide example props from the ComponentProps zod schema, e.g: + + +```tsx /getDocumentationProps/ /documentationProps/ copy filename="Switch.styled.tsx" +/* --- Docs ---------------------- */ + +export const getDocumentationProps = SwitchProps.documentationProps('Switch') +``` diff --git a/apps/docs/pages/@app-core/forms/TextArea.mdx b/apps/docs/pages/@app-core/forms/TextArea.mdx new file mode 100644 index 0000000..de14b72 --- /dev/null +++ b/apps/docs/pages/@app-core/forms/TextArea.mdx @@ -0,0 +1,41 @@ + +import { TextArea, getDocumentationProps } from '@app/forms/TextArea.styled' +import { ComponentDocs } from '@app/core/mdx/ComponentDocs' +import { FileTree, Callout } from 'nextra/components' + +# TextArea + +```typescript copy +import { TextArea } from '@app/forms/TextArea.styled' +``` + + + +### Location + +You can find the source of the `TextArea` component in the following location: + + + + + + + + + + + +### Disclaimer - Automatic Docgen + + +These dynamic component docs were auto-generated with `npm run regenerate-docs`. You can hook into automatic docgen by exporting `getDocumentationProps` from a component file. You'll want to provide example props from the ComponentProps zod schema, e.g: + + +```tsx /getDocumentationProps/ /documentationProps/ copy filename="TextArea.styled.tsx" +/* --- Docs ---------------------- */ + +export const getDocumentationProps = TextAreaProps.documentationProps('TextArea') +``` diff --git a/apps/docs/pages/@app-core/forms/TextInput.mdx b/apps/docs/pages/@app-core/forms/TextInput.mdx new file mode 100644 index 0000000..43bb337 --- /dev/null +++ b/apps/docs/pages/@app-core/forms/TextInput.mdx @@ -0,0 +1,41 @@ + +import { TextInput, getDocumentationProps } from '@app/forms/TextInput.styled' +import { ComponentDocs } from '@app/core/mdx/ComponentDocs' +import { FileTree, Callout } from 'nextra/components' + +# TextInput + +```typescript copy +import { TextInput } from '@app/forms/TextInput.styled' +``` + + + +### Location + +You can find the source of the `TextInput` component in the following location: + + + + + + + + + + + +### Disclaimer - Automatic Docgen + + +These dynamic component docs were auto-generated with `npm run regenerate-docs`. You can hook into automatic docgen by exporting `getDocumentationProps` from a component file. You'll want to provide example props from the ComponentProps zod schema, e.g: + + +```tsx /getDocumentationProps/ /documentationProps/ copy filename="TextInput.styled.tsx" +/* --- Docs ---------------------- */ + +export const getDocumentationProps = TextInputProps.documentationProps('TextInput') +``` diff --git a/apps/docs/pages/@app-core/forms/_meta.ts b/apps/docs/pages/@app-core/forms/_meta.ts new file mode 100644 index 0000000..984bfd9 --- /dev/null +++ b/apps/docs/pages/@app-core/forms/_meta.ts @@ -0,0 +1,11 @@ + +export default { + TextInput: 'TextInput', + TextArea: 'TextArea', + Switch: 'Switch', + Select: 'Select', + RadioGroup: 'RadioGroup', + NumberStepper: 'NumberStepper', + Checkbox: 'Checkbox', + CheckList: 'CheckList', +} diff --git a/apps/docs/pages/@app-core/screens/FormsScreen.mdx b/apps/docs/pages/@app-core/screens/FormsScreen.mdx new file mode 100644 index 0000000..f6ace7a --- /dev/null +++ b/apps/docs/pages/@app-core/screens/FormsScreen.mdx @@ -0,0 +1,41 @@ + +import { FormsScreen, getDocumentationProps } from '@app/screens/FormsScreen' +import { ComponentDocs } from '@app/core/mdx/ComponentDocs' +import { FileTree, Callout } from 'nextra/components' + +# FormsScreen + +```typescript copy +import { FormsScreen } from '@app/screens/FormsScreen' +``` + + + +### Location + +You can find the source of the `FormsScreen` component in the following location: + + + + + + + + + + + +### Disclaimer - Automatic Docgen + + +These dynamic component docs were auto-generated with `npm run regenerate-docs`. You can hook into automatic docgen by exporting `getDocumentationProps` from a component file. You'll want to provide example props from the ComponentProps zod schema, e.g: + + +```tsx /getDocumentationProps/ /documentationProps/ copy filename="FormsScreen.tsx" +/* --- Docs ---------------------- */ + +export const getDocumentationProps = FormsScreenProps.documentationProps('FormsScreen') +``` diff --git a/apps/docs/pages/@app-core/screens/_meta.ts b/apps/docs/pages/@app-core/screens/_meta.ts new file mode 100644 index 0000000..0cc79e9 --- /dev/null +++ b/apps/docs/pages/@app-core/screens/_meta.ts @@ -0,0 +1,4 @@ + +export default { + FormsScreen: 'FormsScreen', +} diff --git a/apps/docs/pages/_app.tsx b/apps/docs/pages/_app.tsx new file mode 100644 index 0000000..a1117ac --- /dev/null +++ b/apps/docs/pages/_app.tsx @@ -0,0 +1,79 @@ +import type { AppProps } from 'next/app' +import React, { useEffect } from 'react' +import { useTheme } from 'nextra-theme-docs' +import { useColorScheme } from 'nativewind' // @ts-ignore +import UniversalAppProviders from '@app/screens/UniversalAppProviders' +import ServerStylesProvider from '@app/next/app/ServerStylesProvider' +import { SafeAreaProvider } from 'react-native-safe-area-context' +import { ComponentDocsContextManager } from '@app/core/mdx/ComponentDocs' +import { Image as NextContextImage } from '@green-stack/components/Image.next' +import { Link as NextContextLink } from '@green-stack/navigation/Link.next' +import { useRouter as useNextContextRouter } from '@green-stack/navigation/useRouter.next' +import { useRouteParams as useNextRouteParams } from '@green-stack/navigation/useRouteParams.next' +import '@app/next/global.css' + +export default function App({ Component, pageProps }: AppProps) { + // Navigation + const nextContextRouter = useNextContextRouter() + + // Styles + const theme = useTheme() + const scheme = useColorScheme() + const resolvedTheme = theme.resolvedTheme || theme.systemTheme + + // -- Effects -- + + useEffect(() => { + // Figure out the theme and apply it + const resolveTheme = () => { + const storedTheme = localStorage.getItem('theme') + let currentTheme = (resolvedTheme || storedTheme) as 'light' | 'dark' | 'system' + if (currentTheme === 'system' || !currentTheme) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const prefersDark = mediaQuery.media === '(prefers-color-scheme: dark)' && mediaQuery.matches + currentTheme = prefersDark ? 'dark' : 'light' + } + theme.setTheme(currentTheme) + scheme.setColorScheme(currentTheme) + } + // Hacky, but it works 🤷‍♂️ (listening for theme or scheme changes is unreliable, sadly) + const queuedThemeCheck = () => { + window.scrollTo(0, 0) + new Array(20).fill(null).forEach((_, idx) => setTimeout(resolveTheme, idx * 200)) + } + // Attach listeners + const $themeButtons = document.querySelectorAll('button[title="Change theme"]') + $themeButtons.forEach($button => $button.addEventListener('click', queuedThemeCheck)) + // Trigger on mount or when theme changes + resolveTheme() + // Remove listeners + return () => $themeButtons.forEach($button => { + $button.removeEventListener('click', queuedThemeCheck) + }) + }, [resolvedTheme]) + + // -- Render -- + + return ( + + + + + + + + + + ) +} diff --git a/apps/docs/pages/_meta.ts b/apps/docs/pages/_meta.ts new file mode 100644 index 0000000..6a35683 --- /dev/null +++ b/apps/docs/pages/_meta.ts @@ -0,0 +1,121 @@ +import { meta as pluginsMeta } from './plugins/_meta' +import { isEmpty } from '@green-stack/utils/commonUtils' +import { featureMeta, packageMeta } from '@app/registries/workspaceImports.generated' + +/* --- Helpers --------------------------------------------------------------------------------- */ + +const renderPluginItems = (options: any$Todo) => { + return Object.entries(pluginsMeta).reduce((acc, [key, value]) => ({ + ...acc, + [key]: { + title: value.title, + route: value.route || `/plugins/${key}`, + ...options, + }}), {}) +} + +/* --- Top Level Sidebar ----------------------------------------------------------------------- */ + +export const meta = { + + '-- Universal Base Starter': { + 'type': 'separator', + 'title': 'Universal Base Starter', + }, + + 'index': 'Introduction', + + // -- Plugins -- + + // '-- Plugin Branches': { + // 'type': 'separator', + // 'title': 'Plugin Branches', + // }, + + // ...renderPluginItems({ display: false }), + + 'plugins': { + title: 'Plugin Branches', + type: 'folder', + items: renderPluginItems({ display: true }), + }, + + // -- FullProduct.dev ⚡️ -- + + '-- FullProduct.dev ⚡️': { + 'type': 'separator', + 'title': '- FullProduct.dev ⚡️ Upgrade -', + }, + + 'quickstart': 'Quickstart', + 'core-concepts': 'Core Concepts', + 'project-structure': 'Project Structure', + 'single-sources-of-truth': 'Single Sources of Truth', + + // -- Building your app -- + + '-- Building universal apps': { + 'type': 'separator', + 'title': 'Building universal apps', + }, + + 'universal-routing': 'Cross-Platform Routing', + 'write-once-styles': 'Styling Universal UI', + 'data-resolvers': 'Flexible Resolvers and API\'s', + 'data-fetching': 'Universal Data Fetching', + 'form-management': 'Form Management', + + // -- Guides -- + + '-- Guides': { + 'type': 'separator', + 'title': 'Guides', + }, + + 'app-config': 'Env Vars + App Config', + + // -- Portability -- + + // '-- Portability': { + // 'type': 'separator', + // 'title': 'Portability', + // }, + + // 'maximum-code-reuse': 'Maximize Code Reuse', + // 'workspace-drivers': 'Workspace Drivers', + + // -- Saving Time -- + + // '-- Saving Time': { + // 'type': 'separator', + // 'title': 'Saving Time', + // }, + + // 'automations': 'Scripts and Automations', + // 'generators': 'Code Generators', + // 'git-based-plugins': 'Git based Plugins', + + // -- Features -- + + ...(!isEmpty(featureMeta) ? { + '-- App Features': { + 'type': 'separator', + 'title': 'App Features', + }, + } : {}), + + ...featureMeta, + + // -- Packages -- + + ...(!isEmpty(packageMeta) ? { + '-- Packages': { + 'type': 'separator', + 'title': 'Packages', + }, + } : {}), + + ...packageMeta, +} + +export default meta diff --git a/apps/docs/pages/app-config.mdx b/apps/docs/pages/app-config.mdx new file mode 100644 index 0000000..5d6474f --- /dev/null +++ b/apps/docs/pages/app-config.mdx @@ -0,0 +1,244 @@ +import { FileTree, Steps } from 'nextra/components' +import { Image } from '@app/primitives' + + + +# Configuring your Universal App + +```ts copy +import { appConfig } from '@app/config' +``` + + + + + + + + + +This doc will cover the basic of configuring your app: + +- [App Context Flags](#app-context-flags) +- [Environment Variables](#managing-environment-variables) +- [Managing Dependencies](#managing-dependencies) +- [Mobile App specific config](#managing-expo-config) +- [Web / next.js specific config](#managing-nextjs-config) + +## App Context Flags + +A good reason to use `@app/config` is for context flags. + +Your universal app can run in different environments like iOS, a web browser, Android, the server, etc. + +Sometimes you want to debug, test or show something only in a specific environment. + +For example: + +```ts +import { appConfig } from '@app/config' + +// Platform flags +if (appConfig.isWeb) console.log('Running in the browser') +if (appConfig.isMobile) console.log('Running on a mobile device') + +// Runtime flags +if (appConfig.isServer) console.log('Running on the server') + +// Framework flags +if (appConfig.isExpo) console.log('Running app with Expo') +if (appConfig.isNext) console.log('Running app with Next.js') + +// Debug flags +if (appConfig.isExpoWebLocal) console.log('Running locally on web with Expo') +if (appConfig.isNextWebLocal) console.log('Running locally on web with Next.js') +``` + +## Managing environment variables + +### Local env vars + + + + + + + + + + + + + + +Local environment variables can be managed in each app's `.env.local` file. + +The `.env.example` files can be copied into `.env.local` using the following command: + +```shell +npm run env:local +``` + +- Use `NEXT_PUBLIC_` to expose your environment variable in your next.js front-end +- Use `EXPO_PUBLIC_` to expose your environment variable in your expo front-end + +We suggest that for each environment variable you add in these `.env.local` files, you also add an entry in `appConfig.ts`. For example: + +```ts {6, 7} filename="appConfig.ts" +export const appConfig = { + // ... + + // - Secrets - + + // Don't use NEXT_PUBLIC_ / EXPO_PUBLIC_ prefixes here to make sure these are undefined client-side + appSecret: process.env.APP_SECRET, + + // ... +} +``` + +### Env vars in Next.js + +For development, staging & production environments, check the next.js docs: +https://nextjs.org/docs/app/building-your-application/configuring/environment-variables + +> Note that you should treat environment variables as if they could be inlined in your bundle during builds & deployments +> This means dynamically retrieving environment variables from e.g. `process.env[someKey]` might not work +> It also means that you should never prefix with `NEXT_PUBLIC_` for sensitive / private keys + +#### Managing secrets + +App secrets that should only be available server-side should: +- not be prefixed with `NEXT_PUBLIC_` (this omits then from the client-side) +- not be included in Expo's `.env.local` file (all expo env vars are public and included in bundle by default) + +### Env vars in Expo + +For development, staging & production environments, check the expo docs: +https://docs.expo.dev/guides/environment-variables/ + +> Note that Expo will inline environment variables in your bundle during builds & deployments +> This means dynamically retrieving environment variables from e.g. `process.env[someKey]` will not work +> It also means that you should never include sensitive / private keys + +### Defining endpoints + +Your most important environment variables are likely your API endpoints: + +```shell filename=".env.local" +## --- General --------------------------------------------------------------------------------- */ +## -i- Env vars that should always be present & the same locally, independent of the simulated environment +## --------------------------------------------------------------------------------------------- */ + +NEXT_PUBLIC_BASE_URL=http://localhost:3000 +NEXT_PUBLIC_BACKEND_URL=http://localhost:3000 +NEXT_PUBLIC_API_URL=http://localhost:3000/api +NEXT_PUBLIC_GRAPH_URL=http://localhost:3000/api/graphql +``` + +> Which you can then consolidate in your `appConfig.ts` + +```ts filename="appConfig.ts" +export const appConfig = { + // ... + + // - Server URLs - + + baseURL: process.env.NEXT_PUBLIC_BASE_URL || process.env.EXPO_PUBLIC_BASE_URL, + backendURL: process.env.NEXT_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_BACKEND_URL, + apiURL: process.env.NEXT_PUBLIC_API_URL || process.env.EXPO_PUBLIC_API_URL, + graphURL: process.env.NEXT_PUBLIC_GRAPH_URL || process.env.EXPO_PUBLIC_GRAPH_URL, + + // ... +} +``` + +> For the examples above, we've already set these up for local use. + +## Feature and Domain config + + + + + + + + + + + + +As stated in our [project structure](/project-structure) docs, workspaces should define their own `package.json` file. + +That means that each feature or package workspace can have its own dependencies. + +> This helps keep things modular, portable and colocated so features remain copy-pasteable. + +### Expo SDK compatible packages + +For your Expo mobile app, it's important to install packages that are compatible with your current Expo SDK. + +However, using Expo's own `expo install` command will only install packages in the expo app workspace, which we don't want. + +To help with this, we've created a script that will install SDK compatible packages in the correct workspace: + +```shell copy +npm run add:dependencies +``` + +This will ask you where you'd like to install the package: + +```shell {5} +>>> Modify "your-project-name" using custom generators + +? Which workspace should we install the Expo SDK compatible versions in? (Use arrow keys) + @db/driver +❯ @green-stack/core + @app/registries + @app/core +``` + +You can then specify one or multiple packages to have their SDK compatible versions installed. + +## Managing Expo config + +Check the Expo docs for configuring your mobile app with Expo: + +- https://docs.expo.dev/workflow/configuration/ +- https://docs.expo.dev/eas/json/ + + + + + + + + + + + + + +## Managing Next.js config + +Check the Next.js docs for configuring your web app with Next.js: + +- https://nextjs.org/docs/app/building-your-application/configuring +- https://nextjs.org/docs/app/api-reference/next-config-js + + + + + + + + + + + + diff --git a/apps/docs/pages/core-concepts.mdx b/apps/docs/pages/core-concepts.mdx new file mode 100644 index 0000000..64a6242 --- /dev/null +++ b/apps/docs/pages/core-concepts.mdx @@ -0,0 +1,496 @@ +import { FileTree } from 'nextra/components' +import { Image } from '@app/primitives' + + + +# Core concepts + +Unlike most boilerplates, this starterkit is not just a collection of tools and libraries. It's a way of working that's been designed to be both scalable and portable from the start. + +This document will cover the **core concepts that make this starterkit unique**: + +- **[Universal, from the start](/core-concepts#universal-from-the-start)** +- **[Evergreen with the GREEN stack](/core-concepts#the-green-stack)** +- **[Single Sources of Truth](/core-concepts#single-sources-of-truth)** +- **[Design for copy-paste](/core-concepts#design-features-for-copy-paste)** + +
+ +## Universal, from the start + +It's a lot harder to add a mobile app later, than to build for web and app stores from the start. + +Luckily, in Next.js web-apps, as well as iOS and Android apps with Expo, using `react-native` for the UI layer will keep your code mostly write-once. + +No extra time wasted writing features twice or more, as 90% of react-native styles translate well to web. All it takes is to start with react-native's `View` / `Text` / `Image` primitives. They'll get transformed to HTML dom nodes on web. + +### Build for nuanced user preferences + +Think about your users for a second. Now think about your own behaviour when interacting with software. + +You likely prefer your phone for some apps. But when you're at your desk in an office, it comes in handy to just type a url and continue from there. + +Depending on the context, your potential users will also have a preference for web or mobile. That no longer matters when you build for all devices and platforms at no extra cost. Everyone is supported. + +### Capture on web, convert on mobile + +For startups and ambitious founders, building a Universal App can be a huge **competitive advantage**. + +When you do market research and see comments on a competitor's social media asking for an Android / iOS / Web version, you can swoop right in. Meanwhile the competition likely still has to hire an entire other team to rebuild the app for whatever platform they're missing. Being available on each platform also **inspires trust**, and trust is a major key to all sales. + +With SEO you could even **show up where users are already searching** for a solution. It's essentially **free organic traffic**. Customer acquisition costs when it comes to paid ads is also cheaper for web than mobile ads. + +Why not show a **quick interactive demo** of the app right on your landing page? The urge to finish what's been started might kick in, making it more likely they sign up web-first, and join those who prefer mobile later in finding and installing the app from the App Store. + +On mobile, your **app icon** is now listed on their phone, taking up valuable screen real estate. A **daily reminder your brand exists**. Mobile also provides stronger notification systems to retarget users and keep them engaged. This is why, in the e-commerce space, *mobile often drives more sales and conversions* than web does. + +### Universal (Deep)links + +Our shared digital experience thrives on links. Expo-router is the *first ever url based routing system for mobile*. Deeplinking, where you set up your mobile app to **open the right screen for specific URLs**, happens automatically in Expo-router. When people share anything from the app or the web version, the links will just work. Whether they've installed the app or not. + +Once users learn they can just share links, it turns existing users into ambassadors. + +### More with less + +Even if you're just freelancing or working as a digital product studio, you'll be able to deliver the same app on multiple platforms. This can give you the advantage you need to win more clients. Or you could charge a premium for the same amount of work. + +> "Web vs. Native is dead. Web **and** Native is the future." +> - Evan Bacon, expo-router maintainer + +
+ +## The GREEN stack + +### Take what works, make it better + +The best way to get really good and really fast at what you do, is to keep using the same tools for a longer period of time. That means not reinventing the wheel. For every new project. Again. And again. You want your way of working to be evergreen. + +### Expo + Next.js for best DX / UX + +When building Universal Apps, the stack you choose should optimize for each device and platform you're trying to target. This is the main reason why this starterkit / tech stack / way of working focuses entirely on Next.js and Expo. + +These two meta frameworks are simply best in class when it comes to DX and UX optimizations for their target platforms. NextJS does all it can to set you up for success when it comes to essential SEO things like web-vitals. + +Expo has made starting, building, testing, deploying, submitting and updating react-native apps just as easy. Apps made with Expo also result in actual native apps, which have the performance and responsiveness that comes with using platform primitives, because that's what it renders under the hood. + +### React-Native for write-once UI + +The dream of React has always been "write-once, use anywhere". For now, react-native and react-native-web gets us 90% of the way there. In the future, things like react-strict-dom will likely bump that number up higher. Until then, pairing react-native with Nativewind or a full-blown universal styling system like Tamagui seems like the way to go. + +### Universal Data Fetching with GraphQL + +GraphQL's ability to **query data from the server, browser and mobile** puts it in the unique position to service all three platforms. Paired with `react-query` for optimizing the caching of queries, and [gql.tada 🪄](https://gql-tada.0no.co/get-started/#a-demo-in-128-seconds) for auto inferring types from the schema + query definitions, integrated with a universal router, and you have a great universal initial data fetching solution. Type-safe end to end. + +*This doesn't mean you only have GraphQL at your disposal.* Resolvers in the GREEN stack are quite flexible. Designed so that porting them to `tRPC` (through a plugin) and/or Next.js API routes is quick and easy to do. From experience, we're convinded GraphQL works best when used in an RPC manner. Instead of a REST-like graph you can explore each domain of your data with, we instead urge you to create resolvers in function of the UI they need data for. + +> To illustrate RPC-style queries in your mind, think of `getDashboardData()` vs. having to call 3 separate `Orders()`, `Products()`, `Transactions()` type resolvers to achieve the same thing. + +When used in this manner, quite similar to tRPC, it remains the best for initial data-fetching. Though mutating data might be better served as a tRPC call or API route POST / PUT / DELETE request. + +To avoid footguns, the starterkit provides a way of working that can automate a bunch of the hard stuff when it comes to doing GraphQL, or data resolvers in general, the right way. + +**This includes things like:** +- auto-generating the entire **schema from Zod definitions** +- auto-generating fetcher functions and **`react-query` hooks** from Zod definitions +- keeping schema definitions a 1 on 1 match with your RPC / "command-like" resolver functions +- universal `graphqlRequest()` util to **auto infer types** from Zod input & output schemas +- ... or using [gql.tada](https://gql-tada.0no.co/get-started/#a-demo-in-128-seconds) for type hints / infers when only requesting specific fields. + +### Tech that's here to stay. + +To bring it all together, you could say the GREEN stack stands for: +- ✅ GraphQL +- ✅ React-Native +- ✅ Expo +- ✅ Next.js. + +The second "E" is in there because Expo, with it's drive to bring react-native to web, is doing double the lifting. + +> ...but in reality, the main goal of this stack is simply to stay 'Evergreen' + +These core technologies and the ecosystems around them create a stack that's **full-featured yet flexible** where needed. Other included essentials like **Typescript, Tailwind and Zod**, have gained enough popularity, adoption, frequent funding and community support they're just as likely to be around for a long time. + +It's a stack you can stick, evolve and perfect your craft with for a longer period of time. + +
+ +## Single Sources of Truth + +Think of all the places you may need to (re)define the shape of data. + +- ✅ Types +- ✅ Validation +- ✅ DB models +- ✅ API inputs & outputs +- ✅ Form state +- ✅ Documentation +- ✅ Mock & test data +- ✅ GraphQL schema defs + +Quite an extensive list for what is **essentially describing the same data**. + +Ideally, you could define the shape of your data just once, and have it be transformed to these other formats where necessary: + +### A strong toolkit around Zod schemas + +Schema validation libraries like zod are actually uniquely positioned to serve as the base of transforming to other formats. Zod even has the design goal to be as compatible with Typescript as possible. There's almost nothing you can define in TS that you can't with Zod. Which is why you can infer super accurate types from your Zod schemas. + +With the hardest 2 of 8 data definition scenario's tackled, the starterkit comes with utils that help convert Zod schemas to the others mentioned above: + +- [createSchemaModel()](TODO) - Create a DB model from Zod schema +- [createDataBridge()](/data-resolvers#start-from-a-databridge) - Combines zod input & output schemas for type-safe API bridges +- [createResolver()](/data-resolvers#add-your-business-logic) - Bind a zod bridge to a promise into a portable data resolver +- [createNextRouteHandler()](/data-resolvers#turning-resolvers-into-an-api-route-handler) - Transform your zod-powered resolver into a Next.js API route +- [createGraphResolver()](/data-resolvers#enable-graphql-without-the-hassle) - Transform your zod-powered resolver into a GraphQL Mutation or Query +- [createGraphSchemaDefs()](TODO) - Auto generate the GraphQL SDL statements for your schema.graphql +- [bridgedFetcher()](/data-fetching#build-a-fetcher-function) - Transform a zod bridge into a typed GraphQL fetcher +- [createQueryBridge()](/data-fetching#define-data-fetching-steps-in-screens) - Use a typed (GraphQL) fetcher to query initial daya for universal routes +- [getDocumentationProps()](TODO) - Combine zod prop defs with a React component for autogenerated docs + +### Zod for Automatic docs + +Writing documentation is essential, *but often requires time teams feel they don't have.* + +Any successfull project will eventually need documentation though. Especially if you want others to build on top of your work: + +> "**Documentation Drives Adoption**" - Storybook + +Once it's time to scale up the team, you'll definitely want them. Ideally, you can onboard new devs rather quickly so they can add value sooner. Good docs reduce how much mentoring new people need from your senior developers. + +*However*, as a new or scaling startup, both docs and onboarding are not necessarily the thing you want to "lose" time on. Which is why, at least at the start: + +> "**Sometimes, the best docs are the ones you don't have to write yourself.**" +> -- Founders that value their time + +This is where `MDX` and **Zod schemas as single sources of truth** are a great match. Using the Starterkit's `with/automatic-docgen` plugin, your components and API's will document themselves. + +How? By reading the example & default values of a component's Zod prop schema and generating an `MDX` file from it. The file will then render the component and provide a table with prop names, descriptions, nullability and interactive controls you can preview the different props with. + +Check out a live example for the [Button](/@app-core/components/Button) component in action: + +[![DocgenExample](https://github.com/user-attachments/assets/1ed43e31-6e02-49fa-a438-6475be00495b)](/@app-core/components/Button) + +> You can think of it like a component Storybook where you could build components in isolation on the docs page. Except these docs automatically grow with your project as a result of your way of working. + +More about this pattern in the [Single Sources of Truth](/single-sources-of-truth) and [Automations](TODO) docs. + +
+ +## Design features for copy-paste + +What tools like Tailwind and Shad-CN enabled for copy-pasting components, we aim to replicate for entire features and domains. + +That includes the UI, hooks, logic, resolvers, API's, zod schemas, db models, fetchers, utils and more. + +### Portable Workspace Folder Structure + +While individual utils, components and styles can be quite easy to reuse across projects these days, entire features are harder to port from one project to another. It's because most project structures don't lean themselves to copy-pasting a single folder between projects in order to reuse a feature. + +*This often stems from grouping on the wrong level, **such as a front-end vs. back-end split.*** + +It does become possible once you start grouping code together on the feature or domain level, as a reusable workspace: + +```shell +features/@some-feature + + └── /schemas/... # <- Single sources of truth + └── /models/... # <- Reuses schemas + └── /resolvers/... # <- Reuses models & schemas + + └── /components/... + └── /screens/... # <- Reuses components + └── /routes/... # <- Reuses screens + └── /api/... # <- Reuses resolvers + + └── /assets/... + └── /icons/... # <- e.g. svg components + └── /constants/... + └── /utils/... +``` + +Each folder that follows this structure should have its own `package.json` file to define the package name and dependencies. This way, you can easily copy-paste a feature or domain from one project to another, and NPM will have it work out of the box. + +Here's what this might look like in the full project. +Don't hesitate to open the `/apps/`, `/features/` or `/packages/` folders: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +### Git based plugins you can learn from + +> **"The best way to learn a new codebase is in the Pull Requests."** - Theo Browne, @t3dotgg + +[![Screenshot of list of Plugin Branches](https://github.com/user-attachments/assets/f2d4d836-c2ad-4249-bc53-de2ab7d5aac1)](https://github.com/Aetherspace/universal-app-router/pulls) + +The best plugin system is one that's close to a workflow you're already used to. + +Github PR's and git branches are typically better for a number of reasons: + +- ✅ Inspectable diff you can review with a team +- ✅ Able to check out and test first +- ✅ Optionally, add your own edits before merging + +Finally, PR based plugins solve common issues with other templates: + +- ✅ Pick and choose your own preferred tech stack + +### Workspace Drivers - Pick your own DB / Auth / Mail / ... + +Drivers are a way to abstract away the specific implementation of e.g. your specific database, storage or authentication system. This allows you to switch between different implementations without changing the rest of your code. + +**Typically, drivers are class based**, *and often back-end only*. However, we've found a different way of providing a familiar interface, while still providing a familiar way of working that works across front and back-end lines. + +The key is **defining drivers as workspace packages**: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +```typescript {1} +// Instead of using specific imports from a package... +import { signIn } from '@clerk/clerk-expo' +``` + +`⬇⬇⬇` + +```typescript {1, 4} /@auth/ /driver/2 +// You can (optionally) import and use a familiar API from a driver workspace: +import { signIn } from '@auth/driver' + +// OR, when mixing solutions, import from multiple drivers: +import { users } from '@db/mongoose' // <- e.g. User data saved in mongo +import { settings } from '@db/airtable' // <- e.g. Configuration saved in Airtable +``` + +You can have multiple drivers merged, *or "installed"*, but you should pick the main one in `appConfig.ts`: + +```ts {3} filename="features / @app-core / appConfig.ts" +export const appConfig = { + + // Here's how you'd set the main driver to be used: + drivers: createDriverConfig({ + db: DRIVER_OPTIONS.db.mockDB, // -> use as '@db/driver' + auth: DRIVER_OPTIONS.auth.clerk // -> @auth/driver + mail: DRIVER_OPTIONS.email.resend // -> @mail/driver + }), + +} as const +``` + +Combining multiple drivers for the same solution can be a good idea if e.g.: +- Specific types of data lend themselves better to a different provider +- You're migrating from one provider to another + +However, if you do, you should always pick a main one to make sure e.g. `@db/driver` corresponds to the main solution used. + +> **Drivers are fully optional, just like most of the suggested ways of working.** +> You won't experience any issues if you don't use them. + +> However, you might find it more difficult to keep features / domains / workspaces easily copy-pasteable without using these kinds of abstractions. + +Drivers and plugins within the starterkit's Way of Working have been designed to: +- Use `Zod` for validating **familiar API across implementations/options** +- Use `Zod` for providing both **types and schemas for said plugin** +- Help keep features **as copy-pasteable as possible** + +
+ +## Maximizing time saved + +> The major difference between a cash-grab boilerplate and a value providing starterkits lies in how far it goes to save you time: + +**Boilerplates:** +- typically only provide a better starting point. +- might save you weeks of setup, sure, but afterwards they rarely do much for you. +- not always aimed at experienced developers (types and scalable architecture) +- might end up still changing a bunch / wasting time + +There are **more ways to save time as a developer** though. To recap our core concepts: + +- ✅ *Starting universally*, building mostly write-once for each platform saves time later on and retains your ability to do **fast iterations while also serving way more users**. +- ✅ Keeping features **copy-pasteable and portable between projects**. +- ✅ *Recommended way of working* that also saves time. By encouraging you to define data shapes once, transform them to other formats where necessary, and providing a toolkit around them. +- ✅ Generators to quickly *scaffold out new schemas, models, resolvers, forms, fetchers, components, hooks, screens, routes from CLI.* + +### Generators to skip repetitive code + +This zod-based way of working, combined with the predictability of file-system based routing, can lead to some huge time saved when you automate the repetitive parts. + +The starterkit comes with a number of generators that can help you skip the repetitive boilerplate code and manual linking of files and objects when creating new features: + +- `npx turbo gen add-dependencies` - Add Expo SDK compatible dependencies to a workspaces +- `npx turbo gen add-workspace` - Add a new feature or package workspace folder to the project +- `npx turbo gen add-schema` - Add a new Zod schema to serve as single source of truth +- `npx turbo gen add-model` - Add a new DB model to the project based on a Zod schema +- `npx turbo gen add-resolver` - GraphQL resolver *and* API route based on Zod input and output +- `npx turbo gen add-form` - Create form hooks for a specific schema in your workspaces +- `npx turbo gen add-route` - New universal route + screen, and integrate with a resolver +- `npx turbo gen add-domain` - Like `add-workspace` on steroids, full domain with all the above + +### Start scalable, without the effort + +With these core-concepts combined, we believe we can provide Typescript and React devs with a really powerful way of working that is at all times: + +- Opinionated yet flexible +- Built for maximum code reuse +- Universal, write-once, reach any device +- Helping you easily onboard and scale up the team +- A huge time-saver at both the start and during the project + +All without having to spend the time figuring it all out yourself. + +If you're ready to dive deeper into these topics, check out the rest of the docs. diff --git a/apps/docs/pages/data-fetching.mdx b/apps/docs/pages/data-fetching.mdx new file mode 100644 index 0000000..6d4418c --- /dev/null +++ b/apps/docs/pages/data-fetching.mdx @@ -0,0 +1,817 @@ +import { FileTree, Steps } from 'nextra/components' +import { Image } from '@app/primitives' + + + +# Universal Data Fetching + +**This doc covers our recommended way to set up data-fetching in a way that:** +- ✅ Remains portable across different projects +- ✅ Reuses as much code between the front and back-end as possible +- ✅ Building a single source of truth for your APIs and using them in the front-end +- ✅ Uses `react-query` to execute the actual data-fetching logic on the server, browser, iOS and Android + +We suggest defining your data-fetching logic in the same place as the screen component requiring that data: + +```shell {4, 6} +features/@some-feature + + └── /resolvers/... # <- Defines portable data bridges + └── /screens/... # <- Reuses data bridges on front-end + └── PostDetailScreen.tsx + └── /routes/... # <- Reuses screens and bridges for data-fetching + └── posts /[slug]/ index.tsx # <- e.g. reuse 'PostDetailScreen.tsx' +``` + +This workspace structure will help keep your features as copy-pasteable and portable across different projects by avoiding a front-end vs. back-end split. + +For an example of what this might look like in an actual project, check the example below. Don't hesitate to click open some of the folders to get a better idea of how portable routes with data-fetching are structured: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +> The recommended and easiest way to create a new route in a workspace is to use the Route Generator: + +
+ +## Using the Route Generator + +```shell +npm run add:route +``` + +> The turborepo route generator will ask you some questions, like which resolver you'd like to fetch data from, and will generate the empty screens and routes folders in the workspace of your choico with data fetching already set up: + +```shell {6, 9} +>>> Modify "your-project-name" using custom generators + +? Where would you like to add this new route? # -> features/@app-core +? What should the screen component be called? # -> NewRouteScreen +? What url do you want this route on? # -> "/examples/[slug]" +? Would you like to fetch initial data for this route from a resolver? + + getPost() resolver -- from: '@app/some-feature' +❯ getExample() resolver -- from: '@app/other-feature' + getData() -- from: '@app/other-feature' + No, this screen doesn't need to fetch data to work + No, I'll figure out data-fetching myself # (editable dummy example) +``` + +> The list of available resolvers is based on the data bridges available in each workspace's `/resolvers/` folder. + +Pick the last option if you want to create a new bridge and resolver alongside the route and screen. + +In which case the generated boilerplate might look like this: + +```shell {4, 5} /(if chosen)/ +>>> Changes made: + • /features/@app-core/resolvers/newResolver.bridge.ts # ← New bridge (if chosen) + • /features/@app-core/resolvers/newResolver.query.ts # ← New graphql fetcher (if chosen) + • /features/@app-core/screens/NewRouteScreen.tsx # ← New screen with fetch config + • /features/@app-core/routes/examples/[slug]/index.tsx # (add) + +>>> Success! +``` + +To explain what the generator does for you, have a look at the recommended steps to add data fetching manually: + +
+ +## Manually set up Data Fetching + +![Graph showing the Unversal Route Screen component using a fetcher with React-Query to retrieve the props for a RouteComponent before rendering on Web, iOS and Android](https://github.com/user-attachments/assets/d64fbf93-4a35-433e-91c7-10d6bdf1a0ab) + +There are 3 environments to consider when providing dynamic data to your screens: + +- **Server-side rendering (SSR)** using the **[executable schema](https://the-guild.dev/graphql/tools/docs/generate-schema)** +- **Client-side rendering (CSR)** in the browser (**hydration** or fetch) +- **Mobile App** client in Expo (fetch only) + +To fetch data the same way in all three, we've written two helpers: +- `createQueryBridge()` - Build **instructions for data-fetching with `react-query`** (step 3) +- `` - Component that **uses the bridge to fetch data** in each environment (step 4) + +This is not the only way to do data-fetching, but our recommended way to do so: + + + +### Start with a Data Bridge + +> If you already set up a [DataBridge](/data-resolvers#start-from-a-databridge) while [building your resolver](/data-resolvers), you can skip to the next step. + + + + + + + + + + + + +
+What is a Data Bridge? + +--- + +Schemas serve as the single source of truth for your data shape. But what about API shape? + +You can combine input and output schemas into a `bridge` file to serve as the single source of truth for your API resolver. You can use `createDataBridge` to ensure all the required fields are present: + +```ts {14, 17} /createDataBridge/1,3 filename="getPost.bridge.ts" +import { Post } from '../schemas/Post.schema' +import { createDataBridge } from '@green-stack/schemas/createDataBridge' +import { schema } from '@green-stack/schemas' + +/* -- Schemas ------------ */ + +// You can create, reuse or edit schemas here, e.g. +export const GetPostArgs = schema('GetPostArgs', { + slug: z.string(), +}) + +/* -- Bridge ------------- */ + +export const getPostBridge = createDataBridge({ + // Assign schemas + inputSchema: GetPostArgs, + outputSchema: Post, + + // GraphQL config + resolverName: 'getPost', + resolverArgsName: 'PostInput', + + // API route config + apiPath: '/api/posts/[slug]', + allowedMethods: ['GET', 'GRAPHQL'], +}) +``` + +Think of a **"Databridge"** as a literal *bridge between the front and back-end*. + +It's a metadata object you can use from either side to provide / transform into: + +- ✅ Input and output **types + validation + defaults** +- ✅ GraphQL **schema definitions** in `schema.graphql` +- ✅ The query string to call our GraphQL API with + +*There's two reasons we suggest you define this "DataBridge" in a separate file*: + +- 1. **Reusability**: If kept separate from business logic, you can reuse it in both front and back-end code. +- 2. **Consistency**: Predicatable patterns make it easier to build automations and generators around them. + +> For this reason, we suggest you add `.bridge.ts` to your bridge filenames. + +
+ +### Build a fetcher function + +> We suggest you use `GraphQL` for building your data-fetching function. + +*It's not the only way you could do it*, but our Bridge + GraphQL fetcher helpers will ensure your query: + +- ✅ can **execute on the server, browser, iOS and Android** (better for universal routes) +- ✅ can **automatically build the query string** from input and output **schemas in the bridge** +- ✅ is **auto typed** *when using the bridge to provide the query automagically* +- ✅ **supports custom queries** that are also auto typed, with `gql.tada` (as an alternative) + + + + + + + + + + + + + + + + + + + +The easiest way to create a fetcher is to use the `bridgedFetcher()` helper: + +```ts {3} /bridgedFetcher/3 filename="getPost.query.ts" +import { getPostBridge } from './getPost.bridge' +// ☝️ Reuse your data bridge +import { bridgedFetcher } from '@green-stack/schemas/bridgedFetcher' +// ☝️ Universal graphql fetcher that can be used in any JS environment + +/* --- getPostFetcher() --------- */ + +export const getPostFetcher = bridgedFetcher(getPostBridge) +``` + +This will automatically build the query string with all relevant fields from the bridge. + +
+How to use custom queries? + +--- + +To write a custom query with only certain fields, you can use our `graphql()` helper *with* `bridgedFetcher()`: + +```ts {3, 11, 21, 31, 41} filename="getPost.query.ts" +import { ResultOf, VariablesOf } from 'gql.tada' +// ☝️ Type helpers that interface with the GraphQL schema +import { graphql } from '../graphql/graphql' +// ☝️ Custom gql.tada query builder that integrates with our types +import { bridgedFetcher } from '@green-stack/schemas/bridgedFetcher' +// ☝️ Universal graphql fetcher that can be used in any JS environment + +/* --- Query ----------------------- */ + +// VSCode and gql.tada will help suggest or autocomplete thanks to our schema definitions +export const getPostQuery = graphql(` + query getPost ($getPostArgs: GetPostInput) { + getPost(args: $getPostArgs) { + slug + title + body + } + } +`) + +// ⬇⬇⬇ automatically typed as ⬇⬇⬇ + +// TadaDocumentNode<{ +// getPost(args: { slug: string }): { +// slug: string | null; +// title: boolean | null; +// body: boolean | null; +// }; +// }> + +// ⬇⬇⬇ can be turned into reusable types ⬇⬇⬇ + +/* --- Types ----------------------- */ + +export type GetPostQueryInput = VariablesOf + +export type GetPostQueryOutput = ResultOf + +/* --- getPostFetcher() --------- */ + +export const getPostFetcher = bridgedFetcher({ + ...getPostBridge, // <- Reuse your data bridge ... + graphqlQuery: getPostQuery, // <- ... BUT, use our custom query +}) +``` + +
+ +> Whether you use a custom query or not, you now have a GraphQL powered fetcher that: + +- ✅ Uses the executable graphql schema serverside +- ✅ Can be used in the browser or mobile using fetch + +### Define data-fetching steps in `/screens/` + +> You'll be using your fetcher in `createQueryBridge()` in one of our universal screen components: + + + + + + + + + + + + + + + + + + + + + + +What `createQueryBridge()` does is provide a set of instructions to: + +- ✅ Transform url params into query variables for our fetcher function +- ✅ Fetch data using our custom graphql fetcher and `react-query` +- ✅ Map the fetched data to props for our screen Component + +> We define these steps here so we can reuse this query bridge to extract types for our screen props: + +```tsx {2, 7, 24, 30} /createQueryBridge/ /routeDataFetcher/ /postData/1,3 filename="PostDetailScreen.tsx" +import { createQueryBridge } from '@green-stack/navigation' +import { getPostFetcher } from '@app/some-feature/getPost.query' +import type { HydratedRouteProps } from '@green-stack/navigation' + +/* --- Data Fetching --------------- */ + +export const queryBridge = createQueryBridge({ + + // 1. Transform the route params into things useable by react-query (e.g. 'slug') + routeParamsToQueryKey: (routeParams) => ['getPost', routeParams.slug], + routeParamsToQueryInput: (routeParams) => ({ getPostArgs: { slug: routeParams.slug } }), + + // 2. Provide the fetcher function to be used by react-query + routeDataFetcher: getPostFetcher, + + // 3. Transform fetcher output to props after react-query was called + fetcherDataToProps: (fetcherData) => ({ postData: fetcherData?.getPost }), +}) + +// ⬇⬇⬇ Extract types ⬇⬇⬇ + +/* --- Types ----------------------- */ + +type PostDetailScreenProps = HydratedRouteProps + +// ⬇⬇⬇ Use fetcher data in screen component ⬇⬇⬇ + +/* --- --------------- */ + +const PostDetailScreen = (props: PostDetailScreenProps) => { + + // Query results from 'fetcherDataToProps()' will be added to it + const { postData } = props + // ☝️ Typed as { + // postData: { + // slug: string, + // title: string, + // body: string, + // } + // } + + // -- Render -- + + return (...) +} +``` + +### Use the bridge and screen in `/routes/` + +> We now have the individual parts of our data-fetching, all that's left is to bring it all together in a route: + + + + + + + + + + + + + + + + + + + + + + + +This is where `UniversalRouteScreen` comes in to **execute each step** op the `queryBridge` **in sequence** until we get to the final props to be provided to the screen. + +```tsx {2, 7} /queryBridge/3 /PostDetailScreen/2 filename="features / @some-feature / routes / index.tsx" +import { PostDetailScreen, queryBridge } from '@app/screens/PostDetailScreen' +import { UniversalRouteScreen } from '@app/navigation' + +/* --- /posts/[slug] ----------- */ + +export default (props) => ( + +) +``` + +In the same `/routes/index.tsx` file, you can add the [Next.js routing config](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config) + +```tsx {} filename="features / @app-core / routes / index.tsx" +// -i- Export any other next.js routing config here +export const dynamic = 'auto' +export const dynamicParams = true +export const revalidate = false +export const fetchCache = 'auto' +export const runtime = 'nodejs' +export const preferredRegion = 'auto' +export const maxDuration = 5 +``` + +> 💡 Check Next.js [route segment config](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config) later to understand the options you can set here. + +We'll be re-exporting this route segment config in the next step. We'll keep it in the same file as the main route component for colocation and enabling `@green-stack/scripts` to automatically re-export it. + +### Re-export the route in your apps + +```shell +npm run link:routes +``` + +This will re-export the route from your workspace to your Expo and Next.js apps: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +> You could do this manually as well, but... why would you? + +
+ +This concludes our recommended flow for manually setting up data-fetching for routes. + +> **However, you might want to fetch data at the component level**, or even mutate / update data instead. For that, we recommend using `react-query` directly in your components: + +
+ +## Using `react-query` + +[react-query](https://tanstack.com/query/latest) is a library that helps you manage your data-fetching logic in a way that's easy to use and understand. It provides tools to fetch, cache, and update data in an efficient and flexible way. + +It's built around the concept of **queries** and **mutations**: + +- **Queries** are used to fetch data from an API +- **Mutations** are used to send data to an API + +> Queries are the most common use case, and the one we've been focusing on here. + +
+ +### `UniversalRouteScreen` + +For example, the **UniversalRouteScreen wrapper** *uses react-query to fetch data* for a route: +- **serverside**, during server side rendering (SSR) +- **in the browser**, during client side rendering (CSR) +- **in the mobile app**, fetching from the client + +
+How do our universal routes fetch initial data with react-query? + +--- + +Here's a quick look at how `UniversalRouteScreen` uses `react-query` to fetch data: + +```tsx {13, 25, 49} filename="UniversalRouteScreen.tsx" +// Props +const { queryBridge, routeScreen: RouteScreen, ...screenProps } = props + +// Extract the fetching steps from our queryBridge +const { routeParamsToQueryKey, routeParamsToQueryInput, routeDataFetcher } = queryBridge +const fetcherDataToProps = queryBridge.fetcherDataToProps + +// Combine all possible route params to serve as query input +const { params: routeParams, searchParams } = props +const nextRouterParams = useRouteParams(props) +const queryParams = { ...routeParams, ...searchParams, ...nextRouterParams } + +// -- Query -- + +const queryClient = useQueryClient() +const queryKey = routeParamsToQueryKey(queryParams) // -> e.g. ['getPost', 'some-slug'] +const queryInput = routeParamsToQueryInput(queryParams) // -> e.g. { getPostArgs: { slug: 'some-slug' } } + +const queryConfig = { + queryKey, + queryFn: async () => routeDataFetcher(queryInput), // <- Uses our fetcher + initialData: queryBridge.initialData, // <- Optional initial data +} + +// -- Browser & Mobile -- + +if (isBrowser || isMobile) { + + // Execute the query as a hook + const { data: fetcherData } = useQuery({ + ...queryConfig, // <- See above, includes our 'queryFn' / fetcher + initialData: { + ...queryConfig.initialData, + ...hydrationData, // <- Only on browser (empty on mobile) + }, + refetchOnMount: shouldRefetchOnMount, + }) + + const routeDataProps = fetcherDataToProps(fetcherData as any) + + // Render in browser: + return ( + + + + ) +} + +// -- Server -- + +if (isServer) { + const fetcherData = use(queryClient.fetchQuery(queryConfig)) + const routeDataProps = fetcherDataToProps(fetcherData) + const dehydratedState = dehydrate(queryClient) + + // Render on server: + return ( + + + + ) +} +``` + +> A lot of this is pseudo code though, if you really want to know the exact internals, check out the `UniversalRouteScreen.(web).tsx` files + +
+ +
+ +### Fetch data with `queryFn` + +You'll need to define a function to tell react-query how to fetch data. This function is responsible for fetching from a server, database, or any other source. It does not have to be an actual API call, but usually, it will be. + +> Your `queryFn` must: + +- Return a promise that resolves the data. +- Throw an error if the data cannot be fetched. + +
+Example + + +```tsx {2, 14} /queryFn/2 filename="getPost.fetcher.ts" +// Define a fetcher function +const fetchProjects = async () => { + + // e.g. fetch data from an API + const response = await fetch('/api/projects') + + // Check if the response is ok, throw error if not + if (!response.ok) throw new Error('Network response was not ok') + + // Return the data + return response.json() +} + +// Use your `queryFn` with react-query +const data = useQuery({ queryKey: ['projects'], queryFn: fetchProjects }) +``` + +
+ +
+ +### `queryKey` best practices + +> At its core, **TanStack Query manages query caching for you based on query keys**. Query keys **have to be an Array** at the top level, and can be as simple as an Array with a single string, or as complex as an array of many strings and nested objects. As long as the query key is serializable, and unique to the query's data, you can use it! +> +> -- React Query Docs + +The simplest form of a key is an array with constants values. For example: + +```tsx +// A list of todos +useQuery({ queryKey: ['todos'], ... }) + +// Something else, whatever! +useQuery({ queryKey: ['something', 'special'], ... }) +``` + +Often, your `queryFn` will take some input, like a `slug` or `id`, and you'll want to refetch the query when that input changes. In this case, you can use the input as part of the query key: + +```tsx +// An individual todo +useQuery({ queryKey: ['todo', 5], ... }) + +// An individual todo in a "preview" format +useQuery({ queryKey: ['todo', 5, { preview: true }], ...}) + +// A list of todos that are "done" +useQuery({ queryKey: ['todos', { type: 'done' }], ... }) +``` + +
+ +### Invalidating + refetching queries + +**Invalidating queries** in React Query **means telling the library to refetch data**, which is necessary when the underlying data may have changed. React Query offers several ways to invalidate queries, such as directly through the queryClient or as a side effect of mutations. + +```tsx +// Invalidate every query with a key that starts with `todos` +queryClient.invalidateQueries({ queryKey: ['todos'] }) + +// Invalidate a specific query +queryClient.invalidateQueries({ queryKey: ['todo', 5] }) +``` + +A common practice is to **refetch queries after updating data**. This can be done by using the `onSuccess` callback of a mutation: + +```tsx {4} +const mutation = useMutation(createTodo, { + ..., + // Refetch the query with key ['todos'] after the mutation succeeds + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }) +}) +``` + +
+ +### Update data with `useMutation` + +**Mutations** are used to send data to an API. They are similar to queries, but instead of fetching data, they send data to the server. React Query provides a `useMutation` hook to handle mutations. + +```tsx {3, 9} +const mutation = useMutation(createTodo, { + // The mutation function + mutationFn: (newTodo) => fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo) }), + // Invalidate the 'todos' query after the mutation succeeds + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }) +}) + +// Call the mutation function +mutation.mutate({ title: 'New todo' }) // <- Data will be passed as the `newTodo` arg +``` + +
+ +### Workspace recommendations + +Like our resolvers and data-bridges, we think it's best to define your custom and/or third-party data-fetching logic in files on the workspace level. + +> This will help **keep your feature folders clean, focused, portable and reusable across different projects**: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +## GraphQL vs. API routes + +We recommend you use GraphQL for your data-fetching logic, as it provides a more flexible and efficient way to fetch data in each environment. + +We do recommend using API routes instead when mutating / updating data. Though here too, GraphQL can be used if you prefer. + +
+ +### Why GraphQL for data-fetching? + +> As mentioned before, the 'G' in GREEN-stack stands for GraphQL. But why do we recommend it? + +GraphQL may look complex, but when simplified, you can get all of the benefits without said complexity: + +- **Type safety**: Your input and output types are automatically validated and inferred. +- **Self-documenting**: Your API is automatically documented in `schema.graphql`. + +> If you don't need to worry much about the complexity of setting up and using GraphQL, it's similar to using tRPC. + +--- + +It's important to note that there are some common footguns to avoid when using GraphQL: + +
+Why avoid a traversible graph? + + +> If you don't know yet, the default way to use GraphQL is to expose many different domains as a graph. + +Think of a graph based API as each data type being a node, and each relationship between them being an edge. Without limits you could infinitely traverse this graph. Such as a `User` having `Post` nodes, which in themselves have related `Reply` data, which have related `User` data again. You could query all of that in one go. + +That might *sound* great, but it can lead to problems like: +- **Performance**: If not limited, a single query could request so much data it puts a severe strain on your server. +- **Scraping**: If you allow deep nesting, people can easily scrape your entire database. + +> Instead of exposing your entire database as a graph, we recommend you use GraphQL in a more functional "Remote Procedure Call" (RPC) style. + +Only when actually offering GraphQL as a public API to third party users to integrate with you, do we recommend a graph / domain style GraphQL API. +
+ +
+Why RPC-style GraphQL? + + +If you're not building a public API, you should shape your resolvers in function of your front-end screens instead of your domain or database collections. This way, you can fetch all the data required for one screen in one go, without having to call multiple resolvers or endpoints. + +> To illustrate RPC-style queries in your mind, think of `getDashboardData()` vs. having to call 3 separate `Orders()`, `Products()`, `Transactions()` type resolvers to achieve the same thing. + +When used in this manner, quite similar to tRPC, it remains the best for initial data-fetching. Though mutating data might be better served as a tRPC call or API route POST / PUT / DELETE request. + +We think GraphQL is great for fetching data, but not always the best for updating data. Especially when you need to handle complex forms and file uploads. + +You might therefore want to handle mutations of data in a more traditional REST-ful way, using tRPC or just plain old API routes. +
+ +
+ +## Further reading + +From our own docs: +- [Single Sources of Truth](/single-sources-of-truth) +- [Data Resolvers](/data-resolvers) + +Relevant external docs: +- [React Query](https://tanstack.com/query/latest/) +- [GraphQL](https://graphql.org/learn/) diff --git a/apps/docs/pages/data-resolvers.mdx b/apps/docs/pages/data-resolvers.mdx new file mode 100644 index 0000000..a99811e --- /dev/null +++ b/apps/docs/pages/data-resolvers.mdx @@ -0,0 +1,531 @@ +import { FileTree, Steps } from 'nextra/components' +import { Image } from '@app/primitives' + + + +# Building a flexible API + +```shell +@app-core + └── /resolvers/... # <- Reusable back-end logic goes here +``` + +> To get an idea of API design in portable workspaces, open the file tree below: + + + + + + + + + + + + + + + + + + + + + + + +This doc focuses on creating APIs that: + +- Are *self-documenting*, *easy to use, reuse and maintain* +- **Keep input + output *shape, validation and types* in sync automatically** +- Portable as part of a fully copy-pasteable feature + +**You'll learn to create an API that can be:** + +- ✅ Reused as functions / promises anywhere else in your back-end +- ✅ A Next.js API route handler +- ✅ An RPC style GraphQL resolver + +
+ +## Using the API generator + +Creating a new API that checks all the above boxes can involve a lot of boilerplate. + +To avoid that + have this boilerplate code generated, you can use the following command: + +```shell +npm run add:resolver +``` + +This will ask you a few questions about your new API, and then generate + link the necessary files for you. + +```shell {4} +>>> Modify "your universal app" using custom generators + +? Where would you like to add this resolver? # (use arrow keys) +❯ features/@app-core -- importable from: '@app/core' + features/@some-feature -- importable from: '@app/some-feature' + features/@other-feature -- importable from: '@app/other-feature' +``` + +`⬇⬇⬇` + +```shell {9, 10, 11, 12} +>>> Modify "your universal app" using custom generators + +? Where would you like to add this resolver? # -> features/@app-core +? How will you name the resolver function? # -> getExampleData +? Optional description: What will the resolver do? # -> Get some test data +? What else would you like to generate? # -> GraphQL Resolver, GET & POST API Routes +? Which API path would you like to use? # -> /api/example/[slug] + +>>> Changes made: + • /features/app-core/schemas/getExampleData.bridge.ts (add) + • /features/app-core/resolvers/getExampleData.resolver.ts (add) + • /features/app-core/routes/api/example/[slug]/route.ts (add) + +>>> Success! +``` + +To explain what happens under the hood, have a look at the following steps. It's what the generator helps you avoid doing manually: + +
+ +## Manually add a new API + +> It's worth noting that it's fine to reuse the Next.js or Expo ways of working to create APIs. We just provide a more portable and structured way to do so: + + + +### Start from a DataBridge + + + + + + + + + + + + +Schemas serve as the single source of truth for your data shape. But what about API shape? + +You can combine input and output schemas into a `bridge` file to serve as the single source of truth for your API resolver. You can use `createDataBridge` to ensure all the required fields are present: + +```ts {6, 8} /createDataBridge/1,3 filename="updatePost.bridge.ts" +import { Post } from '../schemas/Post.schema' +import { createDataBridge } from '@green-stack/schemas/createDataBridge' + +/* -- Bridge ------------- */ + +export const updatePostBridge = createDataBridge({ + // Assign schemas + inputSchema: Post.partial(), // You can create, reuse or extend schemas here if needed + outputSchema: Post, + + // Used for GraphQL defs + resolverName: 'updatePost', + resolverArgsName: 'PostInput', + + // API config + apiPath: '/api/posts/[slug]/update', + allowedMethods: ['POST', 'GRAPHQL'], +}) +``` + +> Don't worry too much about the GraphQL and API route config for now. We'll get to that soon. + +Think of a **"Databridge"** as a literal *bridge between the front and back-end*. + +It's a metadata object you can use from either side to provide / transform / extract: + +- ✅ **Route handler args** from request params / body +- ✅ Input and output **types + validation + defaults** +- ✅ GraphQL **schema definitions** for `schema.graphql` +- ✅ The query string to call our GraphQL API with + +*There's two reasons we suggest you define this "DataBridge" in a separate file*: + +- 1. **Reusability**: If kept separate from business logic, you can reuse it in both front and back-end code. +- 2. **Consistency**: Predicatable patterns make it easier to build automations and generators around them. + +> For this reason, we suggest you add `.bridge.ts` to your bridge filenames. + +### Add your business logic + + + + + + + + + + + + + +Let's use the data bridge we just created to bundle together the input and output types with our business logic. + +You can do so by creating a new resolver file and passing the bridge as the final arg to `createResolver` at the end. The first argument is your resolver function containing your business logic: + +```ts {7, 14, 22} /updatePostBridge/ /createResolver/1,3 filename="updatePost.resolver.ts" +import { createResolver } from '@green-stack/schemas/createResolver' +import { updatePostBridge } from './updatePost.bridge' +import { Posts } from '@db/models' + +/** --- updatePost() ---- */ +/** -i- Update a specific post. */ +export const updatePost = createResolver(async ({ + args, // <- Auto inferred types (from 'inputSchema') + context, // <- Request context (from middleware) + parseArgs, // <- Input validator (from 'inputSchema') + withDefaults, // <- Response helper (from 'outputSchema') +}) => { + + // Validate input and apply defaults, infers input types as well + const { slug, ...postUpdates } = parseArgs(args) + + // -- Context / Auth Guards / Security -- + + // e.g. use the request 'context' to log out current user + const { user } = context // example, requires auth middleware + + // -- Business Logic -- + + // e.g. update the post in the database + const updatedPost = await Posts.updateOne({ slug }, postUpdates) + + // -- Respond -- + + // Typecheck response and apply defaults from bridge's outputSchema + return withDefaults({ + slug, + title, + content, + }) + +}, updatePostBridge) +``` + +> You pass the bridge (☝️) as the second argument to `createResolver()` to: +- 1️⃣ infer the input / arg types from the bridge's `inputSchema` +- 2️⃣ enable `parseArgs()` and `withDefaults()` helpers for validation, hints + defaults +- 3️⃣ automatically extract the request context whether in an API route / GraphQL resolver / ... + +> **The resulting function can be used as just another async function** anywhere in your back-end. + +> The difference with a regular function, since the logic is bundled together with its data-bridge / input + output metadata, is that we can easily transform it into APIs: + +### Turn resolver into an API route handler + +> We recommend workspaces follow Next.js API route conventions. This is so our scripts can automatically re-export them to the `@app/next` workspace later. + + + + + + + + + + + + + + + + + + +You can create a new API route by exporting a `GET` / `POST` / `UPDATE` / `DELETE` handler assigned to a `createNextRouteHandler()` that wraps your "bridged resolver": + +```ts /createNextRouteHandler/1,3 /updatePost/4 filename="@some-feature / routes / api / posts / [slug] / update / route.ts" +import { updatePost } from '@app/resolvers/updatePost.resolver' +import { createNextRouteHandler } from '@green-stack/schemas/createNextRouteHandler' + +/* --- Routes ------------ */ + +export const UPDATE = createNextRouteHandler(updatePost) +// Automatically extracts (☝️) args from url / search params +// based on the zod 'inputSchema' + +// If you want to support e.g. POST (👇), same deal (checks request body too) +export const POST = createNextRouteHandler(updatePost) +``` + +What `createNextRouteHandler()` does under the hood: +- 1. extract the input from the request context +- 2. validate it +- 3. call the resolver function with the args (and e.g. token / session / request context) +- 4. return the output from your resolver with defaults applied + +> Check [Next.js Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) to understand supported exports (like `GET` or `POST`) and their options. + +> Restart your dev server or run `npm run collect:routes` to make sure your new API route is available. + +### GraphQL, without the hassle + +API routes are fine, but we think GraphQL is even better, **if you don't have to deal with the hassle of managing it.** So we made it quite easy to enable GraphQL for your resolvers. The flow is quite similar. + +*In the same file*, add the following: + +```ts {3, 11} filename="features / @app-core / routes / api / health / route.ts" +import { updatePost } from '@app/resolvers/updatePost.resolver' +import { createNextRouteHandler } from '@green-stack/schemas/createNextRouteHandler' +import { createGraphResolver } from '@green-stack/sck,nhemas/createGraphResolver' + +/* --- Routes ------------ */ + +// exports of `GET` / `POST` / `PUT` / ... + +/* --- GraphQL ----------- */ + +export const graphResolver = createGraphResolver(updatePost) +// Automatically extracts input (☝️) from graphql request context +``` + +After exporting `graphResolver` here, restart the dev server or run `npm run build:schema` manually. + +This will: +- 1. pick up the `graphResolver` export +- 2. put it in our list of graphql compatible resolvers at `resolvers.generated.ts` in `@app/registries` +- 3. recreate `schema.graphql` from input & output schemas from registered resolvers + +You can now check out your GraphQL API playground at [/api/graphql](http://localhost:3000/api/graphql) + +![Apollo Server GraphQL Playground Preview](https://github.com/Aetherspace/universal-app-router/assets/5967956/3b6b1d98-1228-44a4-aaa0-968664a027c3) + +### Optional - Add a universal fetcher + +> We'll mostly be using fetchers when providing data to our app screens or routes. You can skip this step as it will be covered on the next page as well. + +
+How to turn a DataBridge into a fetcher? + +--- + +One reason we recommed GraphQL is that it's easier to make a universal fetcher that: + +- can run on the server, browser or mobile +- automatically builds the query string from the input and output schemas +- is auto typed if using the bridge to provide the query +- is auto typed with `gql.tada` if a custom query is used (alt) + + + + + + + + + + + + + + + + + + + +The easiest way to create a fetcher is to use the `bridgedFetcher()` helper: + +```ts {3} /bridgedFetcher/3 filename="updatePost.query.ts" +import { updatePostBridge } from './updatePost.bridge' +// ☝️ Reuse your data bridge +import { bridgedFetcher } from '@green-stack/schemas/bridgedFetcher' +// ☝️ Universal graphql fetcher that can be used in any JS environment + +/* --- updatePostFetcher() --------- */ + +export const updatePostFetcher = bridgedFetcher(updatePostBridge) +``` + +This will automatically build the query string with all relevant fields from the bridge. + +To write a custom query with only certain fields, you can use our `graphql()` helper *with* `bridgedFetcher()`: + +```ts {3, 11, 21, 31, 41} filename="updatePost.query.ts" +import { ResultOf, VariablesOf } from 'gql.tada' +// ☝️ Type helpers that interface with the GraphQL schema +import { graphql } from '../graphql/graphql' +// ☝️ Custom gql.tada query builder that integrates with our types +import { bridgedFetcher } from '@green-stack/schemas/bridgedFetcher' +// ☝️ Universal graphql fetcher that can be used in any JS environment + +/* --- Query ----------------------- */ + +// VSCode and gql.tada will help suggest or autocomplete thanks to our schema definitions +export const updatePostQuery = graphql(` + query updatePost ($updatePostArgs: UpdatePostInput) { + updatePost(args: $updatePostArgs) { + slug + title + body + } + } +`) + +// ⬇⬇⬇ automatically typed as ⬇⬇⬇ + +// TadaDocumentNode<{ +// updatePost(args: Partial): { +// slug: string | null; +// title: boolean | null; +// body: boolean | null; +// }; +// }> + +// ⬇⬇⬇ can be turned into reusable types ⬇⬇⬇ + +/* --- Types ----------------------- */ + +export type UpdatePostQueryInput = VariablesOf + +export type UpdatePostQueryOutput = ResultOf + +/* --- updatePostFetcher() --------- */ + +export const updatePostFetcher = bridgedFetcher({ + ...updatePostBridge, // <- Reuse your data bridge ... + graphqlQuery: updatePostQuery, // <- ... BUT, use our custom query +}) +``` + +> Whether you use a custom query or not, you now have a fetcher that: + +- ✅ Uses the executable graphql schema serverside +- ✅ Can be used in the browser or mobile using fetch + +
+ +
+ +
+ +## Why GraphQL? + +> As mentioned before, the 'G' in GREEN-stack stands for GraphQL. **But why do we recommend it?** + +GraphQL may look complex, but when simplified, you can get most benefits without much complexity: + +- **Type safety**: Your input and output types are automatically validated and inferred. (from bridge or [gql.tada](https://gql-tada.0no.co/get-started/#a-demo-in-128-seconds)) +- **Self-documenting**: The API is automatically documented in `schema.graphql` + introspection. + +> If you don't need to worry much setting up and consuming GraphQL, it's quite similar to using tRPC. + +--- + +It's important to note that there are some common footguns to avoid when using GraphQL: + +
+Why avoid a traversible graph? + + +> If you don't know yet, the default way to use GraphQL is to expose many different domains as a graph. + +Think of a graph based API as each data type being a node, and each relationship between them being an edge. Without limits you could infinitely traverse this graph. Such as a `User` having `Post` nodes, which in themselves have related `Reply` data, which have related `User` data again. You could query all of that in one go. + +That might *sound* great, but it can lead to problems like: +- **Performance**: If not limited, a single query could request so much data it puts a severe strain on your server. +- **Scraping**: If you allow deep nesting, people might more easily scrape your entire database. + +> Instead of exposing all of your data as a graph, we recommend you use GraphQL in a more functional "Remote Procedure Call" (RPC) style. + +Only when actually offering GraphQL as a public API to third party users to integrate with you, do we recommend a graph / domain style GraphQL API. +
+ +
+Why RPC-style GraphQL? + + +If you're not building a public API, you should shape your resolvers in function of your front-end screens instead of your domain or database collections. This way, you can fetch all the data required for one screen in one go, without having to call multiple resolvers or endpoints. + +> To illustrate RPC-style queries in your mind, think of `getDashboardData()` vs. having to call 3 separate `Orders()`, `Products()`, `Transactions()` type resolvers to achieve the same thing. + +When used in this manner, quite similar to tRPC, it remains the best for initial data-fetching. Though mutating data might be better served as a tRPC call or API route POST / PUT / DELETE request. + +We think GraphQL is great for fetching data, but not always the best for updating data. Especially when you need to handle complex forms and file uploads. + +You might therefore want to handle mutations of data in a more traditional REST-ful way, using tRPC or just plain old API routes / handlers. +
+ +
+ +## Securing your API + +By default, any API generated by the `add:resolver` command is open to the public. Consider whether this is the right choice for that specific API. + +To prevent unauthorized acces, you likely want to: +- expand the [Next.js Middleware](https://nextjs.org/docs/pages/building-your-application/routing/middleware) +- or **add auth checks in your business logic** + +> We offer some solutions to help here but do not provide a default for you. More info on this in our recommended [Auth Plugins](TODO) in the sidebar. + +### Using the request context + +Our standard middleware adds any JSON serializable request context (like auth headers) to the `context` object in your resolver utils, e.g.: + +```ts filename="updateProfile.resolver.ts" +export const updateProfile = createResolver(async ({ + args, + context, // <- Request context (from middleware) + parseArgs, + withDefaults, +}) => { + // e.g. use the request 'context' to log out current user + const { req, userId } = context + + // ... + + if (!userId) throw new Error('Unauthorized') + + // ... + +}, updateProfileBridge) +``` + +This could be a good way to check for authorization in your resolver logic itself vs relying solely on middleware. + +### Marking fields as sensitive + +```ts + password: z.string().sensitive() +``` + +In your schemas, you can mark fields as sensitive using `.sensitive()`. This will: + +- Exclude the field from appearing in the GraphQL schema, introspection or queries +- Mark the field as strippable in API resolvers / responses (*) +- Mark the field with `isSensitive: true` in schema introspection + +> * 'Strippable' means when using either `withDefaults()` OR `applyDefaults()` / `formatOutput()` with the `{ stripSensitive: true }` option as second argument. If none of these are used, the field will still be present in API route handler responses, but not GraphQL. + +
+ +## Further reading + +From our own docs: +- [Single Sources of Truth](/single-sources-of-truth) +- [Universal Data Fetching](/data-fetching) + +Relevant external docs: +- [GraphQL](https://graphql.org/learn/) +- [Next.js Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) +- [GraphQL Resolvers](https://www.apollographql.com/docs/apollo-server/data/resolvers/) +- [GraphQL Schema](https://www.apollographql.com/docs/apollo-server/schema/schema/) + +
+ +
diff --git a/apps/docs/pages/form-management.mdx b/apps/docs/pages/form-management.mdx new file mode 100644 index 0000000..9f36114 --- /dev/null +++ b/apps/docs/pages/form-management.mdx @@ -0,0 +1,361 @@ +import { FileTree, Steps, Cards } from 'nextra/components' +import { Image } from '@app/primitives' + + + +# Schema based Form management + +```tsx copy +import { useFormState } from '@green-stack/forms/useFormState' +``` + + + + + + + + + + + + +A major goal of this starterkit is to use `zod` schemas as the ultimate source of truth for your app's data shapes, validation and types. *We've extended this concept to include form management as well:* + +
+ +## `useFormState()` + +```tsx {10} filename="useSomeFormState.ts" +// Define a Zod schema for your form state (or use an existing one) +const SomeFormState = schema('SomeFormState', { + name: z.string(), + age: z.number().optional(), + email: z.string().email(), + birthDate: z.date().optional(), +}) + +// Create a set of form state utils to use in your components +const formState = useFormState(SomeFormState, { + initialValues: { ... }, + // ... other options +}) +``` + +`formState.values` is typed according to the Zod schema you provided as the first argument: + +```tsx +formState.values.name // string +formState.values.age // number | undefined +formState.values.email // string +formState.values.birthDate // Date | undefined +``` + +
+ +### defaults and `initialValues` + +You can provide initial values through the `initialValues` option: + +```tsx {2} +const formState = useFormState(SomeFormSchema, { + initialValues: { + name: 'Thorr Codinsonn', + email: 'thorr@codinsonn.dev', + }, +}) + +formState.values.name // 'Thorr Codinsonn' +formState.values.email // 'thorr@codinsonn.dev' +``` + +Alternatively, your schema can define default values as well: + +```tsx /.default/ +const SomeFormSchema = schema('SomeFormSchema', { + name: z.string().default('Thorr Codinsonn'), + email: z.string().email().default('thorr@codinsonn.dev'), +}) + +const formState = useFormState(SomeFormSchema) + +formState.values.name // 'Thorr Codinsonn' +formState.values.email // 'thorr@codinsonn.dev' +``` + +
+ +### Retrieving and updating values + +You can use `formState.getValue('some-key'){:tsx}` to get a specific value from the form state. The `'some-key'` argument is any key in the Zod schema you provided as stateSchema and the available keys will he hinted by your IDE. + +```tsx +formState.getValue('name') // string +// ?^ Hinted keys: 'name' | 'email' | 'age' | 'birthDate' +``` + +Updating the formState values can similarly be done in two ways: + +```tsx +// Update a single value in the form state by its hinted key +formState.handleChange('email', 'thorr@fullproduct.dev') // OK +formState.handleChange('age', 'thirty two') // ERROR (non a number) +``` + +```tsx +// Update multiple values in the form state by passing an object with keys and values +formState.setValues({ + name: 'Thorr', // OK + email: 'thorr@fullproduct.dev', // OK + age: 'some-string', // ERROR: Type 'string' is not assignable to type 'number' +}) +``` + +Typescript and your IDE will help you out with the available keys and allowed values through hints and error markings if you try to set a value that doesn't match the Zod schema. + +
+ +### Validation and error handling + +Call `formState.validate(){:tsx}` to validate current state values against the Zod schema: + +```tsx +const isValid = formState.validate() // true | false +``` + +Validating the formstate will also update the `formState.errors` object with any validation errors. + +```tsx +formState.errors +// { +// name: ['Required'], +// email: ['Invalid email'] +// } +``` + +You could also trigger validation on any change to the form state by passing the `validateOnChange` option: + +```tsx {2} +const formState = useFormState(SomeFormSchema, { + validateOnChange: true, +}) +``` + +You can manually update or clear the errors object: + +```tsx +formState.updateErrors({ name: ['Required'] }) + +// Clear all errors +formState.clearErrors() +``` + +`clearErrors()` is essentially the same thing as clearing errors manually with: + +```tsx +// e.g. Clear all error messages by passing an empty object or empty arrays +formState.updateErrors({ + username: [], + email: [], + password: [], + twoFactorCode: [], +}) +``` + +
+ +#### Custom Errors + +You can provide custom error messages by passing the `message` prop in your form schema: + +```ts +age: z.number({ message: 'Please provide a number' }) +// Throws => ZodError: [{ message: "Please provide a number", ... }) +``` + +You can provide **custom error messages** for specific validations as well: + +```ts +const SomeFormSchema = schema('SomeFormSchema', { + + age: z.number().min(10, { message: 'You must be at least 18 years old' }), + // Throws => ZodError: [{ message: "You must be at least 18 years old", ... }) + + password: z.string().len(12, { message: 'Passwords must be at least 12 characters long' }), + // Throws => ZodError: [{ message: "Passwords must be at least 12 characters long", ... }) + +}) +``` + +
+ +## Universal Form Components + +Our [full-product.dev](https://fullproduct.dev) starterkit comes with some (minimally styled) universal form components out of the box. + +You can see them in the sidebar under `@app-core/forms`: + + + + + + + + + + + + +> These are built with `useFormState` in mind and based on Nativewind-compatible [react-native-primitives](https://rn-primitives.vercel.app/). + +
+ +### Integrating with Components + +`formState` exposes a few utility functions to make it easy to integrate with form components: + +- ✅ `value` props +- ✅ `onChange` props +- ✅ `hasError` prop + +
+ +#### `getInputProps(...)` + +You can use `formState.getInputProps()` to easily integrate your form state with React components that: + +- Accept a `value` prop +- Accept an `onChange` prop + +```tsx {6, 15} + + + +``` + +
+ +#### `getTextInputProps(...)` + +`formState.getTextInputProps()` is a replacement for `getInputProps()` with a `TextInput` component: + +- Uses `onChangeText` instead of `onChange` + +```tsx {3, 8} +