feat: virtual fields example (#1990)
This commit is contained in:
committed by
GitHub
parent
cfb3632cbc
commit
2af0c04c8a
3
examples/virtual-fields/.env.example
Normal file
3
examples/virtual-fields/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
MONGODB_URI=mongodb://localhost/payload-example-virtual-fields
|
||||
PAYLOAD_SECRET=ENTER-STRING-HERE
|
||||
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
5
examples/virtual-fields/.gitignore
vendored
Normal file
5
examples/virtual-fields/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
build
|
||||
dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
.env
|
||||
1
examples/virtual-fields/.npmrc
Normal file
1
examples/virtual-fields/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
62
examples/virtual-fields/README.md
Normal file
62
examples/virtual-fields/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Virtual Fields Example for Payload CMS
|
||||
|
||||
This example demonstrates multiple use-cases for virtual fields.
|
||||
|
||||
## Getting Started
|
||||
|
||||
First copy this example with [Degit](https://www.npmjs.com/package/degit) by running the following command at your terminal:
|
||||
|
||||
```bash
|
||||
npx degit github:payloadcms/payload/examples/virtual-fields
|
||||
```
|
||||
|
||||
1. `cd` into this directory and run `yarn` or `npm install`
|
||||
2. `cp .env.example .env` to copy the example environment variables
|
||||
3. `yarn dev` or `npm run dev` to start the server and seed the database
|
||||
4. `open http://localhost:3000/admin` to access the admin panel
|
||||
5. Login with email `dev@payloadcms.com` and password `test`
|
||||
|
||||
|
||||
## How It Works
|
||||
|
||||
The term *virtual field* is used to describe any field that is not stored in the database and has its value populated within an `afterRead` hook.
|
||||
|
||||
In this example you have three collections: Locations, Events and Staff.
|
||||
|
||||
#### Locations Collection
|
||||
|
||||
In the locations collection, you have separate text fields to input a city, state and country.
|
||||
|
||||
Everything else here are virtual fields:
|
||||
|
||||
`location`: Text field providing a formatted location name by concatenating `city + state + country` which is then used as the document title
|
||||
|
||||
`events`: Relationship field containing all events associated with the location.
|
||||
|
||||
`nextEvent`: Relationship field that returns the event with the closest date at this location.
|
||||
|
||||
`staff`: Relationship field containing all staff associated with the location.
|
||||
|
||||
#### Events Collection
|
||||
|
||||
This collection takes an event name and date. You will select the event location from the options you have created in the location collection.
|
||||
|
||||
Next we have the Ticket fields, you can input the ticket price, sales tax and additional fees - then our virtual field will calculate the total price for you:
|
||||
|
||||
`totalPrice`: Number field that is automatically populated by the sum of `price * tax + fees`
|
||||
|
||||
#### Staff Collection
|
||||
|
||||
The staff collection contains text fields for a title, first and last name.
|
||||
|
||||
Similarly to Events, you will assign a location to the staff member from the options you created previously.
|
||||
|
||||
This collection uses the following virtual field to format the staff name fields:
|
||||
|
||||
`fullTitle`: Text field providing a formatted name by concatenating `title + firstName + lastName` which is then used as the document title
|
||||
|
||||
In the code, navigate to `src/collections` to see how these fields are functioning and read more about `afterRead` hooks [here](https://payloadcms.com/docs/hooks/fields).
|
||||
|
||||
## Questions
|
||||
|
||||
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/r6sCXqVk3v) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).
|
||||
4
examples/virtual-fields/nodemon.json
Normal file
4
examples/virtual-fields/nodemon.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ext": "ts",
|
||||
"exec": "ts-node src/server.ts"
|
||||
}
|
||||
29
examples/virtual-fields/package.json
Normal file
29
examples/virtual-fields/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "virtual-fields",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/server.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "cross-env PAYLOAD_PUBLIC_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
|
||||
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
|
||||
"build:server": "tsc",
|
||||
"build": "yarn copyfiles && yarn build:payload && yarn build:server",
|
||||
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
|
||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
|
||||
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
|
||||
"generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"payload": "^1.6.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.9",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"nodemon": "^2.0.6",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
}
|
||||
91
examples/virtual-fields/src/collections/Events.ts
Normal file
91
examples/virtual-fields/src/collections/Events.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import payload from 'payload';
|
||||
import { CollectionConfig, FieldHook } from 'payload/types';
|
||||
|
||||
const getTotalPrice: FieldHook = async ({ data }) => {
|
||||
const { price, salesTaxPercentage, fees } = data.tickets;
|
||||
const totalPrice = Math.round(price * (1 + (salesTaxPercentage / 100))) + fees;
|
||||
|
||||
return totalPrice;
|
||||
};
|
||||
|
||||
const Events: CollectionConfig = {
|
||||
slug: 'events',
|
||||
admin: {
|
||||
defaultColumns: ['title', 'date', 'location'],
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'date',
|
||||
type: 'date',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'location',
|
||||
type: 'relationship',
|
||||
relationTo: 'locations',
|
||||
maxDepth: 0,
|
||||
hasMany: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tickets',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'price',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'USD',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'salesTaxPercentage',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: '%',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'fees',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'USD',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'totalPrice',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'USD',
|
||||
readOnly: true,
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [getTotalPrice],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default Events;
|
||||
138
examples/virtual-fields/src/collections/Location.ts
Normal file
138
examples/virtual-fields/src/collections/Location.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import payload from 'payload';
|
||||
import { CollectionConfig, FieldHook } from 'payload/types';
|
||||
|
||||
const formatLocation: FieldHook = async ({ data }) => (
|
||||
`${data.city}${data.state ? `, ${data.state},` : ','} ${data.country}`
|
||||
);
|
||||
|
||||
const getLocationStaff: FieldHook = async ({ data }) => {
|
||||
const staff = await payload.find({
|
||||
collection: 'staff',
|
||||
where: {
|
||||
location: {
|
||||
equals: data.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (staff.docs) {
|
||||
return staff.docs.map((doc) => doc.id);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getNextEvent: FieldHook = async ({ data }) => {
|
||||
const eventsByDate = await payload.find({
|
||||
collection: 'events',
|
||||
sort: 'date',
|
||||
where: {
|
||||
location: {
|
||||
equals: data.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (eventsByDate?.docs) {
|
||||
return eventsByDate.docs[0]?.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getAllEvents: FieldHook = async ({ data }) => {
|
||||
const allEvents = await payload.find({
|
||||
collection: 'events',
|
||||
where: {
|
||||
location: {
|
||||
equals: data.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (allEvents.docs) return allEvents.docs.map((doc) => doc.id);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const Locations: CollectionConfig = {
|
||||
slug: 'locations',
|
||||
admin: {
|
||||
defaultColumns: ['location', 'nextEvent'],
|
||||
useAsTitle: 'location',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'location',
|
||||
label: false,
|
||||
type: 'text',
|
||||
hooks: {
|
||||
afterRead: [
|
||||
formatLocation,
|
||||
],
|
||||
},
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'events',
|
||||
maxDepth: 0,
|
||||
type: 'relationship',
|
||||
relationTo: 'events',
|
||||
hasMany: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [getAllEvents],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'staff',
|
||||
type: 'relationship',
|
||||
relationTo: 'staff',
|
||||
hasMany: true,
|
||||
maxDepth: 0,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [getLocationStaff],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'nextEvent',
|
||||
type: 'relationship',
|
||||
relationTo: 'events',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [getNextEvent],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default Locations;
|
||||
52
examples/virtual-fields/src/collections/Staff.ts
Normal file
52
examples/virtual-fields/src/collections/Staff.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { CollectionConfig, FieldHook } from 'payload/types';
|
||||
|
||||
const populateFullTitle: FieldHook = async ({ data }) => (
|
||||
`${data.title} ${data.firstName} ${data.lastName}`
|
||||
);
|
||||
|
||||
const Staff: CollectionConfig = {
|
||||
slug: 'staff',
|
||||
admin: {
|
||||
defaultColumns: ['fullTitle', 'location'],
|
||||
useAsTitle: 'fullTitle',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'fullTitle',
|
||||
type: 'text',
|
||||
hooks: {
|
||||
afterRead: [
|
||||
populateFullTitle,
|
||||
],
|
||||
},
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'firstName',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'location',
|
||||
type: 'relationship',
|
||||
relationTo: 'locations',
|
||||
maxDepth: 0,
|
||||
hasMany: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default Staff;
|
||||
18
examples/virtual-fields/src/collections/Users.ts
Normal file
18
examples/virtual-fields/src/collections/Users.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
// Email added by default
|
||||
// Add more fields as needed
|
||||
],
|
||||
};
|
||||
|
||||
export default Users;
|
||||
24
examples/virtual-fields/src/components/BeforeLogin/index.tsx
Normal file
24
examples/virtual-fields/src/components/BeforeLogin/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
const BeforeLogin: React.FC = () => {
|
||||
if (process.env.PAYLOAD_PUBLIC_SEED === 'true') {
|
||||
return (
|
||||
<div>
|
||||
<h3>Virtual Fields Demo</h3>
|
||||
<p>
|
||||
Log in with the email
|
||||
{' '}
|
||||
<strong>dev@payloadcms.com</strong>
|
||||
{' '}
|
||||
and the password
|
||||
{' '}
|
||||
<strong>test</strong>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default BeforeLogin;
|
||||
29
examples/virtual-fields/src/payload.config.ts
Normal file
29
examples/virtual-fields/src/payload.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { buildConfig } from 'payload/config';
|
||||
import path from 'path';
|
||||
import Events from './collections/Events';
|
||||
import Locations from './collections/Location';
|
||||
import Staff from './collections/Staff';
|
||||
import Users from './collections/Users';
|
||||
import BeforeLogin from './components/BeforeLogin';
|
||||
|
||||
export default buildConfig({
|
||||
serverURL: 'http://localhost:3000',
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
components: {
|
||||
beforeLogin: [BeforeLogin],
|
||||
},
|
||||
},
|
||||
collections: [
|
||||
Events,
|
||||
Locations,
|
||||
Staff,
|
||||
Users,
|
||||
],
|
||||
typescript: {
|
||||
outputFile: path.resolve(__dirname, 'payload-types.ts'),
|
||||
},
|
||||
graphQL: {
|
||||
schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql'),
|
||||
},
|
||||
});
|
||||
45
examples/virtual-fields/src/seed/events.ts
Normal file
45
examples/virtual-fields/src/seed/events.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const eventsOne = [
|
||||
{
|
||||
title: 'Event 1',
|
||||
date: '2023-02-01T00:00:00.000Z',
|
||||
pricing: {
|
||||
price: 10,
|
||||
fees: 5,
|
||||
},
|
||||
tickets: {
|
||||
price: 100,
|
||||
salesTaxPercentage: 10,
|
||||
fees: 3.50,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Event 2',
|
||||
date: '2023-03-01T00:00:00.000Z',
|
||||
tickets: {
|
||||
price: 20,
|
||||
salesTaxPercentage: 20,
|
||||
fees: 5,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const eventsTwo = [
|
||||
{
|
||||
title: 'Event 3',
|
||||
date: '2023-03-31T23:00:00.000Z',
|
||||
tickets: {
|
||||
price: 10,
|
||||
salesTaxPercentage: 10,
|
||||
fees: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Event 4',
|
||||
date: '2023-04-30T23:00:00.000Z',
|
||||
tickets: {
|
||||
price: 50,
|
||||
salesTaxPercentage: 10,
|
||||
fees: 5,
|
||||
},
|
||||
},
|
||||
];
|
||||
68
examples/virtual-fields/src/seed/index.ts
Normal file
68
examples/virtual-fields/src/seed/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import payload from 'payload';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { eventsOne, eventsTwo } from './events';
|
||||
import { locationOne, locationTwo } from './locations';
|
||||
import { staffOne, staffTwo } from './staff';
|
||||
|
||||
export async function seedData() {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'dev@payloadcms.com',
|
||||
password: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
const { id: locationOneID } = await payload.create({
|
||||
collection: 'locations',
|
||||
data: locationOne,
|
||||
});
|
||||
|
||||
const { id: locationTwoID } = await payload.create({
|
||||
collection: 'locations',
|
||||
data: locationTwo,
|
||||
});
|
||||
|
||||
|
||||
await payload.create({
|
||||
collection: 'staff',
|
||||
data: {
|
||||
...staffOne,
|
||||
location: [locationOneID],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
await payload.create({
|
||||
collection: 'staff',
|
||||
data: {
|
||||
...staffTwo,
|
||||
location: [locationTwoID],
|
||||
},
|
||||
});
|
||||
|
||||
eventsOne.map((event) => {
|
||||
payload.create({
|
||||
collection: 'events',
|
||||
data: {
|
||||
...event,
|
||||
location: locationOneID,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
eventsTwo.map((event) => {
|
||||
payload.create({
|
||||
collection: 'events',
|
||||
data: {
|
||||
...event,
|
||||
location: locationTwoID,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
12
examples/virtual-fields/src/seed/locations.ts
Normal file
12
examples/virtual-fields/src/seed/locations.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const locationOne = {
|
||||
city: 'Grand Rapids',
|
||||
country: 'USA',
|
||||
state: 'MI',
|
||||
events: [],
|
||||
};
|
||||
|
||||
export const locationTwo = {
|
||||
city: 'London',
|
||||
country: 'UK',
|
||||
events: [],
|
||||
};
|
||||
11
examples/virtual-fields/src/seed/staff.ts
Normal file
11
examples/virtual-fields/src/seed/staff.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const staffOne = {
|
||||
title: 'Mr',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
};
|
||||
|
||||
export const staffTwo = {
|
||||
title: 'Miss',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
};
|
||||
37
examples/virtual-fields/src/server.ts
Normal file
37
examples/virtual-fields/src/server.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import express from 'express';
|
||||
import payload from 'payload';
|
||||
import path from 'path';
|
||||
import { seedData } from './seed';
|
||||
|
||||
require('dotenv').config({
|
||||
path: path.resolve(__dirname, '../.env'),
|
||||
});
|
||||
|
||||
const app = express();
|
||||
|
||||
// Redirect all traffic at root to admin UI
|
||||
app.get('/', (_, res) => {
|
||||
res.redirect('/admin');
|
||||
});
|
||||
|
||||
const start = async () => {
|
||||
// Initialize Payload
|
||||
await payload.init({
|
||||
secret: process.env.PAYLOAD_SECRET,
|
||||
mongoURL: process.env.MONGODB_URI,
|
||||
express: app,
|
||||
onInit: async () => {
|
||||
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env.PAYLOAD_PUBLIC_SEED === 'true') {
|
||||
payload.logger.info('---- SEEDING DATABASE ----');
|
||||
await seedData();
|
||||
payload.logger.info('---- SEED COMPLETE ----');
|
||||
}
|
||||
|
||||
app.listen(3000);
|
||||
};
|
||||
|
||||
start();
|
||||
28
examples/virtual-fields/tsconfig.json
Normal file
28
examples/virtual-fields/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
],
|
||||
"ts-node": {
|
||||
"transpileOnly": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user