Compare commits
189 Commits
v3.0.0-bet
...
fix/beta/w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8256b3adfe | ||
|
|
b2662eeb1f | ||
|
|
0b274dd67e | ||
|
|
2ddd50edc4 | ||
|
|
0287acb8f0 | ||
|
|
10c94b3665 | ||
|
|
ea48ca377e | ||
|
|
6f5d86ed84 | ||
|
|
c383f391e3 | ||
|
|
8a91a7adbb | ||
|
|
96181d91a6 | ||
|
|
eff5129a5f | ||
|
|
38e5adc462 | ||
|
|
ff4ea1eecc | ||
|
|
dbfd1beed5 | ||
|
|
4b6774463e | ||
|
|
cb14b97a6e | ||
|
|
18bc4b708c | ||
|
|
6d951e6987 | ||
|
|
365660764d | ||
|
|
8b91af8a5b | ||
|
|
b4092f59ae | ||
|
|
7a768144ea | ||
|
|
3839eb5ab0 | ||
|
|
fd02bee0fe | ||
|
|
42222cd2f6 | ||
|
|
e3222f2ac3 | ||
|
|
35f961fecb | ||
|
|
85bfca79ef | ||
|
|
661a4a099d | ||
|
|
72c0534008 | ||
|
|
78579ed2bd | ||
|
|
7bcb4ba1cc | ||
|
|
6b45cf3197 | ||
|
|
73d0b209d7 | ||
|
|
c93752bdbb | ||
|
|
7a4dd5890e | ||
|
|
60ee55fcaa | ||
|
|
1fe9790d0d | ||
|
|
3c0853a675 | ||
|
|
2b941b7e2c | ||
|
|
db772a058c | ||
|
|
0bfbf9c750 | ||
|
|
5c7647f45b | ||
|
|
6c952875e8 | ||
|
|
af7e12aa2f | ||
|
|
bcc506b423 | ||
|
|
3d7c8277d7 | ||
|
|
a8a2dc2347 | ||
|
|
6c74b0326b | ||
|
|
f51af92491 | ||
|
|
77528a1e7d | ||
|
|
ba8b8e8330 | ||
|
|
23f9a32a99 | ||
|
|
0190eb8b28 | ||
|
|
f482fdcfd5 | ||
|
|
ed4766188d | ||
|
|
e682cb1b04 | ||
|
|
36fda30c61 | ||
|
|
fa7cc376d1 | ||
|
|
3fc2ff1ef9 | ||
|
|
1d81eef805 | ||
|
|
8fcfac61b5 | ||
|
|
0d544dacdb | ||
|
|
147b50e719 | ||
|
|
eeb689dd55 | ||
|
|
c2571cfdb6 | ||
|
|
12c812d379 | ||
|
|
bf106db6e2 | ||
|
|
18009349c0 | ||
|
|
89b6055d61 | ||
|
|
d9a8869132 | ||
|
|
9d5c0d350c | ||
|
|
4dedd6e267 | ||
|
|
276213193b | ||
|
|
553bb4b530 | ||
|
|
5083525189 | ||
|
|
e4185259b4 | ||
|
|
5323d76a5b | ||
|
|
608387084c | ||
|
|
9556d1bd42 | ||
|
|
a6bf05815c | ||
|
|
a4deaf07d6 | ||
|
|
4adf01ab04 | ||
|
|
1abcdf96fa | ||
|
|
3456b5f6a7 | ||
|
|
d053778bf2 | ||
|
|
fbad39a120 | ||
|
|
e8d1d369cf | ||
|
|
fbea52416c | ||
|
|
cb9a20fefa | ||
|
|
8db9664700 | ||
|
|
08add653c7 | ||
|
|
eed9676536 | ||
|
|
22480a7648 | ||
|
|
aa2073f9e9 | ||
|
|
e0618f81a5 | ||
|
|
ea90018979 | ||
|
|
5a4074e90a | ||
|
|
0e9bbecbee | ||
|
|
c8a1ccaf4b | ||
|
|
79f4907cb3 | ||
|
|
6a0fffe002 | ||
|
|
6e116a76fd | ||
|
|
f52607f3b5 | ||
|
|
f716122eab | ||
|
|
353c2b0be2 | ||
|
|
58bbbbd395 | ||
|
|
57b072edfc | ||
|
|
f6039246c6 | ||
|
|
fcee13b017 | ||
|
|
ef5197a514 | ||
|
|
7438812db3 | ||
|
|
48af78278d | ||
|
|
3abc2e8328 | ||
|
|
a48043c2aa | ||
|
|
7e4f50a01c | ||
|
|
40d3078bf2 | ||
|
|
662334abfb | ||
|
|
aa5ad47177 | ||
|
|
890c21dda4 | ||
|
|
c7635b2783 | ||
|
|
47cd5f4d01 | ||
|
|
0d98b4b96f | ||
|
|
23c7ab2bc4 | ||
|
|
0680e0c58b | ||
|
|
b3df253880 | ||
|
|
a78b44d0c1 | ||
|
|
d272a1fd22 | ||
|
|
095e4402ac | ||
|
|
60d2def428 | ||
|
|
2f02b3a6e1 | ||
|
|
4f0ddcf632 | ||
|
|
693621a6e3 | ||
|
|
5b201392cc | ||
|
|
a70bcf81c0 | ||
|
|
ed880d5018 | ||
|
|
ea84e82ad5 | ||
|
|
4216d69ccb | ||
|
|
dcad5003f5 | ||
|
|
bd9c06a99d | ||
|
|
48932ef54d | ||
|
|
4aeefc5a1a | ||
|
|
e96ff90029 | ||
|
|
f6e77b845b | ||
|
|
a0bb02d05a | ||
|
|
354ad7092c | ||
|
|
f9d862d854 | ||
|
|
f41576dd65 | ||
|
|
ffa20aa7d0 | ||
|
|
f7a2cf96b9 | ||
|
|
cfeac79b99 | ||
|
|
821bed0ea6 | ||
|
|
9e9111666b | ||
|
|
5065322d31 | ||
|
|
ad4796cdb2 | ||
|
|
43b7ba82da | ||
|
|
3785c79ac9 | ||
|
|
4384e9eb0e | ||
|
|
9364f8da2e | ||
|
|
a4ef359660 | ||
|
|
848c05f247 | ||
|
|
ec556360b6 | ||
|
|
d99b426e3b | ||
|
|
19a78297b4 | ||
|
|
e95eea694c | ||
|
|
4c6aaafe88 | ||
|
|
dc8c099d9e | ||
|
|
259ae674a1 | ||
|
|
731f023c6d | ||
|
|
86b19d4c74 | ||
|
|
17b8c29799 | ||
|
|
29af2849ba | ||
|
|
a7ac5efd70 | ||
|
|
15c7a9dcf8 | ||
|
|
8e55a2a866 | ||
|
|
0f306da63b | ||
|
|
ba9ea5c752 | ||
|
|
53b7d6f89f | ||
|
|
f5fb095df4 | ||
|
|
721919fae9 | ||
|
|
d3e27e87fe | ||
|
|
e1ff92e8c6 | ||
|
|
ac5d744914 | ||
|
|
b94a265fad | ||
|
|
1ba3a92745 | ||
|
|
9814fd705e | ||
|
|
20455f4fc2 | ||
|
|
9f37bf7397 |
@@ -11,3 +11,4 @@
|
||||
playwright.config.ts
|
||||
jest.config.js
|
||||
test/live-preview/next-app
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -9,6 +9,7 @@ module.exports = {
|
||||
rules: {
|
||||
'payload/no-jsx-import-statements': 'warn',
|
||||
'payload/no-relative-monorepo-imports': 'error',
|
||||
'payload/no-imports-from-exports-dir': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
13
.github/CODEOWNERS
vendored
13
.github/CODEOWNERS
vendored
@@ -3,22 +3,19 @@
|
||||
### Package Exports ###
|
||||
/**/exports/ @denolfe @jmikrut
|
||||
|
||||
### Adapters ###
|
||||
### Packages ###
|
||||
/packages/richtext-*/ @AlessioGr
|
||||
|
||||
### Plugins ###
|
||||
/packages/plugin-cloud*/ @denolfe
|
||||
/packages/email-*/ @denolfe
|
||||
/packages/storage-*/ @denolfe
|
||||
/packages/create-payload-app/ @denolfe
|
||||
/packages/eslint-*/ @denolfe
|
||||
|
||||
### Templates ###
|
||||
/templates/ @jacobsfletch @denolfe
|
||||
|
||||
### Misc ###
|
||||
/packages/create-payload-app/ @denolfe
|
||||
/packages/eslint-*/ @denolfe
|
||||
|
||||
### Build Files ###
|
||||
/**/package.json @denolfe
|
||||
|
||||
/tsconfig.json @denolfe
|
||||
/**/tsconfig*.json @denolfe
|
||||
|
||||
|
||||
48
.github/actions/setup/action.yml
vendored
Normal file
48
.github/actions/setup/action.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Setup node and pnpm
|
||||
description: Configure the Node.js and pnpm versions
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: 'The Node.js version to use'
|
||||
required: true
|
||||
default: 18.20.2
|
||||
pnpm-version:
|
||||
description: 'The pnpm version to use'
|
||||
required: true
|
||||
default: 8.15.7
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# https://github.com/actions/virtual-environments/issues/1187
|
||||
- name: tune linux network
|
||||
shell: bash
|
||||
run: sudo ethtool -K eth0 tx off rx off
|
||||
|
||||
- name: Setup Node@${{ inputs.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: ${{ inputs.pnpm-version }}
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-store-
|
||||
pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- shell: bash
|
||||
run: pnpm install
|
||||
50
.github/workflows/label-author.yml
vendored
Normal file
50
.github/workflows/label-author.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: label-author
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
debug-context:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: View context attributes
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: console.log(context)
|
||||
|
||||
label-created-by:
|
||||
name: Label pr/issue on opening
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Tag with 'created-by'
|
||||
uses: actions/github-script@v7
|
||||
if: github.event.action == 'opened'
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const type = context.payload.pull_request ? 'pull_request' : 'issue';
|
||||
const association = context.payload[type].author_association;
|
||||
let label = ''
|
||||
if (association === 'MEMBER' || association === 'OWNER') {
|
||||
label = 'created-by: Payload team';
|
||||
} else if (association === 'CONTRIBUTOR') {
|
||||
label = 'created-by: Contributor';
|
||||
}
|
||||
|
||||
if (!label) return;
|
||||
|
||||
github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: [label],
|
||||
});
|
||||
console.log('Added created-by: Payload team label');
|
||||
91
.github/workflows/main.yml
vendored
91
.github/workflows/main.yml
vendored
@@ -2,12 +2,18 @@ name: build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
push:
|
||||
branches: ['main', 'beta']
|
||||
branches:
|
||||
- main
|
||||
- beta
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
# <workflow_name>-<branch_name>-<true || commit_sha if branch is protected>
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.ref_protected && github.sha || ''}}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -49,6 +55,50 @@ jobs:
|
||||
echo "needs_build: ${{ steps.filter.outputs.needs_build }}"
|
||||
echo "templates: ${{ steps.filter.outputs.templates }}"
|
||||
|
||||
lint:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# https://github.com/actions/virtual-environments/issues/1187
|
||||
- name: tune linux network
|
||||
run: sudo ethtool -K eth0 tx off rx off
|
||||
|
||||
- name: Setup Node@${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
timeout-minutes: 720
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-store-
|
||||
pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- run: pnpm install
|
||||
- name: Lint staged
|
||||
run: |
|
||||
git diff --name-only --diff-filter=d origin/${GITHUB_BASE_REF}...${GITHUB_SHA}
|
||||
npx lint-staged --diff="origin/${GITHUB_BASE_REF}...${GITHUB_SHA}"
|
||||
|
||||
build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.needs_build == 'true' }}
|
||||
@@ -239,7 +289,8 @@ jobs:
|
||||
suite:
|
||||
- _community
|
||||
- access-control
|
||||
- admin
|
||||
- admin__e2e__1
|
||||
- admin__e2e__2
|
||||
- auth
|
||||
- field-error-states
|
||||
- fields-relationship
|
||||
@@ -248,9 +299,17 @@ jobs:
|
||||
- fields__collections__Array
|
||||
- fields__collections__Relationship
|
||||
- fields__collections__RichText
|
||||
- fields__collections__Lexical
|
||||
- fields__collections__Lexical__e2e__main
|
||||
- fields__collections__Lexical__e2e__blocks
|
||||
- fields__collections__Date
|
||||
- fields__collections__Number
|
||||
- fields__collections__Point
|
||||
- fields__collections__Tabs
|
||||
- fields__collections__Text
|
||||
- fields__collections__Upload
|
||||
- live-preview
|
||||
- localization
|
||||
- i18n
|
||||
- plugin-cloud-storage
|
||||
- plugin-form-builder
|
||||
- plugin-nested-docs
|
||||
@@ -295,7 +354,7 @@ jobs:
|
||||
|
||||
- name: Cache Playwright Browsers for Playwright's Version
|
||||
id: cache-playwright-browsers
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}
|
||||
@@ -368,13 +427,12 @@ jobs:
|
||||
cd templates/blank-3.0
|
||||
cp .env.example .env
|
||||
ls -la
|
||||
pnpm add ./*.tgz
|
||||
pnpm install --ignore-workspace
|
||||
pnpm add ./*.tgz --ignore-workspace
|
||||
pnpm install --ignore-workspace --no-frozen-lockfile
|
||||
cat package.json
|
||||
pnpm run build
|
||||
|
||||
tests-type-generation:
|
||||
if: false # This should be replaced with gen on a real Payload project
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
|
||||
@@ -441,3 +499,18 @@ jobs:
|
||||
yarn install
|
||||
yarn build
|
||||
yarn generate:types
|
||||
|
||||
all-green:
|
||||
name: All Green
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- lint
|
||||
- build
|
||||
- tests-unit
|
||||
- tests-int
|
||||
- tests-e2e
|
||||
|
||||
steps:
|
||||
- if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }}
|
||||
run: exit 1
|
||||
|
||||
36
.github/workflows/release-canary.yml
vendored
Normal file
36
.github/workflows/release-canary.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: release-canary
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
branches:
|
||||
- beta
|
||||
|
||||
env:
|
||||
NODE_VERSION: 18.20.2
|
||||
PNPM_VERSION: 8.15.7
|
||||
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
|
||||
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
- name: Load npm token
|
||||
run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Canary release script
|
||||
# dry run hard-coded to true for testing and no npm token provided
|
||||
run: pnpm tsx ./scripts/publish-canary.ts
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
27
.idea/payload.iml
generated
27
.idea/payload.iml
generated
@@ -47,6 +47,33 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/templates" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/test/.swc" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/versions" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/richtext-slate/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/richtext-slate/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/email-nodemailer/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/email-nodemailer/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/email-resend/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/email-resend/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/live-preview-vue/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/live-preview-vue/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/payload/.swc" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-form-builder/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-relationship-object-ids/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-relationship-object-ids/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-stripe/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/storage-azure/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/storage-azure/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/storage-gcs/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/storage-gcs/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/storage-s3/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/storage-s3/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/storage-uploadthing/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/storage-uploadthing/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/storage-vercel-blob/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/storage-vercel-blob/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/translations/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/translations/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/ui/.swc" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/ui/.turbo" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
@@ -12,3 +12,5 @@
|
||||
tsconfig.json
|
||||
packages/payload/*.js
|
||||
packages/payload/*.d.ts
|
||||
payload-types.ts
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
9
.swcrc
9
.swcrc
@@ -7,6 +7,15 @@
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
},
|
||||
"transform": {
|
||||
"react": {
|
||||
"runtime": "automatic",
|
||||
"pragmaFrag": "React.Fragment",
|
||||
"throwIfNamespace": true,
|
||||
"development": false,
|
||||
"useBuiltins": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
|
||||
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
@@ -16,6 +16,14 @@
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
},
|
||||
{
|
||||
"command": "node --no-deprecation test/dev.js storage-uploadthing",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"name": "Uploadthing",
|
||||
"request": "launch",
|
||||
"type": "node-terminal",
|
||||
"envFile": "${workspaceFolder}/test/storage-uploadthing/.env"
|
||||
},
|
||||
{
|
||||
"command": "node --no-deprecation test/dev.js live-preview",
|
||||
"cwd": "${workspaceFolder}",
|
||||
|
||||
@@ -565,10 +565,11 @@ These are the props that will be passed to your custom Label.
|
||||
#### Example
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import { useTranslation } from '@payloadcms/ui/providers/Translation'
|
||||
|
||||
import { getTranslation } from 'payload/utilities/getTranslation'
|
||||
|
||||
type Props = {
|
||||
htmlFor?: string
|
||||
@@ -680,21 +681,21 @@ To make use of Payload SCSS variables / mixins to use directly in your own compo
|
||||
|
||||
### Getting the current language
|
||||
|
||||
When developing custom components you can support multiple languages to be consistent with Payload's i18n support. The best way to do this is to add your translation resources to the [i18n configuration](https://payloadcms.com/docs/configuration/i18n) and import `useTranslation` from `react-i18next` in your components.
|
||||
When developing custom components you can support multiple languages to be consistent with Payload's i18n support. The best way to do this is to add your translation resources to the [i18n configuration](https://payloadcms.com/docs/configuration/i18n) and import `useTranslation` from `@payloadcms/ui/providers/Translation` in your components.
|
||||
|
||||
For example:
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation } from '@payloadcms/ui/providers/Translation'
|
||||
|
||||
const CustomComponent: React.FC = () => {
|
||||
// highlight-start
|
||||
const { t, i18n } = useTranslation('namespace1')
|
||||
const { t, i18n } = useTranslation()
|
||||
// highlight-end
|
||||
|
||||
return (
|
||||
<ul>
|
||||
<li>{t('key', { variable: 'value' })}</li>
|
||||
<li>{t('namespace1:key', { variable: 'value' })}</li>
|
||||
<li>{t('namespace2:key', { variable: 'value' })}</li>
|
||||
<li>{i18n.language}</li>
|
||||
</ul>
|
||||
|
||||
@@ -33,7 +33,7 @@ All options for the Admin panel are defined in your base Payload config file.
|
||||
| `bundler` | The bundler that you would like to use to bundle the admin panel. Officially supported bundlers: [Webpack](/docs/admin/webpack) and [Vite](/docs/admin/vite). |
|
||||
| `user` | The `slug` of a Collection that you want be used to log in to the Admin dashboard. [More](/docs/admin/overview#the-admin-user-collection) |
|
||||
| `buildPath` | Specify an absolute path for where to store the built Admin panel bundle used in production. Defaults to `path.resolve(process.cwd(), 'build')`. |
|
||||
| `meta` | Base meta data to use for the Admin panel. Included properties are `titleSuffix`, `ogImage`, and `favicon`. |
|
||||
| `meta` | Base meta data to use for the Admin panel. Included properties are `titleSuffix`, `icons`, and `openGraph`. Can be overridden on a per collection or per global basis. |
|
||||
| `disable` | If set to `true`, the entire Admin panel will be disabled. |
|
||||
| `indexHTML` | Optionally replace the entirety of the `index.html` file used by the Admin panel. Reference the [base index.html file](https://github.com/payloadcms/payload/blob/main/packages/payload/src/admin/index.html) to ensure your replacement has the appropriate HTML elements. |
|
||||
| `css` | Absolute path to a stylesheet that you can use to override / customize the Admin panel styling. [More](/docs/admin/customizing-css). |
|
||||
@@ -45,8 +45,7 @@ All options for the Admin panel are defined in your base Payload config file.
|
||||
| `components` | Component overrides that affect the entirety of the Admin panel. [More](/docs/admin/components) |
|
||||
| `webpack` | Customize the Webpack config that's used to generate the Admin panel. [More](/docs/admin/webpack) |
|
||||
| `vite` | Customize the Vite config that's used to generate the Admin panel. [More](/docs/admin/vite) |
|
||||
| `logoutRoute` | The route for the `logout` page. |
|
||||
| `inactivityRoute` | The route for the `logout` inactivity page. |
|
||||
| `routes` | Replace built-in Admin Panel routes with your own custom routes. I.e. `{ logout: '/custom-logout', inactivity: 'custom-inactivity' }` |
|
||||
|
||||
### The Admin User Collection
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ property on a collection's config.
|
||||
| `hideAPIURL` | Hides the "API URL" meta field while editing documents within this collection. |
|
||||
| `enableRichTextLink` | The [Rich Text](/docs/fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
|
||||
| `enableRichTextRelationship` | The [Rich Text](/docs/fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
|
||||
| `meta` | Metadata overrides to apply to the [Admin panel](../admin/overview). Included properties are `description` and `openGraph`. |
|
||||
| `preview` | Function to generate preview URLS within the Admin panel that can point to your app. [More](#preview). |
|
||||
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More](/docs/live-preview/overview). |
|
||||
| `components` | Swap in your own React components to be used within this collection. [More](/docs/admin/components#collections) |
|
||||
|
||||
@@ -74,6 +74,7 @@ You can customize the way that the Admin panel behaves on a Global-by-Global bas
|
||||
| `preview` | Function to generate a preview URL within the Admin panel for this global that can point to your app. [More](#preview). |
|
||||
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More](/docs/live-preview/overview). |
|
||||
| `hideAPIURL` | Hides the "API URL" meta field while editing documents within this collection. |
|
||||
| `meta` | Metadata overrides to apply to the [Admin panel](../admin/overview). Included properties are `description` and `openGraph`. |
|
||||
|
||||
### Preview
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ desc: Manage and customize internationalization support in your CMS editor exper
|
||||
keywords: internationalization, i18n, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express
|
||||
---
|
||||
|
||||
Not only does Payload support managing localized content, it also has internationalization support so that admin users can work in their preferred language. Payload's i18n support is built on top of [i18next](https://www.i18next.com). It comes included by default and can be extended in your config.
|
||||
Not only does Payload support managing localized content, it also has internationalization support so that admin users can work in their preferred language. It comes included by default and can be extended in your config.
|
||||
|
||||
While Payload's built-in features come translated, you may want to also translate parts of your project's configuration too. This is possible in places like collections and globals labels and groups, field labels, descriptions and input placeholder text. The admin UI will display all the correct translations you provide based on the user's language.
|
||||
|
||||
@@ -72,9 +72,7 @@ After a user logs in, they can change their language selection in the `/account`
|
||||
|
||||
Payload's backend uses express middleware to set the language on incoming requests before they are handled. This allows backend validation to return error messages in the user's own language or system generated emails to be sent using the correct translation. You can make HTTP requests with the `accept-language` header and Payload will use that language.
|
||||
|
||||
Anywhere in your Payload app that you have access to the `req` object, you can access i18next's extensive internationalization features assigned to `req.i18n`. To access text translations you can use `req.t('namespace:key')`.
|
||||
|
||||
Read the i18next [API documentation](https://www.i18next.com/overview/api) to learn more.
|
||||
Anywhere in your Payload app that you have access to the `req` object, you can access payload's extensive internationalization features assigned to `req.i18n`. To access text translations you can use `req.t('namespace:key')`.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
@@ -88,9 +86,8 @@ import { buildConfig } from 'payload/config'
|
||||
export default buildConfig({
|
||||
//...
|
||||
i18n: {
|
||||
fallbackLng: 'en', // default
|
||||
debug: false, // default
|
||||
resources: {
|
||||
fallbackLanguage: 'en', // default
|
||||
translations: {
|
||||
en: {
|
||||
custom: {
|
||||
// namespace can be anything you want
|
||||
@@ -107,4 +104,63 @@ export default buildConfig({
|
||||
})
|
||||
```
|
||||
|
||||
See the i18next [configuration options](https://www.i18next.com/overview/configuration-options) to learn more.
|
||||
## Types for custom translations
|
||||
|
||||
In order to use custom translations in your project, you need to provide the types for the translations. Here is an example of how you can define the types for the custom translations in a custom react component:
|
||||
|
||||
```ts
|
||||
'use client'
|
||||
import type { NestedKeysStripped } from '@payloadcms/translations'
|
||||
import type React from 'react'
|
||||
|
||||
import { useTranslation } from '@payloadcms/ui/providers/Translation'
|
||||
|
||||
const customTranslations = {
|
||||
en: {
|
||||
general: {
|
||||
test: 'Custom Translation',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type CustomTranslationObject = typeof customTranslations.en
|
||||
type CustomTranslationKeys = NestedKeysStripped<CustomTranslationObject>
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const { i18n, t } = useTranslation<CustomTranslationObject, CustomTranslationKeys>() // These generics merge your custom translations with the default client translations
|
||||
|
||||
return t('general:test')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Additionally, payload exposes the `t` function in various places, for example in labels. Here is how you would type those:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DefaultTranslationKeys,
|
||||
NestedKeysStripped,
|
||||
TFunction,
|
||||
} from '@payloadcms/translations'
|
||||
import type { Field } from 'payload/types'
|
||||
|
||||
const customTranslations = {
|
||||
en: {
|
||||
general: {
|
||||
test: 'Custom Translation',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type CustomTranslationObject = typeof customTranslations.en
|
||||
type CustomTranslationKeys = NestedKeysStripped<CustomTranslationObject>
|
||||
|
||||
const field: Field = {
|
||||
name: 'myField',
|
||||
type: 'text',
|
||||
label: (
|
||||
{ t }: { t: TFunction<CustomTranslationKeys | DefaultTranslationKeys> }, // The generic passed to TFunction does not automatically merge the custom translations with the default translations. We need to merge them ourselves here
|
||||
) => t('fields:addLabel'),
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -2,166 +2,165 @@
|
||||
title: Email Functionality
|
||||
label: Overview
|
||||
order: 10
|
||||
desc: Payload uses NodeMailer to allow you to send emails smoothly from your app. Set up email functions such as password resets, order confirmations and more.
|
||||
desc: Payload uses an adapter pattern to enable email functionality. Set up email functions such as password resets, order confirmations and more.
|
||||
keywords: email, overview, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express
|
||||
---
|
||||
|
||||
### Introduction
|
||||
|
||||
Payload comes ready to send your application's email. Whether you simply need built-in password reset
|
||||
email to work or you want customers to get an order confirmation email, you're almost there. Payload makes use of
|
||||
[NodeMailer](https://nodemailer.com) for email and won't get in your way for those already familiar.
|
||||
Payload has a few email adapters that can be imported to enable email functionality. The [@payloadcms/email-nodemailer](https://www.npmjs.com/package/@payloadcms/email-nodemailer) package will be the package most will want to install. This package provides an easy way to use [Nodemailer](https://nodemailer.com) for email and won't get in your way for those already familiar.
|
||||
|
||||
For email to send from your Payload server, some configuration is required. The settings you provide will be set
|
||||
in the `email` property object of your payload init call. Payload will make use of the transport that you have configured for it for things like reset password or verifying new user accounts and email send methods are available to you as well on your payload instance.
|
||||
The email adapter should be passed into the `email` property of the Payload config. This will allow Payload to send emails for things like password resets, new user verification, and any other email sending needs you may have.
|
||||
|
||||
### Configuration
|
||||
|
||||
**Three ways to set it up**
|
||||
#### Default Configuration
|
||||
|
||||
1. **Default**: When email is not needed, a mock email handler will be created and used when nothing is provided. This is ideal for development environments and can be changed later when ready to [go to production](/docs/production/deployment).
|
||||
1. **Recommended**: Set the `transportOptions` and Payload will do the set up for you.
|
||||
1. **Advanced**: The `transport` object can be assigned a nodemailer transport object set up in your server scripts and given for Payload to use.
|
||||
When email is not needed or desired, Payload will log a warning on startup notifying that email is not configured. A warning message will also be logged on any attempt to send an email.
|
||||
|
||||
The following options are configurable in the `email` property object as part of the options object when calling payload.init().
|
||||
#### Email Adapter
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`fromName`** \* | The name part of the From field that will be seen on the delivered email |
|
||||
| **`fromAddress`** \* | The email address part of the From field that will be used when delivering email |
|
||||
| **`transport`** | The NodeMailer transport object for when you want to do it yourself, not needed when transportOptions is set |
|
||||
| **`transportOptions`** | An object that configures the transporter that Payload will create. For all the available options see the [NodeMailer documentation](https://nodemailer.com) or see the examples below |
|
||||
| **`logMockCredentials`** | If set to true and no transport/transportOptions, ethereal credentials will be logged to console on startup |
|
||||
An email adapter will require at least the following fields:
|
||||
|
||||
_\* An asterisk denotes that a property is required._
|
||||
| Option | Description |
|
||||
| --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| **`defaultFromName`** \* | The name part of the From field that will be seen on the delivered email |
|
||||
| **`defaultFromAddress`** \* | The email address part of the From field that will be used when delivering email |
|
||||
|
||||
|
||||
#### Officlal Email Adapters
|
||||
|
||||
| Name | Package | Description |
|
||||
| ---------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Nodemailer | [@payloadcms/email-nodemailer](https://www.npmjs.com/package/@payloadcms/email-nodemailer) | Use any [Nodemailer transport](https://nodemailer.com/transports), including SMTP, Resend, SendGrid, and more. This was provided by default in Payload 2.x. This is the easiest migration path. |
|
||||
| Resend | [@payloadcms/email-resend](https://www.npmjs.com/package/@payloadcms/email-resend) | Resend email via their REST API. This is preferred for serverless platforms such as Vercel because it is much more lightweight than the nodemailer adapter. |
|
||||
|
||||
### Nodemailer Configuration
|
||||
|
||||
| Option | Description |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`transport`** | The Nodemailer transport object for when you want to do it yourself, not needed when transportOptions is set |
|
||||
| **`transportOptions`** | An object that configures the transporter that Payload will create. For all the available options see the [Nodemailer documentation](https://nodemailer.com) or see the examples below |
|
||||
|
||||
### Use SMTP
|
||||
|
||||
Simple Mail Transfer Protocol (SMTP) options can be passed in using the `transportOptions` object on the `email` options. See the [NodeMailer SMTP documentation](https://nodemailer.com/smtp/) for more information, including details on when `secure` should and should not be set to `true`.
|
||||
Simple Mail Transfer Protocol (SMTP) options can be passed in using the `transportOptions` object on the `email` options. See the [Nodemailer SMTP documentation](https://nodemailer.com/smtp/) for more information, including details on when `secure` should and should not be set to `true`.
|
||||
|
||||
**Example email options using SMTP:**
|
||||
|
||||
```ts
|
||||
payload.init({
|
||||
email: {
|
||||
import { buildConfig } from 'payload/config'
|
||||
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
|
||||
|
||||
export default buildConfig({
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress: 'info@payloadcms.com',
|
||||
defaultFromName: 'Payload',
|
||||
// Nodemailer transportOptions
|
||||
transportOptions: {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: 587,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
port: Number(process.env.SMTP_HOST),
|
||||
secure: Number(process.env.SMTP_PORT) === 465, // true for port 465, false (the default) for 587 and others
|
||||
requireTLS: true,
|
||||
},
|
||||
fromName: 'hello',
|
||||
fromAddress: 'hello@example.com',
|
||||
},
|
||||
// ...
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
<Banner type="warning">
|
||||
It is best practice to avoid saving credentials or API keys directly in your code, use
|
||||
[environment variables](/docs/configuration/overview#using-environment-variables-in-your-config).
|
||||
</Banner>
|
||||
|
||||
### Use an email service
|
||||
|
||||
Many third party mail providers are available and offer benefits beyond basic SMTP. As an example, your payload init could look like this if you wanted to use SendGrid.com, though the same approach would work for any other [NodeMailer transports](https://nodemailer.com/transports/) shown here or provided by another third party.
|
||||
**Example email options using nodemailer.createTransport:**
|
||||
|
||||
```ts
|
||||
import payload from 'payload'
|
||||
import nodemailerSendgrid from 'nodemailer-sendgrid'
|
||||
|
||||
const sendGridAPIKey = process.env.SENDGRID_API_KEY
|
||||
|
||||
payload.init({
|
||||
...(sendGridAPIKey
|
||||
? {
|
||||
email: {
|
||||
transportOptions: nodemailerSendgrid({
|
||||
apiKey: sendGridAPIKey,
|
||||
}),
|
||||
fromName: 'Admin',
|
||||
fromAddress: 'admin@example.com',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
```
|
||||
|
||||
### Use a custom NodeMailer transport
|
||||
|
||||
To take full control of the mail transport you may wish to use `nodemailer.createTransport()` on your server and provide it to Payload init.
|
||||
|
||||
```ts
|
||||
import payload from 'payload'
|
||||
import { buildConfig } from 'payload/config'
|
||||
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const payload = require('payload')
|
||||
const nodemailer = require('nodemailer')
|
||||
|
||||
const transport = await nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: 587,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
export default buildConfig({
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress: 'info@payloadcms.com',
|
||||
defaultFromName: 'Payload',
|
||||
// Any Nodemailer transport can be used
|
||||
transport: nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: 587,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
payload.init({
|
||||
email: {
|
||||
fromName: 'Admin',
|
||||
fromAddress: 'admin@example.com',
|
||||
transport,
|
||||
},
|
||||
// ...
|
||||
**Custom Transport:**
|
||||
|
||||
You also have the ability to bring your own nodemailer transport. This is an example of using the SendGrid nodemailer transport.
|
||||
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config'
|
||||
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
|
||||
import nodemailerSendgrid from 'nodemailer-sendgrid'
|
||||
|
||||
|
||||
export default buildConfig({
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress: 'info@payloadcms.com',
|
||||
defaultFromName: 'Payload',
|
||||
transportOptions: nodemailerSendgrid({
|
||||
apiKey: process.env.SENDGRID_API_KEY,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
During development, if you pass nothing to `nodemailerAdapter`, it will use the [ethereal.email](https://ethereal.email) service.
|
||||
|
||||
This will log the ethereal.email details to console on startup.
|
||||
|
||||
```ts
|
||||
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
|
||||
|
||||
export default buildConfig({
|
||||
email: nodemailerAdapter(),
|
||||
})
|
||||
```
|
||||
|
||||
### Resend Configuration
|
||||
|
||||
The Resend adapter requires an API key to be passed in the options. This can be found in the Resend dashboard. This is the preferred package if you are deploying on Vercel because this is much more lightweight than the Nodemailer adapter.
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ----------------------------------- |
|
||||
| apiKey | The API key for the Resend service. |
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config'
|
||||
import { resendAdapter } from '@payloadcms/email-resend'
|
||||
|
||||
export default buildConfig({
|
||||
email: resendAdapter({
|
||||
defaultFromAddress: 'dev@payloadcms.com',
|
||||
defaultFromName: 'Payload CMS',
|
||||
apiKey: process.env.RESEND_API_KEY || '',
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
### Sending Mail
|
||||
|
||||
With a working transport you can call it anywhere you have access to payload by calling `payload.sendEmail(message)`. The `message` will contain the `to`, `subject` and `email` or `text` for the email being sent. To see all available message configuration options see [NodeMailer](https://nodemailer.com/message).
|
||||
|
||||
### Mock transport
|
||||
|
||||
By default, Payload uses a mock implementation that only sends mail to the [ethereal](https://ethereal.email) capture service that will never reach a user's inbox. While in development you may wish to make use of the captured messages which is why the payload output during server output helpfully logs this out on the server console.
|
||||
|
||||
To see ethereal credentials, add `logMockCredentials: true` to the email options. This will cause them to be logged to console on startup.
|
||||
With a working transport you can call it anywhere you have access to payload by calling `payload.sendEmail(message)`. The `message` will contain the `to`, `subject` and `html` or `text` for the email being sent. Other options are also available and can be seen in the sendEmail args. Support for these will depend on the adapter being used.
|
||||
|
||||
```ts
|
||||
payload.init({
|
||||
email: {
|
||||
fromName: 'Admin',
|
||||
fromAddress: 'admin@example.com',
|
||||
logMockCredentials: true, // Optional
|
||||
},
|
||||
// ...
|
||||
// Example of sending an email
|
||||
const email = await payload.sendEmail({
|
||||
to: 'test@example.com',
|
||||
subject: 'This is a test email',
|
||||
text: 'This is my message body',
|
||||
})
|
||||
```
|
||||
|
||||
**Console output when starting payload with a mock email instance and logMockCredentials: true**
|
||||
|
||||
```
|
||||
[06:37:21] INFO (payload): Starting Payload...
|
||||
[06:37:22] INFO (payload): Payload Demo Initialized
|
||||
[06:37:22] INFO (payload): listening on 3000...
|
||||
[06:37:22] INFO (payload): Connected to MongoDB server successfully!
|
||||
[06:37:23] INFO (payload): E-mail configured with mock configuration
|
||||
[06:37:23] INFO (payload): Log into mock email provider at https://ethereal.email
|
||||
[06:37:23] INFO (payload): Mock email account username: hhav5jw7doo4euev@ethereal.email
|
||||
[06:37:23] INFO (payload): Mock email account password: VNdGcvDZeyEhtuPBqf
|
||||
```
|
||||
|
||||
The mock email handler is used when payload is started with neither `transport` or `transportOptions` to know how to deliver email.
|
||||
|
||||
<Banner type="warning">
|
||||
The randomly generated email account username and password will be different each time the Payload
|
||||
server starts.
|
||||
</Banner>
|
||||
|
||||
### Using multiple mail providers
|
||||
|
||||
Payload supports the use of a single transporter of email, but there is nothing stopping you from having more. Consider a use case where sending bulk email is handled differently than transactional email and could be done using a [hook](/docs/hooks/overview).
|
||||
|
||||
@@ -57,6 +57,7 @@ In addition to the default [field admin config](/docs/fields/overview#admin-conf
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`initCollapsed`** | Set the initial collapsed state |
|
||||
| **`components.RowLabel`** | Function or React component to be rendered as the label on the array row. Receives `({ data, index, path })` as args |
|
||||
| **`isSortable`** | Disable order sorting by setting this value to `false` |
|
||||
|
||||
### Example
|
||||
|
||||
|
||||
@@ -53,9 +53,10 @@ _\* An asterisk denotes that a property is required._
|
||||
|
||||
In addition to the default [field admin config](/docs/fields/overview#admin-config), you can adjust the following properties:
|
||||
|
||||
| Option | Description |
|
||||
| ------------------- | ------------------------------- |
|
||||
| **`initCollapsed`** | Set the initial collapsed state |
|
||||
| Option | Description |
|
||||
| ------------------- | ---------------------------------- |
|
||||
| **`initCollapsed`** | Set the initial collapsed state |
|
||||
| **`isSortable`** | Disable order sorting by setting this value to `false` |
|
||||
|
||||
### Block configs
|
||||
|
||||
|
||||
@@ -163,19 +163,21 @@ Example:
|
||||
|
||||
In addition to each field's base configuration, you can define specific traits and properties for fields that only have effect on how they are rendered in the Admin panel. The following properties are available for all fields within the `admin` property:
|
||||
|
||||
| Option | Description |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `condition` | You can programmatically show / hide fields based on what other fields are doing. [Click here](#conditional-logic) for more info. |
|
||||
| `components` | All field components can be completely and easily swapped out for custom components that you define. [Click here](#custom-components) for more info. |
|
||||
| `description` | Helper text to display with the field to provide more information for the editor user. [Click here](#description) for more info. |
|
||||
| `position` | Specify if the field should be rendered in the sidebar by defining `position: 'sidebar'`. |
|
||||
| `width` | Restrict the width of a field. you can pass any string-based value here, be it pixels, percentages, etc. This property is especially useful when fields are nested within a `Row` type where they can be organized horizontally. |
|
||||
| `style` | Attach raw CSS style properties to the root DOM element of a field. |
|
||||
| `className` | Attach a CSS class name to the root DOM element of a field. |
|
||||
| `readOnly` | Setting a field to `readOnly` has no effect on the API whatsoever but disables the admin component's editability to prevent editors from modifying the field's value. |
|
||||
| `disabled` | If a field is `disabled`, it is completely omitted from the Admin panel. |
|
||||
| `disableBulkEdit` | Set `disableBulkEdit` to `true` to prevent fields from appearing in the select options when making edits for multiple documents. |
|
||||
| `hidden` | Setting a field's `hidden` property on its `admin` config will transform it into a `hidden` input type. Its value will still submit with the Admin panel's requests, but the field itself will not be visible to editors. |
|
||||
| Option | Description |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `condition` | You can programmatically show / hide fields based on what other fields are doing. [Click here](#conditional-logic) for more info. |
|
||||
| `components` | All field components can be completely and easily swapped out for custom components that you define. [Click here](#custom-components) for more info. |
|
||||
| `description` | Helper text to display with the field to provide more information for the editor user. [Click here](#description) for more info. |
|
||||
| `position` | Specify if the field should be rendered in the sidebar by defining `position: 'sidebar'`. |
|
||||
| `width` | Restrict the width of a field. you can pass any string-based value here, be it pixels, percentages, etc. This property is especially useful when fields are nested within a `Row` type where they can be organized horizontally. |
|
||||
| `style` | Attach raw CSS style properties to the root DOM element of a field. |
|
||||
| `className` | Attach a CSS class name to the root DOM element of a field. |
|
||||
| `readOnly` | Setting a field to `readOnly` has no effect on the API whatsoever but disables the admin component's editability to prevent editors from modifying the field's value. |
|
||||
| `disabled` | If a field is `disabled`, it is completely omitted from the Admin panel. |
|
||||
| `disableBulkEdit` | Set `disableBulkEdit` to `true` to prevent fields from appearing in the select options when making edits for multiple documents. |
|
||||
| `disableListColumn` | Set `disableListColumn` to `true` to prevent fields from appearing in the list view column selector. |
|
||||
| `disableListFilter` | Set `disableListFilter` to `true` to prevent fields from appearing in the list view filter options. |
|
||||
| `hidden` | Setting a field's `hidden` property on its `admin` config will transform it into a `hidden` input type. Its value will still submit with the Admin panel's requests, but the field itself will not be visible to editors. |
|
||||
|
||||
### Custom components
|
||||
|
||||
|
||||
@@ -26,28 +26,28 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma
|
||||
|
||||
### Config
|
||||
|
||||
| Option | Description |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
|
||||
| **`relationTo`** \* | Provide one or many collection `slug`s to be able to assign relationships to. |
|
||||
| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). |
|
||||
| **`hasMany`** | Boolean when, if set to `true`, allows this field to have many relations instead of only one. |
|
||||
| **`minRows`** | A number for the fewest allowed items during validation when a value is present. Used with `hasMany`. |
|
||||
| **`maxRows`** | A number for the most allowed items during validation when a value is present. Used with `hasMany`. |
|
||||
| **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) |
|
||||
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
|
||||
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |
|
||||
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |
|
||||
| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. |
|
||||
| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. |
|
||||
| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) |
|
||||
| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) |
|
||||
| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. |
|
||||
| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) |
|
||||
| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. |
|
||||
| **`required`** | Require this field to have a value. |
|
||||
| **`admin`** | Admin-specific configuration. See the [default field admin config](/docs/fields/overview#admin-config) for more details. |
|
||||
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
|
||||
| Option | Description |
|
||||
|---------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
|
||||
| **`relationTo`** \* | Provide one or many collection `slug`s to be able to assign relationships to. |
|
||||
| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). |
|
||||
| **`hasMany`** | Boolean when, if set to `true`, allows this field to have many relations instead of only one. |
|
||||
| **`minRows`** | A number for the fewest allowed items during validation when a value is present. Used with `hasMany`. |
|
||||
| **`maxRows`** | A number for the most allowed items during validation when a value is present. Used with `hasMany`. |
|
||||
| **`maxDepth`** | Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](/docs/getting-started/concepts#field-level-max-depth) |
|
||||
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
|
||||
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |
|
||||
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |
|
||||
| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. |
|
||||
| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. |
|
||||
| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) |
|
||||
| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) |
|
||||
| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. |
|
||||
| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) |
|
||||
| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. |
|
||||
| **`required`** | Require this field to have a value. |
|
||||
| **`admin`** | Admin-specific configuration. See the [default field admin config](/docs/fields/overview#admin-config) for more details. |
|
||||
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
|
||||
|
||||
_\* An asterisk denotes that a property is required._
|
||||
|
||||
|
||||
@@ -156,6 +156,28 @@ To populate `user.author.department` in it's entirety you could specify `?depth=
|
||||
}
|
||||
```
|
||||
|
||||
#### Field-level max depth
|
||||
|
||||
Fields like relationships or uploads can have a `maxDepth` property that limits the depth of the population for that field. Here are some examples:
|
||||
|
||||
Depth: 10
|
||||
Current depth when field is accessed: 1
|
||||
`maxDepth`: undefined
|
||||
|
||||
In this case, the field would be populated to 9 levels of population.
|
||||
|
||||
Depth: 10
|
||||
Current depth when field is accessed: 0
|
||||
`maxDepth`: 2
|
||||
|
||||
In this case, the field would be populated to 2 levels of population, despite there being a remaining depth of 8.
|
||||
|
||||
Depth: 10
|
||||
Current depth when field is accessed: 2
|
||||
`maxDepth`: 1
|
||||
|
||||
In this case, the field would not be populated, as the current depth (2) has exceeded the `maxDepth` for this field (1).
|
||||
|
||||
<Banner type="warning">
|
||||
<strong>Note:</strong>
|
||||
<br />
|
||||
|
||||
284
docs/live-preview/client.mdx
Normal file
284
docs/live-preview/client.mdx
Normal file
@@ -0,0 +1,284 @@
|
||||
---
|
||||
title: Client-side Live Preview
|
||||
label: Client-side
|
||||
order: 40
|
||||
desc: Learn how to implement Live Preview in your client-side front-end application.
|
||||
keywords: live preview, frontend, react, next.js, vue, nuxt.js, svelte, hook, useLivePreview
|
||||
---
|
||||
|
||||
<Banner type="info">
|
||||
If your front-end application supports Server Components like the [Next.js App Router](https://nextjs.org/docs/app), etc., we suggest setting up [server-side Live Preview](./server).
|
||||
</Banner>
|
||||
|
||||
While using Live Preview, the Admin panel emits a new `window.postMessage` event every time your document has changed. Your front-end application can listen for these events and re-render accordingly.
|
||||
|
||||
If your front-end application is built with [React](#react) or [Vue](#vue), use the `useLivePreview` hooks that Payload provides. In the future, all other major frameworks like Svelte will be officially supported. If you are using any of these frameworks today, you can still integrate with Live Preview yourself using the underlying tooling that Payload provides. See [building your own hook](#building-your-own-hook) for more information.
|
||||
|
||||
By default, all hooks accept the following args:
|
||||
|
||||
| Path | Description |
|
||||
| ------------------ | -------------------------------------------------------------------------------------- |
|
||||
| **`serverURL`** \* | The URL of your Payload server. |
|
||||
| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. |
|
||||
| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. |
|
||||
| **`apiRoute`** | The path of your API route as defined in `routes.api`. Defaults to `/api`. |
|
||||
|
||||
_\* An asterisk denotes that a property is required._
|
||||
|
||||
And return the following values:
|
||||
|
||||
| Path | Description |
|
||||
| --------------- | ---------------------------------------------------------------- |
|
||||
| **`data`** | The live data of the document, merged with the initial data. |
|
||||
| **`isLoading`** | A boolean that indicates whether or not the document is loading. |
|
||||
|
||||
<Banner type="info">
|
||||
If your front-end is tightly coupled to required fields, you should ensure that your UI does not
|
||||
break when these fields are removed. For example, if you are rendering something like
|
||||
`data.relatedPosts[0].title`, your page will break once you remove the first related post. To get
|
||||
around this, use conditional logic, optional chaining, or default values in your UI where needed.
|
||||
For example, `data?.relatedPosts?.[0]?.title`.
|
||||
</Banner>
|
||||
|
||||
<Banner type="info">
|
||||
It is important that the `depth` argument matches exactly with the depth of your initial page request. The depth property is used to populated relationships and uploads beyond their IDs. See [Depth](../getting-started/concepts#depth) for more information.
|
||||
</Banner>
|
||||
|
||||
### React
|
||||
|
||||
If your front-end application is built with client-side [React](https://react.dev) like [Next.js Pages Router](https://nextjs.org/docs/pages), you can use the `useLivePreview` hook that Payload provides.
|
||||
|
||||
First, install the `@payloadcms/live-preview-react` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview-react
|
||||
```
|
||||
|
||||
Then, use the `useLivePreview` hook in your React component:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { useLivePreview } from '@payloadcms/live-preview-react'
|
||||
import { Page as PageType } from '@/payload-types'
|
||||
|
||||
// Fetch the page in a server component, pass it to the client component, then thread it through the hook
|
||||
// The hook will take over from there and keep the preview in sync with the changes you make
|
||||
// The `data` property will contain the live data of the document
|
||||
export const PageClient: React.FC<{
|
||||
page: {
|
||||
title: string
|
||||
}
|
||||
}> = ({ page: initialPage }) => {
|
||||
const { data } = useLivePreview<PageType>({
|
||||
initialData: initialPage,
|
||||
serverURL: PAYLOAD_SERVER_URL,
|
||||
depth: 2,
|
||||
})
|
||||
|
||||
return <h1>{data.title}</h1>
|
||||
}
|
||||
```
|
||||
|
||||
### Vue
|
||||
|
||||
If your front-end application is built with [Vue 3](https://vuejs.org) or [Nuxt 3](https://nuxt.js), you can use the `useLivePreview` composable that Payload provides.
|
||||
|
||||
First, install the `@payloadcms/live-preview-vue` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview-vue
|
||||
```
|
||||
|
||||
Then, use the `useLivePreview` hook in your Vue component:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { PageData } from '~/types';
|
||||
import { defineProps } from 'vue';
|
||||
import { useLivePreview } from '@payloadcms/live-preview-vue';
|
||||
|
||||
// Fetch the initial data on the parent component or using async state
|
||||
const props = defineProps<{ initialData: PageData }>();
|
||||
|
||||
// The hook will take over from here and keep the preview in sync with the changes you make.
|
||||
// The `data` property will contain the live data of the document only when viewed from the Preview view of the Admin UI.
|
||||
const { data } = useLivePreview<PageData>({
|
||||
initialData: props.initialData,
|
||||
serverURL: "<PAYLOAD_SERVER_URL>",
|
||||
depth: 2,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ data.title }}</h1>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Building your own hook
|
||||
|
||||
No matter what front-end framework you are using, you can build your own hook using the same underlying tooling that Payload provides.
|
||||
|
||||
First, install the base `@payloadcms/live-preview` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview
|
||||
```
|
||||
|
||||
This package provides the following functions:
|
||||
|
||||
| Path | Description |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`subscribe`** | Subscribes to the Admin panel's `window.postMessage` events and calls the provided callback function. |
|
||||
| **`unsubscribe`** | Unsubscribes from the Admin panel's `window.postMessage` events. |
|
||||
| **`ready`** | Sends a `window.postMessage` event to the Admin panel to indicate that the front-end is ready to receive messages. |
|
||||
| **`isLivePreviewEvent`** | Checks if a `MessageEvent` originates from the Admin panel and is a Live Preview event, i.e. debounced form state. |
|
||||
|
||||
The `subscribe` function takes the following args:
|
||||
|
||||
| Path | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| **`callback`** \* | A callback function that is called with `data` every time a change is made to the document. |
|
||||
| **`serverURL`** \* | The URL of your Payload server. |
|
||||
| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. |
|
||||
| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. |
|
||||
|
||||
With these functions, you can build your own hook using your front-end framework of choice:
|
||||
|
||||
```tsx
|
||||
import { subscribe, unsubscribe } from '@payloadcms/live-preview'
|
||||
|
||||
// To build your own hook, subscribe to Live Preview events using the `subscribe` function
|
||||
// It handles everything from:
|
||||
// 1. Listening to `window.postMessage` events
|
||||
// 2. Merging initial data with active form state
|
||||
// 3. Populating relationships and uploads
|
||||
// 4. Calling the `onChange` callback with the result
|
||||
// Your hook should also:
|
||||
// 1. Tell the Admin panel when it is ready to receive messages
|
||||
// 2. Handle the results of the `onChange` callback to update the UI
|
||||
// 3. Unsubscribe from the `window.postMessage` events when it unmounts
|
||||
```
|
||||
|
||||
Here is an example of what the same `useLivePreview` React hook from above looks like under the hood:
|
||||
|
||||
```tsx
|
||||
import { subscribe, unsubscribe, ready } from '@payloadcms/live-preview'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
|
||||
export const useLivePreview = <T extends any>(props: {
|
||||
depth?: number
|
||||
initialData: T
|
||||
serverURL: string
|
||||
}): {
|
||||
data: T
|
||||
isLoading: boolean
|
||||
} => {
|
||||
const { depth = 0, initialData, serverURL } = props
|
||||
const [data, setData] = useState<T>(initialData)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const hasSentReadyMessage = useRef<boolean>(false)
|
||||
|
||||
const onChange = useCallback((mergedData) => {
|
||||
// When a change is made, the `onChange` callback will be called with the merged data
|
||||
// Set this merged data into state so that React will re-render the UI
|
||||
setData(mergedData)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for `window.postMessage` events from the Admin panel
|
||||
// When a change is made, the `onChange` callback will be called with the merged data
|
||||
const subscription = subscribe({
|
||||
callback: onChange,
|
||||
depth,
|
||||
initialData,
|
||||
serverURL,
|
||||
})
|
||||
|
||||
// Once subscribed, send a `ready` message back up to the Admin panel
|
||||
// This will indicate that the front-end is ready to receive messages
|
||||
if (!hasSentReadyMessage.current) {
|
||||
hasSentReadyMessage.current = true
|
||||
|
||||
ready({
|
||||
serverURL,
|
||||
})
|
||||
}
|
||||
|
||||
// When the component unmounts, unsubscribe from the `window.postMessage` events
|
||||
return () => {
|
||||
unsubscribe(subscription)
|
||||
}
|
||||
}, [serverURL, onChange, depth, initialData])
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Banner type="info">
|
||||
When building your own hook, ensure that the args and return values are consistent with the ones
|
||||
listed at the top of this document. This will ensure that all hooks follow the same API.
|
||||
</Banner>
|
||||
|
||||
## Example
|
||||
|
||||
For a working demonstration of this, check out the official [Live Preview Example](https://github.com/payloadcms/payload/tree/main/examples/live-preview/payload). There you will find examples of various front-end frameworks and how to integrate each one of them, including:
|
||||
|
||||
- [Next.js App Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-app)
|
||||
- [Next.js Pages Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-pages)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
#### Relationships and/or uploads are not populating
|
||||
|
||||
If you are using relationships or uploads in your front-end application, and your front-end application runs on a different domain than your Payload server, you may need to configure [CORS](../configuration/overview) to allow requests to be made between the two domains. This includes sites that are running on a different port or subdomain. Similarly, if you are protecting resources behind user authentication, you may also need to configure [CSRF](../authentication/overview#csrf-protection) to allow cookies to be sent between the two domains. For example:
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
{
|
||||
// ...
|
||||
// If your site is running on a different domain than your Payload server,
|
||||
// This will allows requests to be made between the two domains
|
||||
cors: {
|
||||
[
|
||||
'http://localhost:3001' // Your front-end application
|
||||
],
|
||||
},
|
||||
// If you are protecting resources behind user authentication,
|
||||
// This will allow cookies to be sent between the two domains
|
||||
csrf: {
|
||||
[
|
||||
'http://localhost:3001' // Your front-end application
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Relationships and/or uploads disappear after editing a document
|
||||
|
||||
It is possible that either you are setting an improper [`depth`](../getting-started/concepts#depth) in your initial request and/or your `useLivePreview` hook, or they're mismatched. Ensure that the `depth` parameter is set to the correct value, and that it matches exactly in both places. For example:
|
||||
|
||||
```tsx
|
||||
// Your initial request
|
||||
const { docs } = await payload.find({
|
||||
collection: 'pages',
|
||||
depth: 1, // Ensure this is set to the proper depth for your application
|
||||
where: {
|
||||
slug: {
|
||||
equals: 'home',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Your hook
|
||||
const { data } = useLivePreview<PageType>({
|
||||
initialData: initialPage,
|
||||
serverURL: PAYLOAD_SERVER_URL,
|
||||
depth: 1, // Ensure this matches the depth of your initial request
|
||||
})
|
||||
```
|
||||
@@ -1,279 +1,16 @@
|
||||
---
|
||||
title: Implementing Live Preview in your app
|
||||
label: Frontend Implementation
|
||||
title: Implementing Live Preview in your frontend
|
||||
label: Frontend
|
||||
order: 20
|
||||
desc: Learn how to implement Live Preview in your front-end application.
|
||||
keywords: live preview, frontend, react, next.js, vue, nuxt.js, svelte, hook, useLivePreview
|
||||
---
|
||||
|
||||
While using Live Preview, the Admin panel emits a new `window.postMessage` event every time a change is made to the document. Your front-end application can listen for these events and re-render accordingly.
|
||||
There are two ways to use Live Preview in your own application depending on whether your front-end framework supports server components:
|
||||
|
||||
Wiring your front-end into Live Preview is easy. If your front-end application is built with React, Next.js, Vue or Nuxt.js, use the `useLivePreview` hook that Payload provides. In the future, all other major frameworks like Svelte will be officially supported. If you are using any of these frameworks today, you can still integrate with Live Preview yourself using the underlying tooling that Payload provides. See [building your own hook](#building-your-own-hook) for more information.
|
||||
|
||||
By default, all hooks accept the following args:
|
||||
|
||||
| Path | Description |
|
||||
| ------------------ | -------------------------------------------------------------------------------------- |
|
||||
| **`serverURL`** \* | The URL of your Payload server. |
|
||||
| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. |
|
||||
| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. |
|
||||
| **`apiRoute`** | The path of your API route as defined in `routes.api`. Defaults to `/api`. |
|
||||
|
||||
_\* An asterisk denotes that a property is required._
|
||||
|
||||
And return the following values:
|
||||
|
||||
| Path | Description |
|
||||
| --------------- | ---------------------------------------------------------------- |
|
||||
| **`data`** | The live data of the document, merged with the initial data. |
|
||||
| **`isLoading`** | A boolean that indicates whether or not the document is loading. |
|
||||
- [Server-side Live Preview (suggested)](./server)
|
||||
- [Client-side Live Preview](./client)
|
||||
|
||||
<Banner type="info">
|
||||
If your front-end is tightly coupled to required fields, you should ensure that your UI does not
|
||||
break when these fields are removed. For example, if you are rendering something like
|
||||
`data.relatedPosts[0].title`, your page will break once you remove the first related post. To get
|
||||
around this, use conditional logic, optional chaining, or default values in your UI where needed.
|
||||
For example, `data?.relatedPosts?.[0]?.title`.
|
||||
We suggest using server-side Live Preview if your framework supports it, it is both simpler to setup and more performant to run than the client-side alternative.
|
||||
</Banner>
|
||||
|
||||
<Banner type="info">
|
||||
It is important that the `depth` argument matches exactly with the depth of your initial page request. The depth property is used to populated relationships and uploads beyond their IDs. See [Depth](../getting-started/concepts#depth) for more information.
|
||||
</Banner>
|
||||
|
||||
### React
|
||||
|
||||
If your front-end application is built with React or Next.js, you can use the `useLivePreview` hook that Payload provides.
|
||||
|
||||
First, install the `@payloadcms/live-preview-react` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview-react
|
||||
```
|
||||
|
||||
Then, use the `useLivePreview` hook in your React component:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { useLivePreview } from '@payloadcms/live-preview-react'
|
||||
import { Page as PageType } from '@/payload-types'
|
||||
|
||||
// Fetch the page in a server component, pass it to the client component, then thread it through the hook
|
||||
// The hook will take over from there and keep the preview in sync with the changes you make
|
||||
// The `data` property will contain the live data of the document
|
||||
export const PageClient: React.FC<{
|
||||
page: {
|
||||
title: string
|
||||
}
|
||||
}> = ({ page: initialPage }) => {
|
||||
const { data } = useLivePreview<PageType>({
|
||||
initialData: initialPage,
|
||||
serverURL: PAYLOAD_SERVER_URL,
|
||||
depth: 2,
|
||||
})
|
||||
|
||||
return <h1>{data.title}</h1>
|
||||
}
|
||||
```
|
||||
|
||||
### Vue
|
||||
|
||||
If your front-end application is built with Vue 3 or Nuxt 3, you can use the `useLivePreview` composable that Payload provides.
|
||||
|
||||
First, install the `@payloadcms/live-preview-vue` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview-vue
|
||||
```
|
||||
|
||||
Then, use the `useLivePreview` hook in your Vue component:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { PageData } from '~/types';
|
||||
import { defineProps } from 'vue';
|
||||
import { useLivePreview } from '@payloadcms/live-preview-vue';
|
||||
|
||||
// Fetch the initial data on the parent component or using async state
|
||||
const props = defineProps<{ initialData: PageData }>();
|
||||
|
||||
// The hook will take over from here and keep the preview in sync with the changes you make.
|
||||
// The `data` property will contain the live data of the document only when viewed from the Preview view of the Admin UI.
|
||||
const { data } = useLivePreview<PageData>({
|
||||
initialData: props.initialData,
|
||||
serverURL: "<PAYLOAD_SERVER_URL>",
|
||||
depth: 2,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ data.title }}</h1>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Building your own hook
|
||||
|
||||
No matter what front-end framework you are using, you can build your own hook using the same underlying tooling that Payload provides.
|
||||
|
||||
First, install the base `@payloadcms/live-preview` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview
|
||||
```
|
||||
|
||||
This package provides the following functions:
|
||||
|
||||
| Path | Description |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **`subscribe`** | Subscribes to the Admin panel's `window.postMessage` events and calls the provided callback function. |
|
||||
| **`unsubscribe`** | Unsubscribes from the Admin panel's `window.postMessage` events. |
|
||||
| **`ready`** | Sends a `window.postMessage` event to the Admin panel to indicate that the front-end is ready to receive messages. |
|
||||
|
||||
The `subscribe` function takes the following args:
|
||||
|
||||
| Path | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| **`callback`** \* | A callback function that is called with `data` every time a change is made to the document. |
|
||||
| **`serverURL`** \* | The URL of your Payload server. |
|
||||
| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. |
|
||||
| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. |
|
||||
|
||||
With these functions, you can build your own hook using your front-end framework of choice:
|
||||
|
||||
```tsx
|
||||
import { subscribe, unsubscribe } from '@payloadcms/live-preview'
|
||||
|
||||
// To build your own hook, subscribe to Live Preview events using the`subscribe` function
|
||||
// It handles everything from:
|
||||
// 1. Listening to `window.postMessage` events
|
||||
// 2. Merging initial data with active form state
|
||||
// 3. Populating relationships and uploads
|
||||
// 4. Calling the `onChange` callback with the result
|
||||
// Your hook should also:
|
||||
// 1. Tell the Admin panel when it is ready to receive messages
|
||||
// 2. Handle the results of the `onChange` callback to update the UI
|
||||
// 3. Unsubscribe from the `window.postMessage` events when it unmounts
|
||||
```
|
||||
|
||||
Here is an example of what the same `useLivePreview` React hook from above looks like under the hood:
|
||||
|
||||
```tsx
|
||||
import { subscribe, unsubscribe, ready } from '@payloadcms/live-preview'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
|
||||
export const useLivePreview = <T extends any>(props: {
|
||||
depth?: number
|
||||
initialData: T
|
||||
serverURL: string
|
||||
}): {
|
||||
data: T
|
||||
isLoading: boolean
|
||||
} => {
|
||||
const { depth = 0, initialData, serverURL } = props
|
||||
const [data, setData] = useState<T>(initialData)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const hasSentReadyMessage = useRef<boolean>(false)
|
||||
|
||||
const onChange = useCallback((mergedData) => {
|
||||
// When a change is made, the `onChange` callback will be called with the merged data
|
||||
// Set this merged data into state so that React will re-render the UI
|
||||
setData(mergedData)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for `window.postMessage` events from the Admin panel
|
||||
// When a change is made, the `onChange` callback will be called with the merged data
|
||||
const subscription = subscribe({
|
||||
callback: onChange,
|
||||
depth,
|
||||
initialData,
|
||||
serverURL,
|
||||
})
|
||||
|
||||
// Once subscribed, send a `ready` message back up to the Admin panel
|
||||
// This will indicate that the front-end is ready to receive messages
|
||||
if (!hasSentReadyMessage.current) {
|
||||
hasSentReadyMessage.current = true
|
||||
|
||||
ready({
|
||||
serverURL,
|
||||
})
|
||||
}
|
||||
|
||||
// When the component unmounts, unsubscribe from the `window.postMessage` events
|
||||
return () => {
|
||||
unsubscribe(subscription)
|
||||
}
|
||||
}, [serverURL, onChange, depth, initialData])
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Banner type="info">
|
||||
When building your own hook, ensure that the args and return values are consistent with the ones
|
||||
listed at the top of this document. This will ensure that all hooks follow the same API.
|
||||
</Banner>
|
||||
|
||||
## Example
|
||||
|
||||
For a working demonstration of this, check out the official [Live Preview Example](https://github.com/payloadcms/payload/tree/main/examples/live-preview/payload). There you will find examples of various front-end frameworks and how to integrate each one of them, including:
|
||||
|
||||
- [Next.js App Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-app)
|
||||
- [Next.js Pages Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-pages)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
#### Relationships and/or uploads are not populating
|
||||
|
||||
If you are using relationships or uploads in your front-end application, and your front-end application runs on a different domain than your Payload server, you may need to configure [CORS](../configuration/overview) to allow requests to be made between the two domains. This includes sites that are running on a different port or subdomain. Similarly, if you are protecting resources behind user authentication, you may also need to configure [CSRF](../authentication/overview#csrf-protection) to allow cookies to be sent between the two domains. For example:
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
{
|
||||
// ...
|
||||
// If your site is running on a different domain than your Payload server,
|
||||
// This will allows requests to be made between the two domains
|
||||
cors: {
|
||||
[
|
||||
'http://localhost:3001' // Your front-end application
|
||||
],
|
||||
},
|
||||
// If you are protecting resources behind user authentication,
|
||||
// This will allow cookies to be sent between the two domains
|
||||
csrf: {
|
||||
[
|
||||
'http://localhost:3001' // Your front-end application
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Relationships and/or uploads disappear after editing a document
|
||||
|
||||
It is possible that either you are setting an improper [`depth`](../getting-started/concepts#depth) in your initial request and/or your `useLivePreview` hook, or they're mismatched. Ensure that the `depth` parameter is set to the correct value, and that it matches exactly in both places. For example:
|
||||
|
||||
```tsx
|
||||
// Your initial request
|
||||
const { docs } = await payload.find({
|
||||
collection: 'pages',
|
||||
depth: 1, // Ensure this is set to the proper depth for your application
|
||||
where: {
|
||||
slug: {
|
||||
equals: 'home',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Your hook
|
||||
const { data } = useLivePreview<PageType>({
|
||||
initialData: initialPage,
|
||||
serverURL: PAYLOAD_SERVER_URL,
|
||||
depth: 1, // Ensure this matches the depth of your initial request
|
||||
})
|
||||
```
|
||||
|
||||
@@ -8,7 +8,7 @@ keywords: live preview, preview, live, iframe, iframe preview, visual editing, d
|
||||
|
||||
**With Live Preview you can render your front-end application directly within the Admin panel. As you type, your changes take effect in real-time. No need to save a draft or publish your changes.**
|
||||
|
||||
Live Preview works by rendering an iframe on the page that loads your front-end application. The Admin panel communicates with your app through [`window.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) events. These events are emitted every time a change is made to the document. Your app then listens for these events and re-renders itself with the data it receives.
|
||||
Live Preview works by rendering an iframe on the page that loads your front-end application. The Admin panel communicates with your app through [`window.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) events. These events are emitted every time a change is made to the document. Your app then listens for these events and re-renders itself with the data it receives. Live Preview works in both server-side as well as client-side environments. See [Front-End](./frontend) for more details.
|
||||
|
||||
{/* IMAGE OF LIVE PREVIEW HERE */}
|
||||
|
||||
|
||||
175
docs/live-preview/server.mdx
Normal file
175
docs/live-preview/server.mdx
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Server-side Live Preview
|
||||
label: Server-side
|
||||
order: 30
|
||||
desc: Learn how to implement Live Preview in your server-side front-end application.
|
||||
keywords: live preview, frontend, react, next.js, vue, nuxt.js, svelte, hook, useLivePreview
|
||||
---
|
||||
|
||||
<Banner type="info">
|
||||
Server-side Live Preview is only for front-end frameworks that support the concept of Server Components, i.e. [React Server Components](https://react.dev/reference/rsc/server-components). If your front-end application is built with a client-side framework like the [Next.js Pages Router](https://nextjs.org/docs/pages), [React Router](https://reactrouter.com), [Vue 3](https://vuejs.org), etc., see [client-side Live Preview](./client).
|
||||
</Banner>
|
||||
|
||||
Server-side Live Preview works by making a roundtrip to the server every time your document is saved, i.e. draft save, autosave, or publish. While using Live Preview, the Admin panel emits a new `window.postMessage` event which your front-end application can use to invoke this process. In Next.js, this means simply calling `router.refresh()` which will hydrate the HTML using new data straight from the [Local API](../local-api/overview).
|
||||
|
||||
<Banner type="warning">
|
||||
It is recommended that you enable [Autosave](../versions/autosave) alongside Live Preview to make the experience feel more responsive.
|
||||
</Banner>
|
||||
|
||||
If your front-end application is built with [React](#react), you can use the `RefreshRouteOnChange` function that Payload provides. In the future, all other major frameworks like Vue and Svelte will be officially supported. If you are using any of these frameworks today, you can still integrate with Live Preview yourself using the underlying tooling that Payload provides. See [building your own router refresh component](#building-your-own-router-refresh-component) for more information.
|
||||
|
||||
### React
|
||||
|
||||
If your front-end application is built with [React](https://react.dev) or [Next.js](https://nextjs.org), you can use the `RefreshRouteOnSave` component that Payload provides.
|
||||
|
||||
First, install the `@payloadcms/live-preview-react` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview-react
|
||||
```
|
||||
|
||||
Then, render `RefreshRouteOnSave` anywhere in your `page.tsx`. Here's an example:
|
||||
|
||||
`page.tsx`:
|
||||
|
||||
```tsx
|
||||
import { RefreshRouteOnSave } from './RefreshRouteOnSave.tsx'
|
||||
import { getPayloadHMR } from '@payloadcms/next'
|
||||
import config from '../payload.config'
|
||||
|
||||
export default async function Page() {
|
||||
const payload = await getPayloadHMR({ config })
|
||||
|
||||
const page = await payload.find({
|
||||
collection: 'pages',
|
||||
draft: true
|
||||
})
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<RefreshRouteOnSave />
|
||||
<h1>{page.title}</h1>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`RefreshRouteOnSave.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react'
|
||||
import { useRouter } from 'next/navigation.js'
|
||||
import React from 'react'
|
||||
|
||||
export const RefreshRouteOnSave: React.FC = () => {
|
||||
const router = useRouter()
|
||||
return <PayloadLivePreview refresh={router.refresh} serverURL={process.env.PAYLOAD_SERVER_URL} />
|
||||
}
|
||||
```
|
||||
|
||||
## Building your own router refresh component
|
||||
|
||||
No matter what front-end framework you are using, you can build your own component using the same underlying tooling that Payload provides.
|
||||
|
||||
First, install the base `@payloadcms/live-preview` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview
|
||||
```
|
||||
|
||||
This package provides the following functions:
|
||||
|
||||
| Path | Description |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **`ready`** | Sends a `window.postMessage` event to the Admin panel to indicate that the front-end is ready to receive messages. |
|
||||
| **`isDocumentEvent`** | Checks if a `MessageEvent` originates from the Admin panel and is a document-level event, i.e. draft save, autosave, publish, etc. |
|
||||
|
||||
With these functions, you can build your own hook using your front-end framework of choice:
|
||||
|
||||
```tsx
|
||||
import { ready, isDocumentEvent } from '@payloadcms/live-preview'
|
||||
|
||||
// To build your own component:
|
||||
// 1. Listen for document-level `window.postMessage` events sent from the Admin panel
|
||||
// 2. Tell the Admin panel when it is ready to receive messages
|
||||
// 3. Refresh the route every time a new document-level event is received
|
||||
// 4. Unsubscribe from the `window.postMessage` events when it unmounts
|
||||
```
|
||||
|
||||
Here is an example of what the same `RefreshRouteOnSave` React component from above looks like under the hood:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
|
||||
import { isDocumentEvent, ready } from '@payloadcms/live-preview'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
export const RefreshRouteOnSave: React.FC<{
|
||||
apiRoute?: string
|
||||
depth?: number
|
||||
refresh: () => void
|
||||
serverURL: string
|
||||
}> = (props) => {
|
||||
const { apiRoute, depth, refresh, serverURL } = props
|
||||
const hasSentReadyMessage = useRef<boolean>(false)
|
||||
|
||||
const onMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
if (isDocumentEvent(event, serverURL)) {
|
||||
if (typeof refresh === 'function') {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
},
|
||||
[refresh, serverURL],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', onMessage)
|
||||
}
|
||||
|
||||
if (!hasSentReadyMessage.current) {
|
||||
hasSentReadyMessage.current = true
|
||||
|
||||
ready({
|
||||
serverURL,
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('message', onMessage)
|
||||
}
|
||||
}
|
||||
}, [serverURL, onMessage, depth, apiRoute])
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
{/* TODO: add example once beta has been release and an example can be created */}
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
#### Updates do not appear as fast as client-side Live Preview
|
||||
|
||||
If you are noticing that updates feel less snappy than client-side Live Preview (i.e. the `useLivePreview` hook), this is because of how the two differ in how they work—instead of emitting events against form state as you type, server-side Live Preview refreshes the route after a new document is saved. You can use autosave to mimic this effect. Try decreasing the value of `versions.autoSave.interval` to make the experience feel more responsive:
|
||||
|
||||
```ts
|
||||
// collection.ts
|
||||
{
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: {
|
||||
interval: 375,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -85,7 +85,7 @@ The following custom endpoints are automatically opened for you:
|
||||
|
||||
##### Stripe REST Proxy
|
||||
|
||||
If `rest` is true, proxies the [Stripe REST API](https://stripe.com/docs/api) behind [Payload access control](https://payloadcms.com/docs/access-control/overview) and returns the result. If you need to proxy the API server-side, use the [stripeProxy](#node) function.
|
||||
If `rest` is true, proxies the [Stripe REST API](https://stripe.com/docs/api) behind [Payload access control](https://payloadcms.com/docs/access-control/overview) and returns the result. This flag should only be used for local development, see the security note below for more information.
|
||||
|
||||
```ts
|
||||
const res = await fetch(`/api/stripe/rest`, {
|
||||
@@ -106,6 +106,8 @@ const res = await fetch(`/api/stripe/rest`, {
|
||||
})
|
||||
```
|
||||
|
||||
If you need to proxy the API server-side, use the [stripeProxy](#node) function.
|
||||
|
||||
<Banner type="info">
|
||||
<strong>Note:</strong>
|
||||
<br />
|
||||
@@ -113,6 +115,12 @@ const res = await fetch(`/api/stripe/rest`, {
|
||||
config.
|
||||
</Banner>
|
||||
|
||||
<Banner type="warning">
|
||||
<strong>Warning:</strong>
|
||||
<br />
|
||||
Opening the REST proxy endpoint in production is a potential security risk. Authenticated users will have open access to the Stripe REST API. In production, open your own endpoint and use the [stripeProxy](#node) function to proxy the Stripe API server-side.
|
||||
</Banner>
|
||||
|
||||
## Webhooks
|
||||
|
||||
[Stripe webhooks](https://stripe.com/docs/webhooks) are used to sync from Stripe to Payload. Webhooks listen for events on your Stripe account so you can trigger reactions to them. Follow the steps below to enable webhooks.
|
||||
|
||||
@@ -52,8 +52,7 @@ Every Payload Collection can opt-in to supporting Uploads by specifying the `upl
|
||||
| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) |
|
||||
| **`handlers`** | Array of Express request handlers to execute before the built-in Payload static middleware executes. |
|
||||
| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) |
|
||||
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
|
||||
| **`staticOptions`** | Set options for `express.static` to use while serving your static files. [More](http://expressjs.com/en/resources/middleware/serve-static.html) |
|
||||
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) | |
|
||||
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
|
||||
| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. |
|
||||
|
||||
|
||||
343
docs/upload/storage-adapters.mdx
Normal file
343
docs/upload/storage-adapters.mdx
Normal file
@@ -0,0 +1,343 @@
|
||||
---
|
||||
title: Storage Adapters
|
||||
label: Overview
|
||||
order: 20
|
||||
desc: Payload provides additional storage adapters to handle file uploads. These adapters allow you to store files in different locations, such as Amazon S3, Vercel Blob Storage, Google Cloud Storage, Uploadthing, and more.
|
||||
keywords: uploads, images, media, storage, adapters, s3, vercel, google cloud, azure
|
||||
---
|
||||
|
||||
Payload offers additional storage adapters to handle file uploads. These adapters allow you to store files in different locations, such as Amazon S3, Vercel Blob Storage, Google Cloud Storage, and more.
|
||||
|
||||
| Service | Package |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Vercel Blob | [`@payloadcms/storage-vercel-blob`](https://github.com/payloadcms/payload/tree/beta/packages/storage-vercel-blob) |
|
||||
| AWS S3 | [`@payloadcms/storage-s3`](https://github.com/payloadcms/payload/tree/beta/packages/storage-s3) |
|
||||
| Azure | [`@payloadcms/storage-azure`](https://github.com/payloadcms/payload/tree/beta/packages/storage-azure) |
|
||||
| Google Cloud Storage | [`@payloadcms/storage-gcs`](https://github.com/payloadcms/payload/tree/beta/packages/storage-gcs) |
|
||||
|
||||
|
||||
### Vercel Blob Storage [`@payloadcms/storage-vercel-blob`](https://www.npmjs.com/package/@payloadcms/storage-vercel-blob)
|
||||
|
||||
#### Installation
|
||||
|
||||
```sh
|
||||
pnpm add @payloadcms/storage-vercel-blob
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
- Configure the `collections` object to specify which collections should use the Vercel Blob adapter. The slug _must_ match one of your existing collection slugs.
|
||||
- Ensure you have `BLOB_READ_WRITE_TOKEN` set in your Vercel environment variables. This is usually set by Vercel automatically after adding blob storage to your project.
|
||||
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
|
||||
|
||||
```ts
|
||||
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
|
||||
import { Media } from './collections/Media'
|
||||
import { MediaWithPrefix } from './collections/MediaWithPrefix'
|
||||
|
||||
export default buildConfig({
|
||||
collections: [Media, MediaWithPrefix],
|
||||
plugins: [
|
||||
vercelBlobStorage({
|
||||
enabled: true, // Optional, defaults to true
|
||||
// Specify which collections should use Vercel Blob
|
||||
collections: {
|
||||
[Media.slug]: true,
|
||||
[MediaWithPrefix.slug]: {
|
||||
prefix: 'my-prefix',
|
||||
},
|
||||
},
|
||||
// Token provided by Vercel once Blob storage is added to your Vercel project
|
||||
token: process.env.BLOB_READ_WRITE_TOKEN,
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
#### Configuration Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| -------------------- | -------------------------------------------------------------------- | ----------------------------- |
|
||||
| `enabled` | Whether or not to enable the plugin | `true` |
|
||||
| `collections` | Collections to apply the Vercel Blob adapter to | |
|
||||
| `addRandomSuffix` | Add a random suffix to the uploaded file name in Vercel Blob storage | `false` |
|
||||
| `cacheControlMaxAge` | Cache-Control max-age in seconds | `365 * 24 * 60 * 60` (1 Year) |
|
||||
| `token` | Vercel Blob storage read/write token | `''` |
|
||||
|
||||
### S3 Storage [`@payloadcms/storage-s3`](https://www.npmjs.com/package/@payloadcms/storage-s3)
|
||||
|
||||
#### Installation
|
||||
|
||||
```sh
|
||||
pnpm add @payloadcms/storage-s3
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
- Configure the `collections` object to specify which collections should use the Vercel Blob adapter. The slug _must_ match one of your existing collection slugs.
|
||||
- The `config` object can be any [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3) object (from [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3)). _This is highly dependent on your AWS setup_. Check the AWS documentation for more information.
|
||||
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
|
||||
|
||||
```ts
|
||||
import { s3Storage } from '@payloadcms/storage-s3'
|
||||
import { Media } from './collections/Media'
|
||||
import { MediaWithPrefix } from './collections/MediaWithPrefix'
|
||||
|
||||
export default buildConfig({
|
||||
collections: [Media, MediaWithPrefix],
|
||||
plugins: [
|
||||
s3Storage({
|
||||
collections: {
|
||||
[mediaSlug]: true,
|
||||
[mediaWithPrefixSlug]: {
|
||||
prefix,
|
||||
},
|
||||
},
|
||||
bucket: process.env.S3_BUCKET,
|
||||
config: {
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
region: process.env.S3_REGION,
|
||||
// ... Other S3 configuration
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
##### Configuration Options
|
||||
|
||||
See the the [AWS SDK Package](https://github.com/aws/aws-sdk-js-v3) and [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3) object for guidance on AWS S3 configuration.
|
||||
|
||||
### Azure Blob Storage - [`@payloadcms/storage-azure`](https://www.npmjs.com/package/@payloadcms/storage-azure)
|
||||
|
||||
#### Installation
|
||||
|
||||
```sh
|
||||
pnpm add @payloadcms/storage-azure
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
- Configure the `collections` object to specify which collections should use the Vercel Blob adapter. The slug _must_ match one of your existing collection slugs.
|
||||
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
|
||||
|
||||
```ts
|
||||
import { azureStorage } from '@payloadcms/storage-azure'
|
||||
import { Media } from './collections/Media'
|
||||
import { MediaWithPrefix } from './collections/MediaWithPrefix'
|
||||
|
||||
export default buildConfig({
|
||||
collections: [Media, MediaWithPrefix],
|
||||
plugins: [
|
||||
azureStorage({
|
||||
collections: {
|
||||
[mediaSlug]: true,
|
||||
[mediaWithPrefixSlug]: {
|
||||
prefix,
|
||||
},
|
||||
},
|
||||
allowContainerCreate: process.env.AZURE_STORAGE_ALLOW_CONTAINER_CREATE === 'true',
|
||||
baseURL: process.env.AZURE_STORAGE_ACCOUNT_BASEURL,
|
||||
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
|
||||
containerName: process.env.AZURE_STORAGE_CONTAINER_NAME,
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
#### Configuration Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| ---------------------- | ------------------------------------------------------------------------ | ------- |
|
||||
| `enabled` | Whether or not to enable the plugin | `true` |
|
||||
| `collections` | Collections to apply the Azure Blob adapter to | |
|
||||
| `allowContainerCreate` | Whether or not to allow the container to be created if it does not exist | `false` |
|
||||
| `baseURL` | Base URL for the Azure Blob storage account | |
|
||||
| `connectionString` | Azure Blob storage connection string | |
|
||||
| `containerName` | Azure Blob storage container name | |
|
||||
|
||||
### Google Cloud Storage [`@payloadcms/storage-gcs`](https://www.npmjs.com/package/@payloadcms/storage-gcs)
|
||||
|
||||
#### Installation
|
||||
|
||||
```sh
|
||||
pnpm add @payloadcms/storage-gcs
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
- Configure the `collections` object to specify which collections should use the Vercel Blob adapter. The slug _must_ match one of your existing collection slugs.
|
||||
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
|
||||
|
||||
```ts
|
||||
import { gcsStorage } from '@payloadcms/storage-gcs'
|
||||
import { Media } from './collections/Media'
|
||||
import { MediaWithPrefix } from './collections/MediaWithPrefix'
|
||||
|
||||
export default buildConfig({
|
||||
collections: [Media, MediaWithPrefix],
|
||||
plugins: [
|
||||
gcsStorage({
|
||||
collections: {
|
||||
[mediaSlug]: true,
|
||||
[mediaWithPrefixSlug]: {
|
||||
prefix,
|
||||
},
|
||||
},
|
||||
bucket: process.env.GCS_BUCKET,
|
||||
options: {
|
||||
apiEndpoint: process.env.GCS_ENDPOINT,
|
||||
projectId: process.env.GCS_PROJECT_ID,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
#### Configuration Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------- | --------- |
|
||||
| `enabled` | Whether or not to enable the plugin | `true` |
|
||||
| `collections` | Collections to apply the storage to | |
|
||||
| `bucket` | The name of the bucket to use | |
|
||||
| `options` | Google Cloud Storage client configuration. See [Docs](https://github.com/googleapis/nodejs-storage) | |
|
||||
| `acl` | Access control list for files that are uploaded | `Private` |
|
||||
|
||||
|
||||
### Uploadthing Storage [`@payloadcms/storage-uploadthing`](https://www.npmjs.com/package/@payloadcms/storage-uploadthing)
|
||||
|
||||
#### Installation
|
||||
|
||||
```sh
|
||||
pnpm add @paylaodcms/storage-uploadthing
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
- Configure the `collections` object to specify which collections should use uploadthing. The slug _must_ match one of your existing collection slugs and be an `upload` type.
|
||||
- Get an API key from Uploadthing and set it as `apiKey` in the `options` object.
|
||||
- `acl` is optional and defaults to `public-read`.
|
||||
|
||||
```ts
|
||||
export default buildConfig({
|
||||
collections: [Media],
|
||||
plugins: [
|
||||
uploadthingStorage({
|
||||
collections: {
|
||||
[mediaSlug]: true,
|
||||
},
|
||||
options: {
|
||||
apiKey: process.env.UPLOADTHING_SECRET,
|
||||
acl: 'public-read',
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
#### Configuration Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| ---------------- | ----------------------------------------------- | ------------- |
|
||||
| `apiKey` | API key from Uploadthing. Required. | |
|
||||
| `acl` | Access control list for files that are uploaded | `public-read` |
|
||||
| `logLevel` | Log level for Uploadthing | `info` |
|
||||
| `fetch` | Custom fetch function | `fetch` |
|
||||
| `defaultKeyType` | Default key type for file operations | `fileKey` |
|
||||
|
||||
|
||||
### Custom Storage Adapters
|
||||
|
||||
If you need to create a custom storage adapter, you can use the [`@payloadcms/plugin-cloud-storage`](https://www.npmjs.com/package/@payloadcms/plugin-cloud-storage) package. This package is used internally by the storage adapters mentioned above.
|
||||
|
||||
#### Installation
|
||||
|
||||
`pnpm add @payloadcms/plugin-cloud-storage`
|
||||
|
||||
#### Usage
|
||||
|
||||
Reference any of the existing storage adapters for guidance on how this should be structured. Create an adapter following the `GeneratedAdapter` interface. Then, pass the adapter to the `cloudStorage` plugin.
|
||||
|
||||
```ts
|
||||
export interface GeneratedAdapter {
|
||||
/**
|
||||
* Additional fields to be injected into the base collection and image sizes
|
||||
*/
|
||||
fields?: Field[]
|
||||
/**
|
||||
* Generates the public URL for a file
|
||||
*/
|
||||
generateURL?: GenerateURL
|
||||
handleDelete: HandleDelete
|
||||
handleUpload: HandleUpload
|
||||
name: string
|
||||
onInit?: () => void
|
||||
staticHandler: StaticHandler
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config'
|
||||
import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage'
|
||||
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
cloudStorage({
|
||||
collections: {
|
||||
'my-collection-slug': {
|
||||
adapter: theAdapterToUse, // see docs for the adapter you want to use
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
// The rest of your config goes here
|
||||
})
|
||||
```
|
||||
|
||||
### Plugin options
|
||||
|
||||
This plugin is configurable to work across many different Payload collections. A `*` denotes that the property is required.
|
||||
|
||||
| Option | Type | Description |
|
||||
| ---------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `collections` \* | `Record<string, CollectionOptions>` | Object with keys set to the slug of collections you want to enable the plugin for, and values set to collection-specific options. |
|
||||
| `enabled` | | `boolean` to conditionally enable/disable plugin. Default: true. |
|
||||
|
||||
### Collection-specific options
|
||||
|
||||
| Option | Type | Description |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `adapter` \* | [Adapter](https://github.com/payloadcms/plugin-cloud-storage/blob/master/src/types.ts#L51) | Pass in the adapter that you'd like to use for this collection. You can also set this field to `null` for local development if you'd like to bypass cloud storage in certain scenarios and use local storage. |
|
||||
| `disableLocalStorage` | `boolean` | Choose to disable local storage on this collection. Defaults to `true`. |
|
||||
| `disablePayloadAccessControl` | `true` | Set to `true` to disable Payload's access control. [More](#payload-access-control) |
|
||||
| `prefix` | `string` | Set to `media/images` to upload files inside `media/images` folder in the bucket. |
|
||||
| `generateFileURL` | [GenerateFileURL](https://github.com/payloadcms/plugin-cloud-storage/blob/master/src/types.ts#L53) | Override the generated file URL with one that you create. |
|
||||
|
||||
### Payload Access Control
|
||||
|
||||
Payload ships with access control that runs _even on statically served files_. The same `read` access control property on your `upload`-enabled collections is used, and it allows you to restrict who can request your uploaded files.
|
||||
|
||||
To preserve this feature, by default, this plugin _keeps all file URLs exactly the same_. Your file URLs won't be updated to point directly to your cloud storage source, as in that case, Payload's access control will be completely bypassed and you would need public readability on your cloud-hosted files.
|
||||
|
||||
Instead, all uploads will still be reached from the default `/collectionSlug/staticURL/filename` path. This plugin will "pass through" all files that are hosted on your third-party cloud service—with the added benefit of keeping your existing access control in place.
|
||||
|
||||
If this does not apply to you (your upload collection has `read: () => true` or similar) you can disable this functionality by setting `disablePayloadAccessControl` to `true`. When this setting is in place, this plugin will update your file URLs to point directly to your cloud host.
|
||||
|
||||
### Conditionally Enabling/Disabling
|
||||
|
||||
The proper way to conditionally enable/disable this plugin is to use the `enabled` property.
|
||||
|
||||
```ts
|
||||
cloudStoragePlugin({
|
||||
enabled: process.env.MY_CONDITION === 'true',
|
||||
collections: {
|
||||
'my-collection-slug': {
|
||||
adapter: theAdapterToUse, // see docs for the adapter you want to use
|
||||
},
|
||||
},
|
||||
}),
|
||||
```
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
parser: 'typescript',
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
arrowParens: 'avoid',
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
parser: 'typescript',
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
arrowParens: 'avoid',
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
"@payloadcms/richtext-slate": "3.0.0-beta.24",
|
||||
"@payloadcms/ui": "3.0.0-beta.24",
|
||||
"cross-env": "^7.0.3",
|
||||
"next": "14.3.0-canary.7",
|
||||
"next": "14.3.0-canary.68",
|
||||
"payload": "3.0.0-beta.24",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
parser: 'typescript',
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
arrowParens: 'avoid',
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
parser: "typescript",
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: "all",
|
||||
arrowParens: "avoid",
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
parser: "typescript",
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: "all",
|
||||
arrowParens: "avoid",
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
parser: "typescript",
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: "all",
|
||||
arrowParens: "avoid",
|
||||
};
|
||||
10
examples/form-builder/next-app/.editorconfig
Normal file
10
examples/form-builder/next-app/.editorconfig
Normal file
@@ -0,0 +1,10 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
end_of_line = lf
|
||||
max_line_length = null
|
||||
1
examples/form-builder/next-app/.env.example
Normal file
1
examples/form-builder/next-app/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_PAYLOAD_URL=http://localhost:3000
|
||||
21
examples/form-builder/next-app/.eslintrc.cjs
Normal file
21
examples/form-builder/next-app/.eslintrc.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
extends: ['plugin:@next/next/core-web-vitals', '@payloadcms'],
|
||||
ignorePatterns: ['**/payload-types.ts'],
|
||||
overrides: [
|
||||
{
|
||||
extends: ['plugin:@typescript-eslint/disable-type-checked'],
|
||||
files: ['*.js', '*.cjs', '*.json', '*.md', '*.yml', '*.yaml'],
|
||||
},
|
||||
{
|
||||
files: ['./app/**/*.ts', './app/**/*.tsx'],
|
||||
rules: {
|
||||
'no-restricted-exports': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
root: true,
|
||||
}
|
||||
38
examples/form-builder/next-app/.gitignore
vendored
Normal file
38
examples/form-builder/next-app/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.env
|
||||
38
examples/form-builder/next-app/README.md
Normal file
38
examples/form-builder/next-app/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Form Builder Example Front-End
|
||||
|
||||
This is a [Next.js](https://nextjs.org) app using the [Pages Router](https://nextjs.org/docs/pages). It was made explicitly for Payload's [Form Builder Example](https://github.com/payloadcms/payload/tree/main/examples/form-builder/payload).
|
||||
|
||||
> This example uses the Pages Router, the legacy API of Next.js. If your app is using the latest [App Router](https://nextjs.org/docs/app), we will add an example for that soon.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Payload
|
||||
|
||||
First you'll need a running Payload app. There is one made explicitly for this example and [can be found here](https://github.com/payloadcms/payload/tree/main/examples/form-builder/payload). If you have not done so already, clone it down and follow the setup instructions there.
|
||||
|
||||
### Next.js App
|
||||
|
||||
1. Clone this repo
|
||||
2. `cd` into this directory and run `yarn` or `npm install`
|
||||
3. `cp .env.example .env` to copy the example environment variables
|
||||
4. `yarn dev` or `npm run dev` to start the server
|
||||
5. `open http://localhost:3001` to see the result
|
||||
|
||||
Once running you will find a couple seeded pages on your local environment with some basic instructions. You can also start editing the pages by modifying the documents within Payload. See the [Form Builder Example](https://github.com/payloadcms/payload/tree/main/examples/form-builder/payload) for full details.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Payload and Next.js, take a look at the following resources:
|
||||
|
||||
- [Payload Documentation](https://payloadcms.com/docs) - learn about Payload features and API.
|
||||
- [Form Builder Plugin Documentation](https://github.com/payloadcms/plugin-form-builder) - learn about the plugin's features.
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Payload GitHub repository](https://github.com/payloadcms/payload/) as well as [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Payload deployment documentation](https://payloadcms.com/docs/production/deployment) or the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
40
examples/form-builder/next-app/app/[slug]/page.tsx
Normal file
40
examples/form-builder/next-app/app/[slug]/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { GetStaticPaths } from 'next'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import type { Page } from '../../payload-types'
|
||||
|
||||
import Blocks from '../../components/Blocks'
|
||||
|
||||
export default async function Page({ params: { slug = 'home' } }) {
|
||||
const page = await getPage(slug)
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Blocks blocks={page.layout} />
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export const getPage = async (slug: string) => {
|
||||
const pageQuery = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/pages?where[slug][equals]=${slug}`,
|
||||
).then((res) => res.json())
|
||||
|
||||
return pageQuery.docs[0]
|
||||
}
|
||||
|
||||
export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
|
||||
const pagesQuery: { docs: Page[] } = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/pages?limit=100`,
|
||||
).then((res) => res.json())
|
||||
|
||||
return {
|
||||
fallback: 'blocking',
|
||||
paths: pagesQuery.docs.map((page) => ({
|
||||
params: {
|
||||
slug: page.slug,
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
53
examples/form-builder/next-app/app/layout.tsx
Normal file
53
examples/form-builder/next-app/app/layout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ModalContainer, ModalProvider } from '@faceless-ui/modal'
|
||||
import React from 'react'
|
||||
|
||||
import type { MainMenu } from '../payload-types'
|
||||
|
||||
import { CloseModalOnRouteChange } from '../components/CloseModalOnRouteChange'
|
||||
import { Header } from '../components/Header'
|
||||
import '../css/app.scss'
|
||||
import { GlobalsProvider } from '../providers/Globals'
|
||||
|
||||
export interface IGlobals {
|
||||
mainMenu: MainMenu
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
description: 'An example of how to authenticate with Payload from a Next.js app.',
|
||||
title: 'Payload Auth + Next.js App Router Example',
|
||||
}
|
||||
|
||||
export const getAllGlobals = async (): Promise<IGlobals> => {
|
||||
const [mainMenu] = await Promise.all([
|
||||
fetch(`${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/globals/main-menu?depth=1`).then((res) =>
|
||||
res.json(),
|
||||
),
|
||||
])
|
||||
|
||||
return {
|
||||
mainMenu,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props
|
||||
const globals = await getAllGlobals()
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<React.Fragment>
|
||||
<GlobalsProvider {...globals}>
|
||||
<ModalProvider classPrefix="form" transTime={0} zIndex="var(--modal-z-index)">
|
||||
<CloseModalOnRouteChange />
|
||||
<Header />
|
||||
{/* <Component {...pageProps} /> */}
|
||||
{children}
|
||||
<ModalContainer />
|
||||
</ModalProvider>
|
||||
</GlobalsProvider>
|
||||
</React.Fragment>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
3
examples/form-builder/next-app/app/page.tsx
Normal file
3
examples/form-builder/next-app/app/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import Page from './[slug]/page'
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,69 @@
|
||||
@use "../shared.scss";
|
||||
|
||||
.checkbox {
|
||||
position: relative;
|
||||
margin-bottom: var(--base);
|
||||
|
||||
:global {
|
||||
button {
|
||||
border: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.input {
|
||||
@include shared.formInput;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
width: var(--base);
|
||||
height: var(--base);
|
||||
margin-right: calc(var(--base) * 0.5);
|
||||
margin-bottom: 0;
|
||||
|
||||
svg {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.checked {
|
||||
:global {
|
||||
svg {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { CheckboxField } from 'payload-plugin-form-builder/dist/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import { Check } from '../../../icons/Check'
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export const Checkbox: React.FC<
|
||||
CheckboxField & {
|
||||
errors: Partial<
|
||||
FieldErrorsImpl<{
|
||||
[x: string]: any
|
||||
}>
|
||||
>
|
||||
getValues: any
|
||||
register: UseFormRegister<FieldValues & any>
|
||||
setValue: any
|
||||
}
|
||||
> = ({
|
||||
name,
|
||||
errors,
|
||||
getValues,
|
||||
label,
|
||||
register,
|
||||
required: requiredFromProps,
|
||||
setValue,
|
||||
width,
|
||||
}) => {
|
||||
const [checked, setChecked] = useState(false)
|
||||
|
||||
const isCheckboxChecked = getValues(name)
|
||||
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={[classes.checkbox, checked && classes.checked].filter(Boolean).join(' ')}>
|
||||
<div className={classes.container}>
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register(name, { required: requiredFromProps })}
|
||||
checked={isCheckboxChecked}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setValue(name, !checked)
|
||||
setChecked(!checked)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className={classes.input}>
|
||||
<Check />
|
||||
</span>
|
||||
</button>
|
||||
<span className={classes.label}>{label}</span>
|
||||
</div>
|
||||
{requiredFromProps && errors[name] && checked === false && <Error />}
|
||||
</div>
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
@use "../shared.scss";
|
||||
|
||||
.select {
|
||||
position: relative;
|
||||
margin-bottom: var(--base);
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reactSelect {
|
||||
display: flex;
|
||||
|
||||
:global {
|
||||
div.rs__control {
|
||||
@include shared.formInput;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.rs__input-container {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.rs__value-container {
|
||||
padding: 0;
|
||||
> * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rs__single-value {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.rs__indicators {
|
||||
position: absolute;
|
||||
top: calc(var(--base) * 0.9);
|
||||
right: calc(var(--base) * 0.9);
|
||||
.arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.rs__indicator {
|
||||
padding: 0px 4px;
|
||||
cursor: pointer;
|
||||
|
||||
svg path {
|
||||
fill: var(--color-dark-gray);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg path {
|
||||
fill: var(--color-dark-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rs__indicator-separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rs__menu {
|
||||
color: var(--color-black);
|
||||
background-color: var(--color-white);
|
||||
z-index: 2;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 4px 11px hsl(0deg 0% 0% / 10%);
|
||||
}
|
||||
|
||||
.rs__menu-list {
|
||||
padding: calc(var(--base) / 4) 0;
|
||||
}
|
||||
|
||||
.rs__group-heading {
|
||||
margin-bottom: calc(var(--base) / 2);
|
||||
}
|
||||
|
||||
.rs__option {
|
||||
font-size: 1rem;
|
||||
padding: calc(var(--base) / 2) var(--base);
|
||||
|
||||
&--is-focused {
|
||||
background-color: var(--color-light-gray);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
&--is-selected {
|
||||
background-color: var(--color-light-gray);
|
||||
color: var(--color-black);
|
||||
}
|
||||
}
|
||||
|
||||
.rs__multi-value {
|
||||
padding: 0;
|
||||
background: var(--color-light-gray);
|
||||
}
|
||||
|
||||
.rs__multi-value__label {
|
||||
max-width: 150px;
|
||||
color: var(--color-black);
|
||||
padding: calc(var(--base) / 8) calc(var(--base) / 4);
|
||||
}
|
||||
|
||||
.rs__multi-value__remove {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-black);
|
||||
background: var(--color-light-gray);
|
||||
}
|
||||
}
|
||||
|
||||
.rs__clear-indicator {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CountryField } from 'payload-plugin-form-builder/dist/types'
|
||||
import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form';
|
||||
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import ReactSelect from 'react-select'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import classes from './index.module.scss'
|
||||
import { countryOptions } from './options'
|
||||
|
||||
export const Country: React.FC<
|
||||
CountryField & {
|
||||
control: Control<FieldValues, any>
|
||||
errors: Partial<
|
||||
FieldErrorsImpl<{
|
||||
[x: string]: any
|
||||
}>
|
||||
>
|
||||
}
|
||||
> = ({ name, control, errors, label, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.select}>
|
||||
<label className={classes.label} htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ReactSelect
|
||||
className={classes.reactSelect}
|
||||
classNamePrefix="rs"
|
||||
inputId={name}
|
||||
instanceId={name}
|
||||
onChange={(val) => onChange(val.value)}
|
||||
options={countryOptions}
|
||||
value={countryOptions.find((c) => c.value === value)}
|
||||
/>
|
||||
)}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{required && errors[name] && <Error />}
|
||||
</div>
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,982 @@
|
||||
export const countryOptions = [
|
||||
{
|
||||
label: 'Afghanistan',
|
||||
value: 'AF',
|
||||
},
|
||||
{
|
||||
label: 'Åland Islands',
|
||||
value: 'AX',
|
||||
},
|
||||
{
|
||||
label: 'Albania',
|
||||
value: 'AL',
|
||||
},
|
||||
{
|
||||
label: 'Algeria',
|
||||
value: 'DZ',
|
||||
},
|
||||
{
|
||||
label: 'American Samoa',
|
||||
value: 'AS',
|
||||
},
|
||||
{
|
||||
label: 'Andorra',
|
||||
value: 'AD',
|
||||
},
|
||||
{
|
||||
label: 'Angola',
|
||||
value: 'AO',
|
||||
},
|
||||
{
|
||||
label: 'Anguilla',
|
||||
value: 'AI',
|
||||
},
|
||||
{
|
||||
label: 'Antarctica',
|
||||
value: 'AQ',
|
||||
},
|
||||
{
|
||||
label: 'Antigua and Barbuda',
|
||||
value: 'AG',
|
||||
},
|
||||
{
|
||||
label: 'Argentina',
|
||||
value: 'AR',
|
||||
},
|
||||
{
|
||||
label: 'Armenia',
|
||||
value: 'AM',
|
||||
},
|
||||
{
|
||||
label: 'Aruba',
|
||||
value: 'AW',
|
||||
},
|
||||
{
|
||||
label: 'Australia',
|
||||
value: 'AU',
|
||||
},
|
||||
{
|
||||
label: 'Austria',
|
||||
value: 'AT',
|
||||
},
|
||||
{
|
||||
label: 'Azerbaijan',
|
||||
value: 'AZ',
|
||||
},
|
||||
{
|
||||
label: 'Bahamas',
|
||||
value: 'BS',
|
||||
},
|
||||
{
|
||||
label: 'Bahrain',
|
||||
value: 'BH',
|
||||
},
|
||||
{
|
||||
label: 'Bangladesh',
|
||||
value: 'BD',
|
||||
},
|
||||
{
|
||||
label: 'Barbados',
|
||||
value: 'BB',
|
||||
},
|
||||
{
|
||||
label: 'Belarus',
|
||||
value: 'BY',
|
||||
},
|
||||
{
|
||||
label: 'Belgium',
|
||||
value: 'BE',
|
||||
},
|
||||
{
|
||||
label: 'Belize',
|
||||
value: 'BZ',
|
||||
},
|
||||
{
|
||||
label: 'Benin',
|
||||
value: 'BJ',
|
||||
},
|
||||
{
|
||||
label: 'Bermuda',
|
||||
value: 'BM',
|
||||
},
|
||||
{
|
||||
label: 'Bhutan',
|
||||
value: 'BT',
|
||||
},
|
||||
{
|
||||
label: 'Bolivia',
|
||||
value: 'BO',
|
||||
},
|
||||
{
|
||||
label: 'Bosnia and Herzegovina',
|
||||
value: 'BA',
|
||||
},
|
||||
{
|
||||
label: 'Botswana',
|
||||
value: 'BW',
|
||||
},
|
||||
{
|
||||
label: 'Bouvet Island',
|
||||
value: 'BV',
|
||||
},
|
||||
{
|
||||
label: 'Brazil',
|
||||
value: 'BR',
|
||||
},
|
||||
{
|
||||
label: 'British Indian Ocean Territory',
|
||||
value: 'IO',
|
||||
},
|
||||
{
|
||||
label: 'Brunei Darussalam',
|
||||
value: 'BN',
|
||||
},
|
||||
{
|
||||
label: 'Bulgaria',
|
||||
value: 'BG',
|
||||
},
|
||||
{
|
||||
label: 'Burkina Faso',
|
||||
value: 'BF',
|
||||
},
|
||||
{
|
||||
label: 'Burundi',
|
||||
value: 'BI',
|
||||
},
|
||||
{
|
||||
label: 'Cambodia',
|
||||
value: 'KH',
|
||||
},
|
||||
{
|
||||
label: 'Cameroon',
|
||||
value: 'CM',
|
||||
},
|
||||
{
|
||||
label: 'Canada',
|
||||
value: 'CA',
|
||||
},
|
||||
{
|
||||
label: 'Cape Verde',
|
||||
value: 'CV',
|
||||
},
|
||||
{
|
||||
label: 'Cayman Islands',
|
||||
value: 'KY',
|
||||
},
|
||||
{
|
||||
label: 'Central African Republic',
|
||||
value: 'CF',
|
||||
},
|
||||
{
|
||||
label: 'Chad',
|
||||
value: 'TD',
|
||||
},
|
||||
{
|
||||
label: 'Chile',
|
||||
value: 'CL',
|
||||
},
|
||||
{
|
||||
label: 'China',
|
||||
value: 'CN',
|
||||
},
|
||||
{
|
||||
label: 'Christmas Island',
|
||||
value: 'CX',
|
||||
},
|
||||
{
|
||||
label: 'Cocos (Keeling) Islands',
|
||||
value: 'CC',
|
||||
},
|
||||
{
|
||||
label: 'Colombia',
|
||||
value: 'CO',
|
||||
},
|
||||
{
|
||||
label: 'Comoros',
|
||||
value: 'KM',
|
||||
},
|
||||
{
|
||||
label: 'Congo',
|
||||
value: 'CG',
|
||||
},
|
||||
{
|
||||
label: 'Congo, The Democratic Republic of the',
|
||||
value: 'CD',
|
||||
},
|
||||
{
|
||||
label: 'Cook Islands',
|
||||
value: 'CK',
|
||||
},
|
||||
{
|
||||
label: 'Costa Rica',
|
||||
value: 'CR',
|
||||
},
|
||||
{
|
||||
label: "Cote D'Ivoire",
|
||||
value: 'CI',
|
||||
},
|
||||
{
|
||||
label: 'Croatia',
|
||||
value: 'HR',
|
||||
},
|
||||
{
|
||||
label: 'Cuba',
|
||||
value: 'CU',
|
||||
},
|
||||
{
|
||||
label: 'Cyprus',
|
||||
value: 'CY',
|
||||
},
|
||||
{
|
||||
label: 'Czech Republic',
|
||||
value: 'CZ',
|
||||
},
|
||||
{
|
||||
label: 'Denmark',
|
||||
value: 'DK',
|
||||
},
|
||||
{
|
||||
label: 'Djibouti',
|
||||
value: 'DJ',
|
||||
},
|
||||
{
|
||||
label: 'Dominica',
|
||||
value: 'DM',
|
||||
},
|
||||
{
|
||||
label: 'Dominican Republic',
|
||||
value: 'DO',
|
||||
},
|
||||
{
|
||||
label: 'Ecuador',
|
||||
value: 'EC',
|
||||
},
|
||||
{
|
||||
label: 'Egypt',
|
||||
value: 'EG',
|
||||
},
|
||||
{
|
||||
label: 'El Salvador',
|
||||
value: 'SV',
|
||||
},
|
||||
{
|
||||
label: 'Equatorial Guinea',
|
||||
value: 'GQ',
|
||||
},
|
||||
{
|
||||
label: 'Eritrea',
|
||||
value: 'ER',
|
||||
},
|
||||
{
|
||||
label: 'Estonia',
|
||||
value: 'EE',
|
||||
},
|
||||
{
|
||||
label: 'Ethiopia',
|
||||
value: 'ET',
|
||||
},
|
||||
{
|
||||
label: 'Falkland Islands (Malvinas)',
|
||||
value: 'FK',
|
||||
},
|
||||
{
|
||||
label: 'Faroe Islands',
|
||||
value: 'FO',
|
||||
},
|
||||
{
|
||||
label: 'Fiji',
|
||||
value: 'FJ',
|
||||
},
|
||||
{
|
||||
label: 'Finland',
|
||||
value: 'FI',
|
||||
},
|
||||
{
|
||||
label: 'France',
|
||||
value: 'FR',
|
||||
},
|
||||
{
|
||||
label: 'French Guiana',
|
||||
value: 'GF',
|
||||
},
|
||||
{
|
||||
label: 'French Polynesia',
|
||||
value: 'PF',
|
||||
},
|
||||
{
|
||||
label: 'French Southern Territories',
|
||||
value: 'TF',
|
||||
},
|
||||
{
|
||||
label: 'Gabon',
|
||||
value: 'GA',
|
||||
},
|
||||
{
|
||||
label: 'Gambia',
|
||||
value: 'GM',
|
||||
},
|
||||
{
|
||||
label: 'Georgia',
|
||||
value: 'GE',
|
||||
},
|
||||
{
|
||||
label: 'Germany',
|
||||
value: 'DE',
|
||||
},
|
||||
{
|
||||
label: 'Ghana',
|
||||
value: 'GH',
|
||||
},
|
||||
{
|
||||
label: 'Gibraltar',
|
||||
value: 'GI',
|
||||
},
|
||||
{
|
||||
label: 'Greece',
|
||||
value: 'GR',
|
||||
},
|
||||
{
|
||||
label: 'Greenland',
|
||||
value: 'GL',
|
||||
},
|
||||
{
|
||||
label: 'Grenada',
|
||||
value: 'GD',
|
||||
},
|
||||
{
|
||||
label: 'Guadeloupe',
|
||||
value: 'GP',
|
||||
},
|
||||
{
|
||||
label: 'Guam',
|
||||
value: 'GU',
|
||||
},
|
||||
{
|
||||
label: 'Guatemala',
|
||||
value: 'GT',
|
||||
},
|
||||
{
|
||||
label: 'Guernsey',
|
||||
value: 'GG',
|
||||
},
|
||||
{
|
||||
label: 'Guinea',
|
||||
value: 'GN',
|
||||
},
|
||||
{
|
||||
label: 'Guinea-Bissau',
|
||||
value: 'GW',
|
||||
},
|
||||
{
|
||||
label: 'Guyana',
|
||||
value: 'GY',
|
||||
},
|
||||
{
|
||||
label: 'Haiti',
|
||||
value: 'HT',
|
||||
},
|
||||
{
|
||||
label: 'Heard Island and Mcdonald Islands',
|
||||
value: 'HM',
|
||||
},
|
||||
{
|
||||
label: 'Holy See (Vatican City State)',
|
||||
value: 'VA',
|
||||
},
|
||||
{
|
||||
label: 'Honduras',
|
||||
value: 'HN',
|
||||
},
|
||||
{
|
||||
label: 'Hong Kong',
|
||||
value: 'HK',
|
||||
},
|
||||
{
|
||||
label: 'Hungary',
|
||||
value: 'HU',
|
||||
},
|
||||
{
|
||||
label: 'Iceland',
|
||||
value: 'IS',
|
||||
},
|
||||
{
|
||||
label: 'India',
|
||||
value: 'IN',
|
||||
},
|
||||
{
|
||||
label: 'Indonesia',
|
||||
value: 'ID',
|
||||
},
|
||||
{
|
||||
label: 'Iran, Islamic Republic Of',
|
||||
value: 'IR',
|
||||
},
|
||||
{
|
||||
label: 'Iraq',
|
||||
value: 'IQ',
|
||||
},
|
||||
{
|
||||
label: 'Ireland',
|
||||
value: 'IE',
|
||||
},
|
||||
{
|
||||
label: 'Isle of Man',
|
||||
value: 'IM',
|
||||
},
|
||||
{
|
||||
label: 'Israel',
|
||||
value: 'IL',
|
||||
},
|
||||
{
|
||||
label: 'Italy',
|
||||
value: 'IT',
|
||||
},
|
||||
{
|
||||
label: 'Jamaica',
|
||||
value: 'JM',
|
||||
},
|
||||
{
|
||||
label: 'Japan',
|
||||
value: 'JP',
|
||||
},
|
||||
{
|
||||
label: 'Jersey',
|
||||
value: 'JE',
|
||||
},
|
||||
{
|
||||
label: 'Jordan',
|
||||
value: 'JO',
|
||||
},
|
||||
{
|
||||
label: 'Kazakhstan',
|
||||
value: 'KZ',
|
||||
},
|
||||
{
|
||||
label: 'Kenya',
|
||||
value: 'KE',
|
||||
},
|
||||
{
|
||||
label: 'Kiribati',
|
||||
value: 'KI',
|
||||
},
|
||||
{
|
||||
label: "Democratic People's Republic of Korea",
|
||||
value: 'KP',
|
||||
},
|
||||
{
|
||||
label: 'Korea, Republic of',
|
||||
value: 'KR',
|
||||
},
|
||||
{
|
||||
label: 'Kosovo',
|
||||
value: 'XK',
|
||||
},
|
||||
{
|
||||
label: 'Kuwait',
|
||||
value: 'KW',
|
||||
},
|
||||
{
|
||||
label: 'Kyrgyzstan',
|
||||
value: 'KG',
|
||||
},
|
||||
{
|
||||
label: "Lao People's Democratic Republic",
|
||||
value: 'LA',
|
||||
},
|
||||
{
|
||||
label: 'Latvia',
|
||||
value: 'LV',
|
||||
},
|
||||
{
|
||||
label: 'Lebanon',
|
||||
value: 'LB',
|
||||
},
|
||||
{
|
||||
label: 'Lesotho',
|
||||
value: 'LS',
|
||||
},
|
||||
{
|
||||
label: 'Liberia',
|
||||
value: 'LR',
|
||||
},
|
||||
{
|
||||
label: 'Libyan Arab Jamahiriya',
|
||||
value: 'LY',
|
||||
},
|
||||
{
|
||||
label: 'Liechtenstein',
|
||||
value: 'LI',
|
||||
},
|
||||
{
|
||||
label: 'Lithuania',
|
||||
value: 'LT',
|
||||
},
|
||||
{
|
||||
label: 'Luxembourg',
|
||||
value: 'LU',
|
||||
},
|
||||
{
|
||||
label: 'Macao',
|
||||
value: 'MO',
|
||||
},
|
||||
{
|
||||
label: 'Macedonia, The Former Yugoslav Republic of',
|
||||
value: 'MK',
|
||||
},
|
||||
{
|
||||
label: 'Madagascar',
|
||||
value: 'MG',
|
||||
},
|
||||
{
|
||||
label: 'Malawi',
|
||||
value: 'MW',
|
||||
},
|
||||
{
|
||||
label: 'Malaysia',
|
||||
value: 'MY',
|
||||
},
|
||||
{
|
||||
label: 'Maldives',
|
||||
value: 'MV',
|
||||
},
|
||||
{
|
||||
label: 'Mali',
|
||||
value: 'ML',
|
||||
},
|
||||
{
|
||||
label: 'Malta',
|
||||
value: 'MT',
|
||||
},
|
||||
{
|
||||
label: 'Marshall Islands',
|
||||
value: 'MH',
|
||||
},
|
||||
{
|
||||
label: 'Martinique',
|
||||
value: 'MQ',
|
||||
},
|
||||
{
|
||||
label: 'Mauritania',
|
||||
value: 'MR',
|
||||
},
|
||||
{
|
||||
label: 'Mauritius',
|
||||
value: 'MU',
|
||||
},
|
||||
{
|
||||
label: 'Mayotte',
|
||||
value: 'YT',
|
||||
},
|
||||
{
|
||||
label: 'Mexico',
|
||||
value: 'MX',
|
||||
},
|
||||
{
|
||||
label: 'Micronesia, Federated States of',
|
||||
value: 'FM',
|
||||
},
|
||||
{
|
||||
label: 'Moldova, Republic of',
|
||||
value: 'MD',
|
||||
},
|
||||
{
|
||||
label: 'Monaco',
|
||||
value: 'MC',
|
||||
},
|
||||
{
|
||||
label: 'Mongolia',
|
||||
value: 'MN',
|
||||
},
|
||||
{
|
||||
label: 'Montenegro',
|
||||
value: 'ME',
|
||||
},
|
||||
{
|
||||
label: 'Montserrat',
|
||||
value: 'MS',
|
||||
},
|
||||
{
|
||||
label: 'Morocco',
|
||||
value: 'MA',
|
||||
},
|
||||
{
|
||||
label: 'Mozambique',
|
||||
value: 'MZ',
|
||||
},
|
||||
{
|
||||
label: 'Myanmar',
|
||||
value: 'MM',
|
||||
},
|
||||
{
|
||||
label: 'Namibia',
|
||||
value: 'NA',
|
||||
},
|
||||
{
|
||||
label: 'Nauru',
|
||||
value: 'NR',
|
||||
},
|
||||
{
|
||||
label: 'Nepal',
|
||||
value: 'NP',
|
||||
},
|
||||
{
|
||||
label: 'Netherlands',
|
||||
value: 'NL',
|
||||
},
|
||||
{
|
||||
label: 'Netherlands Antilles',
|
||||
value: 'AN',
|
||||
},
|
||||
{
|
||||
label: 'New Caledonia',
|
||||
value: 'NC',
|
||||
},
|
||||
{
|
||||
label: 'New Zealand',
|
||||
value: 'NZ',
|
||||
},
|
||||
{
|
||||
label: 'Nicaragua',
|
||||
value: 'NI',
|
||||
},
|
||||
{
|
||||
label: 'Niger',
|
||||
value: 'NE',
|
||||
},
|
||||
{
|
||||
label: 'Nigeria',
|
||||
value: 'NG',
|
||||
},
|
||||
{
|
||||
label: 'Niue',
|
||||
value: 'NU',
|
||||
},
|
||||
{
|
||||
label: 'Norfolk Island',
|
||||
value: 'NF',
|
||||
},
|
||||
{
|
||||
label: 'Northern Mariana Islands',
|
||||
value: 'MP',
|
||||
},
|
||||
{
|
||||
label: 'Norway',
|
||||
value: 'NO',
|
||||
},
|
||||
{
|
||||
label: 'Oman',
|
||||
value: 'OM',
|
||||
},
|
||||
{
|
||||
label: 'Pakistan',
|
||||
value: 'PK',
|
||||
},
|
||||
{
|
||||
label: 'Palau',
|
||||
value: 'PW',
|
||||
},
|
||||
{
|
||||
label: 'Palestinian Territory, Occupied',
|
||||
value: 'PS',
|
||||
},
|
||||
{
|
||||
label: 'Panama',
|
||||
value: 'PA',
|
||||
},
|
||||
{
|
||||
label: 'Papua New Guinea',
|
||||
value: 'PG',
|
||||
},
|
||||
{
|
||||
label: 'Paraguay',
|
||||
value: 'PY',
|
||||
},
|
||||
{
|
||||
label: 'Peru',
|
||||
value: 'PE',
|
||||
},
|
||||
{
|
||||
label: 'Philippines',
|
||||
value: 'PH',
|
||||
},
|
||||
{
|
||||
label: 'Pitcairn',
|
||||
value: 'PN',
|
||||
},
|
||||
{
|
||||
label: 'Poland',
|
||||
value: 'PL',
|
||||
},
|
||||
{
|
||||
label: 'Portugal',
|
||||
value: 'PT',
|
||||
},
|
||||
{
|
||||
label: 'Puerto Rico',
|
||||
value: 'PR',
|
||||
},
|
||||
{
|
||||
label: 'Qatar',
|
||||
value: 'QA',
|
||||
},
|
||||
{
|
||||
label: 'Reunion',
|
||||
value: 'RE',
|
||||
},
|
||||
{
|
||||
label: 'Romania',
|
||||
value: 'RO',
|
||||
},
|
||||
{
|
||||
label: 'Russian Federation',
|
||||
value: 'RU',
|
||||
},
|
||||
{
|
||||
label: 'Rwanda',
|
||||
value: 'RW',
|
||||
},
|
||||
{
|
||||
label: 'Saint Helena',
|
||||
value: 'SH',
|
||||
},
|
||||
{
|
||||
label: 'Saint Kitts and Nevis',
|
||||
value: 'KN',
|
||||
},
|
||||
{
|
||||
label: 'Saint Lucia',
|
||||
value: 'LC',
|
||||
},
|
||||
{
|
||||
label: 'Saint Pierre and Miquelon',
|
||||
value: 'PM',
|
||||
},
|
||||
{
|
||||
label: 'Saint Vincent and the Grenadines',
|
||||
value: 'VC',
|
||||
},
|
||||
{
|
||||
label: 'Samoa',
|
||||
value: 'WS',
|
||||
},
|
||||
{
|
||||
label: 'San Marino',
|
||||
value: 'SM',
|
||||
},
|
||||
{
|
||||
label: 'Sao Tome and Principe',
|
||||
value: 'ST',
|
||||
},
|
||||
{
|
||||
label: 'Saudi Arabia',
|
||||
value: 'SA',
|
||||
},
|
||||
{
|
||||
label: 'Senegal',
|
||||
value: 'SN',
|
||||
},
|
||||
{
|
||||
label: 'Serbia',
|
||||
value: 'RS',
|
||||
},
|
||||
{
|
||||
label: 'Seychelles',
|
||||
value: 'SC',
|
||||
},
|
||||
{
|
||||
label: 'Sierra Leone',
|
||||
value: 'SL',
|
||||
},
|
||||
{
|
||||
label: 'Singapore',
|
||||
value: 'SG',
|
||||
},
|
||||
{
|
||||
label: 'Slovakia',
|
||||
value: 'SK',
|
||||
},
|
||||
{
|
||||
label: 'Slovenia',
|
||||
value: 'SI',
|
||||
},
|
||||
{
|
||||
label: 'Solomon Islands',
|
||||
value: 'SB',
|
||||
},
|
||||
{
|
||||
label: 'Somalia',
|
||||
value: 'SO',
|
||||
},
|
||||
{
|
||||
label: 'South Africa',
|
||||
value: 'ZA',
|
||||
},
|
||||
{
|
||||
label: 'South Georgia and the South Sandwich Islands',
|
||||
value: 'GS',
|
||||
},
|
||||
{
|
||||
label: 'Spain',
|
||||
value: 'ES',
|
||||
},
|
||||
{
|
||||
label: 'Sri Lanka',
|
||||
value: 'LK',
|
||||
},
|
||||
{
|
||||
label: 'Sudan',
|
||||
value: 'SD',
|
||||
},
|
||||
{
|
||||
label: 'Suriname',
|
||||
value: 'SR',
|
||||
},
|
||||
{
|
||||
label: 'Svalbard and Jan Mayen',
|
||||
value: 'SJ',
|
||||
},
|
||||
{
|
||||
label: 'Swaziland',
|
||||
value: 'SZ',
|
||||
},
|
||||
{
|
||||
label: 'Sweden',
|
||||
value: 'SE',
|
||||
},
|
||||
{
|
||||
label: 'Switzerland',
|
||||
value: 'CH',
|
||||
},
|
||||
{
|
||||
label: 'Syrian Arab Republic',
|
||||
value: 'SY',
|
||||
},
|
||||
{
|
||||
label: 'Taiwan',
|
||||
value: 'TW',
|
||||
},
|
||||
{
|
||||
label: 'Tajikistan',
|
||||
value: 'TJ',
|
||||
},
|
||||
{
|
||||
label: 'Tanzania, United Republic of',
|
||||
value: 'TZ',
|
||||
},
|
||||
{
|
||||
label: 'Thailand',
|
||||
value: 'TH',
|
||||
},
|
||||
{
|
||||
label: 'Timor-Leste',
|
||||
value: 'TL',
|
||||
},
|
||||
{
|
||||
label: 'Togo',
|
||||
value: 'TG',
|
||||
},
|
||||
{
|
||||
label: 'Tokelau',
|
||||
value: 'TK',
|
||||
},
|
||||
{
|
||||
label: 'Tonga',
|
||||
value: 'TO',
|
||||
},
|
||||
{
|
||||
label: 'Trinidad and Tobago',
|
||||
value: 'TT',
|
||||
},
|
||||
{
|
||||
label: 'Tunisia',
|
||||
value: 'TN',
|
||||
},
|
||||
{
|
||||
label: 'Turkey',
|
||||
value: 'TR',
|
||||
},
|
||||
{
|
||||
label: 'Turkmenistan',
|
||||
value: 'TM',
|
||||
},
|
||||
{
|
||||
label: 'Turks and Caicos Islands',
|
||||
value: 'TC',
|
||||
},
|
||||
{
|
||||
label: 'Tuvalu',
|
||||
value: 'TV',
|
||||
},
|
||||
{
|
||||
label: 'Uganda',
|
||||
value: 'UG',
|
||||
},
|
||||
{
|
||||
label: 'Ukraine',
|
||||
value: 'UA',
|
||||
},
|
||||
{
|
||||
label: 'United Arab Emirates',
|
||||
value: 'AE',
|
||||
},
|
||||
{
|
||||
label: 'United Kingdom',
|
||||
value: 'GB',
|
||||
},
|
||||
{
|
||||
label: 'United States',
|
||||
value: 'US',
|
||||
},
|
||||
{
|
||||
label: 'United States Minor Outlying Islands',
|
||||
value: 'UM',
|
||||
},
|
||||
{
|
||||
label: 'Uruguay',
|
||||
value: 'UY',
|
||||
},
|
||||
{
|
||||
label: 'Uzbekistan',
|
||||
value: 'UZ',
|
||||
},
|
||||
{
|
||||
label: 'Vanuatu',
|
||||
value: 'VU',
|
||||
},
|
||||
{
|
||||
label: 'Venezuela',
|
||||
value: 'VE',
|
||||
},
|
||||
{
|
||||
label: 'Viet Nam',
|
||||
value: 'VN',
|
||||
},
|
||||
{
|
||||
label: 'Virgin Islands, British',
|
||||
value: 'VG',
|
||||
},
|
||||
{
|
||||
label: 'Virgin Islands, U.S.',
|
||||
value: 'VI',
|
||||
},
|
||||
{
|
||||
label: 'Wallis and Futuna',
|
||||
value: 'WF',
|
||||
},
|
||||
{
|
||||
label: 'Western Sahara',
|
||||
value: 'EH',
|
||||
},
|
||||
{
|
||||
label: 'Yemen',
|
||||
value: 'YE',
|
||||
},
|
||||
{
|
||||
label: 'Zambia',
|
||||
value: 'ZM',
|
||||
},
|
||||
{
|
||||
label: 'Zimbabwe',
|
||||
value: 'ZW',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
@use "../shared.scss";
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
margin-bottom: var(--base);
|
||||
}
|
||||
|
||||
.input {
|
||||
@include shared.formInput;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { EmailField } from 'payload-plugin-form-builder/dist/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export const Email: React.FC<
|
||||
EmailField & {
|
||||
errors: Partial<
|
||||
FieldErrorsImpl<{
|
||||
[x: string]: any
|
||||
}>
|
||||
>
|
||||
register: UseFormRegister<FieldValues & any>
|
||||
}
|
||||
> = ({ name, errors, label, register, required: requiredFromProps, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.wrap}>
|
||||
<label className={classes.label} htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
className={classes.input}
|
||||
id={name}
|
||||
placeholder="Email"
|
||||
type="text"
|
||||
{...register(name, { pattern: /^\S[^\s@]*@\S+$/, required: requiredFromProps })}
|
||||
/>
|
||||
{requiredFromProps && errors[name] && <Error />}
|
||||
</div>
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.error {
|
||||
margin-top: 5px;
|
||||
color: var(--color-red);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export const Error: React.FC = () => {
|
||||
return <div className={classes.error}>This field is required</div>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@use '../.../../../../../css/queries.scss' as *;
|
||||
|
||||
.message {
|
||||
margin: var(--base) 0 var(--base) 0;
|
||||
|
||||
@include mid-break {
|
||||
margin: calc(var(--base) * 0.5) 0 calc(var(--base) * 0.5) 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { MessageField } from 'payload-plugin-form-builder/dist/types'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import RichText from '../../../RichText'
|
||||
import { Width } from '../Width'
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export const Message: React.FC<MessageField> = ({ message }) => {
|
||||
return (
|
||||
<Width width="100">
|
||||
<RichText className={classes.message} content={message} />
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@use "../shared.scss";
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
margin-bottom: var(--base);
|
||||
}
|
||||
|
||||
.input {
|
||||
@include shared.formInput;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TextField } from 'payload-plugin-form-builder/dist/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export const Number: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<
|
||||
FieldErrorsImpl<{
|
||||
[x: string]: any
|
||||
}>
|
||||
>
|
||||
register: UseFormRegister<FieldValues & any>
|
||||
}
|
||||
> = ({ name, errors, label, register, required: requiredFromProps, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.wrap}>
|
||||
<label className={classes.label} htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
className={classes.input}
|
||||
id={name}
|
||||
type="number"
|
||||
{...register(name, { required: requiredFromProps })}
|
||||
/>
|
||||
{requiredFromProps && errors[name] && <Error />}
|
||||
</div>
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
@use "../shared.scss";
|
||||
|
||||
.select {
|
||||
position: relative;
|
||||
margin-bottom: var(--base);
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reactSelect {
|
||||
display: flex;
|
||||
|
||||
:global {
|
||||
div.rs__control {
|
||||
@include shared.formInput;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.rs__input-container {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.rs__value-container {
|
||||
padding: 0;
|
||||
> * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rs__single-value {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.rs__indicators {
|
||||
position: absolute;
|
||||
top: calc(var(--base) * 0.9);
|
||||
right: calc(var(--base) * 0.9);
|
||||
.arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.rs__indicator {
|
||||
padding: 0px 4px;
|
||||
cursor: pointer;
|
||||
|
||||
svg path {
|
||||
fill: var(--color-dark-gray);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg path {
|
||||
fill: var(--color-dark-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rs__indicator-separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rs__menu {
|
||||
color: var(--color-black);
|
||||
background-color: var(--color-white);
|
||||
z-index: 2;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 4px 11px hsl(0deg 0% 0% / 10%);
|
||||
}
|
||||
|
||||
.rs__menu-list {
|
||||
padding: calc(var(--base) / 4) 0;
|
||||
}
|
||||
|
||||
.rs__group-heading {
|
||||
margin-bottom: calc(var(--base) / 2);
|
||||
}
|
||||
|
||||
.rs__option {
|
||||
font-size: 1rem;
|
||||
padding: calc(var(--base) / 2) var(--base);
|
||||
|
||||
&--is-focused {
|
||||
background-color: var(--color-light-gray);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
&--is-selected {
|
||||
background-color: var(--color-light-gray);
|
||||
color: var(--color-black);
|
||||
}
|
||||
}
|
||||
|
||||
.rs__multi-value {
|
||||
padding: 0;
|
||||
background: var(--color-light-gray);
|
||||
}
|
||||
|
||||
.rs__multi-value__label {
|
||||
max-width: 150px;
|
||||
color: var(--color-black);
|
||||
padding: calc(var(--base) / 8) calc(var(--base) / 4);
|
||||
}
|
||||
|
||||
.rs__multi-value__remove {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-black);
|
||||
background: var(--color-light-gray);
|
||||
}
|
||||
}
|
||||
|
||||
.rs__clear-indicator {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SelectField } from 'payload-plugin-form-builder/dist/types'
|
||||
import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form';
|
||||
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import ReactSelect from 'react-select'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export const Select: React.FC<
|
||||
SelectField & {
|
||||
control: Control<FieldValues, any>
|
||||
errors: Partial<
|
||||
FieldErrorsImpl<{
|
||||
[x: string]: any
|
||||
}>
|
||||
>
|
||||
}
|
||||
> = ({ name, control, errors, label, options, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.select}>
|
||||
<label className={classes.label} htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ReactSelect
|
||||
className={classes.reactSelect}
|
||||
classNamePrefix="rs"
|
||||
inputId={name}
|
||||
instanceId={name}
|
||||
onChange={(val) => onChange(val.value)}
|
||||
options={options}
|
||||
value={options.find((s) => s.value === value)}
|
||||
/>
|
||||
)}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{required && errors[name] && <Error />}
|
||||
</div>
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
@use "../shared.scss";
|
||||
|
||||
.select {
|
||||
position: relative;
|
||||
margin-bottom: var(--base);
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reactSelect {
|
||||
display: flex;
|
||||
|
||||
:global {
|
||||
div.rs__control {
|
||||
@include shared.formInput;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.rs__input-container {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.rs__value-container {
|
||||
padding: 0;
|
||||
> * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rs__single-value {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.rs__indicators {
|
||||
position: absolute;
|
||||
top: calc(var(--base) * 0.9);
|
||||
right: calc(var(--base) * 0.9);
|
||||
.arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.rs__indicator {
|
||||
padding: 0px 4px;
|
||||
cursor: pointer;
|
||||
|
||||
svg path {
|
||||
fill: var(--color-dark-gray);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg path {
|
||||
fill: var(--color-dark-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rs__indicator-separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rs__menu {
|
||||
color: var(--color-black);
|
||||
background-color: var(--color-white);
|
||||
z-index: 2;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 4px 11px hsl(0deg 0% 0% / 10%);
|
||||
}
|
||||
|
||||
.rs__menu-list {
|
||||
padding: calc(var(--base) / 4) 0;
|
||||
}
|
||||
|
||||
.rs__group-heading {
|
||||
margin-bottom: calc(var(--base) / 2);
|
||||
}
|
||||
|
||||
.rs__option {
|
||||
font-size: 1rem;
|
||||
padding: calc(var(--base) / 2) var(--base);
|
||||
|
||||
&--is-focused {
|
||||
background-color: var(--color-light-gray);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
&--is-selected {
|
||||
background-color: var(--color-light-gray);
|
||||
color: var(--color-black);
|
||||
}
|
||||
}
|
||||
|
||||
.rs__multi-value {
|
||||
padding: 0;
|
||||
background: var(--color-light-gray);
|
||||
}
|
||||
|
||||
.rs__multi-value__label {
|
||||
max-width: 150px;
|
||||
color: var(--color-black);
|
||||
padding: calc(var(--base) / 8) calc(var(--base) / 4);
|
||||
}
|
||||
|
||||
.rs__multi-value__remove {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-black);
|
||||
background: var(--color-light-gray);
|
||||
}
|
||||
}
|
||||
|
||||
.rs__clear-indicator {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { StateField } from 'payload-plugin-form-builder/dist/types'
|
||||
import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form';
|
||||
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import ReactSelect from 'react-select'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import classes from './index.module.scss'
|
||||
import { stateOptions } from './options'
|
||||
|
||||
export const State: React.FC<
|
||||
StateField & {
|
||||
control: Control<FieldValues, any>
|
||||
errors: Partial<
|
||||
FieldErrorsImpl<{
|
||||
[x: string]: any
|
||||
}>
|
||||
>
|
||||
}
|
||||
> = ({ name, control, errors, label, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.select}>
|
||||
<label className={classes.label} htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ReactSelect
|
||||
className={classes.reactSelect}
|
||||
classNamePrefix="rs"
|
||||
id={name}
|
||||
instanceId={name}
|
||||
onChange={(val) => onChange(val.value)}
|
||||
options={stateOptions}
|
||||
value={stateOptions.find((t) => t.value === value)}
|
||||
/>
|
||||
)}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{required && errors[name] && <Error />}
|
||||
</div>
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export const stateOptions = [
|
||||
{ label: 'Alabama', value: 'AL' },
|
||||
{ label: 'Alaska', value: 'AK' },
|
||||
{ label: 'Arizona', value: 'AZ' },
|
||||
{ label: 'Arkansas', value: 'AR' },
|
||||
{ label: 'California', value: 'CA' },
|
||||
{ label: 'Colorado', value: 'CO' },
|
||||
{ label: 'Connecticut', value: 'CT' },
|
||||
{ label: 'Delaware', value: 'DE' },
|
||||
{ label: 'Florida', value: 'FL' },
|
||||
{ label: 'Georgia', value: 'GA' },
|
||||
{ label: 'Hawaii', value: 'HI' },
|
||||
{ label: 'Idaho', value: 'ID' },
|
||||
{ label: 'Illinois', value: 'IL' },
|
||||
{ label: 'Indiana', value: 'IN' },
|
||||
{ label: 'Iowa', value: 'IA' },
|
||||
{ label: 'Kansas', value: 'KS' },
|
||||
{ label: 'Kentucky', value: 'KY' },
|
||||
{ label: 'Louisiana', value: 'LA' },
|
||||
{ label: 'Maine', value: 'ME' },
|
||||
{ label: 'Maryland', value: 'MD' },
|
||||
{ label: 'Massachusetts', value: 'MA' },
|
||||
{ label: 'Michigan', value: 'MI' },
|
||||
{ label: 'Minnesota', value: 'MN' },
|
||||
{ label: 'Mississippi', value: 'MS' },
|
||||
{ label: 'Missouri', value: 'MO' },
|
||||
{ label: 'Montana', value: 'MT' },
|
||||
{ label: 'Nebraska', value: 'NE' },
|
||||
{ label: 'Nevada', value: 'NV' },
|
||||
{ label: 'New Hampshire', value: 'NH' },
|
||||
{ label: 'New Jersey', value: 'NJ' },
|
||||
{ label: 'New Mexico', value: 'NM' },
|
||||
{ label: 'New York', value: 'NY' },
|
||||
{ label: 'North Carolina', value: 'NC' },
|
||||
{ label: 'North Dakota', value: 'ND' },
|
||||
{ label: 'Ohio', value: 'OH' },
|
||||
{ label: 'Oklahoma', value: 'OK' },
|
||||
{ label: 'Oregon', value: 'OR' },
|
||||
{ label: 'Pennsylvania', value: 'PA' },
|
||||
{ label: 'Rhode Island', value: 'RI' },
|
||||
{ label: 'South Carolina', value: 'SC' },
|
||||
{ label: 'South Dakota', value: 'SD' },
|
||||
{ label: 'Tennessee', value: 'TN' },
|
||||
{ label: 'Texas', value: 'TX' },
|
||||
{ label: 'Utah', value: 'UT' },
|
||||
{ label: 'Vermont', value: 'VT' },
|
||||
{ label: 'Virginia', value: 'VA' },
|
||||
{ label: 'Washington', value: 'WA' },
|
||||
{ label: 'West Virginia', value: 'WV' },
|
||||
{ label: 'Wisconsin', value: 'WI' },
|
||||
{ label: 'Wyoming', value: 'WY' },
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
@use "../shared.scss";
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
margin-bottom: var(--base);
|
||||
}
|
||||
|
||||
.input {
|
||||
@include shared.formInput;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TextField } from 'payload-plugin-form-builder/dist/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export const Text: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<
|
||||
FieldErrorsImpl<{
|
||||
[x: string]: any
|
||||
}>
|
||||
>
|
||||
register: UseFormRegister<FieldValues & any>
|
||||
}
|
||||
> = ({ name, errors, label, register, required: requiredFromProps, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.wrap}>
|
||||
<label className={classes.label} htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
className={classes.input}
|
||||
id={name}
|
||||
type="text"
|
||||
{...register(name, { required: requiredFromProps })}
|
||||
/>
|
||||
{requiredFromProps && errors[name] && <Error />}
|
||||
</div>
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@use "../shared.scss";
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
margin-bottom: var(--base);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
@include shared.formInput;
|
||||
height: unset;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { TextField } from 'payload-plugin-form-builder/dist/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export const Textarea: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<
|
||||
FieldErrorsImpl<{
|
||||
[x: string]: any
|
||||
}>
|
||||
>
|
||||
register: UseFormRegister<FieldValues & any>
|
||||
rows?: number
|
||||
}
|
||||
> = ({ name, errors, label, register, required: requiredFromProps, rows = 3, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.wrap}>
|
||||
<label className={classes.label} htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
className={classes.textarea}
|
||||
id={name}
|
||||
rows={rows}
|
||||
{...register(name, { required: requiredFromProps })}
|
||||
/>
|
||||
{requiredFromProps && errors[name] && <Error />}
|
||||
</div>
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@use '../../../../css/queries.scss' as *;
|
||||
|
||||
.width {
|
||||
padding-left: calc(var(--base) * 0.5);
|
||||
padding-right: calc(var(--base) * 0.5);
|
||||
|
||||
@include mid-break {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export const Width: React.FC<{
|
||||
children: React.ReactNode
|
||||
width?: string
|
||||
}> = ({ children, width }) => {
|
||||
return (
|
||||
<div className={classes.width} style={{ width: width ? `${width}%` : undefined }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { FormFieldBlock } from 'payload-plugin-form-builder/dist/types'
|
||||
|
||||
export const buildInitialFormState = (fields: FormFieldBlock[]) => {
|
||||
return fields.reduce((initialSchema, field) => {
|
||||
if (field.blockType === 'checkbox') {
|
||||
return {
|
||||
...initialSchema,
|
||||
[field.name]: false,
|
||||
}
|
||||
}
|
||||
if (field.blockType === 'country') {
|
||||
return {
|
||||
...initialSchema,
|
||||
[field.name]: '',
|
||||
}
|
||||
}
|
||||
if (field.blockType === 'email') {
|
||||
return {
|
||||
...initialSchema,
|
||||
[field.name]: '',
|
||||
}
|
||||
}
|
||||
if (field.blockType === 'text') {
|
||||
return {
|
||||
...initialSchema,
|
||||
[field.name]: '',
|
||||
}
|
||||
}
|
||||
if (field.blockType === 'select') {
|
||||
return {
|
||||
...initialSchema,
|
||||
[field.name]: '',
|
||||
}
|
||||
}
|
||||
if (field.blockType === 'state') {
|
||||
return {
|
||||
...initialSchema,
|
||||
[field.name]: '',
|
||||
}
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Checkbox } from './Checkbox'
|
||||
import { Country } from './Country'
|
||||
import { Email } from './Email'
|
||||
import { Message } from './Message'
|
||||
import { Number } from './Number'
|
||||
import { Select } from './Select'
|
||||
import { State } from './State'
|
||||
import { Text } from './Text'
|
||||
import { Textarea } from './Textarea'
|
||||
|
||||
export const fields = {
|
||||
checkbox: Checkbox,
|
||||
country: Country,
|
||||
email: Email,
|
||||
message: Message,
|
||||
number: Number,
|
||||
select: Select,
|
||||
state: State,
|
||||
text: Text,
|
||||
textarea: Textarea,
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
@use '../.../../../../css/queries.scss' as *;
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hasSubmitted {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80vh;
|
||||
|
||||
@include small-break {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-bottom: var(--base);
|
||||
|
||||
@include mid-break {
|
||||
margin-bottom: var(--base);
|
||||
}
|
||||
}
|
||||
|
||||
.confirmationMessage {
|
||||
max-width: 800px;
|
||||
text-align: center;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldWrap {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-left: calc(var(--base) * -0.5);
|
||||
margin-right: calc(var(--base) * -0.5);
|
||||
width: calc(100% + #{var(--base)});
|
||||
|
||||
>* {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.Select {
|
||||
& label {
|
||||
position: absolute;
|
||||
top: calc(var(--base) * -0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebarContent {
|
||||
@include mid-break {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileSidebar {
|
||||
display: none;
|
||||
|
||||
@include mid-break {
|
||||
display: block;
|
||||
margin-bottom: calc(var(--base) * 2);
|
||||
}
|
||||
}
|
||||
174
examples/form-builder/next-app/components/Blocks/Form/index.tsx
Normal file
174
examples/form-builder/next-app/components/Blocks/Form/index.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
import type { Form as FormType } from '@payloadcms/plugin-form-builder/types'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
import { Button } from '../../Button'
|
||||
import { Gutter } from '../../Gutter'
|
||||
import RichText from '../../RichText'
|
||||
import { buildInitialFormState } from './buildInitialFormState'
|
||||
import { fields } from './fields'
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export type Value = unknown
|
||||
|
||||
export interface Property {
|
||||
[key: string]: Value
|
||||
}
|
||||
|
||||
export interface Data {
|
||||
[key: string]: Property | Property[] | Value
|
||||
}
|
||||
|
||||
export type FormBlockType = {
|
||||
blockName?: string
|
||||
blockType?: 'formBlock'
|
||||
enableIntro: boolean
|
||||
form: FormType
|
||||
introContent?: {
|
||||
[k: string]: unknown
|
||||
}[]
|
||||
}
|
||||
|
||||
export const FormBlock: React.FC<
|
||||
FormBlockType & {
|
||||
id?: string
|
||||
}
|
||||
> = (props) => {
|
||||
const {
|
||||
enableIntro,
|
||||
form: formFromProps,
|
||||
form: { id: formID, confirmationMessage, confirmationType, redirect, submitButtonLabel } = {},
|
||||
introContent,
|
||||
} = props
|
||||
|
||||
const formMethods = useForm({
|
||||
defaultValues: buildInitialFormState(formFromProps.fields),
|
||||
})
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
getValues,
|
||||
handleSubmit,
|
||||
register,
|
||||
setValue,
|
||||
} = formMethods
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasSubmitted, setHasSubmitted] = useState<boolean>()
|
||||
const [error, setError] = useState<{ message: string; status?: string } | undefined>()
|
||||
const router = useRouter()
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: Data) => {
|
||||
let loadingTimerID: ReturnType<typeof setTimeout>
|
||||
const submitForm = async () => {
|
||||
setError(undefined)
|
||||
|
||||
const dataToSend = Object.entries(data).map(([name, value]) => ({
|
||||
field: name,
|
||||
value,
|
||||
}))
|
||||
|
||||
// delay loading indicator by 1s
|
||||
loadingTimerID = setTimeout(() => {
|
||||
setIsLoading(true)
|
||||
}, 1000)
|
||||
|
||||
try {
|
||||
const req = await fetch(`${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/form-submissions`, {
|
||||
body: JSON.stringify({
|
||||
form: formID,
|
||||
submissionData: dataToSend,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const res = await req.json()
|
||||
|
||||
clearTimeout(loadingTimerID)
|
||||
|
||||
if (req.status >= 400) {
|
||||
setIsLoading(false)
|
||||
|
||||
setError({
|
||||
message: res.errors?.[0]?.message || 'Internal Server Error',
|
||||
status: res.status,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
setHasSubmitted(true)
|
||||
|
||||
if (confirmationType === 'redirect' && redirect) {
|
||||
const { url } = redirect
|
||||
|
||||
const redirectUrl = url
|
||||
|
||||
if (redirectUrl) router.push(redirectUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
setIsLoading(false)
|
||||
setError({
|
||||
message: 'Something went wrong.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void submitForm()
|
||||
},
|
||||
[router, formID, redirect, confirmationType],
|
||||
)
|
||||
|
||||
return (
|
||||
<Gutter>
|
||||
<div
|
||||
className={[classes.form, hasSubmitted && classes.hasSubmitted].filter(Boolean).join(' ')}
|
||||
>
|
||||
{enableIntro && introContent && !hasSubmitted && (
|
||||
<RichText className={classes.intro} content={introContent} />
|
||||
)}
|
||||
{!isLoading && hasSubmitted && confirmationType === 'message' && (
|
||||
<RichText className={classes.confirmationMessage} content={confirmationMessage} />
|
||||
)}
|
||||
{isLoading && !hasSubmitted && <p>Loading, please wait...</p>}
|
||||
{error && <div>{`${error.status || '500'}: ${error.message || ''}`}</div>}
|
||||
{!hasSubmitted && (
|
||||
<form id={formID} onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className={classes.fieldWrap}>
|
||||
{formFromProps &&
|
||||
formFromProps.fields &&
|
||||
formFromProps.fields.map((field, index) => {
|
||||
const Field: React.FC<any> = fields?.[field.blockType]
|
||||
if (Field) {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Field
|
||||
form={formFromProps}
|
||||
{...field}
|
||||
{...formMethods}
|
||||
control={control}
|
||||
errors={errors}
|
||||
register={register}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
<Button appearance="primary" el="button" form={formID} label={submitButtonLabel} />
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</Gutter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@use "../../../css/common.scss" as *;
|
||||
|
||||
@mixin formInput() {
|
||||
all: unset;
|
||||
-webkit-appearance: none;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-body);
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-black);
|
||||
background: var(--color-white);
|
||||
color: var(--color-black);
|
||||
font-size: 1rem;
|
||||
height: calc(var(--base) * 2.5);
|
||||
line-height: var(--base);
|
||||
padding: calc(var(--base) * 0.75);
|
||||
|
||||
&::-moz-placeholder,
|
||||
&::-webkit-input-placeholder {
|
||||
color: var(--color-mid-gray);
|
||||
font-weight: normal;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-mid-gray);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
border-color: var(--color-gray);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--color-light-gray);
|
||||
color: var(--color-gray);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-light-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
examples/form-builder/next-app/components/Blocks/index.tsx
Normal file
51
examples/form-builder/next-app/components/Blocks/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
import type { Page } from '../../payload-types'
|
||||
|
||||
import { toKebabCase } from '../../utilities/toKebabCase'
|
||||
import { VerticalPadding } from '../VerticalPadding'
|
||||
import { FormBlock } from './Form'
|
||||
|
||||
const blockComponents = {
|
||||
formBlock: FormBlock,
|
||||
}
|
||||
|
||||
const Blocks: React.FC<{
|
||||
blocks: Page['layout']
|
||||
}> = (props) => {
|
||||
const { blocks } = props
|
||||
|
||||
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0
|
||||
|
||||
if (hasBlocks) {
|
||||
return (
|
||||
<Fragment>
|
||||
{blocks.map((block, index) => {
|
||||
const { blockName, blockType, form } = block
|
||||
|
||||
const isFormBlock = blockType === 'formBlock'
|
||||
{
|
||||
/*@ts-expect-error*/
|
||||
}
|
||||
const formID: string = isFormBlock && form && (typeof form === 'string' ? form : form.id)
|
||||
|
||||
if (blockType && blockType in blockComponents) {
|
||||
const Block = blockComponents[blockType]
|
||||
|
||||
return (
|
||||
<VerticalPadding bottom="small" key={isFormBlock ? formID : index} top="small">
|
||||
{/*@ts-expect-error*/}
|
||||
<Block id={toKebabCase(blockName)} {...block} />
|
||||
</VerticalPadding>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default Blocks
|
||||
@@ -0,0 +1,59 @@
|
||||
@import '../../css/type.scss';
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
margin-right: calc(var(--base) / 2);
|
||||
width: var(--base);
|
||||
height: var(--base);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
@extend %label;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
padding: 12px 18px;
|
||||
margin-bottom: var(--base);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appearance--primary {
|
||||
background-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-white);
|
||||
color: var(--color-black);
|
||||
box-shadow: inset 0 0 0 1px var(--color-black);
|
||||
}
|
||||
}
|
||||
|
||||
.appearance--secondary {
|
||||
background-color: var(--color-white);
|
||||
box-shadow: inset 0 0 0 1px var(--color-black);
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
box-shadow: inset 0 0 0 1px var(--color-black);
|
||||
}
|
||||
}
|
||||
|
||||
.appearance--default {
|
||||
padding: 0;
|
||||
margin-left: -8px;
|
||||
}
|
||||
63
examples/form-builder/next-app/components/Button/index.tsx
Normal file
63
examples/form-builder/next-app/components/Button/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export type Props = {
|
||||
appearance?: 'default' | 'primary' | 'secondary'
|
||||
className?: string
|
||||
el?: 'a' | 'button' | 'link'
|
||||
form?: string
|
||||
href?: string
|
||||
label: string
|
||||
newTab?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const elements = {
|
||||
a: 'a',
|
||||
button: 'button',
|
||||
link: Link,
|
||||
}
|
||||
|
||||
export const Button: React.FC<Props> = ({
|
||||
appearance,
|
||||
className: classNameFromProps,
|
||||
el = 'button',
|
||||
form,
|
||||
href,
|
||||
label,
|
||||
newTab,
|
||||
}) => {
|
||||
const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {}
|
||||
const Element = elements[el]
|
||||
const className = [classNameFromProps, classes[`appearance--${appearance}`], classes.button]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const elementProps = {
|
||||
...newTabProps,
|
||||
className,
|
||||
form,
|
||||
href,
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div className={classes.content}>
|
||||
<span className={classes.label}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Element {...elementProps}>
|
||||
<React.Fragment>
|
||||
{el === 'link' && (
|
||||
<Link {...newTabProps} className={elementProps.className} href={href}>
|
||||
{content}
|
||||
</Link>
|
||||
)}
|
||||
{el !== 'link' && <React.Fragment>{content}</React.Fragment>}
|
||||
</React.Fragment>
|
||||
</Element>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
import type React from 'react';
|
||||
|
||||
import { useModal } from '@faceless-ui/modal'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const CloseModalOnRouteChange: React.FC = () => {
|
||||
const { closeAllModals } = useModal()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
closeAllModals()
|
||||
}, [pathname, closeAllModals])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.gutterLeft {
|
||||
padding-left: var(--gutter-h);
|
||||
}
|
||||
|
||||
.gutterRight {
|
||||
padding-right: var(--gutter-h);
|
||||
}
|
||||
30
examples/form-builder/next-app/components/Gutter/index.tsx
Normal file
30
examples/form-builder/next-app/components/Gutter/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Ref } from 'react';
|
||||
|
||||
import React, { forwardRef } from 'react'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
left?: boolean
|
||||
ref?: Ref<HTMLDivElement>
|
||||
right?: boolean
|
||||
}
|
||||
|
||||
export const Gutter: React.FC<Props> = forwardRef<HTMLDivElement, Props>((props, ref) => {
|
||||
const { children, className, left = true, right = true } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[left && classes.gutterLeft, right && classes.gutterRight, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Gutter.displayName = 'Gutter'
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
import { Modal } from '@faceless-ui/modal'
|
||||
import React from 'react'
|
||||
|
||||
import type { MainMenu } from '../../payload-types'
|
||||
|
||||
import { HeaderBar } from '.'
|
||||
import { Gutter } from '../Gutter'
|
||||
import { CMSLink } from '../Link'
|
||||
import classes from './mobileMenuModal.module.scss'
|
||||
|
||||
type Props = {
|
||||
navItems: MainMenu['navItems']
|
||||
}
|
||||
|
||||
export const slug = 'menu-modal'
|
||||
|
||||
export const MobileMenuModal: React.FC<Props> = ({ navItems }) => {
|
||||
return (
|
||||
<Modal className={classes.mobileMenuModal} slug={slug}>
|
||||
<HeaderBar />
|
||||
|
||||
<Gutter>
|
||||
<div className={classes.mobileMenuItems}>
|
||||
{navItems.map(({ link }, i) => {
|
||||
return <CMSLink className={classes.menuItem} key={i} {...link} />
|
||||
})}
|
||||
</div>
|
||||
</Gutter>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@use '../../css/queries.scss' as *;
|
||||
|
||||
.header {
|
||||
padding: var(--base) 0;
|
||||
z-index: var(--header-z-index);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav {
|
||||
a {
|
||||
text-decoration: none;
|
||||
margin-left: var(--base);
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileMenuToggler {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
|
||||
&[aria-expanded="true"] {
|
||||
transform: rotate(-25deg);
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
54
examples/form-builder/next-app/components/Header/index.tsx
Normal file
54
examples/form-builder/next-app/components/Header/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
import { ModalToggler } from '@faceless-ui/modal'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import { useGlobals } from '../../providers/Globals'
|
||||
import { Gutter } from '../Gutter'
|
||||
import { CMSLink } from '../Link'
|
||||
import { Logo } from '../Logo'
|
||||
import { MenuIcon } from '../icons/Menu'
|
||||
import { MobileMenuModal, slug as menuModalSlug } from './MobileMenuModal'
|
||||
import classes from './index.module.scss'
|
||||
|
||||
type HeaderBarProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const HeaderBar: React.FC<HeaderBarProps> = ({ children }) => {
|
||||
return (
|
||||
<header className={classes.header}>
|
||||
<Gutter className={classes.wrap}>
|
||||
<Link href="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
|
||||
{children}
|
||||
|
||||
<ModalToggler className={classes.mobileMenuToggler} slug={menuModalSlug}>
|
||||
<MenuIcon />
|
||||
</ModalToggler>
|
||||
</Gutter>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
const {
|
||||
mainMenu: { navItems },
|
||||
} = useGlobals()
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<HeaderBar>
|
||||
<nav className={classes.nav}>
|
||||
{navItems.map(({ link }, i) => {
|
||||
return <CMSLink key={i} {...link} />
|
||||
})}
|
||||
</nav>
|
||||
</HeaderBar>
|
||||
|
||||
<MobileMenuModal navItems={navItems} />
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@use '../../css/common.scss' as *;
|
||||
|
||||
.mobileMenuModal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
opacity: 1;
|
||||
display: none;
|
||||
|
||||
@include mid-break {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mobileMenuItems {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
@extend %h4;
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import type { Page } from '../../../payload/payload-types'
|
||||
import type { Props as ButtonProps } from '../Button'
|
||||
import type { Page } from '../../payload-types'
|
||||
|
||||
import { Button } from '../Button'
|
||||
|
||||
type CMSLinkType = {
|
||||
appearance?: ButtonProps['appearance']
|
||||
appearance?: 'default' | 'primary' | 'secondary'
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
invert?: ButtonProps['invert']
|
||||
label?: string
|
||||
newTab?: boolean
|
||||
reference?: {
|
||||
@@ -26,7 +24,6 @@ export const CMSLink: React.FC<CMSLinkType> = ({
|
||||
appearance,
|
||||
children,
|
||||
className,
|
||||
invert,
|
||||
label,
|
||||
newTab,
|
||||
reference,
|
||||
@@ -34,19 +31,24 @@ export const CMSLink: React.FC<CMSLinkType> = ({
|
||||
}) => {
|
||||
const href =
|
||||
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
|
||||
? `${reference?.relationTo !== 'pages' ? `/${reference?.relationTo}` : ''}/${
|
||||
reference.value.slug
|
||||
}`
|
||||
? `/${reference.value.slug}`
|
||||
: url
|
||||
|
||||
if (!href) return null
|
||||
|
||||
if (!appearance) {
|
||||
const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {}
|
||||
|
||||
if (href || url) {
|
||||
if (type === 'custom') {
|
||||
return (
|
||||
<Link {...newTabProps} className={className} href={href || url}>
|
||||
<Link href={url} {...newTabProps} className={className}>
|
||||
{label && label}
|
||||
{children && children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} {...newTabProps} className={className}>
|
||||
{label && label}
|
||||
{children && children}
|
||||
</Link>
|
||||
@@ -54,14 +56,12 @@ export const CMSLink: React.FC<CMSLinkType> = ({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
appearance={appearance}
|
||||
className={className}
|
||||
href={href}
|
||||
invert={invert}
|
||||
label={label}
|
||||
newTab={newTab}
|
||||
/>
|
||||
)
|
||||
const buttonProps = {
|
||||
appearance,
|
||||
href,
|
||||
label,
|
||||
newTab,
|
||||
}
|
||||
|
||||
return <Button className={className} {...buttonProps} el="link" />
|
||||
}
|
||||
48
examples/form-builder/next-app/components/Logo/index.tsx
Normal file
48
examples/form-builder/next-app/components/Logo/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
|
||||
export const Logo: React.FC = () => {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="29"
|
||||
viewBox="0 0 123 29"
|
||||
width="123"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M34.7441 22.9997H37.2741V16.3297H41.5981C44.7031 16.3297 46.9801 14.9037 46.9801 11.4537C46.9801 8.00369 44.7031 6.55469 41.5981 6.55469H34.7441V22.9997ZM37.2741 14.1447V8.73969H41.4831C43.3921 8.73969 44.3581 9.59069 44.3581 11.4537C44.3581 13.2937 43.3921 14.1447 41.4831 14.1447H37.2741Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M51.3652 23.3217C53.2742 23.3217 54.6082 22.5627 55.3672 21.3437H55.4132C55.5512 22.6777 56.1492 23.1147 57.2762 23.1147C57.6442 23.1147 58.0352 23.0687 58.4262 22.9767V21.5967C58.2882 21.6197 58.2192 21.6197 58.1502 21.6197C57.7132 21.6197 57.5982 21.1827 57.5982 20.3317V14.9497C57.5982 11.9137 55.6662 10.9017 53.2512 10.9017C49.6632 10.9017 48.1912 12.6727 48.0762 14.9267H50.3762C50.4912 13.3627 51.1122 12.7187 53.1592 12.7187C54.8842 12.7187 55.3902 13.4317 55.3902 14.2827C55.3902 15.4327 54.2632 15.6627 52.4232 16.0077C49.5022 16.5597 47.5242 17.3417 47.5242 19.9637C47.5242 21.9647 49.0192 23.3217 51.3652 23.3217ZM49.8702 19.8027C49.8702 18.5837 50.7442 18.0087 52.8142 17.5947C54.0102 17.3417 55.0222 17.0887 55.3902 16.7437V18.4227C55.3902 20.4697 53.8952 21.5047 51.8712 21.5047C50.4682 21.5047 49.8702 20.9067 49.8702 19.8027Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M61.4996 27.1167C63.3166 27.1167 64.4436 26.1737 65.5706 23.2757L70.2166 11.2697H67.8476L64.6276 20.2397H64.5816L61.1546 11.2697H58.6936L63.4316 22.8847C62.9716 24.7247 61.9136 25.1847 61.0166 25.1847C60.6486 25.1847 60.4416 25.1617 60.0506 25.1157V26.9557C60.6486 27.0707 60.9936 27.1167 61.4996 27.1167Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path d="M71.5939 22.9997H73.8479V6.55469H71.5939V22.9997Z" fill="black" />
|
||||
<path
|
||||
d="M81.6221 23.3447C85.2791 23.3447 87.4871 20.7917 87.4871 17.1117C87.4871 13.4547 85.2791 10.9017 81.6451 10.9017C77.9651 10.9017 75.7571 13.4777 75.7571 17.1347C75.7571 20.8147 77.9651 23.3447 81.6221 23.3447ZM78.1031 17.1347C78.1031 14.6737 79.2071 12.7877 81.6451 12.7877C84.0371 12.7877 85.1411 14.6737 85.1411 17.1347C85.1411 19.5727 84.0371 21.4817 81.6451 21.4817C79.2071 21.4817 78.1031 19.5727 78.1031 17.1347Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M92.6484 23.3217C94.5574 23.3217 95.8914 22.5627 96.6504 21.3437H96.6964C96.8344 22.6777 97.4324 23.1147 98.5594 23.1147C98.9274 23.1147 99.3184 23.0687 99.7094 22.9767V21.5967C99.5714 21.6197 99.5024 21.6197 99.4334 21.6197C98.9964 21.6197 98.8814 21.1827 98.8814 20.3317V14.9497C98.8814 11.9137 96.9494 10.9017 94.5344 10.9017C90.9464 10.9017 89.4744 12.6727 89.3594 14.9267H91.6594C91.7744 13.3627 92.3954 12.7187 94.4424 12.7187C96.1674 12.7187 96.6734 13.4317 96.6734 14.2827C96.6734 15.4327 95.5464 15.6627 93.7064 16.0077C90.7854 16.5597 88.8074 17.3417 88.8074 19.9637C88.8074 21.9647 90.3024 23.3217 92.6484 23.3217ZM91.1534 19.8027C91.1534 18.5837 92.0274 18.0087 94.0974 17.5947C95.2934 17.3417 96.3054 17.0887 96.6734 16.7437V18.4227C96.6734 20.4697 95.1784 21.5047 93.1544 21.5047C91.7514 21.5047 91.1534 20.9067 91.1534 19.8027Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M106.181 23.3217C108.021 23.3217 109.148 22.4477 109.792 21.6197H109.838V22.9997H112.092V6.55469H109.838V12.6957H109.792C109.148 11.7757 108.021 10.9247 106.181 10.9247C103.191 10.9247 100.914 13.2707 100.914 17.1347C100.914 20.9987 103.191 23.3217 106.181 23.3217ZM103.26 17.1347C103.26 14.8347 104.341 12.8107 106.549 12.8107C108.573 12.8107 109.815 14.4667 109.815 17.1347C109.815 19.7797 108.573 21.4587 106.549 21.4587C104.341 21.4587 103.26 19.4347 103.26 17.1347Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M12.2464 2.33838L22.2871 8.83812V21.1752L14.7265 25.8854V13.5484L4.67383 7.05725L12.2464 2.33838Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path d="M11.477 25.2017V15.5747L3.90039 20.2936L11.477 25.2017Z" fill="black" />
|
||||
<path
|
||||
d="M120.442 6.30273C119.086 6.30273 117.998 7.29978 117.998 8.75952C117.998 10.2062 119.086 11.1968 120.442 11.1968C121.791 11.1968 122.879 10.2062 122.879 8.75952C122.879 7.29978 121.791 6.30273 120.442 6.30273ZM120.442 10.7601C119.34 10.7601 118.48 9.95207 118.48 8.75952C118.48 7.54742 119.34 6.73935 120.442 6.73935C121.563 6.73935 122.397 7.54742 122.397 8.75952C122.397 9.95207 121.563 10.7601 120.442 10.7601ZM120.52 8.97457L121.048 9.9651H121.641L121.041 8.86378C121.367 8.72042 121.511 8.45975 121.511 8.17302C121.511 7.49528 121.054 7.36495 120.285 7.36495H119.49V9.9651H120.025V8.97457H120.52ZM120.37 7.78853C120.729 7.78853 120.976 7.86673 120.976 8.17953C120.976 8.43368 120.807 8.56402 120.403 8.56402H120.025V7.78853H120.37Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.container {
|
||||
max-width: 480px;
|
||||
}
|
||||
26
examples/form-builder/next-app/components/RichText/index.tsx
Normal file
26
examples/form-builder/next-app/components/RichText/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
|
||||
import styles from './index.module.scss'
|
||||
import { serializeLexical } from './serialize'
|
||||
|
||||
const RichText: React.FC<{ className?: string; content: any; enableGutter?: boolean }> = ({
|
||||
className,
|
||||
content,
|
||||
enableGutter = true,
|
||||
}) => {
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={[className].filter(Boolean).join(' ')}>
|
||||
{content &&
|
||||
!Array.isArray(content) &&
|
||||
typeof content === 'object' &&
|
||||
'root' in content &&
|
||||
serializeLexical({ nodes: content?.root?.children })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RichText
|
||||
@@ -0,0 +1,128 @@
|
||||
/* eslint-disable regexp/no-obscure-range */
|
||||
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
|
||||
// @ts-nocheck
|
||||
//This copy-and-pasted from lexical here here: https://github.com/facebook/lexical/blob/c2ceee223f46543d12c574e62155e619f9a18a5d/packages/lexical/src/LexicalConstants.ts
|
||||
|
||||
import type { ElementFormatType, TextFormatType } from 'lexical'
|
||||
import type { TextDetailType, TextModeType } from 'lexical/nodes/LexicalTextNode'
|
||||
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
// DOM
|
||||
export const DOM_ELEMENT_TYPE = 1
|
||||
export const DOM_TEXT_TYPE = 3
|
||||
|
||||
// Reconciling
|
||||
export const NO_DIRTY_NODES = 0
|
||||
export const HAS_DIRTY_NODES = 1
|
||||
export const FULL_RECONCILE = 2
|
||||
|
||||
// Text node modes
|
||||
export const IS_NORMAL = 0
|
||||
export const IS_TOKEN = 1
|
||||
export const IS_SEGMENTED = 2
|
||||
// IS_INERT = 3
|
||||
|
||||
// Text node formatting
|
||||
export const IS_BOLD = 1
|
||||
export const IS_ITALIC = 1 << 1
|
||||
export const IS_STRIKETHROUGH = 1 << 2
|
||||
export const IS_UNDERLINE = 1 << 3
|
||||
export const IS_CODE = 1 << 4
|
||||
export const IS_SUBSCRIPT = 1 << 5
|
||||
export const IS_SUPERSCRIPT = 1 << 6
|
||||
export const IS_HIGHLIGHT = 1 << 7
|
||||
|
||||
export const IS_ALL_FORMATTING =
|
||||
IS_BOLD |
|
||||
IS_ITALIC |
|
||||
IS_STRIKETHROUGH |
|
||||
IS_UNDERLINE |
|
||||
IS_CODE |
|
||||
IS_SUBSCRIPT |
|
||||
IS_SUPERSCRIPT |
|
||||
IS_HIGHLIGHT
|
||||
|
||||
// Text node details
|
||||
export const IS_DIRECTIONLESS = 1
|
||||
export const IS_UNMERGEABLE = 1 << 1
|
||||
|
||||
// Element node formatting
|
||||
export const IS_ALIGN_LEFT = 1
|
||||
export const IS_ALIGN_CENTER = 2
|
||||
export const IS_ALIGN_RIGHT = 3
|
||||
export const IS_ALIGN_JUSTIFY = 4
|
||||
export const IS_ALIGN_START = 5
|
||||
export const IS_ALIGN_END = 6
|
||||
|
||||
// Reconciliation
|
||||
export const NON_BREAKING_SPACE = '\u00A0'
|
||||
const ZERO_WIDTH_SPACE = '\u200b'
|
||||
|
||||
export const DOUBLE_LINE_BREAK = '\n\n'
|
||||
|
||||
// For FF, we need to use a non-breaking space, or it gets composition
|
||||
// in a stuck state.
|
||||
|
||||
const RTL = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC'
|
||||
const LTR =
|
||||
'A-Za-z\u00C0-\u00D6\u00D8-\u00F6' +
|
||||
'\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C' +
|
||||
'\uFE00-\uFE6F\uFEFD-\uFFFF'
|
||||
|
||||
// eslint-disable-next-line
|
||||
export const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']')
|
||||
// eslint-disable-next-line
|
||||
export const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']')
|
||||
|
||||
export const TEXT_TYPE_TO_FORMAT: Record<TextFormatType | string, number> = {
|
||||
bold: IS_BOLD,
|
||||
code: IS_CODE,
|
||||
highlight: IS_HIGHLIGHT,
|
||||
italic: IS_ITALIC,
|
||||
strikethrough: IS_STRIKETHROUGH,
|
||||
subscript: IS_SUBSCRIPT,
|
||||
superscript: IS_SUPERSCRIPT,
|
||||
underline: IS_UNDERLINE,
|
||||
}
|
||||
|
||||
export const DETAIL_TYPE_TO_DETAIL: Record<TextDetailType | string, number> = {
|
||||
directionless: IS_DIRECTIONLESS,
|
||||
unmergeable: IS_UNMERGEABLE,
|
||||
}
|
||||
|
||||
export const ELEMENT_TYPE_TO_FORMAT: Record<Exclude<ElementFormatType, ''>, number> = {
|
||||
center: IS_ALIGN_CENTER,
|
||||
end: IS_ALIGN_END,
|
||||
justify: IS_ALIGN_JUSTIFY,
|
||||
left: IS_ALIGN_LEFT,
|
||||
right: IS_ALIGN_RIGHT,
|
||||
start: IS_ALIGN_START,
|
||||
}
|
||||
|
||||
export const ELEMENT_FORMAT_TO_TYPE: Record<number, ElementFormatType> = {
|
||||
[IS_ALIGN_CENTER]: 'center',
|
||||
[IS_ALIGN_END]: 'end',
|
||||
[IS_ALIGN_JUSTIFY]: 'justify',
|
||||
[IS_ALIGN_LEFT]: 'left',
|
||||
[IS_ALIGN_RIGHT]: 'right',
|
||||
[IS_ALIGN_START]: 'start',
|
||||
}
|
||||
|
||||
export const TEXT_MODE_TO_TYPE: Record<TextModeType, 0 | 1 | 2> = {
|
||||
normal: IS_NORMAL,
|
||||
segmented: IS_SEGMENTED,
|
||||
token: IS_TOKEN,
|
||||
}
|
||||
|
||||
export const TEXT_TYPE_TO_MODE: Record<number, TextModeType> = {
|
||||
[IS_NORMAL]: 'normal',
|
||||
[IS_SEGMENTED]: 'segmented',
|
||||
[IS_TOKEN]: 'token',
|
||||
}
|
||||
200
examples/form-builder/next-app/components/RichText/serialize.tsx
Normal file
200
examples/form-builder/next-app/components/RichText/serialize.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { SerializedListItemNode, SerializedListNode } from '@lexical/list'
|
||||
import type { SerializedHeadingNode } from '@lexical/rich-text'
|
||||
import type { LinkFields, SerializedLinkNode } from '@payloadcms/richtext-lexical'
|
||||
import type { SerializedElementNode, SerializedLexicalNode, SerializedTextNode } from 'lexical'
|
||||
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
import { CMSLink } from '../Link'
|
||||
import {
|
||||
IS_BOLD,
|
||||
IS_CODE,
|
||||
IS_ITALIC,
|
||||
IS_STRIKETHROUGH,
|
||||
IS_SUBSCRIPT,
|
||||
IS_SUPERSCRIPT,
|
||||
IS_UNDERLINE,
|
||||
} from './nodeFormat'
|
||||
|
||||
interface Props {
|
||||
nodes: SerializedLexicalNode[]
|
||||
}
|
||||
|
||||
export function serializeLexical({ nodes }: Props): JSX.Element {
|
||||
return (
|
||||
<Fragment>
|
||||
{nodes?.map((_node, index): JSX.Element | null => {
|
||||
if (_node.type === 'text') {
|
||||
const node = _node as SerializedTextNode
|
||||
let text = <React.Fragment key={index}>{node.text}</React.Fragment>
|
||||
if (node.format & IS_BOLD) {
|
||||
text = <strong key={index}>{text}</strong>
|
||||
}
|
||||
if (node.format & IS_ITALIC) {
|
||||
text = <em key={index}>{text}</em>
|
||||
}
|
||||
if (node.format & IS_STRIKETHROUGH) {
|
||||
text = (
|
||||
<span key={index} style={{ textDecoration: 'line-through' }}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (node.format & IS_UNDERLINE) {
|
||||
text = (
|
||||
<span key={index} style={{ textDecoration: 'underline' }}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (node.format & IS_CODE) {
|
||||
text = <code key={index}>{node.text}</code>
|
||||
}
|
||||
if (node.format & IS_SUBSCRIPT) {
|
||||
text = <sub key={index}>{text}</sub>
|
||||
}
|
||||
if (node.format & IS_SUPERSCRIPT) {
|
||||
text = <sup key={index}>{text}</sup>
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
if (_node == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// NOTE: Hacky fix for
|
||||
// https://github.com/facebook/lexical/blob/d10c4e6e55261b2fdd7d1845aed46151d0f06a8c/packages/lexical-list/src/LexicalListItemNode.ts#L133
|
||||
// which does not return checked: false (only true - i.e. there is no prop for false)
|
||||
const serializedChildrenFn = (node: SerializedElementNode): JSX.Element | null => {
|
||||
if (node.children == null) {
|
||||
return null
|
||||
} else {
|
||||
if (node?.type === 'list' && (node as SerializedListNode)?.listType === 'check') {
|
||||
for (const item of node.children) {
|
||||
if ('checked' in item) {
|
||||
if (!item?.checked) {
|
||||
item.checked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return serializeLexical({ nodes: node.children })
|
||||
} else {
|
||||
return serializeLexical({ nodes: node.children })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serializedChildren =
|
||||
'children' in _node ? serializedChildrenFn(_node as SerializedElementNode) : ''
|
||||
|
||||
switch (_node.type) {
|
||||
case 'linebreak': {
|
||||
return <br key={index} />
|
||||
}
|
||||
case 'paragraph': {
|
||||
return <p key={index}>{serializedChildren}</p>
|
||||
}
|
||||
case 'heading': {
|
||||
const node = _node as SerializedHeadingNode
|
||||
|
||||
type Heading = Extract<keyof JSX.IntrinsicElements, 'h1' | 'h2' | 'h3' | 'h4' | 'h5'>
|
||||
const Tag = node?.tag as Heading
|
||||
return <Tag key={index}>{serializedChildren}</Tag>
|
||||
}
|
||||
case 'list': {
|
||||
const node = _node as SerializedListNode
|
||||
|
||||
type List = Extract<keyof JSX.IntrinsicElements, 'ol' | 'ul'>
|
||||
const Tag = node?.tag as List
|
||||
return (
|
||||
<Tag className="list" key={index}>
|
||||
{serializedChildren}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
case 'listitem': {
|
||||
const node = _node as SerializedListItemNode
|
||||
|
||||
if (node?.checked != null) {
|
||||
return (
|
||||
<li
|
||||
aria-checked={node.checked ? 'true' : 'false'}
|
||||
className={` ${node.checked ? '' : ''}`}
|
||||
key={index}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
|
||||
role="checkbox"
|
||||
tabIndex={-1}
|
||||
value={node?.value}
|
||||
>
|
||||
{serializedChildren}
|
||||
</li>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<li key={index} value={node?.value}>
|
||||
{serializedChildren}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
}
|
||||
case 'quote': {
|
||||
return <blockquote key={index}>{serializedChildren}</blockquote>
|
||||
}
|
||||
case 'link': {
|
||||
const node = _node as SerializedLinkNode
|
||||
|
||||
const fields: LinkFields = node.fields
|
||||
|
||||
return (
|
||||
<CMSLink
|
||||
key={index}
|
||||
newTab={Boolean(fields?.newTab)}
|
||||
reference={fields.doc as any}
|
||||
type={fields.linkType === 'internal' ? 'reference' : 'custom'}
|
||||
url={fields.url}
|
||||
>
|
||||
{serializedChildren}
|
||||
</CMSLink>
|
||||
)
|
||||
}
|
||||
|
||||
/* case 'block': {
|
||||
// todo: fix types
|
||||
|
||||
//@ts-expect-error
|
||||
const block = _node.fields
|
||||
|
||||
//@ts-expect-error
|
||||
const blockType = _node.fields?.blockType
|
||||
|
||||
if (!block || !blockType) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (blockType) {
|
||||
case 'content':
|
||||
return <ContentBlock {...block} />
|
||||
case 'cta':
|
||||
return <CallToActionBlock {...block} />
|
||||
case 'archive':
|
||||
return <ArchiveBlock {...block} />
|
||||
case 'mediaBlock':
|
||||
return <MediaBlock {...block} />
|
||||
case 'banner':
|
||||
return <BannerBlock {...block} />
|
||||
case 'code':
|
||||
return <CodeBlock {...block} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
} */
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
padding-top: calc(var(--block-padding) / 2);
|
||||
}
|
||||
|
||||
.top-small {
|
||||
padding-top: calc(var(--block-padding) / 3);
|
||||
}
|
||||
|
||||
.bottom-large {
|
||||
padding-bottom: var(--block-padding);
|
||||
}
|
||||
@@ -13,3 +17,7 @@
|
||||
.bottom-medium {
|
||||
padding-bottom: calc(var(--block-padding) / 2);
|
||||
}
|
||||
|
||||
.bottom-small {
|
||||
padding-bottom: calc(var(--block-padding) / 3);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export type VerticalPaddingOptions = 'large' | 'medium' | 'none'
|
||||
export type VerticalPaddingOptions = 'large' | 'medium' | 'none' | 'small'
|
||||
|
||||
type Props = {
|
||||
bottom?: VerticalPaddingOptions
|
||||
@@ -0,0 +1,16 @@
|
||||
.checkBox {
|
||||
height: var(--base);
|
||||
width: var(--base);
|
||||
|
||||
.stroke {
|
||||
stroke-width: 1px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
&:local() {
|
||||
.stroke {
|
||||
stroke-width: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export const Check: React.FC = () => {
|
||||
return (
|
||||
<svg className={classes.checkBox} height="100%" viewBox="0 0 25 25" width="100%">
|
||||
<path
|
||||
className={classes.stroke}
|
||||
d="M10.6092 16.0192L17.6477 8.98076"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="bevel"
|
||||
/>
|
||||
<path
|
||||
className={classes.stroke}
|
||||
d="M7.35229 12.7623L10.6092 16.0192"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="bevel"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
@use './queries.scss' as *;
|
||||
@use './colors.scss' as *;
|
||||
@use './type.scss' as *;
|
||||
@import './theme.scss';
|
||||
|
||||
:root {
|
||||
--breakpoint-xs-width : #{$breakpoint-xs-width};
|
||||
--breakpoint-s-width : #{$breakpoint-s-width};
|
||||
--breakpoint-m-width : #{$breakpoint-m-width};
|
||||
--breakpoint-l-width : #{$breakpoint-l-width};
|
||||
--scrollbar-width: 17px;
|
||||
|
||||
--base: 24px;
|
||||
--font-body: system-ui;
|
||||
--font-mono: 'Roboto Mono', monospace;
|
||||
@@ -11,6 +16,9 @@
|
||||
--gutter-h: 180px;
|
||||
--block-padding: 120px;
|
||||
|
||||
--header-z-index: 100;
|
||||
--modal-z-index: 90;
|
||||
|
||||
@include large-break {
|
||||
--gutter-h: 144px;
|
||||
--block-padding: 96px;
|
||||
@@ -22,20 +30,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// GLOBAL STYLES
|
||||
/////////////////////////////
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
@extend %body;
|
||||
background: var(--theme-bg);
|
||||
background: var(--color-white);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
opacity: 0;
|
||||
|
||||
&[data-theme='dark'],
|
||||
&[data-theme='light'] {
|
||||
opacity: initial;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -46,18 +52,18 @@ body,
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-black);
|
||||
margin: 0;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--theme-success-500);
|
||||
color: var(--color-base-800);
|
||||
background: var(--color-green);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--theme-success-500);
|
||||
color: var(--color-base-800);
|
||||
background: var(--color-green);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
img {
|
||||
@@ -94,7 +100,7 @@ p {
|
||||
margin: var(--base) 0;
|
||||
|
||||
@include mid-break {
|
||||
margin: calc(var(--base) * 0.75) 0;
|
||||
margin: calc(var(--base) * .75) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,12 +114,12 @@ a {
|
||||
color: currentColor;
|
||||
|
||||
&:focus {
|
||||
opacity: 0.8;
|
||||
opacity: .8;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
opacity: .7;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
10
examples/form-builder/next-app/css/colors.scss
Normal file
10
examples/form-builder/next-app/css/colors.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
:root {
|
||||
--color-red: rgb(255,0,0);
|
||||
--color-green: rgb(178, 255, 214);
|
||||
--color-white: rgb(255, 255, 255);
|
||||
--color-dark-gray: rgb(51,52,52);
|
||||
--color-mid-gray: rgb(196,196,196);
|
||||
--color-gray: rgb(212,212,212);
|
||||
--color-light-gray: rgb(244,244,244);
|
||||
--color-black: rgb(0, 0, 0);
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
@forward './queries.scss';
|
||||
@forward './type.scss';
|
||||
@forward './type.scss';
|
||||
@@ -1,10 +1,12 @@
|
||||
// Keep these in sync with the breakpoints exported in '../cssVariables.js'
|
||||
|
||||
$breakpoint-xs-width: 400px;
|
||||
$breakpoint-s-width: 768px;
|
||||
$breakpoint-m-width: 1024px;
|
||||
$breakpoint-l-width: 1440px;
|
||||
|
||||
////////////////////////////
|
||||
// MEDIA QUERIES
|
||||
/////////////////////////////
|
||||
|
||||
@mixin extra-small-break {
|
||||
@media (max-width: #{$breakpoint-xs-width}) {
|
||||
@content;
|
||||
@@ -27,4 +29,4 @@ $breakpoint-l-width: 1440px;
|
||||
@media (max-width: #{$breakpoint-l-width}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
}
|
||||
168
examples/form-builder/next-app/css/type.scss
Normal file
168
examples/form-builder/next-app/css/type.scss
Normal file
@@ -0,0 +1,168 @@
|
||||
@use 'queries' as *;
|
||||
|
||||
/////////////////////////////
|
||||
// HEADINGS
|
||||
/////////////////////////////
|
||||
|
||||
%h1,
|
||||
%h2,
|
||||
%h3,
|
||||
%h4,
|
||||
%h5,
|
||||
%h6 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
%h1 {
|
||||
margin: 50px 0;
|
||||
font-size: 84px;
|
||||
line-height: 1;
|
||||
|
||||
@include mid-break {
|
||||
font-size: 70px;
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
margin: 24px 0;
|
||||
font-size: 36px;
|
||||
line-height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
%h2 {
|
||||
margin: 32px 0;
|
||||
font-size: 56px;
|
||||
line-height: 1;
|
||||
|
||||
@include mid-break {
|
||||
margin: 36px 0;
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
margin: 24px 0;
|
||||
font-size: 28px;
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
%h3 {
|
||||
margin: 28px 0;
|
||||
font-size: 48px;
|
||||
line-height: 56px;
|
||||
|
||||
@include mid-break {
|
||||
font-size: 40px;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
margin: 24px 0;
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
%h4 {
|
||||
margin: 24px 0;
|
||||
font-size: 40px;
|
||||
line-height: 48px;
|
||||
|
||||
@include mid-break {
|
||||
font-size: 33px;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
margin: 20px 0;
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
%h5 {
|
||||
margin: 20px 0;
|
||||
font-size: 32px;
|
||||
line-height: 42px;
|
||||
|
||||
@include mid-break {
|
||||
font-size: 26px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
margin: 16px 0;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
%h6 {
|
||||
margin: 20px 0;
|
||||
font-size: 24px;
|
||||
line-height: 28px;
|
||||
|
||||
@include mid-break {
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
margin: 16px 0;
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// TYPE STYLES
|
||||
/////////////////////////////
|
||||
|
||||
%body {
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
|
||||
@include mid-break {
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
%large-body {
|
||||
font-size: 25px;
|
||||
line-height: 32px;
|
||||
|
||||
@include mid-break {
|
||||
font-size: 22px;
|
||||
line-height: 30px;
|
||||
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
font-size: 17px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
%label {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
|
||||
@include mid-break {
|
||||
font-size: 13px;
|
||||
letter-spacing: 2.75px;
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
letter-spacing: 2.625px;
|
||||
}
|
||||
}
|
||||
10
examples/form-builder/next-app/next.config.js
Normal file
10
examples/form-builder/next-app/next.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
domains: ['localhost', process.env.NEXT_PUBLIC_PAYLOAD_URL || ''].filter(Boolean),
|
||||
},
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user