Merge branch 'feat/db-adapters' of github.com:payloadcms/payload into feat/db-adapters

This commit is contained in:
James
2023-08-25 15:59:30 -04:00
287 changed files with 5636 additions and 3278 deletions

View File

@@ -206,7 +206,7 @@ describe('access control', () => {
const duplicateAction = page.locator('.collection-edit__collection-actions >> li').last();
await expect(duplicateAction).toContainText('Duplicate');
await page.locator('#field-approvedForRemoval + button').click();
await page.locator('#field-approvedForRemoval').check();
await page.locator('#action-save').click();
const deleteAction = page.locator('.collection-edit__collection-actions >> li').last();

View File

@@ -186,6 +186,15 @@ export default buildConfigWithDefaults({
},
],
},
{
slug: 'geo',
fields: [
{
name: 'point',
type: 'point',
},
],
},
],
globals: [
{
@@ -259,5 +268,19 @@ export default buildConfigWithDefaults({
},
});
});
await payload.create({
collection: 'geo',
data: {
point: [7, -7],
},
});
await payload.create({
collection: 'geo',
data: {
point: [5, -5],
},
});
},
});

View File

@@ -1,5 +1,6 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import qs from 'qs';
import payload from '../../src';
import { AdminUrlUtil } from '../helpers/adminUrlUtil';
import { initPayloadE2E } from '../helpers/configHelpers';
@@ -15,12 +16,13 @@ const title = 'title';
const description = 'description';
let url: AdminUrlUtil;
let serverURL: string;
describe('admin', () => {
let page: Page;
beforeAll(async ({ browser }) => {
const { serverURL } = await initPayloadE2E(__dirname);
serverURL = (await initPayloadE2E(__dirname)).serverURL;
await clearDocs(); // Clear any seeded data from onInit
url = new AdminUrlUtil(serverURL, slug);
@@ -198,7 +200,7 @@ describe('admin', () => {
await page.goto(url.list);
await page.locator('.select-all__input').click();
await page.locator('input#select-all').check();
await page.locator('.delete-documents__toggle').click();
@@ -216,7 +218,7 @@ describe('admin', () => {
const bulkTitle = 'Bulk update title';
await page.goto(url.list);
await page.locator('.select-all__input').click();
await page.locator('input#select-all').check();
await page.locator('.edit-many__toggle').click();
await page.locator('.field-select .rs__control').click();
const options = page.locator('.rs__option');
@@ -394,6 +396,73 @@ describe('admin', () => {
await page.locator('.condition__actions-remove').click();
await expect(page.locator(tableRowLocator)).toHaveCount(2);
});
test('should accept where query from valid URL where parameter', async () => {
await createPost({ title: 'post1' });
await createPost({ title: 'post2' });
await page.goto(`${url.list}?limit=10&page=1&where[or][0][and][0][title][equals]=post1`);
await expect(page.locator('.react-select--single-value').first()).toContainText('Title en');
await expect(page.locator(tableRowLocator)).toHaveCount(1);
});
test('should accept transformed where query from invalid URL where parameter', async () => {
await createPost({ title: 'post1' });
await createPost({ title: 'post2' });
// [title][equals]=post1 should be getting transformed into a valid where[or][0][and][0][title][equals]=post1
await page.goto(`${url.list}?limit=10&page=1&where[title][equals]=post1`);
await expect(page.locator('.react-select--single-value').first()).toContainText('Title en');
await expect(page.locator(tableRowLocator)).toHaveCount(1);
});
test('should accept where query from complex, valid URL where parameter using the near operator', async () => {
// We have one point collection with the point [5,-5] and one with [7,-7]. This where query should kick out the [5,-5] point
await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}?limit=10&page=1&where[or][0][and][0][point][near]=6,-7,200000`);
await expect(page.getByPlaceholder('Enter a value')).toHaveValue('6,-7,200000');
await expect(page.locator(tableRowLocator)).toHaveCount(1);
});
test('should accept transformed where query from complex, invalid URL where parameter using the near operator', async () => {
// We have one point collection with the point [5,-5] and one with [7,-7]. This where query should kick out the [5,-5] point
await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}?limit=10&page=1&where[point][near]=6,-7,200000`);
await expect(page.getByPlaceholder('Enter a value')).toHaveValue('6,-7,200000');
await expect(page.locator(tableRowLocator)).toHaveCount(1);
});
test('should accept where query from complex, valid URL where parameter using the within operator', async () => {
type Point = [number, number];
const polygon: Point[] = [
[3.5, -3.5], // bottom-left
[3.5, -6.5], // top-left
[6.5, -6.5], // top-right
[6.5, -3.5], // bottom-right
[3.5, -3.5], // back to starting point to close the polygon
];
const whereQueryJSON = {
point: {
within: {
type: 'Polygon',
coordinates: [polygon],
},
},
};
const whereQuery = qs.stringify({
...({ where: whereQueryJSON }),
}, {
addQueryPrefix: false,
});
// We have one point collection with the point [5,-5] and one with [7,-7]. This where query should kick out the [7,-7] point, as it's not within the polygon
await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}?limit=10&page=1&${whereQuery}`);
await expect(page.getByPlaceholder('Enter a value')).toHaveValue('[object Object]');
await expect(page.locator(tableRowLocator)).toHaveCount(1);
});
});
describe('table columns', () => {
@@ -523,18 +592,18 @@ describe('admin', () => {
});
test('should select multiple rows', async () => {
const selectAll = page.locator('.select-all');
await page.locator('.row-1 .select-row button').click();
const selectAll = page.locator('.custom-checkbox:has(#select-all)');
await page.locator('.row-1 .cell-_select input').check();
const indeterminateSelectAll = selectAll.locator('.icon--line');
const indeterminateSelectAll = selectAll.locator('.custom-checkbox__icon.partial');
expect(indeterminateSelectAll).toBeDefined();
await selectAll.locator('button').click();
const emptySelectAll = selectAll.locator('.icon');
await selectAll.locator('input').click();
const emptySelectAll = selectAll.locator('.custom-checkbox__icon:not(.check):not(.partial)');
await expect(emptySelectAll).toHaveCount(0);
await selectAll.locator('button').click();
const checkSelectAll = selectAll.locator('.icon .icon--check');
await selectAll.locator('input').click();
const checkSelectAll = selectAll.locator('.custom-checkbox__icon.check');
expect(checkSelectAll).toBeDefined();
});
@@ -542,16 +611,16 @@ describe('admin', () => {
// delete should not appear without selection
await expect(page.locator('#confirm-delete')).toHaveCount(0);
// select one row
await page.locator('.row-1 .select-row button').click();
await page.locator('.row-1 .cell-_select input').check();
// delete button should be present
await expect(page.locator('#confirm-delete')).toHaveCount(1);
await page.locator('.row-2 .select-row button').click();
await page.locator('.row-2 .cell-_select input').check();
await page.locator('.delete-documents__toggle').click();
await page.locator('#confirm-delete').click();
await expect(page.locator('.select-row')).toHaveCount(1);
await expect(await page.locator('.cell-_select')).toHaveCount(1);
});
});

View File

@@ -1,4 +1,3 @@
import mongoose from 'mongoose';
import jwtDecode from 'jwt-decode';
import { GraphQLClient } from 'graphql-request';
import payload from '../../src';

View File

@@ -6,7 +6,7 @@ import { postgresAdapter } from '../packages/db-postgres/src';
const databaseAdapters = {
mongoose: mongooseAdapter({
url: 'mongodb://127.0.0.1/payload',
url: process.env.MONGO_URL || 'mongodb://127.0.0.1/payload',
}),
postgres: postgresAdapter({
client: {
@@ -15,6 +15,9 @@ const databaseAdapters = {
}),
};
// TODO: temporary
process.env.PAYLOAD_DATABASE = 'postgres';
export function buildConfigWithDefaults(testConfig?: Partial<Config>): Promise<SanitizedConfig> {
const [name] = process.argv.slice(2);

View File

@@ -30,6 +30,9 @@ const collectionWithName = (collectionSlug: string): CollectionConfig => {
export const slug = 'posts';
export const relationSlug = 'relation';
export const pointSlug = 'point';
export default buildConfigWithDefaults({
graphQL: {
schemaOutputFile: path.resolve(__dirname, 'schema.graphql'),
@@ -41,6 +44,16 @@ export default buildConfigWithDefaults({
access: openAccess,
fields: [],
},
{
slug: pointSlug,
access: openAccess,
fields: [
{
type: 'point',
name: 'point',
},
],
},
{
slug,
access: openAccess,
@@ -414,5 +427,12 @@ export default buildConfigWithDefaults({
relation: payloadAPITest1.id,
},
});
await payload.create({
collection: pointSlug,
data: {
point: [10, 20],
},
});
},
});

View File

@@ -1,8 +1,9 @@
import { GraphQLClient } from 'graphql-request';
import { initPayloadTest } from '../helpers/configHelpers';
import configPromise, { slug } from './config';
import configPromise, { pointSlug, slug } from './config';
import payload from '../../src';
import type { Post } from './payload-types';
import { mapAsync } from '../../src/utilities/mapAsync';
const title = 'title';
@@ -383,6 +384,228 @@ describe('collections-graphql', () => {
expect(docs).toContainEqual(expect.objectContaining({ id: specialPost.id }));
});
describe('near', () => {
const point = [10, 20];
const [lat, lng] = point;
it('should return a document near a point', async () => {
const nearQuery = `
query {
Points(
where: {
point: {
near: [${lat + 0.01}, ${lng + 0.01}, 10000]
}
}
) {
docs {
id
point
}
}
}`;
const response = await client.request(nearQuery);
const { docs } = response.Points;
expect(docs).toHaveLength(1);
});
it('should not return a point far away', async () => {
const nearQuery = `
query {
Points(
where: {
point: {
near: [${lng + 1}, ${lat - 1}, 5000]
}
}
) {
docs {
id
point
}
}
}`;
const response = await client.request(nearQuery);
const { docs } = response.Points;
expect(docs).toHaveLength(0);
});
it('should sort find results by nearest distance', async () => {
// creating twice as many records as we are querying to get a random sample
await mapAsync([...Array(10)], async () => {
// setTimeout used to randomize the creation timestamp
setTimeout(async () => {
await payload.create({
collection: pointSlug,
data: {
// only randomize longitude to make distance comparison easy
point: [Math.random(), 0],
},
});
}, Math.random());
});
const nearQuery = `
query {
Points(
where: {
point: {
near: [0, 0, 100000, 0]
}
},
limit: 5
) {
docs {
id
point
}
}
}`;
const response = await client.request(nearQuery);
const { docs } = response.Points;
let previous = 0;
docs.forEach(({ point: coordinates }) => {
// The next document point should always be greater than the one before
expect(previous).toBeLessThanOrEqual(coordinates[0]);
[previous] = coordinates;
});
});
});
describe('within', () => {
type Point = [number, number];
const polygon: Point[] = [
[9.0, 19.0], // bottom-left
[9.0, 21.0], // top-left
[11.0, 21.0], // top-right
[11.0, 19.0], // bottom-right
[9.0, 19.0], // back to starting point to close the polygon
];
it('should return a document with the point inside the polygon', async () => {
const query = `
query {
Points(
where: {
point: {
within: {
type: "Polygon",
coordinates: ${JSON.stringify([polygon])}
}
}
}) {
docs {
id
point
}
}
}`;
const response = await client.request(query);
const { docs } = response.Points;
expect(docs).toHaveLength(1);
expect(docs[0].point).toEqual([10, 20]);
});
it('should not return a document with the point outside the polygon', async () => {
const reducedPolygon = polygon.map((vertex) => vertex.map((coord) => coord * 0.1));
const query = `
query {
Points(
where: {
point: {
within: {
type: "Polygon",
coordinates: ${JSON.stringify([reducedPolygon])}
}
}
}) {
docs {
id
point
}
}
}`;
const response = await client.request(query);
const { docs } = response.Points;
expect(docs).toHaveLength(0);
});
});
describe('intersects', () => {
type Point = [number, number];
const polygon: Point[] = [
[9.0, 19.0], // bottom-left
[9.0, 21.0], // top-left
[11.0, 21.0], // top-right
[11.0, 19.0], // bottom-right
[9.0, 19.0], // back to starting point to close the polygon
];
it('should return a document with the point intersecting the polygon', async () => {
const query = `
query {
Points(
where: {
point: {
intersects: {
type: "Polygon",
coordinates: ${JSON.stringify([polygon])}
}
}
}) {
docs {
id
point
}
}
}`;
const response = await client.request(query);
const { docs } = response.Points;
expect(docs).toHaveLength(1);
expect(docs[0].point).toEqual([10, 20]);
});
it('should not return a document with the point not intersecting a smaller polygon', async () => {
const reducedPolygon = polygon.map((vertex) => vertex.map((coord) => coord * 0.1));
const query = `
query {
Points(
where: {
point: {
within: {
type: "Polygon",
coordinates: ${JSON.stringify([reducedPolygon])}
}
}
}) {
docs {
id
point
}
}
}`;
const response = await client.request(query);
const { docs } = response.Points;
expect(docs).toHaveLength(0);
});
});
it('can query deeply nested fields within rows, tabs, collapsibles', async () => {
const withNestedField = await createPost({ D1: { D2: { D3: { D4: 'nested message' } } } });
const query = `{

View File

@@ -862,6 +862,98 @@ describe('collections-rest', () => {
});
});
describe('within', () => {
type Point = [number, number];
const polygon: Point[] = [
[9.0, 19.0], // bottom-left
[9.0, 21.0], // top-left
[11.0, 21.0], // top-right
[11.0, 19.0], // bottom-right
[9.0, 19.0], // back to starting point to close the polygon
];
it('should return a document with the point inside the polygon', async () => {
// There should be 1 total points document populated by default with the point [10, 20]
const { status, result } = await client.find({
slug: pointSlug,
query: {
point: {
within: {
type: 'Polygon',
coordinates: [polygon],
},
},
},
});
expect(status).toEqual(200);
expect(result.docs).toHaveLength(1);
});
it('should not return a document with the point outside a smaller polygon', async () => {
const { status, result } = await client.find({
slug: pointSlug,
query: {
point: {
within: {
type: 'Polygon',
coordinates: [polygon.map((vertex) => vertex.map((coord) => coord * 0.1))], // Reduce polygon to 10% of its size
},
},
},
});
expect(status).toEqual(200);
expect(result.docs).toHaveLength(0);
});
});
describe('intersects', () => {
type Point = [number, number];
const polygon: Point[] = [
[9.0, 19.0], // bottom-left
[9.0, 21.0], // top-left
[11.0, 21.0], // top-right
[11.0, 19.0], // bottom-right
[9.0, 19.0], // back to starting point to close the polygon
];
it('should return a document with the point intersecting the polygon', async () => {
// There should be 1 total points document populated by default with the point [10, 20]
const { status, result } = await client.find({
slug: pointSlug,
query: {
point: {
intersects: {
type: 'Polygon',
coordinates: [polygon],
},
},
},
});
expect(status).toEqual(200);
expect(result.docs).toHaveLength(1);
});
it('should not return a document with the point not intersecting a smaller polygon', async () => {
const { status, result } = await client.find({
slug: pointSlug,
query: {
point: {
intersects: {
type: 'Polygon',
coordinates: [polygon.map((vertex) => vertex.map((coord) => coord * 0.1))], // Reduce polygon to 10% of its size
},
},
},
});
expect(status).toEqual(200);
expect(result.docs).toHaveLength(0);
});
});
it('or', async () => {
const post1 = await createPost({ title: 'post1' });
const post2 = await createPost({ title: 'post2' });

View File

@@ -57,6 +57,26 @@ const TextFields: CollectionConfig = {
type: 'text',
maxLength: 50000,
},
{
name: 'fieldWithDefaultValue',
type: 'text',
defaultValue: async () => {
const defaultValue = new Promise((resolve) => setTimeout(() => resolve('some-value'), 1000));
return defaultValue;
},
},
{
name: 'dependentOnFieldWithDefaultValue',
type: 'text',
hooks: {
beforeChange: [
({ data }) => {
return data?.fieldWithDefaultValue || '';
},
],
},
},
],
};

View File

@@ -48,6 +48,15 @@ describe('Fields', () => {
expect(doc.defaultFunction).toEqual(defaultText);
expect(doc.defaultAsync).toEqual(defaultText);
});
it('should populate default values in beforeValidate hook', async () => {
const { fieldWithDefaultValue, dependentOnFieldWithDefaultValue } = await payload.create({
collection: 'text-fields',
data: { text },
});
await expect(fieldWithDefaultValue).toEqual(dependentOnFieldWithDefaultValue);
});
});
describe('timestamps', () => {

View File

@@ -0,0 +1,71 @@
import { AfterOperationHook, CollectionConfig } from '../../../../src/collections/config/types';
import { AfterOperation } from '../../payload-types';
export const afterOperationSlug = 'afterOperation';
const AfterOperation: CollectionConfig = {
slug: afterOperationSlug,
hooks: {
// beforeRead: [(operation) => operation.doc],
afterOperation: [
async ({ result, operation }) => {
if (operation === 'create') {
if ('docs' in result) {
return {
...result,
docs: result.docs?.map((doc) => ({
...doc,
title: 'Title created',
})),
};
}
return { ...result, title: 'Title created' };
}
if (operation === 'find') {
// only modify the first doc for `find` operations
// this is so we can test against the other operations
return {
...result,
docs: result.docs?.map((doc, index) => (index === 0 ? {
...doc,
title: 'Title read',
} : doc)),
};
}
if (operation === 'findByID') {
return { ...result, title: 'Title read' };
}
if (operation === 'update') {
if ('docs' in result) {
return {
...result,
docs: result.docs?.map((doc) => ({
...doc,
title: 'Title updated',
})),
};
}
}
if (operation === 'updateByID') {
return { ...result, title: 'Title updated' };
}
return result;
},
] as AfterOperationHook<AfterOperation>[],
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
};
export default AfterOperation;

View File

@@ -4,11 +4,13 @@ import Hooks, { hooksSlug } from './collections/Hook';
import NestedAfterReadHooks from './collections/NestedAfterReadHooks';
import ChainingHooks from './collections/ChainingHooks';
import Relations from './collections/Relations';
import AfterOperation from './collections/AfterOperation';
import Users, { seedHooksUsers } from './collections/Users';
import ContextHooks from './collections/ContextHooks';
export default buildConfigWithDefaults({
collections: [
AfterOperation,
ContextHooks,
TransformHooks,
Hooks,

View File

@@ -1,5 +1,5 @@
import { initPayloadTest } from '../helpers/configHelpers';
import config from './config';
import configPromise from './config';
import payload from '../../src';
import { RESTClient } from '../helpers/rest';
import { transformSlug } from './collections/Transform';
@@ -7,10 +7,10 @@ import { hooksSlug } from './collections/Hook';
import { chainingHooksSlug } from './collections/ChainingHooks';
import { generatedAfterReadText, nestedAfterReadHooksSlug } from './collections/NestedAfterReadHooks';
import { relationsSlug } from './collections/Relations';
import type { NestedAfterReadHook } from './payload-types';
import { hooksUsersSlug } from './collections/Users';
import { devUser, regularUser } from '../credentials';
import { AuthenticationError } from '../../src/errors';
import { afterOperationSlug } from './collections/AfterOperation';
import { contextHooksSlug } from './collections/ContextHooks';
let client: RESTClient;
@@ -19,6 +19,7 @@ let apiUrl;
describe('Hooks', () => {
beforeAll(async () => {
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
const config = await configPromise;
client = new RESTClient(config, { serverURL, defaultSlug: transformSlug });
apiUrl = `${serverURL}/api`;
});
@@ -73,7 +74,7 @@ describe('Hooks', () => {
});
it('should save data generated with afterRead hooks in nested field structures', async () => {
const document = await payload.create<NestedAfterReadHook>({
const document = await payload.create({
collection: nestedAfterReadHooksSlug,
data: {
text: 'ok',
@@ -154,6 +155,62 @@ describe('Hooks', () => {
expect(retrievedDocs[0].text).toEqual('ok!!');
});
it('should execute collection afterOperation hook', async () => {
const [doc1, doc2] = await Promise.all([
await payload.create({
collection: afterOperationSlug,
data: {
title: 'Title',
},
}),
await payload.create({
collection: afterOperationSlug,
data: {
title: 'Title',
},
}),
]);
expect(doc1.title === 'Title created').toBeTruthy();
expect(doc2.title === 'Title created').toBeTruthy();
const findResult = await payload.find({
collection: afterOperationSlug,
});
expect(findResult.docs).toHaveLength(2);
expect(findResult.docs[0].title === 'Title read').toBeTruthy();
expect(findResult.docs[1].title === 'Title').toBeTruthy();
const [updatedDoc1, updatedDoc2] = await Promise.all([
await payload.update({
collection: afterOperationSlug,
id: doc1.id,
data: {
title: 'Title',
},
}),
await payload.update({
collection: afterOperationSlug,
id: doc2.id,
data: {
title: 'Title',
},
}),
]);
expect(updatedDoc1.title === 'Title updated').toBeTruthy();
expect(updatedDoc2.title === 'Title updated').toBeTruthy();
const findResult2 = await payload.find({
collection: afterOperationSlug,
});
expect(findResult2.docs).toHaveLength(2);
expect(findResult2.docs[0].title === 'Title read').toBeTruthy();
expect(findResult2.docs[1].title === 'Title').toBeTruthy();
});
it('should pass context from beforeChange to afterChange', async () => {
const document = await payload.create({
collection: contextHooksSlug,

View File

@@ -5,11 +5,24 @@
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "transforms".
*/
export interface Config {
collections: {
afterOperation: AfterOperation;
transforms: Transform;
hooks: Hook;
'nested-after-read-hooks': NestedAfterReadHook;
'chaining-hooks': ChainingHook;
relations: Relation;
'hooks-users': HooksUser;
};
globals: {};
}
export interface AfterOperation {
id: string;
title: string;
updatedAt: string;
createdAt: string;
}
export interface Transform {
id: string;
/**
@@ -22,13 +35,9 @@ export interface Transform {
* @maxItems 2
*/
localizedTransform?: [number, number];
createdAt: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hooks".
*/
export interface Hook {
id: string;
fieldBeforeValidate?: boolean;
@@ -40,53 +49,48 @@ export interface Hook {
collectionAfterChange?: boolean;
collectionBeforeRead?: boolean;
collectionAfterRead?: boolean;
createdAt: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "nested-after-read-hooks".
*/
export interface NestedAfterReadHook {
id: string;
text?: string;
group: {
array: {
group?: {
array?: {
input?: string;
afterRead?: string;
shouldPopulate?: string | Relation;
id?: string;
}[];
subGroup: {
subGroup?: {
afterRead?: string;
shouldPopulate?: string | Relation;
};
};
createdAt: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relations".
*/
export interface Relation {
id: string;
title: string;
createdAt: string;
updatedAt: string;
createdAt: string;
}
export interface ChainingHook {
id: string;
text?: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hooks-users".
*/
export interface HooksUser {
id: string;
roles: ('admin' | 'user')[];
updatedAt: string;
createdAt: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
password?: string;
}

372
test/localization-rtl/ar.js Normal file
View File

@@ -0,0 +1,372 @@
export const ar = {
authentication: {
account: 'الحساب',
accountOfCurrentUser: 'حساب المستخدم الحالي',
alreadyActivated: 'تمّ التّفعيل بالفعل',
alreadyLoggedIn: 'تمّ تسجيل الدّخول بالفعل',
apiKey: 'مفتاح API',
backToLogin: 'العودة لتسجيل الدخول',
beginCreateFirstUser: 'للبدء, قم بإنشاء المستخدم الأوّل.',
changePassword: 'تغيير كلمة المرور',
checkYourEmailForPasswordReset:
'تحقّق من بريدك الإلكتروني بحثًا عن رابط يسمح لك بإعادة تعيين كلمة المرور الخاصّة بك بشكل آمن.',
confirmGeneration: 'تأكيد التّوليد',
confirmPassword: 'تأكيد كلمة المرور',
createFirstUser: 'إنشاء المستخدم الأوّل',
emailNotValid: 'البريد الإلكتروني غير صالح',
emailSent: 'تمّ ارسال البريد الإلكتروني',
enableAPIKey: 'تفعيل مفتاح API',
failedToUnlock: 'فشل فتح القفل',
forceUnlock: 'إجبار فتح القفل',
forgotPassword: 'نسيت كلمة المرور',
forgotPasswordEmailInstructions:
'يرجى إدخال البريد الالكتروني أدناه. ستتلقّى رسالة بريد إلكتروني تحتوي على إرشادات حول كيفيّة إعادة تعيين كلمة المرور الخاصّة بك.',
forgotPasswordQuestion: 'هل نسيت كلمة المرور؟',
generate: 'توليد',
generateNewAPIKey: 'توليد مفتاح API جديد',
generatingNewAPIKeyWillInvalidate:
'سيؤدّي إنشاء مفتاح API جديد إلى <1> إبطال </ 1> المفتاح السّابق. هل أنت متأكّد أنّك تريد المتابعة؟',
lockUntil: 'قفل حتى',
logBackIn: 'تسجيل الدّخول من جديد',
logOut: 'تسجيل الخروج',
loggedIn:
'لتسجيل الدّخول مع مستخدم آخر ، يجب عليك <0> تسجيل الخروج </0> أوّلاً.',
loggedInChangePassword:
'لتغيير كلمة المرور الخاصّة بك ، انتقل إلى <0>حسابك</0> وقم بتعديل كلمة المرور هناك.',
loggedOutInactivity: 'لقد تمّ تسجيل الخروج بسبب عدم النّشاط.',
loggedOutSuccessfully: 'لقد تمّ تسجيل خروجك بنجاح.',
login: 'تسجيل الدخول',
loginAttempts: 'محاولات تسجيل الدخول',
loginUser: 'تسجيل دخول المستخدم',
loginWithAnotherUser:
'لتسجيل الدخول مع مستخدم آخر ، يجب عليك <0> تسجيل الخروج </0> أوّلاً.',
logout: 'تسجيل الخروج',
logoutUser: 'تسجيل خروج المستخدم',
newAPIKeyGenerated: 'تمّ توليد مفتاح API جديد.',
newAccountCreated:
'تمّ إنشاء حساب جديد لتتمكّن من الوصول إلى <a href="{{serverURL}}"> {{serverURL}} </a> الرّجاء النّقر فوق الرّابط التّالي أو لصق عنوان URL أدناه في متصفّحّك لتأكيد بريدك الإلكتروني : <a href="{{verificationURL}}"> {{verificationURL}} </a> <br> بعد التّحقّق من بريدك الإلكتروني ، ستتمكّن من تسجيل الدّخول بنجاح.',
newPassword: 'كلمة مرور جديدة',
resetPassword: 'إعادة تعيين كلمة المرور',
resetPasswordExpiration: 'انتهاء صلاحيّة إعادة تعيين كلمة المرور',
resetPasswordToken: 'رمز إعادة تعيين كلمة المرور',
resetYourPassword: 'إعادة تعيين كلمة المرور الخاصّة بك',
stayLoggedIn: 'ابق متّصلًا',
successfullyUnlocked: 'تمّ فتح القفل بنجاح',
unableToVerify: 'غير قادر على التحقق من',
verified: 'تمّ التحقّق',
verifiedSuccessfully: 'تمّ التحقّق بنجاح',
verify: 'قم بالتّحقّق',
verifyUser: 'قم بالتّحقّق من المستخدم',
verifyYourEmail: 'قم بتأكيد بريدك الألكتروني',
youAreInactive:
'لم تكن نشطًا منذ فترة قصيرة وسيتمّ تسجيل خروجك قريبًا تلقائيًا من أجل أمنك. هل ترغب في البقاء مسجّلا؟',
youAreReceivingResetPassword:
'أنت تتلقّى هذا البريد الالكتروني لأنّك (أو لأنّ شخص آخر) طلبت إعادة تعيين كلمة المرور لحسابك. الرّجاء النّقر فوق الرّابط التّالي ، أو لصق هذا الرّابط في متصفّحك لإكمال العمليّة:',
youDidNotRequestPassword:
'إن لم تطلب هذا ، يرجى تجاهل هذا البريد الإلكتروني وستبقى كلمة مرورك ذاتها بدون تغيير.',
},
error: {
accountAlreadyActivated: 'لقد تمّ تنشيط هذا الحساب بالفعل.',
autosaving: 'حدث خطأ أثناء الحفظ التّلقائي لهذا المستند.',
correctInvalidFields: 'الرّجاء تصحيح الحقول الغير صالحة.',
deletingFile: 'حدث خطأ أثناء حذف الملفّ.',
deletingTitle:
'حدث خطأ أثناء حذف {{title}}. يرجى التحقّق من اتّصالك والمحاولة مرة أخرى.',
emailOrPasswordIncorrect: 'البريد الإلكتروني أو كلمة المرور غير صحيح/ة.',
followingFieldsInvalid_many: 'الحقول التّالية غير صالحة:',
followingFieldsInvalid_one: 'الحقل التّالي غير صالح:',
incorrectCollection: 'المجموعة غير صحيحة',
invalidFileType: 'نوع الملفّ غير صالح',
invalidFileTypeValue: 'نوع الملفّ غير صالح: {{value}}',
loadingDocument: 'حدث خطأ أثناء تحميل المستند بمعرّف {{id}}.',
missingEmail: 'البريد الإلكتروني مفقود.',
missingIDOfDocument: 'معرّف المستند المراد تحديثه مفقود.',
missingIDOfVersion: 'معرّف النسخة مفقود.',
missingRequiredData: 'توجد بيانات مطلوبة مفقودة.',
noFilesUploaded: 'لم يتمّ رفع أيّة ملفّات.',
noMatchedField: 'لم يتمّ العثور على حقل مطابق لـ "{{label}}"',
noUser: 'لا يوجد مستخدم',
notAllowedToAccessPage: 'لا يسمح لك الوصول إلى هذه الصّفحة.',
notAllowedToPerformAction: 'لا يسمح لك القيام بهذه العمليّة.',
notFound: 'لم يتمّ العثور على المورد المطلوب.',
previewing: 'حدث خطأ في اثناء معاينة هذا المستند.',
problemUploadingFile: 'حدث خطأ اثناء رفع الملفّ.',
tokenInvalidOrExpired: 'الرّمز إمّا غير صالح أو منتهي الصّلاحيّة.',
unPublishingDocument: 'حدث خطأ أثناء إلغاء نشر هذا المستند.',
unableToDeleteCount: 'يتعذّر حذف {{count}} من {{total}} {{label}}.',
unableToUpdateCount: 'يتعذّر تحديث {{count}} من {{total}} {{label}}.',
unauthorized:
'غير مصرّح لك ، عليك أن تقوم بتسجيل الدّخول لتتمكّن من تقديم هذا الطّلب.',
unknown: 'حدث خطأ غير معروف.',
unspecific: 'حدث خطأ.',
userLocked:
'تمّ قفل هذا المستخدم نظرًا لوجود عدد كبير من محاولات تسجيل الدّخول الغير ناجحة.',
valueMustBeUnique: 'على القيمة أن تكون فريدة',
verificationTokenInvalid: 'رمز التحقّق غير صالح.',
},
fields: {
addLabel: 'أضف {{label}}',
addLink: 'أضف رابط',
addNew: 'أضف جديد',
addNewLabel: 'أضف {{label}} جديد',
addRelationship: 'أضف علاقة',
addUpload: 'أضف تحميل',
block: 'وحدة محتوى',
blockType: 'نوع وحدة المحتوى',
blocks: 'وحدات المحتوى',
chooseBetweenCustomTextOrDocument:
'اختر بين إدخال عنوان URL نصّي مخصّص أو الرّبط بمستند آخر.',
chooseDocumentToLink: 'اختر مستندًا للربط',
chooseFromExisting: 'اختر من القائمة',
chooseLabel: 'اختر {{label}}',
collapseAll: 'طيّ الكلّ',
customURL: 'URL مخصّص',
editLabelData: 'عدّل بيانات {{label}}',
editLink: 'عدّل الرّابط',
editRelationship: 'عدّل العلاقة',
enterURL: 'ادخل عنوان URL',
internalLink: 'رابط داخلي',
itemsAndMore: '{{items}} و {{count}} أخرى',
labelRelationship: '{{label}} علاقة',
latitude: 'خطّ العرض',
linkType: 'نوع الرّابط',
linkedTo: 'تمّ الرّبط ل <0>{{label}}</0>',
longitude: 'خطّ الطّول',
newLabel: '{{label}} جديد',
openInNewTab: 'الفتح في علامة تبويب جديدة',
passwordsDoNotMatch: 'كلمة المرور غير مطابقة.',
relatedDocument: 'مستند مربوط',
relationTo: 'ربط ل',
removeRelationship: 'حذف العلاقة',
removeUpload: 'حذف المحتوى المرفوع',
saveChanges: 'حفظ التّغييرات',
searchForBlock: 'ابحث عن وحدة محتوى',
selectExistingLabel: 'اختيار {{label}} من القائمة',
selectFieldsToEdit: 'حدّد الحقول اللتي تريد تعديلها',
showAll: 'إظهار الكلّ',
swapRelationship: 'تبديل العلاقة',
swapUpload: 'تبديل المحتوى المرفوع',
textToDisplay: 'النصّ الذي تريد إظهاره',
toggleBlock: 'Toggle block',
uploadNewLabel: 'رفع {{label}} جديد',
},
general: {
aboutToDelete: 'أنت على وشك حذف {{label}} <1>{{title}}</1>. هل أنت متأكّد؟',
aboutToDeleteCount_many: 'أنت على وشك حذف {{count}} {{label}}',
aboutToDeleteCount_one: 'أنت على وشك حذف {{count}} {{label}}',
aboutToDeleteCount_other: 'أنت على وشك حذف {{count}} {{label}}',
addBelow: 'أضف في الاسفل',
addFilter: 'أضف فلتر',
adminTheme: 'شكل واجهة المستخدم',
and: 'و',
ascending: 'تصاعدي',
automatic: 'تلقائي',
backToDashboard: 'العودة للوحة التّحكّم',
cancel: 'إلغاء',
changesNotSaved: 'لم يتمّ حفظ التّغييرات. إن غادرت الآن ، ستفقد تغييراتك.',
close: 'إغلاق',
collections: 'المجموعات',
columnToSort: 'التّرتيب حسب العامود',
columns: 'الأعمدة',
confirm: 'تأكيد',
confirmDeletion: 'تأكيد الحذف',
confirmDuplication: 'تأكيد التّكرار',
copied: 'تمّ النّسخ',
copy: 'نسخ',
create: 'إنشاء',
createNew: 'أنشاء جديد',
createNewLabel: 'إنشاء {{label}} جديد',
created: 'تمّ الإنشاء',
createdAt: 'تمّ الإنشاء في',
creating: 'يتمّ الإنشاء',
dark: 'غامق',
dashboard: 'لوحة التّحكّم',
delete: 'حذف',
deletedCountSuccessfully: 'تمّ حذف {{count}} {{label}} بنجاح.',
deletedSuccessfully: 'تمّ الحذف بنجاح.',
deleting: 'يتمّ الحذف...',
descending: 'تنازلي',
duplicate: 'تكرار',
duplicateWithoutSaving: 'تكرار بدون حفظ التّغييرات',
edit: 'تعديل',
editLabel: 'تعديل {{label}}',
editing: 'يتمّ التّعديل',
editingLabel_many: 'يتمّ تعديل {{count}} {{label}}',
editingLabel_one: 'يتمّ تعديل {{count}} {{label}}',
editingLabel_other: 'يتمّ تعديل {{count}} {{label}}',
email: 'البريد الالكتروني',
emailAddress: 'عنوان البريد الالكتروني',
enterAValue: 'أدخل قيمة',
fallbackToDefaultLocale: 'يتمّ استخدام اللّغة الافتراضيّة',
filter: 'فلتر',
filterWhere: 'فلتر {{label}} أينما',
filters: 'فلاتر',
globals: 'المجموعات العامّة',
language: 'اللّغة',
lastModified: 'آخر تعديل في',
leaveAnyway: 'المغادرة على أيّة حال',
leaveWithoutSaving: 'المغادرة بدون حفظ',
light: 'فاتح',
loading: 'يتمّ التّحميل',
locales: 'اللّغات',
moveDown: 'التّحريك إلى الأسفل',
moveUp: 'التّحريك إلى الأعلى',
newPassword: 'كلمة مرور جديدة',
noFiltersSet: 'لم يتمّ تحديد فلتر',
noLabel: '<لا يوجد {{label}}>',
noResults:
'لم يتمّ العثور على {{label}}. إمّا أنّه لا يوجد {{label}} حتّى الآن أو أنّه لا يتطابق أيّ منها مع الفلاتر التّي حدّدتها أعلاه.',
noValue: 'لا توجد قيمة',
none: 'None',
notFound: 'غير معثور عليه',
nothingFound: 'لم يتمّ العثور على شيء',
of: 'من',
or: 'أو',
order: 'التّرتيب',
pageNotFound: 'الصّفحة غير موجودة',
password: 'كلمة المرور',
payloadSettings: 'الإعدادات',
perPage: 'لكلّ صفحة: {{limit}}',
remove: 'إزالة',
row: 'سطر',
rows: 'أسطُر',
save: 'حفظ',
saving: 'يتمّ الحفظ...',
searchBy: 'البحث بواسطة {{label}}',
selectAll: 'اختر الكلّ {{count}} {{label}}',
selectValue: 'اختر قيمة',
selectedCount: '{{count}} {{label}} تمّ اختيارها',
sorryNotFound: 'عذرًا - ليس هناك ما يتوافق مع طلبك.',
sort: 'ترتيب',
stayOnThisPage: 'البقاء في هذه الصّفحة',
submissionSuccessful: 'تمّ التّقديم بنجاح.',
submit: 'تقديم',
successfullyCreated: 'تمّ إنشاء {{label}} بنجاح.',
successfullyDuplicated: 'تمّ التّكرار{{label}} بنجاح.',
thisLanguage: 'العربيّة',
titleDeleted: 'تمّ حذف {{label}} "{{title}}" بنجاح.',
unauthorized: 'غير مصرّح',
unsavedChangesDuplicate:
'لم تحفظ التّغييرات. هل ترغب في الاستمرار في التّكرار?',
untitled: 'غير مُعنوَن',
updatedAt: 'تمّ التحديث في',
updatedCountSuccessfully: 'تمّ تحديث {{count}} {{label}} بنجاح.',
updatedSuccessfully: 'تمّ التّحديث بنجاح.',
updating: 'يتمّ التّحديث',
uploading: 'يتمّ الرّفع',
user: 'مستخدم',
users: 'مستخدمين',
welcome: 'اهلاً وسهلاً بك',
},
operators: {
contains: 'يحتوي',
equals: 'يساوي',
exists: 'موجود',
isGreaterThan: 'أكبر من',
isGreaterThanOrEqualTo: 'أكبر أو يساوي',
isIn: 'موجود في',
isLessThan: 'أصغر من',
isLessThanOrEqualTo: 'أصغر أو يساوي',
isLike: 'هو مثل',
isNotEqualTo: 'لا يساوي',
isNotIn: 'غير موجود في',
near: 'قريب من',
},
upload: {
dragAndDrop: 'قم بسحب وإسقاط ملفّ',
dragAndDropHere: 'أو اسحب الملفّ وأفلته هنا',
fileName: 'اسم الملفّ',
fileSize: 'حجم الملفّ',
height: 'الطّول',
lessInfo: 'معلومات أقلّ',
moreInfo: 'معلومات أكثر',
selectCollectionToBrowse: 'حدّد مجموعة لاستعراضها',
selectFile: 'اختر ملفّ',
sizes: 'الاحجام',
width: 'العرض',
},
validation: {
emailAddress: 'يرجى إدخال عنوان بريد إلكتروني صالح.',
enterNumber: 'يرجى إدخال رقم صالح.',
fieldHasNo: 'هذا الحقل لا يحتوي على {{label}}',
greaterThanMax: '"{{value}}" هو أكبر من القيمة القصوى المسموحة {{max}}.',
invalidInput: 'هذا الحقل يحتوي على حقل غير صالح.',
invalidSelection: 'هذا الحقل يحتوي تحديد غير صالح.',
invalidSelections: 'هذا الحقل يحتوي التّحديدات الغير صالحة التّلية:',
lessThanMin: '"{{value}}" هو أصغر من القيمة الدنيا المسموحة {{min}}.',
longerThanMin:
'يجب أن تكون هذه القيمة أطول من الحدّ الأدنى للطول وهو {{minLength}} حرفًا.',
notValidDate: '"{{value}}" ليس تاريخًا صالحًا.',
required: 'هذه الخانة مطلوبه.',
requiresAtLeast: 'هذه الخانة تتطلب على الأقلّ {{count}} {{label}}.',
requiresNoMoreThan: 'هذه الخانة تتطلّب ما لا يزيد عن {{count}} {{label}}.',
requiresTwoNumbers: 'هذه الخانة تتطلّب رقمين.',
shorterThanMax:
'يجب أن تكون هذه القيمة أقصر من الحدّ الأقصى للطول وهو {{maxLength}} حرفًا.',
trueOrFalse: 'هذه الخانة يجب أن تكون صحيح او خطأ.',
validUploadID: 'هذه الخانة ليست معرّف تحميل صالح.',
},
version: {
aboutToPublishSelection:
'أنت على وشك نشر كلّ {{label}} في التّحديد. هل أنت متأكّد؟',
aboutToRestore:
'أنت على وشك استرجاع هذا المستند {{label}} إلى الحالة التّي كان عليها في {{versionDate}}.',
aboutToRestoreGlobal:
'أنت على وشك استرجاع الاعداد العامّ {{label}} إلى الحالة التي كان عليها في {{versionDate}}.',
aboutToRevertToPublished:
'أنت على وشك إعادة هذا المستند إلى حالته المنشورة. هل أنت متأكّد؟',
aboutToUnpublish: 'أنت على وشك إلغاء نشر هذا المستند. هل أنت متأكّد؟',
aboutToUnpublishSelection:
'أنت على وشك إلغاء نشر كلّ {{label}} في التّحديد. هل أنت متأكّد؟',
autosave: 'حفظ تلقائي',
autosavedSuccessfully: 'تمّ الحفظ التّلقائي بنجاح.',
autosavedVersion: 'النّسخة المحفوظة تلقائياً',
changed: 'تمّ التّغيير',
compareVersion: 'مقارنة النّسخة مع:',
confirmPublish: 'تأكيد النّشر',
confirmRevertToSaved: 'تأكيد الرّجوع للنسخة المنشورة',
confirmUnpublish: 'تأكيد إلغاء النّشر',
confirmVersionRestoration: 'تأكيد إستعادة النّسخة',
currentDocumentStatus: 'المستند {{docStatus}} الحالي',
draft: 'مسودّة',
draftSavedSuccessfully: 'تمّ حفظ المسودّة بنجاح.',
lastSavedAgo: 'آخر حفظ في {{distance, relativetime(minutes)}}',
noFurtherVersionsFound: 'لم يتمّ العثور على نسخات أخرى',
noRowsFound: 'لم يتمّ العثور على {{label}}',
preview: 'معاينة',
problemRestoringVersion: 'حدث خطأ في استعادة هذه النّسخة',
publish: 'نشر',
publishChanges: 'نشر التّغييرات',
published: 'تمّ النّشر',
restoreThisVersion: 'استعادة هذه النّسخة',
restoredSuccessfully: 'تمّت الاستعادة بنحاح.',
restoring: 'تتمّ الاستعادة...',
revertToPublished: 'الرّجوع للنسخة المنشورة',
reverting: 'يتمّ الاسترجاع...',
saveDraft: 'حفظ المسودّة',
selectLocales: 'حدّد اللّغات المراد عرضها',
selectVersionToCompare: 'حدّد نسخة للمقارنة',
showLocales: 'اظهر اللّغات:',
showingVersionsFor: 'يتمّ عرض النًّسخ ل:',
status: 'الحالة',
type: 'النّوع',
unpublish: 'الغاء النّشر',
unpublishing: 'يتمّ الغاء النّشر...',
version: 'النّسخة',
versionCount_many: 'تمّ العثور على {{count}} نُسخ',
versionCount_none: 'لم يتمّ العثور على أيّ من النّسخ',
versionCount_one: 'تمّ العثور على {{count}} من النّسخ',
versionCount_other: 'تمّ العثور على {{count}} نُسخ',
versionCreatedOn: 'تمّ ﻹنشاء النّسخة في {{version}}:',
versionID: 'مُعرّف النّسخة',
versions: 'النُّسَخ',
viewingVersion: 'يتمّ استعراض نسخة ل {{entityLabel}} {{documentTitle}}',
viewingVersionGlobal: 'يتمّ استعراض نسخة للاعداد العامّ {{entityLabel}}',
viewingVersions:
'يتمّ استعراض النُّسَخ ل {{entityLabel}} {{documentTitle}}',
viewingVersionsGlobal:
'يتمّ استعراض النُّسَخ للاعداد العامّ {{entityLabel}}',
},
};
export default ar;

View File

@@ -0,0 +1,47 @@
import type { CollectionConfig } from '../../../src/collections/config/types';
export const Posts: CollectionConfig = {
slug: 'posts',
labels: {
singular: {
en: 'Post',
ar: 'منشور',
},
plural: {
en: 'Posts',
ar: 'منشورات',
},
},
admin: {
description: { en: 'Description', ar: 'وصف' },
listSearchableFields: ['title', 'description'],
useAsTitle: 'title',
defaultColumns: ['id', 'title', 'description'],
},
fields: [
{
name: 'title',
label: {
en: 'Title',
ar: 'عنوان',
},
type: 'text',
admin: {
rtl: false,
},
},
{
name: 'description',
type: 'text',
localized: true,
admin: {
rtl: true,
},
},
{
name: 'content',
type: 'richText',
localized: true,
},
],
};

View File

@@ -0,0 +1,7 @@
import type { CollectionConfig } from '../../../src/collections/config/types';
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [],
};

View File

@@ -0,0 +1,43 @@
import { devUser } from '../credentials';
import { localization } from './localization';
import { Users } from './collections/users';
import { Posts } from './collections/posts';
import en from '../../src/translations/en.json';
import { ar } from './ar';
import deepMerge from './deepMerge';
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
export default buildConfigWithDefaults({
collections: [Users, Posts],
i18n: {
fallbackLng: 'en', // default
debug: false, // default
resources: {
ar: deepMerge(en, ar),
},
},
localization: {
locales: [
{
label: 'English',
code: 'en',
},
{
label: 'Arabic',
code: 'ar',
rtl: true,
},
],
defaultLocale: 'en',
fallback: true,
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
},
});

View File

@@ -0,0 +1,36 @@
/**
* Simple object check.
* @param item
* @returns {boolean}
*/
export function isObject(item: unknown): boolean {
return Boolean(item && typeof item === 'object' && !Array.isArray(item));
}
/**
* Deep merge two objects.
* @param target
* @param ...sources
*/
export default function deepMerge<T extends object, R extends object>(
target: T,
source: R,
): T {
const output = { ...target };
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach((key) => {
if (isObject(source[key])) {
// @ts-ignore
if (!(key in target)) {
Object.assign(output, { [key]: source[key] });
} else {
output[key] = deepMerge(target[key], source[key]);
}
} else {
Object.assign(output, { [key]: source[key] });
}
});
}
return output;
}

View File

@@ -0,0 +1,15 @@
export const localization = {
locales: [
{
label: 'English',
value: 'en',
},
{
label: 'Arabic',
value: 'ar',
rtl: true,
},
],
defaultLocale: 'en',
fallback: true,
};

View File

@@ -1,7 +1,7 @@
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
import { devUser } from '../credentials';
import { ArrayCollection } from './collections/Array';
import { LocalizedPost, RelationshipLocalized } from './payload-types';
import { LocalizedPost } from './payload-types';
import {
defaultLocale,
englishTitle,
@@ -34,7 +34,23 @@ const openAccess = {
export default buildConfigWithDefaults({
localization: {
locales: [defaultLocale, spanishLocale],
locales: [
{
label: 'en',
code: defaultLocale,
rtl: false,
},
{
label: 'es',
code: spanishLocale,
rtl: false,
},
{
label: 'ar',
code: 'ar',
rtl: true,
},
],
defaultLocale,
fallback: true,
},
@@ -223,7 +239,7 @@ export default buildConfigWithDefaults({
},
});
const localizedPost = await payload.create<LocalizedPost>({
const localizedPost = await payload.create({
collection,
data: {
title: englishTitle,
@@ -239,7 +255,7 @@ export default buildConfigWithDefaults({
},
});
await payload.update<LocalizedPost>({
await payload.update({
collection,
id: localizedPost.id,
locale: spanishLocale,
@@ -248,14 +264,14 @@ export default buildConfigWithDefaults({
},
});
const localizedRelation = await payload.create<LocalizedPost>({
const localizedRelation = await payload.create({
collection,
data: {
title: relationEnglishTitle,
},
});
await payload.update<LocalizedPost>({
await payload.update({
collection,
id: localizedPost.id,
locale: spanishLocale,
@@ -264,13 +280,13 @@ export default buildConfigWithDefaults({
},
});
const localizedRelation2 = await payload.create<LocalizedPost>({
const localizedRelation2 = await payload.create({
collection,
data: {
title: relationEnglishTitle2,
},
});
await payload.update<LocalizedPost>({
await payload.update({
collection,
id: localizedPost.id,
locale: spanishLocale,
@@ -279,7 +295,7 @@ export default buildConfigWithDefaults({
},
});
await payload.create<RelationshipLocalized>({
await payload.create({
collection: withLocalizedRelSlug,
data: {
relationship: localizedRelation.id,

View File

@@ -23,6 +23,7 @@ let url: AdminUrlUtil;
const defaultLocale = 'en';
const title = 'english title';
const spanishTitle = 'spanish title';
const arabicTitle = 'arabic title';
const description = 'description';
let page: Page;
@@ -91,6 +92,34 @@ describe('Localization', () => {
await expect(page.locator('#field-title')).toHaveValue(title);
await expect(page.locator('#field-description')).toHaveValue(description);
});
test('create arabic post, add english', async () => {
await page.goto(url.create);
const newLocale = 'ar';
// Change to Arabic
await changeLocale(newLocale);
await fillValues({ title: arabicTitle, description });
await saveDocAndAssert(page);
// Change back to English
await changeLocale(defaultLocale);
// Localized field should not be populated
await expect(page.locator('#field-title')).toBeEmpty();
await expect(page.locator('#field-description')).toHaveValue(description);
// Add English
await fillValues({ title, description });
await saveDocAndAssert(page);
await saveDocAndAssert(page);
await expect(page.locator('#field-title')).toHaveValue(title);
await expect(page.locator('#field-description')).toHaveValue(description);
});
});
describe('localized duplicate', () => {

View File

@@ -24,7 +24,7 @@ describe('refresh-permissions', () => {
await expect(page.locator('#nav-global-test')).toBeHidden();
// Allow access to test global.
await page.locator('.custom-checkbox:has(#field-test) button').click();
await page.locator('.custom-checkbox:has(#field-test) input').check();
await page.locator('#action-save').click();
// Now test collection should appear in the menu.

View File

@@ -190,6 +190,12 @@ export default buildConfigWithDefaults({
name: 'name',
type: 'text',
},
{
name: 'movies',
type: 'relationship',
relationTo: 'movies',
hasMany: true,
},
],
},
],

View File

@@ -358,6 +358,64 @@ describe('Relationships', () => {
expect(query.docs).toHaveLength(1);
});
});
describe('Multiple Docs', () => {
const movieList = [
'Pulp Fiction',
'Reservoir Dogs',
'Once Upon a Time in Hollywood',
'Shrek',
'Shrek 2',
'Shrek 3',
'Scream',
'The Matrix',
'The Matrix Reloaded',
'The Matrix Revolutions',
'The Matrix Resurrections',
'The Haunting',
'The Haunting of Hill House',
'The Haunting of Bly Manor',
'Insidious',
];
beforeAll(async () => {
await Promise.all(movieList.map((movie) => {
return payload.create({
collection: 'movies',
data: {
name: movie,
},
});
}));
});
it('should return more than 10 docs in relationship', async () => {
const allMovies = await payload.find({
collection: 'movies',
limit: 20,
});
const movieIDs = allMovies.docs.map((doc) => doc.id);
await payload.create({
collection: 'directors',
data: {
name: 'Quentin Tarantino',
movies: movieIDs,
},
});
const director = await payload.find({
collection: 'directors',
where: {
name: {
equals: 'Quentin Tarantino',
},
},
});
expect(director.docs[0].movies.length).toBeGreaterThan(10);
});
});
});
});

View File

@@ -85,6 +85,7 @@ export interface Movie {
export interface Director {
id: string;
name?: string;
movies?: Array<string | Movie>;
updatedAt: string;
createdAt: string;
}

View File

@@ -54,7 +54,7 @@ describe('versions', () => {
test('should bulk publish', async () => {
await page.goto(url.list);
await page.locator('.select-all__input').click();
await page.locator('.custom-checkbox:has(#select-all) input').check();
await page.locator('.publish-many__toggle').click();
@@ -67,7 +67,7 @@ describe('versions', () => {
test('should bulk unpublish', async () => {
await page.goto(url.list);
await page.locator('.select-all__input').click();
await page.locator('.custom-checkbox:has(#select-all) input').check();
await page.locator('.unpublish-many__toggle').click();
@@ -80,7 +80,7 @@ describe('versions', () => {
test('should publish while editing many', async () => {
const description = 'published document';
await page.goto(url.list);
await page.locator('.select-all__input').click();
await page.locator('.custom-checkbox:has(#select-all) input').check();
await page.locator('.edit-many__toggle').click();
await page.locator('.field-select .rs__control').click();
const options = page.locator('.rs__option');
@@ -97,7 +97,7 @@ describe('versions', () => {
test('should save as draft while editing many', async () => {
const description = 'draft document';
await page.goto(url.list);
await page.locator('.select-all__input').click();
await page.locator('.custom-checkbox:has(#select-all) input').check();
await page.locator('.edit-many__toggle').click();
await page.locator('.field-select .rs__control').click();
const options = page.locator('.rs__option');