Compare commits

..

12 Commits

Author SHA1 Message Date
Elliot DeNolf
a8c60c1c02 chore(release): v3.0.0-beta.101 [skip ci] 2024-09-09 16:04:45 -04:00
James Mikrut
d44fb2db37 fix: #6800, graphql parallel queries with different fallback locales (#8140)
## Description

Fixes #6800 where parallel GraphQL queries with different locales /
fallbackLocales do not return their data properly.
2024-09-09 16:01:58 -04:00
Dan Ribbens
852f9fc1fd fix!: multiple preferences for the same user and entry (#8100)
fixes #7762

This change mitigates having multiple preferences for one user but not
awaiting the change to a preference and reduces querying by skipping the
access control. In the event that a user has multiple preferences with
the same key, only the one with the latest updatedAt will be returned.

BREAKING CHANGES:
- payload/preferences/operations are no longer default exports

## Description

<!-- Please include a summary of the pull request and any related issues
it fixes. Please also include relevant motivation and context. -->

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

<!-- Please delete options that are not relevant. -->

- [ ] Chore (non-breaking change which does not add functionality)
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Change to the
[templates](https://github.com/payloadcms/payload/tree/main/templates)
directory (does not affect core functionality)
- [ ] Change to the
[examples](https://github.com/payloadcms/payload/tree/main/examples)
directory (does not affect core functionality)
- [ ] This change requires a documentation update

## Checklist:

- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation

---------

Co-authored-by: Paul Popus <paul@nouance.io>
2024-09-09 14:00:51 -04:00
Dan Ribbens
e2d803800d fix: removes transactions wrapping auth strategies and login (#8137)
## Description

By default all api requests are creating transactions due to the
authentication stategy. This change removes transactions for auth and
login requests. This should only happen when the database needs to make
changes in which case the auth strategy or login lockout updates will
invoke their own transactions still.

This should improve performance without any sacrifice to database
consistency.

Fixes #8092 

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

<!-- Please delete options that are not relevant. -->

- [ ] Chore (non-breaking change which does not add functionality)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Change to the
[templates](https://github.com/payloadcms/payload/tree/main/templates)
directory (does not affect core functionality)
- [ ] Change to the
[examples](https://github.com/payloadcms/payload/tree/main/examples)
directory (does not affect core functionality)
- [ ] This change requires a documentation update

## Checklist:

- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation
2024-09-09 13:27:21 -04:00
Germán Jabloñski
7fa68d17f5 fix(ui): wrong block indication when an error occurred (#7963) 2024-09-09 10:20:03 -04:00
Paul
9ec431a5bd fix(ui): bulk select checkbox being selected by default when in drawer (#8126) 2024-09-09 06:47:35 +00:00
Paul
cadf815ef6 fix(ui): thumbnails when serverURL config is provided (#8124) 2024-09-09 06:16:43 +00:00
Paul
638382e7fd feat: add validation for useAsTitle to throw an error if it's an invalid or nested field (#8122) 2024-09-08 18:53:12 -06:00
Elliot DeNolf
08fdbcacc0 chore: proper error log format (#8105)
Fix some error log formatting to use `{ msg, err }` properly
2024-09-07 02:48:59 +00:00
Paul
b27e42c484 fix(ui): various issues around documents lists, listQuery provider and search params (#8081)
This PR fixes and improves:
- ListQuery provider is now the source of truth for searchParams instead
of having components use the `useSearchParams` hook
- Various issues with search params and filters sticking around when
navigating between collections
- Pagination and limits not working inside DocumentDrawer
- Searching and filtering causing a flash of overlay in DocumentDrawer,
this now only shows for the first load and on slow networks
- Preferences are now respected in DocumentDrawer
- Changing the limit now resets your page back to 1 in case the current
page no longer exists

Fixes https://github.com/payloadcms/payload/issues/7085
Fixes https://github.com/payloadcms/payload/pull/8081
Fixes https://github.com/payloadcms/payload/issues/8086
2024-09-06 15:51:09 -06:00
Tylan Davis
32cc1a5761 fix(ui): missing thumbnail for non-image files in bulk upload sidebar (#8102)
## Description

Uses the `Thumbnail` component used in other places for the bulk upload
file rows. Closes #8099

In the future, we should consider adding different thumbnail icons based
on the `mimeType` to better describe the files being uploaded.

Before:
![Screenshot 2024-09-06 at 4 51
56 PM](https://github.com/user-attachments/assets/35cd528c-5086-465e-8d3c-7bb66d7c35da)


After:
![Screenshot 2024-09-06 at 4 50
12 PM](https://github.com/user-attachments/assets/54d2b98d-ac11-481e-abe5-4be071c3c8f2)


- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation
2024-09-06 21:28:50 +00:00
Tylan Davis
38be69b7d3 fix(ui): better responsiveness for upload fields in sidebar (#8101)
## Description

Adjusts the styling for the Dropzone component for upload fields with
`admin.position: sidebar`.

Before:
![Screenshot 2024-09-06 at 4 10
28 PM](https://github.com/user-attachments/assets/221d43f9-f426-4a44-ba58-29123050c775)

After:
![Screenshot 2024-09-06 at 4 09
32 PM](https://github.com/user-attachments/assets/c4369a65-d842-4e65-9153-19244fcf5600)


- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation
2024-09-06 20:37:38 +00:00
71 changed files with 691 additions and 289 deletions

View File

@@ -5,13 +5,13 @@ export const recordLastLoggedInTenant: AfterLoginHook = async ({ req, user }) =>
const relatedOrg = await req.payload
.find({
collection: 'tenants',
depth: 0,
limit: 1,
where: {
'domains.domain': {
in: [req.headers.host],
},
},
depth: 0,
limit: 1,
})
?.then((res) => res.docs?.[0])
@@ -24,7 +24,10 @@ export const recordLastLoggedInTenant: AfterLoginHook = async ({ req, user }) =>
req,
})
} catch (err: unknown) {
req.payload.logger.error(`Error recording last logged in tenant for user ${user.id}: ${err}`)
req.payload.logger.error({
err,
msg: `Error recording last logged in tenant for user ${user.id}`,
})
}
return user

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -23,7 +23,7 @@ const connectWithReconnect = async function ({
} else {
try {
result = await adapter.pool.connect()
} catch (err) {
} catch (ignore) {
setTimeout(() => {
payload.logger.info('Reconnecting to postgres')
void connectWithReconnect({ adapter, payload, reconnect: true })
@@ -38,7 +38,7 @@ const connectWithReconnect = async function ({
if (err.code === 'ECONNRESET') {
void connectWithReconnect({ adapter, payload, reconnect: true })
}
} catch (err) {
} catch (ignore) {
// swallow error
}
})
@@ -76,7 +76,7 @@ export const connect: Connect = async function connect(
}
}
} catch (err) {
this.payload.logger.error(`Error: cannot connect to Postgres. Details: ${err.message}`, err)
this.payload.logger.error({ err, msg: `Error: cannot connect to Postgres: ${err.message}` })
if (typeof this.rejectInitializing === 'function') {
this.rejectInitializing()
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -37,7 +37,7 @@ export const connect: Connect = async function connect(
}
}
} catch (err) {
this.payload.logger.error(`Error: cannot connect to SQLite. Details: ${err.message}`, err)
this.payload.logger.error({ err, msg: `Error: cannot connect to SQLite: ${err.message}` })
if (typeof this.rejectInitializing === 'function') {
this.rejectInitializing()
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-vercel-postgres",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -39,7 +39,7 @@ export const connect: Connect = async function connect(
}
}
} catch (err) {
this.payload.logger.error(`Error: cannot connect to Postgres. Details: ${err.message}`, err)
this.payload.logger.error({ err, msg: `Error: cannot connect to Postgres: ${err.message}` })
if (typeof this.rejectInitializing === 'function') {
this.rejectInitializing()
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -37,6 +37,7 @@ export const beginTransaction: BeginTransaction = async function beginTransactio
return done
}
reject = () => {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
rej()
return done
}
@@ -57,7 +58,7 @@ export const beginTransaction: BeginTransaction = async function beginTransactio
resolve,
}
} catch (err) {
this.payload.logger.error(`Error: cannot begin transaction: ${err.message}`, err)
this.payload.logger.error({ err, msg: `Error: cannot begin transaction: ${err.message}` })
process.exit(1)
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-resend",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-vue",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "The official Vue SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -28,7 +28,6 @@ import {
useListQuery,
useModal,
useRouteCache,
useSearchParams,
useStepNav,
useTranslation,
useWindowInfo,
@@ -54,8 +53,7 @@ export const DefaultListView: React.FC = () => {
newDocumentURL,
} = useListInfo()
const { data, defaultLimit, handlePageChange, handlePerPageChange } = useListQuery()
const { searchParams } = useSearchParams()
const { data, defaultLimit, handlePageChange, handlePerPageChange, params } = useListQuery()
const { openModal } = useModal()
const { clearRouteCache } = useRouteCache()
const { setCollectionSlug, setOnSuccess } = useBulkUpload()
@@ -226,9 +224,7 @@ export const DefaultListView: React.FC = () => {
</div>
<PerPage
handleChange={(limit) => void handlePerPageChange(limit)}
limit={
isNumber(searchParams?.limit) ? Number(searchParams.limit) : defaultLimit
}
limit={isNumber(params?.limit) ? Number(params.limit) : defaultLimit}
limits={collectionConfig?.admin?.pagination?.limits}
resetPage={data.totalDocs <= data.pagingCounter}
/>

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",

View File

@@ -2,8 +2,6 @@ import type { TypedUser } from '../../index.js'
import type { PayloadRequest } from '../../types/index.js'
import type { Permissions } from '../types.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { executeAuthStrategies } from '../executeAuthStrategies.js'
import { getAccessResults } from '../getAccessResults.js'
@@ -25,8 +23,6 @@ export const auth = async (args: Required<AuthArgs>): Promise<AuthResult> => {
const { payload } = req
try {
const shouldCommit = await initTransaction(req)
const { responseHeaders, user } = await executeAuthStrategies({
headers,
payload,
@@ -39,10 +35,6 @@ export const auth = async (args: Required<AuthArgs>): Promise<AuthResult> => {
req,
})
if (shouldCommit) {
await commitTransaction(req)
}
return {
permissions,
responseHeaders,

View File

@@ -12,8 +12,6 @@ import type { User } from '../types.js'
import { buildAfterOperation } from '../../collections/operations/utils.js'
import { AuthenticationError, LockedAuth, ValidationError } from '../../errors/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields.js'
import { getFieldsToSign } from '../getFieldsToSign.js'
@@ -43,8 +41,6 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
let args = incomingArgs
try {
const shouldCommit = await initTransaction(args.req)
// /////////////////////////////////////
// beforeOperation - Collection
// /////////////////////////////////////
@@ -202,10 +198,6 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
})
}
if (shouldCommit) {
await commitTransaction(req)
}
throw new AuthenticationError(req.t)
}
@@ -334,10 +326,6 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
// Return results
// /////////////////////////////////////
if (shouldCommit) {
await commitTransaction(req)
}
return result
} catch (error: unknown) {
await killTransaction(args.req)

View File

@@ -14,6 +14,7 @@ import baseVersionFields from '../../versions/baseFields.js'
import { versionDefaults } from '../../versions/defaults.js'
import { authDefaults, defaults, loginWithUsernameDefaults } from './defaults.js'
import { sanitizeAuthFields, sanitizeUploadFields } from './reservedFieldNames.js'
import { validateUseAsTitle } from './useAsTitle.js'
export const sanitizeCollection = async (
config: Config,
@@ -44,6 +45,8 @@ export const sanitizeCollection = async (
validRelationships,
})
validateUseAsTitle(sanitized)
if (sanitized.timestamps !== false) {
// add default timestamps fields only as needed
let hasUpdatedAt = null

View File

@@ -0,0 +1,204 @@
import type { Config } from '../../config/types.js'
import type { CollectionConfig } from '../../index.js'
import { InvalidConfiguration } from '../../errors/InvalidConfiguration.js'
import { sanitizeCollection } from './sanitize.js'
describe('sanitize - collections -', () => {
const config = {
collections: [],
globals: [],
} as Partial<Config>
describe('validate useAsTitle -', () => {
const defaultCollection: CollectionConfig = {
slug: 'collection-with-defaults',
fields: [
{
name: 'title',
type: 'text',
},
],
}
it('should throw on invalid field', async () => {
const collectionConfig: CollectionConfig = {
...defaultCollection,
admin: {
useAsTitle: 'invalidField',
},
}
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [collectionConfig],
},
collectionConfig,
)
}).rejects.toThrow(InvalidConfiguration)
})
it('should not throw on valid field', async () => {
const collectionConfig: CollectionConfig = {
...defaultCollection,
admin: {
useAsTitle: 'title',
},
}
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [collectionConfig],
},
collectionConfig,
)
}).not.toThrow()
})
it('should not throw on valid field inside tabs', async () => {
const collectionConfig: CollectionConfig = {
...defaultCollection,
admin: {
useAsTitle: 'title',
},
fields: [
{
type: 'tabs',
tabs: [
{
label: 'General',
fields: [
{
name: 'title',
type: 'text',
},
],
},
],
},
],
}
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [collectionConfig],
},
collectionConfig,
)
}).not.toThrow()
})
it('should not throw on valid field inside collapsibles', async () => {
const collectionConfig: CollectionConfig = {
...defaultCollection,
admin: {
useAsTitle: 'title',
},
fields: [
{
type: 'collapsible',
label: 'Collapsible',
fields: [
{
name: 'title',
type: 'text',
},
],
},
],
}
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [collectionConfig],
},
collectionConfig,
)
}).not.toThrow()
})
it('should throw on nested useAsTitle', async () => {
const collectionConfig: CollectionConfig = {
...defaultCollection,
admin: {
useAsTitle: 'content.title',
},
}
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [collectionConfig],
},
collectionConfig,
)
}).rejects.toThrow(InvalidConfiguration)
})
it('should not throw on default field: id', async () => {
const collectionConfig: CollectionConfig = {
...defaultCollection,
admin: {
useAsTitle: 'id',
},
}
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [collectionConfig],
},
collectionConfig,
)
}).not.toThrow()
})
it('should not throw on default field: email if auth is enabled', async () => {
const collectionConfig: CollectionConfig = {
...defaultCollection,
auth: true,
admin: {
useAsTitle: 'email',
},
}
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [collectionConfig],
},
collectionConfig,
)
}).not.toThrow()
})
it('should throw on default field: email if auth is not enabled', async () => {
const collectionConfig: CollectionConfig = {
...defaultCollection,
admin: {
useAsTitle: 'email',
},
}
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [collectionConfig],
},
collectionConfig,
)
}).rejects.toThrow(InvalidConfiguration)
})
})
})

