feat: virtual fields example (#1990)

This commit is contained in:
Jessica Chowdhury
2023-02-16 15:26:25 +00:00
committed by GitHub
parent cfb3632cbc
commit 2af0c04c8a
18 changed files with 657 additions and 0 deletions

View 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
View File

@@ -0,0 +1,5 @@
build
dist
node_modules
package-lock.json
.env

View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View 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).

View File

@@ -0,0 +1,4 @@
{
"ext": "ts",
"exec": "ts-node src/server.ts"
}

View 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"
}
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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'),
},
});

View 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,
},
},
];

View 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;
});
}

View File

@@ -0,0 +1,12 @@
export const locationOne = {
city: 'Grand Rapids',
country: 'USA',
state: 'MI',
events: [],
};
export const locationTwo = {
city: 'London',
country: 'UK',
events: [],
};

View File

@@ -0,0 +1,11 @@
export const staffOne = {
title: 'Mr',
firstName: 'John',
lastName: 'Doe',
};
export const staffTwo = {
title: 'Miss',
firstName: 'Jane',
lastName: 'Doe',
};

View 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();

View 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
}
}