View File

@@ -0,0 +1,43 @@
import type { CollectionConfig } from '../../index.js'
import { InvalidConfiguration } from '../../errors/InvalidConfiguration.js'
import { fieldAffectsData } from '../../fields/config/types.js'
import flattenFields from '../../utilities/flattenTopLevelFields.js'
/**
* Validate useAsTitle for collections.
*/
export const validateUseAsTitle = (config: CollectionConfig) => {
if (config.admin.useAsTitle.includes('.')) {
throw new InvalidConfiguration(
`"useAsTitle" cannot be a nested field. Please specify a top-level field in the collection "${config.slug}"`,
)
}
if (config?.admin && config.admin?.useAsTitle && config.admin.useAsTitle !== 'id') {
const fields = flattenFields(config.fields)
const useAsTitleField = fields.find((field) => {
if (fieldAffectsData(field) && config.admin) {
return field.name === config.admin.useAsTitle
}
return false
})
// If auth is enabled then we don't need to
if (config.auth) {
if (config.admin.useAsTitle !== 'email') {
if (!useAsTitleField) {
throw new InvalidConfiguration(
`The field "${config.admin.useAsTitle}" specified in "admin.useAsTitle" does not exist in the collection "${config.slug}"`,
)
}
}
} else {
if (!useAsTitleField) {
throw new InvalidConfiguration(
`The field "${config.admin.useAsTitle}" specified in "admin.useAsTitle" does not exist in the collection "${config.slug}"`,
)
}
}
}
}

View File

@@ -136,8 +136,8 @@ const batchAndLoadDocs =
depth,
docID: doc.id,
draft,
fallbackLocale: req.fallbackLocale,
locale: req.locale,
fallbackLocale,
locale,
overrideAccess,
showHiddenFields,
transactionID: req.transactionID,

View File

@@ -1,15 +1,12 @@
import type { Document, Where } from '../../types/index.js'
import type { PreferenceRequest } from '../types.js'
import defaultAccess from '../../auth/defaultAccess.js'
import executeAccess from '../../auth/executeAccess.js'
import { NotFound } from '../../errors/NotFound.js'
import { UnauthorizedError } from '../../errors/UnathorizedError.js'
async function deleteOperation(args: PreferenceRequest): Promise<Document> {
export async function deleteOperation(args: PreferenceRequest): Promise<Document> {
const {
key,
overrideAccess,
req: { payload },
req,
user,
@@ -19,10 +16,6 @@ async function deleteOperation(args: PreferenceRequest): Promise<Document> {
throw new UnauthorizedError(req.t)
}
if (!overrideAccess) {
await executeAccess({ req }, defaultAccess)
}
const where: Where = {
and: [
{ key: { equals: key } },
@@ -42,5 +35,3 @@ async function deleteOperation(args: PreferenceRequest): Promise<Document> {
}
throw new NotFound(req.t)
}
export default deleteOperation

View File

@@ -2,7 +2,7 @@ import type { TypedCollection } from '../../index.js'
import type { Where } from '../../types/index.js'
import type { PreferenceRequest } from '../types.js'
async function findOne(args: PreferenceRequest): Promise<TypedCollection['_preference']> {
export async function findOne(args: PreferenceRequest): Promise<TypedCollection['_preference']> {
const {
key,
req: { payload },
@@ -22,11 +22,14 @@ async function findOne(args: PreferenceRequest): Promise<TypedCollection['_prefe
],
}
return await payload.db.findOne({
const { docs } = await payload.db.find({
collection: 'payload-preferences',
limit: 1,
pagination: false,
req,
sort: '-updatedAt',
where,
})
}
export default findOne
return docs?.[0] || null
}

View File

@@ -1,13 +1,11 @@
import type { Where } from '../../types/index.js'
import type { PreferenceUpdateRequest } from '../types.js'
import defaultAccess from '../../auth/defaultAccess.js'
import executeAccess from '../../auth/executeAccess.js'
import { UnauthorizedError } from '../../errors/UnathorizedError.js'
async function update(args: PreferenceUpdateRequest) {
export async function update(args: PreferenceUpdateRequest) {
const {
key,
overrideAccess,
req: { payload },
req,
user,
@@ -20,10 +18,12 @@ async function update(args: PreferenceUpdateRequest) {
const collection = 'payload-preferences'
const filter = {
key: { equals: key },
'user.relationTo': { equals: user.collection },
'user.value': { equals: user.id },
const where: Where = {
and: [
{ key: { equals: key } },
{ 'user.value': { equals: user.id } },
{ 'user.relationTo': { equals: user.collection } },
],
}
const preference = {
@@ -35,27 +35,23 @@ async function update(args: PreferenceUpdateRequest) {
value,
}
if (!overrideAccess) {
await executeAccess({ req }, defaultAccess)
}
let result
try {
// try/catch because we attempt to update without first reading to check if it exists first to save on db calls
await payload.db.updateOne({
result = await payload.db.updateOne({
collection,
data: preference,
req,
where: filter,
where,
})
} catch (err: unknown) {
await payload.db.create({
result = await payload.db.create({
collection,
data: preference,
req,
})
}
return preference
return result
}
export default update

View File

@@ -3,7 +3,7 @@ import httpStatus from 'http-status'
import type { PayloadHandler } from '../../config/types.js'
import type { PayloadRequest } from '../../types/index.js'
import deleteOperation from '../operations/delete.js'
import { deleteOperation } from '../operations/delete.js'
export const deleteHandler: PayloadHandler = async (incomingReq): Promise<Response> => {
// We cannot import the addDataAndFileToRequest utility here from the 'next' package because of dependency issues

View File

@@ -3,7 +3,7 @@ import httpStatus from 'http-status'
import type { PayloadHandler } from '../../config/types.js'
import type { PayloadRequest } from '../../types/index.js'
import findOne from '../operations/findOne.js'
import { findOne } from '../operations/findOne.js'
export const findByIDHandler: PayloadHandler = async (incomingReq): Promise<Response> => {
// We cannot import the addDataAndFileToRequest utility here from the 'next' package because of dependency issues

View File

@@ -3,7 +3,7 @@ import httpStatus from 'http-status'
import type { PayloadHandler } from '../../config/types.js'
import type { PayloadRequest } from '../../types/index.js'
import update from '../operations/update.js'
import { update } from '../operations/update.js'
export const updateHandler: PayloadHandler = async (incomingReq) => {
// We cannot import the addDataAndFileToRequest utility here from the 'next' package because of dependency issues

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud-storage",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "The official cloud storage plugin for Payload CMS",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-nested-docs",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "The official Nested Docs plugin for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-redirects",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-relationship-object-ids",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "A Payload plugin to store all relationship IDs as ObjectIDs",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-search",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Search plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-seo",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "SEO plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-stripe",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Stripe plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-slate",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "The officially supported Slate richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-azure",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Payload storage adapter for Azure Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-gcs",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Payload storage adapter for Google Cloud Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-s3",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Payload storage adapter for Amazon S3",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-uploadthing",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Payload storage adapter for uploadthing",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-vercel-blob",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"description": "Payload storage adapter for Vercel Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/translations",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/ui",
"version": "3.0.0-beta.100",
"version": "3.0.0-beta.101",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -82,12 +82,12 @@
background-color: var(--theme-elevation-100);
}
img {
width: 25px;
height: 25px;
.thumbnail {
width: base(1.2);
height: base(1.2);
flex-shrink: 0;
object-fit: cover;
border-radius: var(--style-radius-m);
border-radius: var(--style-radius-s);
}
p {

View File

@@ -2,6 +2,7 @@
import { useModal } from '@faceless-ui/modal'
import { useWindowInfo } from '@faceless-ui/window-info'
import { isImage } from 'payload/shared'
import React from 'react'
import AnimateHeightImport from 'react-animate-height'
@@ -13,6 +14,7 @@ import { Drawer } from '../../Drawer/index.js'
import { ErrorPill } from '../../ErrorPill/index.js'
import { Pill } from '../../Pill/index.js'
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
import { Thumbnail } from '../../Thumbnail/index.js'
import { Actions } from '../ActionsBar/index.js'
import { AddFilesView } from '../AddFilesView/index.js'
import { useFormsManager } from '../FormsManager/index.js'
@@ -144,7 +146,12 @@ export function FileSidebar() {
onClick={() => setActiveIndex(index)}
type="button"
>
<img alt={currentFile.name} src={URL.createObjectURL(currentFile)} />
<Thumbnail
className={`${baseClass}__thumbnail`}
fileSrc={
isImage(currentFile.type) ? URL.createObjectURL(currentFile) : undefined
}
/>
<div className={`${baseClass}__fileDetails`}>
<p className={`${baseClass}__fileName`} title={currentFile.name}>
{currentFile.name}

View File

@@ -14,7 +14,6 @@ import { ChevronIcon } from '../../icons/Chevron/index.js'
import { SearchIcon } from '../../icons/Search/index.js'
import { useListInfo } from '../../providers/ListInfo/index.js'
import { useListQuery } from '../../providers/ListQuery/index.js'
import { useSearchParams } from '../../providers/SearchParams/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { ColumnSelector } from '../ColumnSelector/index.js'
import { DeleteMany } from '../DeleteMany/index.js'
@@ -48,17 +47,13 @@ export type ListControlsProps = {
export const ListControls: React.FC<ListControlsProps> = (props) => {
const { collectionConfig, enableColumns = true, enableSort = false, fields } = props
const { handleSearchChange } = useListQuery()
const { handleSearchChange, params } = useListQuery()
const { beforeActions, collectionSlug, disableBulkDelete, disableBulkEdit } = useListInfo()
const { searchParams } = useSearchParams()
const titleField = useUseTitleField(collectionConfig, fields)
const { i18n, t } = useTranslation()
const {
breakpoints: { s: smallBreak },
} = useWindowInfo()
const [search, setSearch] = useState(
typeof searchParams?.search === 'string' ? searchParams?.search : '',
)
const searchLabel =
(titleField &&
@@ -81,21 +76,21 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
t('general:searchBy', { label: getTranslation(searchLabel, i18n) }),
)
const hasWhereParam = useRef(Boolean(searchParams?.where))
const hasWhereParam = useRef(Boolean(params?.where))
const shouldInitializeWhereOpened = validateWhereQuery(searchParams?.where)
const shouldInitializeWhereOpened = validateWhereQuery(params?.where)
const [visibleDrawer, setVisibleDrawer] = useState<'columns' | 'sort' | 'where'>(
shouldInitializeWhereOpened ? 'where' : undefined,
)
useEffect(() => {
if (hasWhereParam.current && !searchParams?.where) {
if (hasWhereParam.current && !params?.where) {
setVisibleDrawer(undefined)
hasWhereParam.current = false
} else if (searchParams?.where) {
} else if (params?.where) {
hasWhereParam.current = true
}
}, [setVisibleDrawer, searchParams?.where])
}, [setVisibleDrawer, params?.where])
useEffect(() => {
if (listSearchableFields?.length > 0) {
@@ -134,11 +129,10 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
handleChange={(search) => {
return void handleSearchChange(search)
}}
initialParams={searchParams}
// @ts-expect-error @todo: fix types
initialParams={params}
key={collectionSlug}
label={searchLabelTranslated.current}
setValue={setSearch}
value={search}
/>
<div className={`${baseClass}__buttons`}>
<div className={`${baseClass}__buttons-wrap`}>
@@ -216,7 +210,7 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
collectionPluralLabel={collectionConfig?.labels?.plural}
collectionSlug={collectionConfig.slug}
fields={fields}
key={String(hasWhereParam.current && !searchParams?.where)}
key={String(hasWhereParam.current && !params?.where)}
/>
</AnimateHeight>
{enableSort && (

View File

@@ -3,13 +3,14 @@ import type { ClientCollectionConfig, Where } from 'payload'
import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import React, { useCallback, useEffect, useReducer, useState } from 'react'
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import type { ListDrawerProps } from './types.js'
import { SelectMany } from '../../elements/SelectMany/index.js'
import { FieldLabel } from '../../fields/FieldLabel/index.js'
import { usePayloadAPI } from '../../hooks/usePayloadAPI.js'
import { useThrottledEffect } from '../../hooks/useThrottledEffect.js'
import { XIcon } from '../../icons/X/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
@@ -54,13 +55,25 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
}) => {
const { i18n, t } = useTranslation()
const { permissions } = useAuth()
const { setPreference } = usePreferences()
const { getPreference, setPreference } = usePreferences()
const { closeModal, isModalOpen } = useModal()
const [limit, setLimit] = useState<number>()
// Track the page limit so we can reset the page number when it changes
const previousLimit = useRef<number>(limit || null)
const [sort, setSort] = useState<string>(null)
const [page, setPage] = useState<number>(1)
const [where, setWhere] = useState<Where>(null)
const [search, setSearch] = useState<string>('')
const [showLoadingOverlay, setShowLoadingOverlay] = useState<boolean>(true)
const hasInitialised = useRef(false)
const params = {
limit,
page,
search,
sort,
where,
}
const {
config: {
@@ -94,12 +107,6 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
: undefined,
)
// const [fields, setFields] = useState<Field[]>(() => formatFields(selectedCollectionConfig))
useEffect(() => {
// setFields(formatFields(selectedCollectionConfig))
}, [selectedCollectionConfig])
// allow external control of selected collection, same as the initial state logic above
useEffect(() => {
if (selectedCollection) {
@@ -111,7 +118,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
}
}, [selectedCollection, enabledCollectionConfigs, onSelect, t])
const preferenceKey = `${selectedCollectionConfig.slug}-list`
const preferencesKey = `${selectedCollectionConfig.slug}-list`
// this is the 'create new' drawer
const [DocumentDrawer, DocumentDrawerToggler, { drawerSlug: documentDrawerSlug }] =
@@ -147,6 +154,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
admin: { listSearchableFields, useAsTitle } = {},
versions,
} = selectedCollectionConfig
const params: {
cacheBust?: number
depth?: number
@@ -194,6 +202,17 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
if (cacheBust) {
params.cacheBust = cacheBust
}
if (limit) {
params.limit = limit
if (limit !== previousLimit.current) {
previousLimit.current = limit
// Reset page if limit changes
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setPage(1)
}
}
if (copyOfWhere) {
params.where = copyOfWhere
}
@@ -202,7 +221,18 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
}
setParams(params)
}, [page, sort, where, search, cacheBust, filterOptions, selectedCollectionConfig, t, setParams])
}, [
page,
sort,
where,
search,
limit,
cacheBust,
filterOptions,
selectedCollectionConfig,
t,
setParams,
])
useEffect(() => {
const newPreferences = {
@@ -210,8 +240,49 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
sort,
}
void setPreference(preferenceKey, newPreferences, true)
}, [sort, limit, setPreference, preferenceKey])
if (limit || sort) {
void setPreference(preferencesKey, newPreferences, true)
}
}, [sort, limit, setPreference, preferencesKey])
// Get existing preferences if they exist
useEffect(() => {
if (preferencesKey && !limit) {
const getInitialPref = async () => {
const existingPreferences = await getPreference<{ limit?: number }>(preferencesKey)
if (existingPreferences?.limit) {
setLimit(existingPreferences?.limit)
}
}
void getInitialPref()
}
}, [getPreference, limit, preferencesKey])
useThrottledEffect(
() => {
if (isLoadingList) {
setShowLoadingOverlay(true)
}
},
1750,
[isLoadingList, setShowLoadingOverlay],
)
useEffect(() => {
if (isOpen) {
hasInitialised.current = true
} else {
hasInitialised.current = false
}
}, [isOpen])
useEffect(() => {
if (!isLoadingList && showLoadingOverlay) {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setShowLoadingOverlay(false)
}
}, [isLoadingList, showLoadingOverlay])
const onCreateNew = useCallback(
({ doc }) => {
@@ -232,108 +303,111 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
return null
}
if (isLoadingList) {
return <LoadingOverlay />
}
return (
<ListInfoProvider
beforeActions={
enableRowSelections ? [<SelectMany key="select-many" onClick={onBulkSelect} />] : undefined
}
collectionConfig={selectedCollectionConfig}
collectionSlug={selectedCollectionConfig.slug}
disableBulkDelete
disableBulkEdit
hasCreatePermission={hasCreatePermission}
Header={
<header className={`${baseClass}__header`}>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header-content`}>
<h2 className={`${baseClass}__header-text`}>
{!customHeader
? getTranslation(selectedCollectionConfig?.labels?.plural, i18n)
: customHeader}
</h2>
{hasCreatePermission && (
<DocumentDrawerToggler className={`${baseClass}__create-new-button`}>
<Pill>{t('general:createNew')}</Pill>
</DocumentDrawerToggler>
)}
<>
{showLoadingOverlay && <LoadingOverlay />}
<ListInfoProvider
beforeActions={
enableRowSelections
? [<SelectMany key="select-many" onClick={onBulkSelect} />]
: undefined
}
collectionConfig={selectedCollectionConfig}
collectionSlug={selectedCollectionConfig.slug}
disableBulkDelete
disableBulkEdit
hasCreatePermission={hasCreatePermission}
Header={
<header className={`${baseClass}__header`}>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header-content`}>
<h2 className={`${baseClass}__header-text`}>
{!customHeader
? getTranslation(selectedCollectionConfig?.labels?.plural, i18n)
: customHeader}
</h2>
{hasCreatePermission && (
<DocumentDrawerToggler className={`${baseClass}__create-new-button`}>
<Pill>{t('general:createNew')}</Pill>
</DocumentDrawerToggler>
)}
</div>
<button
aria-label={t('general:close')}
className={`${baseClass}__header-close`}
onClick={() => {
closeModal(drawerSlug)
}}
type="button"
>
<XIcon />
</button>
</div>
<button
aria-label={t('general:close')}
className={`${baseClass}__header-close`}
onClick={() => {
closeModal(drawerSlug)
}}
type="button"
>
<XIcon />
</button>
</div>
{(selectedCollectionConfig?.admin?.description ||
selectedCollectionConfig?.admin?.components?.Description) && (
<div className={`${baseClass}__sub-header`}>
<ViewDescription
Description={selectedCollectionConfig.admin?.components?.Description}
description={selectedCollectionConfig.admin?.description}
/>
</div>
)}
{moreThanOneAvailableCollection && (
<div className={`${baseClass}__select-collection-wrap`}>
<FieldLabel field={null} label={t('upload:selectCollectionToBrowse')} />
<ReactSelect
className={`${baseClass}__select-collection`}
onChange={setSelectedOption} // this is only changing the options which is not rerunning my effect
options={enabledCollectionConfigs.map((coll) => ({
label: getTranslation(coll.labels.singular, i18n),
value: coll.slug,
}))}
value={selectedOption}
/>
</div>
)}
</header>
}
newDocumentURL={null}
>
<ListQueryProvider
data={data}
defaultLimit={limit || selectedCollectionConfig?.admin?.pagination?.defaultLimit}
defaultSort={sort}
handlePageChange={setPage}
handlePerPageChange={setLimit}
handleSearchChange={setSearch}
handleSortChange={setSort}
handleWhereChange={setWhere}
modifySearchParams={false}
preferenceKey={preferenceKey}
{(selectedCollectionConfig?.admin?.description ||
selectedCollectionConfig?.admin?.components?.Description) && (
<div className={`${baseClass}__sub-header`}>
<ViewDescription
Description={selectedCollectionConfig.admin?.components?.Description}
description={selectedCollectionConfig.admin?.description}
/>
</div>
)}
{moreThanOneAvailableCollection && (
<div className={`${baseClass}__select-collection-wrap`}>
<FieldLabel field={null} label={t('upload:selectCollectionToBrowse')} />
<ReactSelect
className={`${baseClass}__select-collection`}
onChange={setSelectedOption} // this is only changing the options which is not rerunning my effect
options={enabledCollectionConfigs.map((coll) => ({
label: getTranslation(coll.labels.singular, i18n),
value: coll.slug,
}))}
value={selectedOption}
/>
</div>
)}
</header>
}
newDocumentURL={null}
>
<TableColumnsProvider
cellProps={[
{
className: `${baseClass}__first-cell`,
link: false,
onClick: ({ collectionSlug: rowColl, rowData }) => {
if (typeof onSelect === 'function') {
onSelect({
collectionSlug: rowColl,
docID: rowData.id as string,
})
}
},
},
]}
collectionSlug={selectedCollectionConfig.slug}
enableRowSelections={enableRowSelections}
preferenceKey={preferenceKey}
<ListQueryProvider
data={data}
defaultLimit={limit || selectedCollectionConfig?.admin?.pagination?.defaultLimit}
defaultSort={sort}
handlePageChange={setPage}
handlePerPageChange={setLimit}
handleSearchChange={setSearch}
handleSortChange={setSort}
handleWhereChange={setWhere}
modifySearchParams={false}
// @ts-expect-error todo: fix types
params={params}
preferenceKey={preferencesKey}
>
<RenderComponent mappedComponent={List} />
<DocumentDrawer onSave={onCreateNew} />
</TableColumnsProvider>
</ListQueryProvider>
</ListInfoProvider>
<TableColumnsProvider
cellProps={[
{
className: `${baseClass}__first-cell`,
link: false,
onClick: ({ collectionSlug: rowColl, rowData }) => {
if (typeof onSelect === 'function') {
onSelect({
collectionSlug: rowColl,
docID: rowData.id as string,
})
}
},
},
]}
collectionSlug={selectedCollectionConfig.slug}
enableRowSelections={enableRowSelections}
preferenceKey={preferencesKey}
>
<RenderComponent mappedComponent={List} />
<DocumentDrawer onSave={onCreateNew} />
</TableColumnsProvider>
</ListQueryProvider>
</ListInfoProvider>
</>
)
}

View File

@@ -1,5 +1,5 @@
'use client'
import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef, useState } from 'react'
export type SearchFilterProps = {
fieldName?: string
@@ -12,32 +12,53 @@ export type SearchFilterProps = {
import type { ParsedQs } from 'qs-esm'
import { usePathname } from 'next/navigation.js'
import { useDebounce } from '../../hooks/useDebounce.js'
import './index.scss'
const baseClass = 'search-filter'
export const SearchFilter: React.FC<SearchFilterProps> = (props) => {
const { handleChange, initialParams, label, setValue, value } = props
const previousSearch = useRef(
typeof initialParams?.search === 'string' ? initialParams?.search : '',
const { handleChange, initialParams, label } = props
const pathname = usePathname()
const [search, setSearch] = useState(
typeof initialParams?.search === 'string' ? initialParams?.search : undefined,
)
const debouncedSearch = useDebounce(value, 300)
/**
* Tracks whether the state should be updated based on the search value.
* If the value is updated from the URL, we don't want to update the state as it causes additional renders.
*/
const shouldUpdateState = useRef(true)
/**
* Tracks the previous search value to compare with the current debounced search value.
*/
const previousSearch = useRef(
typeof initialParams?.search === 'string' ? initialParams?.search : undefined,
)
const debouncedSearch = useDebounce(search, 300)
useEffect(() => {
if (debouncedSearch !== previousSearch.current) {
if (initialParams?.search !== previousSearch.current) {
shouldUpdateState.current = false
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setSearch(initialParams?.search as string)
previousSearch.current = initialParams?.search as string
}
}, [initialParams?.search, pathname])
useEffect(() => {
if (debouncedSearch !== previousSearch.current && shouldUpdateState.current) {
if (handleChange) {
handleChange(debouncedSearch)
}
previousSearch.current = debouncedSearch
}
}, [debouncedSearch, previousSearch, handleChange])
// Cleans up the search input when the component is unmounted
useEffect(() => () => setValue(''), [])
}, [debouncedSearch, handleChange])
return (
<div className={baseClass}>
@@ -45,10 +66,13 @@ export const SearchFilter: React.FC<SearchFilterProps> = (props) => {
aria-label={label}
className={`${baseClass}__input`}
id="search-filter-input"
onChange={(e) => setValue(e.target.value)}
onChange={(e) => {
shouldUpdateState.current = true
setSearch(e.target.value)
}}
placeholder={label}
type="text"
value={value || ''}
value={search || ''}
/>
</div>
)

View File

@@ -5,7 +5,6 @@ import React from 'react'
import { ChevronIcon } from '../../icons/Chevron/index.js'
import { useListQuery } from '../../providers/ListQuery/index.js'
import { useSearchParams } from '../../providers/SearchParams/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import './index.scss'
@@ -20,11 +19,10 @@ const baseClass = 'sort-column'
export const SortColumn: React.FC<SortColumnProps> = (props) => {
const { name, disable = false, Label, label } = props
const { searchParams } = useSearchParams()
const { handleSortChange } = useListQuery()
const { handleSortChange, params } = useListQuery()
const { t } = useTranslation()
const { sort } = searchParams
const { sort } = params
const desc = `-${name}`
const asc = name

View File

@@ -91,6 +91,8 @@
&__dropzoneContent {
display: flex;
flex-wrap: wrap;
gap: base(0.4);
justify-content: space-between;
width: 100%;
}
@@ -106,6 +108,7 @@
}
&__dragAndDropText {
flex-shrink: 0;
margin: 0;
text-transform: lowercase;
align-self: center;

View File

@@ -7,7 +7,6 @@ import React, { useEffect, useState } from 'react'
import type { WhereBuilderProps } from './types.js'
import { useListQuery } from '../../providers/ListQuery/index.js'
import { useSearchParams } from '../../providers/SearchParams/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import { Condition } from './Condition/index.js'
@@ -31,11 +30,11 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
const [reducedFields, setReducedColumns] = useState(() => reduceClientFields({ fields, i18n }))
useEffect(() => {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setReducedColumns(reduceClientFields({ fields, i18n }))
}, [fields, i18n])
const { searchParams } = useSearchParams()
const { handleWhereChange } = useListQuery()
const { handleWhereChange, params } = useListQuery()
const [shouldUpdateQuery, setShouldUpdateQuery] = React.useState(false)
// This handles initializing the where conditions from the search query (URL). That way, if you pass in
@@ -67,7 +66,7 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
*/
const [conditions, setConditions] = React.useState(() => {
const whereFromSearch = searchParams.where
const whereFromSearch = params.where
if (whereFromSearch) {
if (validateWhereQuery(whereFromSearch)) {
return whereFromSearch.or

View File

@@ -284,7 +284,7 @@ const BlocksFieldComponent: React.FC<BlockFieldProps> = (props) => {
if (blockToRender) {
const rowErrorCount = errorPaths.filter((errorPath) =>
errorPath.startsWith(`${path}.${i}`),
errorPath.startsWith(`${path}.${i}.`),
).length
return (
<DraggableSortableItem disabled={disabled || !isSortable} id={row.id} key={row.id}>

View File

@@ -62,6 +62,15 @@ export function UploadComponentHasMany(props: Props) {
>
{fileDocs.map(({ relationTo, value }, index) => {
const id = String(value.id)
const url: string = value.thumbnailURL || value.url
let src: string
try {
src = new URL(url, serverURL).toString()
} catch {
src = `${serverURL}${url}`
}
return (
<DraggableSortableItem disabled={!isSortable} id={id} key={id}>
{(draggableSortableItemProps) => (
@@ -100,7 +109,7 @@ export function UploadComponentHasMany(props: Props) {
id={id}
mimeType={value?.mimeType as string}
onRemove={() => removeItem(index)}
src={`${serverURL}${value.url}`}
src={src}
withMeta={false}
x={value?.width as number}
y={value?.height as number}

View File

@@ -26,6 +26,15 @@ export function UploadComponentHasOne(props: Props) {
const { relationTo, value } = fileDoc
const id = String(value.id)
const url: string = value.thumbnailURL || value.url
let src: string
try {
src = new URL(url, serverURL).toString()
} catch {
src = `${serverURL}${url}`
}
return (
<UploadCard className={[baseClass, className].filter(Boolean).join(' ')}>
<RelationshipContent
@@ -38,7 +47,7 @@ export function UploadComponentHasOne(props: Props) {
id={id}
mimeType={value?.mimeType as string}
onRemove={onRemove}
src={`${serverURL}${value.url}`}
src={src}
x={value?.width as number}
y={value?.height as number}
/>

View File

@@ -9,6 +9,8 @@
&__dropzoneContent {
display: flex;
flex-wrap: wrap;
gap: base(0.4);
justify-content: space-between;
width: 100%;
}
@@ -34,6 +36,7 @@
}
&__dragAndDropText {
flex-shrink: 0;
margin: 0;
text-transform: lowercase;
align-self: center;

View File

@@ -9,6 +9,13 @@ type useThrottledEffect = (
deps: React.DependencyList,
) => void
/**
* A hook that will throttle the execution of a callback function inside a useEffect.
* This is useful for things like throttling loading states or other UI updates.
* @param callback The callback function to be executed.
* @param delay The delay in milliseconds to throttle the callback.
* @param deps The dependencies to watch for changes.
*/
export const useThrottledEffect: useThrottledEffect = (callback, delay, deps = []) => {
const lastRan = useRef(Date.now())

View File

@@ -4,7 +4,7 @@ import type { PaginatedDocs, Where } from 'payload'
import { useRouter } from 'next/navigation.js'
import { isNumber } from 'payload/shared'
import * as qs from 'qs-esm'
import React, { createContext, useContext } from 'react'
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
import type { Column } from '../../elements/Table/index.js'
@@ -27,6 +27,7 @@ type ContextHandlers = {
handleSearchChange?: (search: string) => Promise<void>
handleSortChange?: (sort: string) => Promise<void>
handleWhereChange?: (where: Where) => Promise<void>
params: RefineOverrides
}
export type ListQueryProps = {
@@ -35,6 +36,11 @@ export type ListQueryProps = {
readonly defaultLimit?: number
readonly defaultSort?: string
readonly modifySearchParams?: boolean
/**
* Used to manage the query params manually. If you pass this prop, the provider will not manage the query params from the searchParams.
* Useful for modals or other components that need to manage the query params themselves.
*/
readonly params?: RefineOverrides
readonly preferenceKey?: string
} & PropHandlers
@@ -68,14 +74,15 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
handleSortChange: handleSortChangeFromProps,
handleWhereChange: handleWhereChangeFromProps,
modifySearchParams,
params: paramsFromProps,
preferenceKey,
}) => {
const router = useRouter()
const { setPreference } = usePreferences()
const hasSetInitialParams = React.useRef(false)
const { searchParams: currentQuery } = useSearchParams()
const [params, setParams] = useState(paramsFromProps || currentQuery)
const refineListData = React.useCallback(
const refineListData = useCallback(
async (query: RefineOverrides) => {
if (!modifySearchParams) {
return
@@ -114,10 +121,20 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
router.replace(`${qs.stringify(params, { addQueryPrefix: true })}`)
},
[preferenceKey, modifySearchParams, router, setPreference, currentQuery],
[
modifySearchParams,
currentQuery?.page,
currentQuery?.limit,
currentQuery?.search,
currentQuery?.sort,
currentQuery?.where,
preferenceKey,
router,
setPreference,
],
)
const handlePageChange = React.useCallback(
const handlePageChange = useCallback(
async (arg: number) => {
if (typeof handlePageChangeFromProps === 'function') {
await handlePageChangeFromProps(arg)
@@ -134,23 +151,25 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
await handlePerPageChangeFromProps(arg)
}
await refineListData({ limit: String(arg) })
await refineListData({ limit: String(arg), page: '1' })
},
[refineListData, handlePerPageChangeFromProps],
)
const handleSearchChange = React.useCallback(
const handleSearchChange = useCallback(
async (arg: string) => {
const search = arg === '' ? undefined : arg
if (typeof handleSearchChangeFromProps === 'function') {
await handleSearchChangeFromProps(arg)
await handleSearchChangeFromProps(search)
}
await refineListData({ search: arg })
await refineListData({ search })
},
[refineListData, handleSearchChangeFromProps],
[handleSearchChangeFromProps, refineListData],
)
const handleSortChange = React.useCallback(
const handleSortChange = useCallback(
async (arg: string) => {
if (typeof handleSortChangeFromProps === 'function') {
await handleSortChangeFromProps(arg)
@@ -161,7 +180,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
[refineListData, handleSortChangeFromProps],
)
const handleWhereChange = React.useCallback(
const handleWhereChange = useCallback(
async (arg: Where) => {
if (typeof handleWhereChangeFromProps === 'function') {
await handleWhereChangeFromProps(arg)
@@ -172,8 +191,11 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
[refineListData, handleWhereChangeFromProps],
)
React.useEffect(() => {
if (!hasSetInitialParams.current) {
useEffect(() => {
if (paramsFromProps) {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setParams(paramsFromProps)
} else {
if (modifySearchParams) {
let shouldUpdateQueryString = false
@@ -187,14 +209,15 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
shouldUpdateQueryString = true
}
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setParams(currentQuery)
if (shouldUpdateQueryString) {
router.replace(`?${qs.stringify(currentQuery)}`)
}
}
hasSetInitialParams.current = true
}
}, [defaultSort, defaultLimit, router, modifySearchParams, currentQuery])
}, [defaultSort, defaultLimit, router, modifySearchParams, currentQuery, paramsFromProps, params])
return (
<Context.Provider
@@ -205,6 +228,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
handleSearchChange,
handleSortChange,
handleWhereChange,
params,
refineListData,
}}
>

View File

@@ -129,10 +129,16 @@ export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalD
}
let some = false
let all = true
Object.values(selected).forEach((val) => {
all = all && val
some = some || val
})
if (!Object.values(selected).length) {
all = false
some = false
} else {
Object.values(selected).forEach((val) => {
all = all && val
some = some || val
})
}
if (all) {
setSelectAll(SelectAllStatus.AllInPage)

View File

@@ -220,9 +220,6 @@ export default buildConfigWithDefaults({
slug: relationWithTitleSlug,
},
{
admin: {
useAsTitle: 'name',
},
fields: [
{
fields: [

View File

@@ -4,9 +4,6 @@ import { defaultEmail, emailFieldsSlug } from './shared.js'
const EmailFields: CollectionConfig = {
slug: emailFieldsSlug,
admin: {
useAsTitle: 'text',
},
defaultSort: 'id',
fields: [
{

View File

@@ -0,0 +1,31 @@
'use client'
import type { PayloadClientReactComponent, SanitizedConfig } from 'payload'
import { NavGroup, useConfig } from '@payloadcms/ui'
import LinkImport from 'next/link.js'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
import React from 'react'
const baseClass = 'after-nav-links'
export const AfterNavLinks: PayloadClientReactComponent<
SanitizedConfig['admin']['components']['afterNavLinks'][0]
> = () => {
const {
config: {
routes: { admin: adminRoute },
},
} = useConfig()
return (
<NavGroup key="extra-links" label="Extra Links">
{/* Open link to payload admin url */}
{/* <Link href={`${adminRoute}/collections/uploads`}>Internal Payload Admin Link</Link> */}
{/* Open link to payload admin url with prefiltered query */}
<Link href={`${adminRoute}/collections/uploads?page=1&search=jpg&limit=10`}>
Prefiltered Media
</Link>
</NavGroup>
)
}

View File

@@ -105,6 +105,9 @@ export default buildConfigWithDefaults({
importMap: {
baseDir: path.resolve(dirname),
},
components: {
afterNavLinks: ['/components/AfterNavLinks.js#AfterNavLinks'],
},
custom: {
client: {
'new-value': 'client available',

View File

@@ -3,8 +3,5 @@ import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'title',
},
fields: [],
}