diff --git a/.github/workflows/audit-dependencies.sh b/.github/workflows/audit-dependencies.sh index 107c2a34a4..ae284744e9 100755 --- a/.github/workflows/audit-dependencies.sh +++ b/.github/workflows/audit-dependencies.sh @@ -1,18 +1,20 @@ #!/bin/bash -severity=${1:-"critical"} -audit_json=$(pnpm audit --prod --json) +severity=${1:-"high"} output_file="audit_output.json" echo "Auditing for ${severity} vulnerabilities..." +audit_json=$(pnpm audit --prod --json) + echo "${audit_json}" | jq --arg severity "${severity}" ' .advisories | to_entries | - map(select(.value.patched_versions != "<0.0.0" and .value.severity == $severity) | + map(select(.value.patched_versions != "<0.0.0" and (.value.severity == $severity or ($severity == "high" and .value.severity == "critical"))) | { package: .value.module_name, vulnerable: .value.vulnerable_versions, - fixed_in: .value.patched_versions + fixed_in: .value.patched_versions, + findings: .value.findings } ) ' >$output_file @@ -22,7 +24,11 @@ audit_length=$(jq 'length' $output_file) if [[ "${audit_length}" -gt "0" ]]; then echo "Actionable vulnerabilities found in the following packages:" jq -r '.[] | "\u001b[1m\(.package)\u001b[0m vulnerable in \u001b[31m\(.vulnerable)\u001b[0m fixed in \u001b[32m\(.fixed_in)\u001b[0m"' $output_file | while read -r line; do echo -e "$line"; done + echo "" echo "Output written to ${output_file}" + cat $output_file + echo "" + echo "This script can be rerun with: './.github/workflows/audit-dependencies.sh $severity'" exit 1 else echo "No actionable vulnerabilities" diff --git a/.github/workflows/audit-dependencies.yml b/.github/workflows/audit-dependencies.yml index 043ef633e9..1280166816 100644 --- a/.github/workflows/audit-dependencies.yml +++ b/.github/workflows/audit-dependencies.yml @@ -9,7 +9,7 @@ on: audit-level: description: The level of audit to run (low, moderate, high, critical) required: false - default: critical + default: high debug: description: Enable debug logging required: false @@ -46,7 +46,7 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": "🚨 Actionable vulnerabilities found: " + "text": "🚨 Actionable vulnerabilities found: " } }, ] diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9ca829bcbe..96415ff778 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -153,6 +153,7 @@ jobs: matrix: database: - mongodb + - firestore - postgres - postgres-custom-schema - postgres-uuid @@ -283,6 +284,8 @@ jobs: - fields__collections__Text - fields__collections__UI - fields__collections__Upload + - group-by + - folders - hooks - lexical__collections__Lexical__e2e__main - lexical__collections__Lexical__e2e__blocks @@ -301,6 +304,7 @@ jobs: - plugin-nested-docs - plugin-seo - sort + - trash - versions - uploads env: @@ -417,6 +421,8 @@ jobs: - fields__collections__Text - fields__collections__UI - fields__collections__Upload + - group-by + - folders - hooks - lexical__collections__Lexical__e2e__main - lexical__collections__Lexical__e2e__blocks @@ -435,6 +441,7 @@ jobs: - plugin-nested-docs - plugin-seo - sort + - trash - versions - uploads env: @@ -718,6 +725,8 @@ jobs: DO_NOT_TRACK: 1 # Disable Turbopack telemetry - name: Analyze esbuild bundle size + # Temporarily disable this for community PRs until this can be implemented in a separate workflow + if: github.event.pull_request.head.repo.fork == false uses: exoego/esbuild-bundle-analyzer@v1 with: metafiles: 'packages/payload/meta_index.json,packages/payload/meta_shared.json,packages/ui/meta_client.json,packages/ui/meta_shared.json,packages/next/meta_index.json,packages/richtext-lexical/meta_client.json' diff --git a/.github/workflows/post-release-templates.yml b/.github/workflows/post-release-templates.yml index 71c0a9739c..bf6f1f0b3f 100644 --- a/.github/workflows/post-release-templates.yml +++ b/.github/workflows/post-release-templates.yml @@ -82,6 +82,11 @@ jobs: with: mongodb-version: 6.0 + # The template generation script runs import map generation which needs the built payload bin scripts + - run: pnpm run build:all + env: + DO_NOT_TRACK: 1 # Disable Turbopack telemetry + - name: Update template lockfiles and migrations run: pnpm script:gen-templates diff --git a/.github/workflows/post-release.yml b/.github/workflows/post-release.yml index daea79ead2..e765150a9e 100644 --- a/.github/workflows/post-release.yml +++ b/.github/workflows/post-release.yml @@ -17,6 +17,9 @@ env: jobs: post_release: + permissions: + issues: write + pull-requests: write runs-on: ubuntu-24.04 if: ${{ github.event_name != 'workflow_dispatch' }} steps: diff --git a/.vscode/launch.json b/.vscode/launch.json index 26980151e0..74b52f6d21 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -139,6 +139,13 @@ "request": "launch", "type": "node-terminal" }, + { + "command": "pnpm tsx --no-deprecation test/dev.ts trash", + "cwd": "${workspaceFolder}", + "name": "Run Dev Trash", + "request": "launch", + "type": "node-terminal" + }, { "command": "pnpm tsx --no-deprecation test/dev.ts uploads", "cwd": "${workspaceFolder}", diff --git a/docs/admin/overview.mdx b/docs/admin/overview.mdx index 069357d585..30be428847 100644 --- a/docs/admin/overview.mdx +++ b/docs/admin/overview.mdx @@ -77,7 +77,7 @@ All auto-generated files will contain the following comments at the top of each ## Admin Options -All options for the Admin Panel are defined in your [Payload Config](../configuration/overview) under the `admin` property: +All root-level options for the Admin Panel are defined in your [Payload Config](../configuration/overview) under the `admin` property: ```ts import { buildConfig } from 'payload' diff --git a/docs/admin/react-hooks.mdx b/docs/admin/react-hooks.mdx index 0fc05b4933..5640f185ca 100644 --- a/docs/admin/react-hooks.mdx +++ b/docs/admin/react-hooks.mdx @@ -114,7 +114,12 @@ const MyComponent: React.FC = () => { ## useAllFormFields -**To retrieve more than one field**, you can use the `useAllFormFields` hook. Your component will re-render when _any_ field changes, so use this hook only if you absolutely need to. Unlike the `useFormFields` hook, this hook does not accept a "selector", and it always returns an array with type of `[fields: Fields, dispatch: React.Dispatch]]`. +**To retrieve more than one field**, you can use the `useAllFormFields` hook. Unlike the `useFormFields` hook, this hook does not accept a "selector", and it always returns an array with type of `[fields: Fields, dispatch: React.Dispatch]]`. + + + **Warning:** Your component will re-render when _any_ field changes, so use + this hook only if you absolutely need to. + You can do lots of powerful stuff by retrieving the full form state, like using built-in helper functions to reduce field state to values only, or to retrieve sibling data by path. diff --git a/docs/cloud/configuration.mdx b/docs/cloud/configuration.mdx deleted file mode 100644 index 6bb352ef96..0000000000 --- a/docs/cloud/configuration.mdx +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: Project Configuration -label: Configuration -order: 20 -desc: Quickly configure and deploy your Payload Cloud project in a few simple steps. -keywords: configuration, config, settings, project, cloud, payload cloud, deploy, deployment ---- - -## Select your plan - -Once you have created a project, you will need to select your plan. This will determine the resources that are allocated to your project and the features that are available to you. - - - Note: All Payload Cloud teams that deploy a project require a card on file. - This helps us prevent fraud and abuse on our platform. If you select a plan - with a free trial, you will not be charged until your trial period is over. - We’ll remind you 7 days before your trial ends and you can cancel anytime. - - -## Project Details - -| Option | Description | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Region** | Select the region closest to your audience. This will ensure the fastest communication between your data and your client. | -| **Project Name** | A name for your project. You can change this at any time. | -| **Project Slug** | Choose a unique slug to identify your project. This needs to be unique for your team and you can change it any time. | -| **Team** | Select the team you want to create the project under. If this is your first project, a personal team will be created for you automatically. You can modify your team settings and invite new members at any time from the Team Settings page. | - -## Build Settings - -If you are deploying a new project from a template, the following settings will be automatically configured for you. If you are using your own repository, you need to make sure your build settings are accurate for your project to deploy correctly. - -| Option | Description | -| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Root Directory** | The folder where your `package.json` file lives. | -| **Install Command** | The command used to install your modules, for example: `yarn install` or `npm install` | -| **Build Command** | The command used to build your application, for example: `yarn build` or `npm run build` | -| **Serve Command** | The command used to serve your application, for example: `yarn serve` or `npm run serve` | -| **Branch to Deploy** | Select the branch of your repository that you want to deploy from. This is the branch that will be used to build your project when you commit new changes. | -| **Default Domain** | Set a default domain for your project. This must be unique and you will not able to change it. You can always add a custom domain later in your project settings. | - -## Environment Variables - -Any of the features in Payload Cloud that require environment variables will automatically be provided to your application. If your app requires any custom environment variables, you can set them here. - - - Note: For security reasons, any variables you wish to provide to the [Admin - Panel](../admin/overview) must be prefixed with `NEXT_PUBLIC_`.  Learn more - [here](../configuration/environment-vars). - - -## Payment - -Payment methods can be set per project and can be updated any time. You can use team’s default payment method, or add a new one. Modify your payment methods in your Project settings / Team settings. - - - **Note:** All Payload Cloud teams that deploy a project require a card on - file. This helps us prevent fraud and abuse on our platform. If you select a - plan with a free trial, you will not be charged until your trial period is - over. We’ll remind you 7 days before your trial ends and you can cancel - anytime. - diff --git a/docs/cloud/creating-a-project.mdx b/docs/cloud/creating-a-project.mdx deleted file mode 100644 index cabda09025..0000000000 --- a/docs/cloud/creating-a-project.mdx +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Getting Started -label: Getting Started -order: 10 -desc: Get started with Payload Cloud, a deployment solution specifically designed for Node + MongoDB applications. -keywords: cloud, hosted, database, storage, email, deployment, serverless, node, mongodb, s3, aws, cloudflare, atlas, resend, payload, cms ---- - -A deployment solution specifically designed for Node.js + MongoDB applications, offering seamless deployment of your entire stack in one place. You can get started in minutes with a one-click template or bring your own codebase with you. - -Payload Cloud offers various plans tailored to meet your specific needs, including a MongoDB Atlas database, S3 file storage, and email delivery powered by [Resend](https://resend.com). To see a full breakdown of features and plans, see our [Cloud Pricing page](https://payloadcms.com/cloud-pricing). - -To get started, you first need to create an account. Head over to [the login screen](https://payloadcms.com/login) and **Register for Free**. - - - To create your first project, you can either select [a - template](#starting-from-a-template) or [import an existing - project](#importing-from-an-existing-codebase) from GitHub. - - -## Starting from a Template - -Templates come preconfigured and provide a one-click solution to quickly deploy a new application. - -![Screen for creating a new project from a template](https://payloadcms.com/images/docs/cloud/create-from-template.jpg) -_Creating a new project from a template._ - -After creating an account, select your desired template from the Projects page. At this point, you need to connect to authorize the Payload Cloud application with your GitHub account. Click Continue with GitHub and follow the prompts to authorize the app. - -Next, select your `GitHub Scope`. If you belong to multiple organizations, they will show up here. If you do not see the organization you are looking for, you may need to adjust your GitHub app permissions. - -After selecting your scope, create a unique `repository name` and select whether you want your repository to be public or private on GitHub. - - - **Note:** Public repositories can be accessed by anyone online, while private - repositories grant access only to you and anyone you explicitly authorize. - - -Once you are ready, click **Create Project**. This will clone the selected template to a new repository in your GitHub account, and take you to the configuration page to set up your project for deployment. - -## Importing from an Existing Codebase - -Payload Cloud works for any Node.js + MongoDB app. From the New Project page, select **import an existing Git codebase**. Choose the organization and select the repository you want to import. From here, you will be taken to the configuration page to set up your project for deployment. - -![Screen for creating a new project from an existing repository](https://payloadcms.com/images/docs/cloud/create-from-existing.jpg) -_Creating a new project from an existing repository._ - - - **Note:** In order to make use of the features of Payload Cloud in your own - codebase, you will need to add the [Cloud - Plugin](https://github.com/payloadcms/payload/tree/main/packages/payload-cloud) - to your Payload app. - diff --git a/docs/cloud/projects.mdx b/docs/cloud/projects.mdx deleted file mode 100644 index 79df6a69bb..0000000000 --- a/docs/cloud/projects.mdx +++ /dev/null @@ -1,137 +0,0 @@ ---- -title: Cloud Projects -label: Projects -order: 40 -desc: Manage your Payload Cloud projects. -keywords: cloud, payload cloud, projects, project, overview, database, file storage, build settings, environment variables, custom domains, email, developing locally ---- - -## Overview - - - The overview tab shows your most recent deployment, along with build and - deployment logs. From here, you can see your live URL, deployment details like - timestamps and commit hash, as well as the status of your deployment. You can - also trigger a redeployment manually, which will rebuild your project using - the current configuration. - - -![Payload Cloud Overview Page](https://payloadcms.com/images/docs/cloud/overview-page.jpg) -_A screenshot of the Overview page for a Cloud project._ - -## Database - -Your Payload Cloud project comes with a MongoDB serverless Atlas DB instance or a Dedicated Atlas cluster, depending on your plan. To interact with your cloud database, you will be provided with a MongoDB connection string. This can be found under the **Database** tab of your project. - -`mongodb+srv://your_connection_string` - -## File Storage - -Payload Cloud gives you S3 file storage backed by Cloudflare as a CDN, and this plugin extends Payload so that all of your media will be stored in S3 rather than locally. - -AWS Cognito is used for authentication to your S3 bucket. The [Payload Cloud Plugin](https://github.com/payloadcms/payload/tree/main/packages/payload-cloud) will automatically pick up these values. These values are only if you'd like to access your files directly, outside of Payload Cloud. - -### Accessing Files Outside of Payload Cloud - -If you'd like to access your files outside of Payload Cloud, you'll need to retrieve some values from your project's settings and put them into your environment variables. In Payload Cloud, navigate to the File Storage tab and copy the values using the copy button. Put these values in your .env file. Also copy the Cognito Password value separately and put into your .env file as well. - -When you are done, you should have the following values in your .env file: - -```env -PAYLOAD_CLOUD=true -PAYLOAD_CLOUD_ENVIRONMENT=prod -PAYLOAD_CLOUD_COGNITO_USER_POOL_CLIENT_ID= -PAYLOAD_CLOUD_COGNITO_USER_POOL_ID= -PAYLOAD_CLOUD_COGNITO_IDENTITY_POOL_ID= -PAYLOAD_CLOUD_PROJECT_ID= -PAYLOAD_CLOUD_BUCKET= -PAYLOAD_CLOUD_BUCKET_REGION= -PAYLOAD_CLOUD_COGNITO_PASSWORD= -``` - -The plugin will pick up these values and use them to access your files. - -## Build Settings - -You can update settings from your Project’s Settings tab. Changes to your build settings will trigger a redeployment of your project. - -## Environment Variables - -From the Environment Variables page of the Settings tab, you can add, update and delete variables for use in your project. Like build settings, these changes will trigger a redeployment of your project. - - - Note: For security reasons, any variables you wish to provide to the [Admin - Panel](../admin/overview) must be prefixed with `NEXT_PUBLIC_`. [More - details](../configuration/environment-vars). - - -## Custom Domains - -With Payload Cloud, you can add custom domain names to your project. To do so, first go to the Domains page of the Settings tab of your project. Here you can see your default domain. To add a new domain, type in the domain name you wish to use. - - - Note: do not include the protocol (http:// or https://) or any paths (/page). - Only include the domain name and extension, and optionally a subdomain. - - your-domain.com - backend.your-domain.com - - -Once you click save, a DNS record will be generated for your domain name to point to your live project. Add this record into your DNS provider’s records, and once the records are resolving properly (this can take 1hr to 48hrs in some cases), your domain will now to point to your live project. - -You will also need to configure your Payload project to use your specified domain. In your `payload.config.ts` file, specify your `serverURL` with your domain: - -```ts -export default buildConfig({ - serverURL: 'https://example.com', - // the rest of your config, -}) -``` - -## Email - -Powered by [Resend](https://resend.com), Payload Cloud comes with integrated email support out of the box. No configuration is needed, and you can use `payload.sendEmail()` to send email right from your Payload app. To learn more about sending email with Payload, checkout the [Email Configuration](../email/overview) overview. - -If you are on the Pro or Enterprise plan, you can add your own custom Email domain name. From the Email page of your project’s Settings, add the domain you wish to use for email delivery. This will generate a set of DNS records. Add these records to your DNS provider and click verify to check that your records are resolving properly. Once verified, your emails will now be sent from your custom domain name. - -## Developing Locally - -To make changes to your project, you will need to clone the repository defined in your project settings to your local machine. In order to run your project locally, you will need configure your local environment first. Refer to your repository’s `README.md` file to see the steps needed for your specific template. - -From there, you are ready to make updates to your project. When you are ready to make your changes live, commit your changes to the branch you specified in your Project settings, and your application will automatically trigger a redeploy and build from your latest commit. - -## Cloud Plugin - -Projects generated from a template will come pre-configured with the official Cloud Plugin, but if you are using your own repository you will need to add this into your project. To do so, add the plugin to your Payload Config: - -`pnpm add @payloadcms/payload-cloud` - -```js -import { payloadCloudPlugin } from '@payloadcms/payload-cloud' -import { buildConfig } from 'payload' - -export default buildConfig({ - plugins: [payloadCloudPlugin()], - // rest of config -}) -``` - - - **Note:** If your Payload Config already has an email with transport, this - will take precedence over Payload Cloud's email service. - - - - Good to know: the Payload Cloud Plugin was previously named - `@payloadcms/plugin-cloud`. If you are using this plugin, you should update to - the new package name. - - -#### **Optional configuration** - -If you wish to opt-out of any Payload cloud features, the plugin also accepts options to do so. - -```js -payloadCloud({ - storage: false, // Disable file storage - email: false, // Disable email delivery -}) -``` diff --git a/docs/cloud/teams.mdx b/docs/cloud/teams.mdx deleted file mode 100644 index a7f5bd97db..0000000000 --- a/docs/cloud/teams.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: Cloud Teams -label: Teams -order: 30 -desc: Manage your Payload Cloud team and billing settings. -keywords: team, teams, billing, subscription, payment, plan, plans, cloud, payload cloud ---- - - - Within Payload Cloud, the team management feature offers you the ability to - manage your organization, team members, billing, and subscription settings. - - -![Payload Cloud Team Settings](https://payloadcms.com/images/docs/cloud/team-settings.jpg) -_A screenshot of the Team Settings page._ - -## Members - -Each team has members that can interact with your projects. You can invite multiple people to your team and each individual can belong to more than one team. You can assign them either `owner` or `user` permissions. Owners are able to make admin-only changes, such as deleting projects, and editing billing information. - -## Adding Members - -To add a new member to your team, visit your Team’s Settings page, and click “Invite Teammate”. You can then add their email address, and assign their role. Press “Save” to send the invitations, which will send an email to the invited team member where they can create a new account. - -## Billing - -Users can update billing settings and subscriptions for any teams where they are designated as an `owner`. To make updates to the team’s payment methods, visit the Billing page under the Team Settings tab. You can add new cards, delete cards, and set a payment method as a default. The default payment method will be used in the event that another payment method fails. - -## Subscriptions - -From the Subscriptions page, a team owner can see all current plans for their team. From here, you can see the price of each plan, if there is an active trial, and when you will be billed next. - -## Invoices - -The Invoices page will you show you the invoices for your account, as well as the status on their payment. diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 66b11f06f0..eac4efcbe9 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -79,6 +79,7 @@ The following options are available: | `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). | | `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. | | `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. | +| `trash` | A boolean to enable soft deletes for this collection. Defaults to `false`. [More details](../trash/overview). | | `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. | | `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. | | `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). | @@ -130,6 +131,7 @@ The following options are available: | `description` | Text to display below the Collection label in the List View to give editors more information. Alternatively, you can use the `admin.components.Description` to render a React component. [More details](#custom-components). | | `defaultColumns` | Array of field names that correspond to which columns to show by default in this Collection's List View. | | `disableCopyToLocale` | Disables the "Copy to Locale" button while editing documents within this Collection. Only applicable when localization is enabled. | +| `groupBy` | Beta. Enable grouping by a field in the list view. | | `hideAPIURL` | Hides the "API URL" meta field while editing documents within this Collection. | | `enableRichTextLink` | The [Rich Text](../fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. | | `enableRichTextRelationship` | The [Rich Text](../fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. | @@ -140,7 +142,7 @@ The following options are available: | `components` | Swap in your own React components to be used within this Collection. [More details](#custom-components). | | `listSearchableFields` | Specify which fields should be searched in the List search view. [More details](#list-searchable-fields). | | `pagination` | Set pagination-specific options for this Collection. [More details](#pagination). | -| `baseListFilter` | You can define a default base filter for this collection's List view, which will be merged into any filters that the user performs. | +| `baseFilter` | Defines a default base filter which will be applied to the List View (along with any other filters applied by the user) and internal links in Lexical Editor, | **Note:** If you set `useAsTitle` to a relationship or join field, it will use diff --git a/docs/configuration/environment-vars.mdx b/docs/configuration/environment-vars.mdx index 6ef525066d..c73d1ae523 100644 --- a/docs/configuration/environment-vars.mdx +++ b/docs/configuration/environment-vars.mdx @@ -1,7 +1,7 @@ --- title: Environment Variables label: Environment Variables -order: 100 +order: 60 desc: Learn how to use Environment Variables in your Payload project --- @@ -72,7 +72,7 @@ const MyClientComponent = () => { } ``` -For more information, check out the [Next.js Documentation](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables). +For more information, check out the [Next.js documentation](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables). ## Outside of Next.js diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index 417849ce9d..cbbd35d068 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -110,7 +110,7 @@ _\* An asterisk denotes that a property is required._ details](../custom-components/overview#accessing-the-payload-config). -### Typescript Config +### TypeScript Config Payload exposes a variety of TypeScript settings that you can leverage. These settings are used to auto-generate TypeScript interfaces for your [Collections](./collections) and [Globals](./globals), and to ensure that Payload uses your [Generated Types](../typescript/overview) for all [Local API](../local-api/overview) methods. @@ -121,10 +121,11 @@ import { buildConfig } from 'payload' export default buildConfig({ // ... + // highlight-start typescript: { - // highlight-line // ... }, + // highlight-end }) ``` @@ -227,7 +228,9 @@ import { buildConfig } from 'payload' export default buildConfig({ // ... - cors: '*', // highlight-line + // highlight-start + cors: '*', + // highlight-end }) ``` diff --git a/docs/custom-components/overview.mdx b/docs/custom-components/overview.mdx index 3ea36e3149..86b8198d81 100644 --- a/docs/custom-components/overview.mdx +++ b/docs/custom-components/overview.mdx @@ -505,3 +505,51 @@ Payload also exports its [SCSS](https://sass-lang.com) library for reuse which i **Note:** You can also drill into Payload's own component styles, or easily apply global, app-wide CSS. More on that [here](../admin/customizing-css). + +## Performance + +An often overlooked aspect of Custom Components is performance. If unchecked, Custom Components can lead to slow load times of the Admin Panel and ultimately a poor user experience. + +This is different from front-end performance of your public-facing site. + + + For more performance tips, see the [Performance + documentation](../performance/overview). + + +### Follow React and Next.js best practices + +All Custom Components are built using [React](https://react.dev). For this reason, it is important to follow React best practices. This includes using memoization, streaming, caching, optimizing renders, using hooks appropriately, and more. + +To learn more, see the [React documentation](https://react.dev/learn). + +The Admin Panel itself is a [Next.js](https://nextjs.org) application. For this reason, it is _also_ important to follow Next.js best practices. This includes bundling, when to use layouts vs pages, where to place the server/client boundary, and more. + +To learn more, see the [Next.js documentation](https://nextjs.org/docs). + +### Reducing initial HTML size + +With Server Components, be aware of what is being sent to through the server/client boundary. All props are serialized and sent through the network. This can lead to large HTML sizes and slow initial load times if too much data is being sent to the client. + +To minimize this, you must be explicit about what props are sent to the client. Prefer server components and only send the necessary props to the client. This will also offset some of the JS execution to the server. + + + **Tip:** Use [React Suspense](https://react.dev/reference/react/Suspense) to + progressively load components and improve perceived performance. + + +### Prevent unnecessary re-renders + +If subscribing your component to form state, it may be re-rendering more often than necessary. + +To do this, use the [`useFormFields`](../admin/react-hooks) hook instead of `useFields` when you only need to access specific fields. + +```ts +'use client' +import { useFormFields } from '@payloadcms/ui' + +const MyComponent: TextFieldClientComponent = ({ path }) => { + const value = useFormFields(([fields, dispatch]) => fields[path]) + // ... +} +``` diff --git a/docs/database/indexes.mdx b/docs/database/indexes.mdx new file mode 100644 index 0000000000..e399320e11 --- /dev/null +++ b/docs/database/indexes.mdx @@ -0,0 +1,65 @@ +--- +title: Indexes +label: Indexes +order: 40 +keywords: database, indexes +desc: Index fields to produce faster queries. +--- + +Database indexes are a way to optimize the performance of your database by allowing it to quickly locate and retrieve data. If you have a field that you frequently query or sort by, adding an index to that field can significantly improve the speed of those operations. + +When your query runs, the database will not scan the entire document to find that one field, but will instead use the index to quickly locate the data. + +To index a field, set the `index` option to `true` in your field's config: + +```ts +import type { CollectionConfig } from 'payload' + +export MyCollection: CollectionConfig = { + // ... + fields: [ + // ... + { + name: 'title', + type: 'text', + // highlight-start + index: true, + // highlight-end + }, + ] +} +``` + + + **Note:** The `id`, `createdAt`, and `updatedAt` fields are indexed by + default. + + + + **Tip:** If you're using MongoDB, you can use [MongoDB + Compass](https://www.mongodb.com/products/compass) to visualize and manage + your indexes. + + +## Compound Indexes + +In addition to indexing single fields, you can also create compound indexes that index multiple fields together. This can be useful for optimizing queries that filter or sort by multiple fields. + +To create a compound index, use the `indexes` option in your [Collection Config](../configuration/collections): + +```ts +import type { CollectionConfig } from 'payload' + +export const MyCollection: CollectionConfig = { + // ... + fields: [ + // ... + ], + indexes: [ + { + fields: ['title', 'createdAt'], + unique: true, // Optional, if you want the combination of fields to be unique + }, + ], +} +``` diff --git a/docs/database/mongodb.mdx b/docs/database/mongodb.mdx index 0be3d87cbe..26a139bae3 100644 --- a/docs/database/mongodb.mdx +++ b/docs/database/mongodb.mdx @@ -1,7 +1,7 @@ --- title: MongoDB label: MongoDB -order: 40 +order: 50 desc: Payload has supported MongoDB natively since we started. The flexible nature of MongoDB lends itself well to Payload's powerful fields. keywords: MongoDB, documentation, typescript, Content Management System, cms, headless, javascript, node, react, nextjs --- @@ -30,18 +30,22 @@ export default buildConfig({ ## Options -| Option | Description | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `autoPluralization` | Tell Mongoose to auto-pluralize any collection names if it encounters any singular words used as collection `slug`s. | -| `connectOptions` | Customize MongoDB connection options. Payload will connect to your MongoDB database using default options which you can override and extend to include all the [options](https://mongoosejs.com/docs/connections.html#options) available to mongoose. | -| `collectionsSchemaOptions` | Customize Mongoose schema options for collections. | -| `disableIndexHints` | Set to true to disable hinting to MongoDB to use 'id' as index. This is currently done when counting documents for pagination, as it increases the speed of the count function used in that query. Disabling this optimization might fix some problems with AWS DocumentDB. Defaults to false | -| `migrationDir` | Customize the directory that migrations are stored. | -| `transactionOptions` | An object with configuration properties used in [transactions](https://www.mongodb.com/docs/manual/core/transactions/) or `false` which will disable the use of transactions. | -| `collation` | Enable language-specific string comparison with customizable options. Available on MongoDB 3.4+. Defaults locale to "en". Example: `{ strength: 3 }`. For a full list of collation options and their definitions, see the [MongoDB documentation](https://www.mongodb.com/docs/manual/reference/collation/). | -| `allowAdditionalKeys` | By default, Payload strips all additional keys from MongoDB data that don't exist in the Payload schema. If you have some data that you want to include to the result but it doesn't exist in Payload, you can set this to `true`. Be careful as Payload access control _won't_ work for this data. | -| `allowIDOnCreate` | Set to `true` to use the `id` passed in data on the create API operations without using a custom ID field. | -| `disableFallbackSort` | Set to `true` to disable the adapter adding a fallback sort when sorting by non-unique fields, this can affect performance in some cases but it ensures a consistent order of results. | +| Option | Description | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `autoPluralization` | Tell Mongoose to auto-pluralize any collection names if it encounters any singular words used as collection `slug`s. | +| `connectOptions` | Customize MongoDB connection options. Payload will connect to your MongoDB database using default options which you can override and extend to include all the [options](https://mongoosejs.com/docs/connections.html#options) available to mongoose. | +| `collectionsSchemaOptions` | Customize Mongoose schema options for collections. | +| `disableIndexHints` | Set to true to disable hinting to MongoDB to use 'id' as index. This is currently done when counting documents for pagination, as it increases the speed of the count function used in that query. Disabling this optimization might fix some problems with AWS DocumentDB. Defaults to false | +| `migrationDir` | Customize the directory that migrations are stored. | +| `transactionOptions` | An object with configuration properties used in [transactions](https://www.mongodb.com/docs/manual/core/transactions/) or `false` which will disable the use of transactions. | +| `collation` | Enable language-specific string comparison with customizable options. Available on MongoDB 3.4+. Defaults locale to "en". Example: `{ strength: 3 }`. For a full list of collation options and their definitions, see the [MongoDB documentation](https://www.mongodb.com/docs/manual/reference/collation/). | +| `allowAdditionalKeys` | By default, Payload strips all additional keys from MongoDB data that don't exist in the Payload schema. If you have some data that you want to include to the result but it doesn't exist in Payload, you can set this to `true`. Be careful as Payload access control _won't_ work for this data. | +| `allowIDOnCreate` | Set to `true` to use the `id` passed in data on the create API operations without using a custom ID field. | +| `disableFallbackSort` | Set to `true` to disable the adapter adding a fallback sort when sorting by non-unique fields, this can affect performance in some cases but it ensures a consistent order of results. | +| `useAlternativeDropDatabase` | Set to `true` to use an alternative `dropDatabase` implementation that calls `collection.deleteMany({})` on every collection instead of sending a raw `dropDatabase` command. Payload only uses `dropDatabase` for testing purposes. Defaults to `false`. | +| `useBigIntForNumberIDs` | Set to `true` to use `BigInt` for custom ID fields of type `'number'`. Useful for databases that don't support `double` or `int32` IDs. Defaults to `false`. | +| `useJoinAggregations` | Set to `false` to disable join aggregations (which use correlated subqueries) and instead populate join fields via multiple `find` queries. Defaults to `true`. | +| `usePipelineInSortLookup` | Set to `false` to disable the use of `pipeline` in the `$lookup` aggregation in sorting. Defaults to `true`. | ## Access to Mongoose models @@ -56,9 +60,21 @@ You can access Mongoose models as follows: ## Using other MongoDB implementations -Limitations with [DocumentDB](https://aws.amazon.com/documentdb/) and [Azure Cosmos DB](https://azure.microsoft.com/en-us/products/cosmos-db): +You can import the `compatabilityOptions` object to get the recommended settings for other MongoDB implementations. Since these databases aren't officially supported by payload, you may still encounter issues even with these settings (please create an issue or PR if you believe these options should be updated): -- For Azure Cosmos DB you must pass `transactionOptions: false` to the adapter options. Azure Cosmos DB does not support transactions that update two and more documents in different collections, which is a common case when using Payload (via hooks). -- For Azure Cosmos DB the root config property `indexSortableFields` must be set to `true`. -- The [Join Field](../fields/join) is not supported in DocumentDB and Azure Cosmos DB, as we internally use MongoDB aggregations to query data for that field, which are limited there. This can be changed in the future. -- For DocumentDB pass `disableIndexHints: true` to disable hinting to the DB to use `id` as index which can cause problems with DocumentDB. +```ts +import { mongooseAdapter, compatabilityOptions } from '@payloadcms/db-mongodb' + +export default buildConfig({ + db: mongooseAdapter({ + url: process.env.DATABASE_URI, + // For example, if you're using firestore: + ...compatabilityOptions.firestore, + }), +}) +``` + +We export compatability options for [DocumentDB](https://aws.amazon.com/documentdb/), [Azure Cosmos DB](https://azure.microsoft.com/en-us/products/cosmos-db) and [Firestore](https://cloud.google.com/firestore/mongodb-compatibility/docs/overview). Known limitations: + +- Azure Cosmos DB does not support transactions that update two or more documents in different collections, which is a common case when using Payload (via hooks). +- Azure Cosmos DB the root config property `indexSortableFields` must be set to `true`. diff --git a/docs/database/postgres.mdx b/docs/database/postgres.mdx index 9f9c0ed046..99e39aca07 100644 --- a/docs/database/postgres.mdx +++ b/docs/database/postgres.mdx @@ -1,7 +1,7 @@ --- title: Postgres label: Postgres -order: 50 +order: 60 desc: Payload supports Postgres through an officially supported Drizzle Database Adapter. keywords: Postgres, documentation, typescript, Content Management System, cms, headless, javascript, node, react, nextjs --- diff --git a/docs/database/sqlite.mdx b/docs/database/sqlite.mdx index 64082da7a3..5c1e9d3753 100644 --- a/docs/database/sqlite.mdx +++ b/docs/database/sqlite.mdx @@ -1,7 +1,7 @@ --- title: SQLite label: SQLite -order: 60 +order: 70 desc: Payload supports SQLite through an officially supported Drizzle Database Adapter. keywords: SQLite, documentation, typescript, Content Management System, cms, headless, javascript, node, react, nextjs --- diff --git a/docs/fields/array.mdx b/docs/fields/array.mdx index 77396b9164..a4c597bd87 100644 --- a/docs/fields/array.mdx +++ b/docs/fields/array.mdx @@ -41,17 +41,17 @@ export const MyArrayField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`label`** | Text used as the heading in the [Admin Panel](../admin/overview) or an object with keys for each language. Auto-generated from name if not defined. | | **`fields`** \* | Array of field types to correspond to each row of the Array. | -| **`validate`** | Provide a custom validation function that will be executed on both the [Admin Panel](../admin/overview) and the backend. [More](/docs/fields/overview#validation) | +| **`validate`** | Provide a custom validation function that will be executed on both the [Admin Panel](../admin/overview) and the backend. [More details](/docs/fields/overview#validation). | | **`minRows`** | A number for the fewest allowed items during validation when a value is present. | | **`maxRows`** | A number for the most allowed items during validation when a value is present. | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide an array of row data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide an array of row data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. If enabled, a separate, localized set of all data within this Array will be kept, so there is no need to specify each nested field as `localized`. | | **`required`** | Require this field to have a value. | | **`labels`** | Customize the row labels appearing in the Admin dashboard. | diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx index 9fbe6c9755..3a840ae71c 100644 --- a/docs/fields/blocks.mdx +++ b/docs/fields/blocks.mdx @@ -41,17 +41,17 @@ export const MyBlocksField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`label`** | Text used as the heading in the Admin Panel or an object with keys for each language. Auto-generated from name if not defined. | | **`blocks`** \* | Array of [block configs](/docs/fields/blocks#block-configs) to be made available to this field. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | | **`minRows`** | A number for the fewest allowed items during validation when a value is present. | | **`maxRows`** | A number for the most allowed items during validation when a value is present. | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API response or the Admin Panel. | -| **`defaultValue`** | Provide an array of block data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide an array of block data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. If enabled, a separate, localized set of all data within this field will be kept, so there is no need to specify each nested field as `localized`. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | | **`labels`** | Customize the block row labels appearing in the Admin dashboard. | diff --git a/docs/fields/checkbox.mdx b/docs/fields/checkbox.mdx index c135b54b47..95c7a04d92 100644 --- a/docs/fields/checkbox.mdx +++ b/docs/fields/checkbox.mdx @@ -30,15 +30,15 @@ export const MyCheckboxField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value, will default to false if field is also `required`. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value, will default to false if field is also `required`. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](./overview#admin-options). | diff --git a/docs/fields/code.mdx b/docs/fields/code.mdx index 0b16ee4838..4cf7f405bc 100644 --- a/docs/fields/code.mdx +++ b/docs/fields/code.mdx @@ -31,18 +31,18 @@ export const MyBlocksField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | | **`minLength`** | Used by the default validation function to ensure values are of a minimum character length. | | **`maxLength`** | Used by the default validation function to ensure values are of a maximum character length. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. See below for [more detail](#admin-options). | diff --git a/docs/fields/date.mdx b/docs/fields/date.mdx index aec8650a2b..3f575a52d8 100644 --- a/docs/fields/date.mdx +++ b/docs/fields/date.mdx @@ -30,15 +30,15 @@ export const MyDateField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | diff --git a/docs/fields/email.mdx b/docs/fields/email.mdx index 55812c4a00..206c74beb1 100644 --- a/docs/fields/email.mdx +++ b/docs/fields/email.mdx @@ -30,16 +30,16 @@ export const MyEmailField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | diff --git a/docs/fields/group.mdx b/docs/fields/group.mdx index 29da25afd0..e3803afea2 100644 --- a/docs/fields/group.mdx +++ b/docs/fields/group.mdx @@ -35,15 +35,15 @@ export const MyGroupField: Field = { | Option | Description | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`fields`** \* | Array of field types to nest within this Group. | | **`label`** | Used as a heading in the Admin Panel and to name the generated GraphQL type. Defaults to the field name, if defined. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide an object of data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide an object of data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. If enabled, a separate, localized set of all data within this Group will be kept, so there is no need to specify each nested field as `localized`. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | diff --git a/docs/fields/join.mdx b/docs/fields/join.mdx index f0e03befb1..67c753183c 100644 --- a/docs/fields/join.mdx +++ b/docs/fields/join.mdx @@ -135,7 +135,7 @@ powerful Admin UI. | Option | Description | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when retrieved from the database. [More](./overview#field-names) | +| **`name`** \* | To be used as the property name when retrieved from the database. [More details](./overview#field-names). | | **`collection`** \* | The `slug`s having the relationship field or an array of collection slugs. | | **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. If `collection` is an array, this field must exist for all specified collections | | **`orderable`** | If true, enables custom ordering and joined documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. | @@ -296,11 +296,16 @@ query { sort: "createdAt" limit: 5 where: { author: { equals: "66e3431a3f23e684075aaeb9" } } + """ + Optionally pass count: true if you want to retrieve totalDocs + """ + count: true -- s ) { docs { title } hasNextPage + totalDocs } } } diff --git a/docs/fields/json.mdx b/docs/fields/json.mdx index db9db40c8f..4b22f75aff 100644 --- a/docs/fields/json.mdx +++ b/docs/fields/json.mdx @@ -31,17 +31,17 @@ export const MyJSONField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | | **`jsonSchema`** | Provide a JSON schema that will be used for validation. [JSON schemas](https://json-schema.org/learn/getting-started-step-by-step) | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | diff --git a/docs/fields/number.mdx b/docs/fields/number.mdx index 8b8813f4a7..3cee5a4bde 100644 --- a/docs/fields/number.mdx +++ b/docs/fields/number.mdx @@ -30,7 +30,7 @@ export const MyNumberField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | | **`min`** | Minimum value accepted. Used in the default `validation` function. | | **`max`** | Maximum value accepted. Used in the default `validation` function. | @@ -38,13 +38,13 @@ export const MyNumberField: Field = { | **`minRows`** | Minimum number of numbers in the numbers array, if `hasMany` is set to true. | | **`maxRows`** | Maximum number of numbers in the numbers array, if `hasMany` is set to true. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 49fbb64aaa..be4ffe2238 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -157,6 +157,7 @@ The following field names are forbidden and cannot be used: - `salt` - `hash` - `file` +- `status` - with Postgres Adapter and when drafts are enabled ### Field-level Hooks @@ -303,7 +304,7 @@ The following additional properties are provided in the `ctx` object: | `path` | The full path to the field in the schema, represented as an array of string segments, including array indexes. I.e `['group', 'myArray', '1', 'textField']`. | | `id` | The `id` of the current document being edited. `id` is `undefined` during the `create` operation. | | `req` | The current HTTP request object. Contains `payload`, `user`, etc. | -| `event` | Either `onChange` or `submit` depending on the current action. Used as a performance opt-in. [More details](#async-field-validations). | +| `event` | Either `onChange` or `submit` depending on the current action. Used as a performance opt-in. [More details](#validation-performance). | #### Localized and Built-in Error Messages @@ -365,11 +366,11 @@ import { } from 'payload/shared' ``` -#### Async Field Validations +#### Validation Performance -Custom validation functions can also be asynchronous depending on your needs. This makes it possible to make requests to external services or perform other miscellaneous asynchronous logic. +When writing async or computationally heavy validation functions, it is important to consider the performance implications. Within the Admin Panel, validations are executed on every change to the field, so they should be as lightweight as possible and only run when necessary. -When writing async validation functions, it is important to consider the performance implications. Validations are executed on every change to the field, so they should be as lightweight as possible. If you need to perform expensive validations, such as querying the database, consider using the `event` property in the `ctx` object to only run the validation on form submission. +If you need to perform expensive validations, such as querying the database, consider using the `event` property in the `ctx` object to only run that particular validation on form submission. To write asynchronous validation functions, use the `async` keyword to define your function: @@ -403,6 +404,11 @@ export const Orders: CollectionConfig = { } ``` + + For more performance tips, see the [Performance + documentation](../performance/overview). + + ## Custom ID Fields All [Collections](../configuration/collections) automatically generate their own ID field. If needed, you can override this behavior by providing an explicit ID field to your config. This field should either be required or have a hook to generate the ID dynamically. diff --git a/docs/fields/point.mdx b/docs/fields/point.mdx index 5a2c2e575b..88cabbae74 100644 --- a/docs/fields/point.mdx +++ b/docs/fields/point.mdx @@ -34,16 +34,16 @@ export const MyPointField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`label`** | Used as a field label in the Admin Panel and to name the generated GraphQL type. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. To support location queries, point index defaults to `2dsphere`, to disable the index set to `false`. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. To support location queries, point index defaults to `2dsphere`, to disable the index set to `false`. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](./overview#admin-options). | diff --git a/docs/fields/radio.mdx b/docs/fields/radio.mdx index 3d02e9d1ed..2e11f3715a 100644 --- a/docs/fields/radio.mdx +++ b/docs/fields/radio.mdx @@ -35,16 +35,16 @@ export const MyRadioField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`options`** \* | Array of options to allow the field to store. Can either be an array of strings, or an array of objects containing a `label` string and a `value` string. | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. The default value must exist within provided values in `options`. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value. The default value must exist within provided values in `options`. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 2b277d6776..4bc7e80834 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -39,22 +39,22 @@ export const MyRelationshipField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`relationTo`** \* | Provide one or many collection `slug`s to be able to assign relationships to. | -| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). | +| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More details](#filtering-relationship-options). | | **`hasMany`** | Boolean when, if set to `true`, allows this field to have many relations instead of only one. | | **`minRows`** | A number for the fewest allowed items during validation when a value is present. Used with `hasMany`. | | **`maxRows`** | A number for the most allowed items during validation when a value is present. Used with `hasMany`. | | **`maxDepth`** | Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](/docs/queries/depth#max-depth) | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | @@ -93,7 +93,7 @@ The Relationship Field inherits all of the default admin options from the base [ | **`isSortable`** | Set to `true` if you'd like this field to be sortable within the Admin UI using drag and drop (only works when `hasMany` is set to `true`). | | **`allowCreate`** | Set to `false` if you'd like to disable the ability to create new documents from within the relationship field. | | **`allowEdit`** | Set to `false` if you'd like to disable the ability to edit documents from within the relationship field. | -| **`sortOptions`** | Define a default sorting order for the options within a Relationship field's dropdown. [More](#sort-options) | +| **`sortOptions`** | Define a default sorting order for the options within a Relationship field's dropdown. [More details](#sort-options) | | **`placeholder`** | Define a custom text or function to replace the generic default placeholder | | **`appearance`** | Set to `drawer` or `select` to change the behavior of the field. Defaults to `select`. | diff --git a/docs/fields/rich-text.mdx b/docs/fields/rich-text.mdx index 78c81917eb..9a864238d2 100644 --- a/docs/fields/rich-text.mdx +++ b/docs/fields/rich-text.mdx @@ -23,14 +23,14 @@ Instead, you can invest your time and effort into learning the underlying open-s | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](./overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](./overview#field-names). | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](./overview#validation) | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](./overview#validation). | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](../authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](./overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value. [More details](./overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](../configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index 07cd04e568..b2fbfca3e7 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -35,18 +35,18 @@ export const MySelectField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`options`** \* | Array of options to allow the field to store. Can either be an array of strings, or an array of objects containing a `label` string and a `value` string. | | **`hasMany`** | Boolean when, if set to `true`, allows this field to have many selections instead of only one. | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. See the [default field admin config](/docs/fields/overview#admin-options) for more details. | diff --git a/docs/fields/tabs.mdx b/docs/fields/tabs.mdx index 5e603dd8cd..723a6d9f13 100644 --- a/docs/fields/tabs.mdx +++ b/docs/fields/tabs.mdx @@ -45,7 +45,7 @@ Each tab must have either a `name` or `label` and the required `fields` array. Y | Option | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** | Groups field data into an object when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** | Groups field data into an object when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`label`** | The label to render on the tab itself. Required when name is undefined, defaults to name converted to words. | | **`fields`** \* | The fields to render within this tab. | | **`description`** | Optionally render a description within this tab to describe the contents of the tab itself. | diff --git a/docs/fields/text.mdx b/docs/fields/text.mdx index 3d2042822b..c1cd4e77e1 100644 --- a/docs/fields/text.mdx +++ b/docs/fields/text.mdx @@ -30,18 +30,18 @@ export const MyTextField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | | **`minLength`** | Used by the default validation function to ensure values are of a minimum character length. | | **`maxLength`** | Used by the default validation function to ensure values are of a maximum character length. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | diff --git a/docs/fields/textarea.mdx b/docs/fields/textarea.mdx index c8913c800d..6c4aa78f52 100644 --- a/docs/fields/textarea.mdx +++ b/docs/fields/textarea.mdx @@ -30,18 +30,18 @@ export const MyTextareaField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | | **`minLength`** | Used by the default validation function to ensure values are of a minimum character length. | | **`maxLength`** | Used by the default validation function to ensure values are of a maximum character length. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`defaultValue`** | Provide data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | diff --git a/docs/fields/ui.mdx b/docs/fields/ui.mdx index 98133a224e..e7d787b88b 100644 --- a/docs/fields/ui.mdx +++ b/docs/fields/ui.mdx @@ -32,8 +32,8 @@ export const MyUIField: Field = { | ------------------------------- | ---------------------------------------------------------------------------------------------------------- | | **`name`** \* | A unique identifier for this field. | | **`label`** | Human-readable label for this UI field. | -| **`admin.components.Field`** \* | React component to be rendered for this field within the Edit View. [More](./overview#field) | -| **`admin.components.Cell`** | React component to be rendered as a Cell within collection List views. [More](./overview#cell) | +| **`admin.components.Field`** \* | React component to be rendered for this field within the Edit View. [More details](./overview#field). | +| **`admin.components.Cell`** | React component to be rendered as a Cell within collection List views. [More details](./overview#cell). | | **`admin.disableListColumn`** | Set `disableListColumn` to `true` to prevent the UI field from appearing in the list view column selector. | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx index 2370c55492..177bcf3dc8 100644 --- a/docs/fields/upload.mdx +++ b/docs/fields/upload.mdx @@ -46,23 +46,23 @@ export const MyUploadField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | | **`relationTo`** \* | Provide a single collection `slug` to allow this field to accept a relation to. **Note: the related collection must be configured to support Uploads.** | -| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-upload-options). | +| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More details](#filtering-upload-options). | | **`hasMany`** | Boolean which, if set to true, allows this field to have many relations instead of only one. | | **`minRows`** | A number for the fewest allowed items during validation when a value is present. Used with hasMany. | | **`maxRows`** | A number for the most allowed items during validation when a value is present. Used with hasMany. | | **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](../queries/depth) | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | -| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More details](/docs/fields/overview#validation). | +| **`index`** | Build an [index](../database/indexes) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | | **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin Panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | -| **`displayPreview`** | Enable displaying preview of the uploaded file. Overrides related Collection's `displayPreview` option. [More](/docs/upload/overview#collection-upload-options). | +| **`defaultValue`** | Provide data to be used for this field's default value. [More details](/docs/fields/overview#default-values). | +| **`displayPreview`** | Enable displaying preview of the uploaded file. Overrides related Collection's `displayPreview` option. [More details](/docs/upload/overview#collection-upload-options). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [Admin Options](./overview#admin-options). | diff --git a/docs/hooks/overview.mdx b/docs/hooks/overview.mdx index c1601e8578..1d1afb4923 100644 --- a/docs/hooks/overview.mdx +++ b/docs/hooks/overview.mdx @@ -93,12 +93,108 @@ All Hooks can be written as either synchronous or asynchronous functions. Choosi #### Asynchronous -If the Hook should modify data before a Document is updated or created, and it relies on asynchronous actions such as fetching data from a third party, it might make sense to define your Hook as an asynchronous function. This way you can be sure that your Hook completes before the operation's lifecycle continues. Async hooks are run in series - so if you have two async hooks defined, the second hook will wait for the first to complete before it starts. +If the Hook should modify data before a Document is updated or created, and it relies on asynchronous actions such as fetching data from a third party, it might make sense to define your Hook as an asynchronous function. This way you can be sure that your Hook completes before the operation's lifecycle continues. + +Async hooks are run in series - so if you have two async hooks defined, the second hook will wait for the first to complete before it starts. + + + **Tip:** If your hook executes a long-running task that doesn't affect the + response in any way, consider [offloading it to the job + queue](#offloading-long-running-tasks). That will free up the request to + continue processing without waiting for the task to complete. + #### Synchronous -If your Hook simply performs a side-effect, such as updating a CRM, it might be okay to define it synchronously, so the Payload operation does not have to wait for your hook to complete. +If your Hook simply performs a side-effect, such as mutating document data, it might be okay to define it synchronously, so the Payload operation does not have to wait for your hook to complete. ## Server-only Execution Hooks are only triggered on the server and are automatically excluded from the client-side bundle. This means that you can safely use sensitive business logic in your Hooks without worrying about exposing it to the client. + +## Performance + +Hooks are a powerful way to customize the behavior of your APIs, but some hooks are run very often and can add significant overhead to your requests if not optimized. + +When building hooks, combine together as many of these strategies as possible to ensure your hooks are as performant as they can be. + + + For more performance tips, see the [Performance + documentation](../performance/overview). + + +### Writing efficient hooks + +Consider when hooks are run. One common pitfall is putting expensive logic in hooks that run very often. + +For example, the `read` operation runs on every read request, so avoid putting expensive logic in a `beforeRead` or `afterRead` hook. + +```ts +{ + hooks: { + beforeRead: [ + async () => { + // This runs on every read request - avoid expensive logic here + await doSomethingExpensive() + return data + }, + ], + }, +} +``` + +Instead, you might want to use a `beforeChange` or `afterChange` hook, which only runs when a document is created or updated. + +```ts +{ + hooks: { + beforeChange: [ + async ({ context }) => { + // This is more acceptable here, although still should be mindful of performance + await doSomethingExpensive() + // ... + }, + ] + }, +} +``` + +### Using Hook Context + +Use [Hook Context](./context) avoid prevent infinite loops or avoid repeating expensive operations across multiple hooks in the same request. + +```ts +{ + hooks: { + beforeChange: [ + async ({ context }) => { + const somethingExpensive = await doSomethingExpensive() + context.somethingExpensive = somethingExpensive + // ... + }, + ], + }, +} +``` + +To learn more, see the [Hook Context documentation](./context). + +### Offloading to the jobs queue + +If your hooks perform any long-running tasks that don't direct affect request lifecycle, consider offloading them to the [jobs queue](../jobs-queue/overview). This will free up the request to continue processing without waiting for the task to complete. + +```ts +{ + hooks: { + afterChange: [ + async ({ doc, req }) => { + // Offload to job queue + await req.payload.jobs.queue(...) + // ... + }, + ], + }, +} +``` + +To learn more, see the [Job Queue documentation](../jobs-queue/overview). diff --git a/docs/integrations/vercel-content-link.mdx b/docs/integrations/vercel-content-link.mdx index 24d077a616..29356b4ec7 100644 --- a/docs/integrations/vercel-content-link.mdx +++ b/docs/integrations/vercel-content-link.mdx @@ -34,20 +34,20 @@ npm i @payloadcms/plugin-csm Then in the `plugins` array of your Payload Config, call the plugin and enable any collections that require Content Source Maps. ```ts -import { buildConfig } from "payload/config" -import contentSourceMaps from "@payloadcms/plugin-csm" +import { buildConfig } from 'payload/config' +import contentSourceMaps from '@payloadcms/plugin-csm' const config = buildConfig({ collections: [ { - slug: "pages", + slug: 'pages', fields: [ { name: 'slug', type: 'text', }, { - name: 'title,' + name: 'title', type: 'text', }, ], @@ -55,7 +55,7 @@ const config = buildConfig({ ], plugins: [ contentSourceMaps({ - collections: ["pages"], + collections: ['pages'], }), ], }) diff --git a/docs/jobs-queue/queues.mdx b/docs/jobs-queue/queues.mdx index b67014c560..6b0172df2c 100644 --- a/docs/jobs-queue/queues.mdx +++ b/docs/jobs-queue/queues.mdx @@ -51,7 +51,7 @@ export default buildConfig({ // add as many cron jobs as you want ], shouldAutoRun: async (payload) => { - // Tell Payload if it should run jobs or not. + // Tell Payload if it should run jobs or not. This function is optional and will return true by default. // This function will be invoked each time Payload goes to pick up and run jobs. // If this function ever returns false, the cron schedule will be stopped. return true diff --git a/docs/jobs-queue/schedules.mdx b/docs/jobs-queue/schedules.mdx new file mode 100644 index 0000000000..45869d8ca6 --- /dev/null +++ b/docs/jobs-queue/schedules.mdx @@ -0,0 +1,155 @@ +--- +title: Job Schedules +label: Schedules +order: 60 +desc: Payload allows you to schedule jobs to run periodically +keywords: jobs queue, application framework, typescript, node, react, nextjs, scheduling, cron, schedule +--- + +Payload's `schedule` property lets you enqueue Jobs regularly according to a cron schedule - daily, weekly, hourly, or any custom interval. This is ideal for tasks or workflows that must repeat automatically and without manual intervention. + +Scheduling Jobs differs significantly from running them: + +- **Queueing**: Scheduling only creates (enqueues) the Job according to your cron expression. It does not immediately execute any business logic. +- **Running**: Execution happens separately through your Jobs runner - such as autorun, or manual invocation using `payload.jobs.run()` or the `payload-jobs/run` endpoint. + +Use the `schedule` property specifically when you have recurring tasks or workflows. To enqueue a single Job to run once in the future, use the `waitUntil` property instead. + +## Example use cases + +**Regular emails or notifications** + +Send nightly digests, weekly newsletters, or hourly updates. + +**Batch processing during off-hours** + +Process analytics data or rebuild static sites during low-traffic times. + +**Periodic data synchronization** + +Regularly push or pull updates to or from external APIs. + +## Handling schedules + +Something needs to actually trigger the scheduling of jobs (execute the scheduling lifecycle seen below). By default, the `jobs.autorun` configuration, as well as the `/api/payload-jobs/run` will also handle scheduling for the queue specified in the `autorun` configuration. + +You can disable this behavior by setting `disableScheduling: true` in your `autorun` configuration, or by passing `disableScheduling=true` to the `/api/payload-jobs/run` endpoint. This is useful if you want to handle scheduling manually, for example, by using a cron job or a serverless function that calls the `/api/payload-jobs/handle-schedules` endpoint or the `payload.jobs.handleSchedules()` local API method. + +## Defining schedules on Tasks or Workflows + +Schedules are defined using the `schedule` property: + +```ts +export type ScheduleConfig = { + cron: string // required, supports seconds precision + queue: string // required, the queue to push Jobs onto + hooks?: { + // Optional hooks to customize scheduling behavior + beforeSchedule?: BeforeScheduleFn + afterSchedule?: AfterScheduleFn + } +} +``` + +### Example schedule + +The following example demonstrates scheduling a Job to enqueue every day at midnight: + +```ts +import type { TaskConfig } from 'payload' + +export const SendDigestEmail: TaskConfig<'SendDigestEmail'> = { + slug: 'SendDigestEmail', + schedule: [ + { + cron: '0 0 * * *', // Every day at midnight + queue: 'nightly', + }, + ], + handler: async () => { + await sendDigestToAllUsers() + }, +} +``` + +This configuration only queues the Job - it does not execute it immediately. To actually run the queued Job, you configure autorun in your Payload config (note that autorun should **not** be used on serverless platforms): + +```ts +export default buildConfig({ + jobs: { + autoRun: [ + { + cron: '* * * * *', // Runs every minute + queue: 'nightly', + }, + ], + tasks: [SendDigestEmail], + }, +}) +``` + +That way, Payload's scheduler will automatically enqueue the job into the `nightly` queue every day at midnight. The autorun configuration will check the `nightly` queue every minute and execute any Jobs that are due to run. + +## Scheduling lifecycle + +Here's how the scheduling process operates in detail: + +1. **Cron evaluation**: Payload (or your external trigger in `manual` mode) identifies which schedules are due to run. To do that, it will + read the `payload-jobs-stats` global which contains information about the last time each scheduled task or workflow was run. +2. **BeforeSchedule hook**: + - The default beforeSchedule hook checks how many active or runnable jobs of the same type that have been queued by the scheduling system currently exist. + If such a job exists, it will skip scheduling a new one. + - You can provide your own `beforeSchedule` hook to customize this behavior. For example, you might want to allow multiple overlapping Jobs or dynamically set the Job input data. +3. **Enqueue Job**: Payload queues up a new job. This job will have `waitUntil` set to the next scheduled time based on the cron expression. +4. **AfterSchedule hook**: + - The default afterSchedule hook updates the `payload-jobs-stats` global metadata with the last scheduled time for the Job. + - You can provide your own afterSchedule hook to it for custom logging, metrics, or other post-scheduling actions. + +## Customizing concurrency and input (Advanced) + +You may want more control over concurrency or dynamically set Job inputs at scheduling time. For instance, allowing multiple overlapping Jobs to be scheduled, even if a previously scheduled job has not completed yet, or preparing dynamic data to pass to your Job handler: + +```ts +import { countRunnableOrActiveJobsForQueue } from 'payload' + +schedule: [ + { + cron: '* * * * *', // every minute + queue: 'reports', + hooks: { + beforeSchedule: async ({ queueable, req }) => { + const runnableOrActiveJobsForQueue = + await countRunnableOrActiveJobsForQueue({ + queue: queueable.scheduleConfig.queue, + req, + taskSlug: queueable.taskConfig?.slug, + workflowSlug: queueable.workflowConfig?.slug, + onlyScheduled: true, + }) + + // Allow up to 3 simultaneous scheduled jobs and set dynamic input + return { + shouldSchedule: runnableOrActiveJobsForQueue < 3, + input: { text: 'Hi there' }, + } + }, + }, + }, +] +``` + +This allows fine-grained control over how many Jobs can run simultaneously and provides dynamically computed input values each time a Job is scheduled. + +## Scheduling in serverless environments + +On serverless platforms, scheduling must be triggered externally since Payload does not automatically run cron schedules in ephemeral environments. You have two main ways to trigger scheduling manually: + +- **Invoke via Payload's API:** `payload.jobs.handleSchedules()` +- **Use the REST API endpoint:** `/api/payload-jobs/handle-schedules` +- **Use the run endpoint, which also handles scheduling by default:** `GET /api/payload-jobs/run` + +For example, on Vercel, you can set up a Vercel Cron to regularly trigger scheduling: + +- **Vercel Cron Job:** Configure Vercel Cron to periodically call `GET /api/payload-jobs/handle-schedules`. If you would like to auto-run your scheduled jobs as well, you can use the `GET /api/payload-jobs/run` endpoint. + +Once Jobs are queued, their execution depends entirely on your configured runner setup (e.g., autorun, or manual invocation). diff --git a/docs/live-preview/overview.mdx b/docs/live-preview/overview.mdx index 74556e0a66..9a86c5588a 100644 --- a/docs/live-preview/overview.mdx +++ b/docs/live-preview/overview.mdx @@ -45,13 +45,11 @@ The following options are available: | Path | Description | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`url`** \* | String, or function that returns a string, pointing to your front-end application. This value is used as the iframe `src`. [More details](#url). | +| **`url`** | String, or function that returns a string, pointing to your front-end application. This value is used as the iframe `src`. [More details](#url). | | **`breakpoints`** | Array of breakpoints to be used as “device sizes” in the preview window. Each item appears as an option in the toolbar. [More details](#breakpoints). | | **`collections`** | Array of collection slugs to enable Live Preview on. | | **`globals`** | Array of global slugs to enable Live Preview on. | -_\* An asterisk denotes that a property is required._ - ### URL The `url` property resolves to a string that points to your front-end application. This value is used as the `src` attribute of the iframe rendering your front-end. Once loaded, the Admin Panel will communicate directly with your app through `window.postMessage` events. @@ -88,17 +86,16 @@ const config = buildConfig({ // ... livePreview: { // highlight-start - url: ({ - data, - collectionConfig, - locale - }) => `${data.tenant.url}${ // Multi-tenant top-level domain - collectionConfig.slug === 'posts' ? `/posts/${data.slug}` : `${data.slug !== 'home' : `/${data.slug}` : ''}` - }${locale ? `?locale=${locale?.code}` : ''}`, // Localization query param + url: ({ data, collectionConfig, locale }) => + `${data.tenant.url}${ + collectionConfig.slug === 'posts' + ? `/posts/${data.slug}` + : `${data.slug !== 'home' ? `/${data.slug}` : ''}` + }${locale ? `?locale=${locale?.code}` : ''}`, // Localization query param collections: ['pages'], }, // highlight-end - } + }, }) ``` diff --git a/docs/live-preview/server.mdx b/docs/live-preview/server.mdx index 48745ac3e8..4163fed49b 100644 --- a/docs/live-preview/server.mdx +++ b/docs/live-preview/server.mdx @@ -51,6 +51,7 @@ export default async function Page() { collection: 'pages', id: '123', draft: true, + trash: true, // add this if trash is enabled in your collection and want to preview trashed documents }) return ( diff --git a/docs/local-api/overview.mdx b/docs/local-api/overview.mdx index cb2e74f4fa..4d39424b5f 100644 --- a/docs/local-api/overview.mdx +++ b/docs/local-api/overview.mdx @@ -194,6 +194,27 @@ const result = await payload.count({ }) ``` +### FindDistinct#collection-find-distinct + +```js +// Result will be an object with: +// { +// values: ['value-1', 'value-2'], // array of distinct values, +// field: 'title', // the field +// totalDocs: 10, // count of the distinct values satisfies query, +// perPage: 10, // count of distinct values per page (based on provided limit) +// } +const result = await payload.findDistinct({ + collection: 'posts', // required + locale: 'en', + where: {}, // pass a `where` query here + user: dummyUser, + overrideAccess: false, + field: 'title', + sort: 'title', +}) +``` + ### Update by ID#collection-update-by-id ```js diff --git a/docs/performance/overview.mdx b/docs/performance/overview.mdx new file mode 100644 index 0000000000..274312d4ce --- /dev/null +++ b/docs/performance/overview.mdx @@ -0,0 +1,244 @@ +--- +title: Performance +label: Overview +order: 10 +desc: Ensure your Payload app runs as quickly and efficiently as possible. +keywords: performance, optimization, indexes, depth, select, block references, documentation, Content Management System, cms, headless, javascript, node, react, nextjs +--- + +Payload is designed with performance in mind, but its customizability means that there are many ways to configure your app that can impact performance. + +With this in mind, Payload provides several options and best practices to help you optimize your app's specific performance needs. This includes the database, APIs, and Admin Panel. + +Whether you're building an app or troubleshooting an existing one, follow these guidelines to ensure that it runs as quickly and efficiently as possible. + +## Building your application + +### Database proximity + +The proximity of your database to your server can significantly impact performance. Ensure that your database is hosted in the same region as your server to minimize latency and improve response times. + +### Indexing your fields + +If a particular field is queried often, build an [Index](../database/indexes) for that field to produce faster queries. + +When your query runs, the database will not search the entire document to find that one field, but will instead use the index to quickly locate the data. + +To learn more, see the [Indexes](../database/indexes) docs. + +### Querying your data + +There are several ways to optimize your [Queries](../queries/overview). Many of these options directly impact overall database overhead, response sizes, and/or computational load and can significantly improve performance. + +When building queries, combine as many of these options together as possible. This will ensure your queries are as efficient as they can be. + +To learn more, see the [Query Performance](../queries/overview#performance) docs. + +### Optimizing your APIs + +When querying data through Payload APIs, the request lifecycle includes running hooks, access control, validations, and other operations that can add significant overhead to the request. + +To optimize your APIs, any custom logic should be as efficient as possible. This includes writing lightweight hooks, preventing memory leaks, offloading long-running tasks, and optimizing custom validations. + +To learn more, see the [Hooks Performance](../hooks/overview#performance) docs. + +### Writing efficient validations + +If your validation functions are asynchronous or computationally heavy, ensure they only run when necessary. + +To learn more, see the [Validation Performance](../fields/overview#validation-performance) docs. + +### Optimizing custom components + +When building custom components in the Admin Panel, ensure that they are as efficient as possible. This includes using React best practices such as memoization, lazy loading, and avoiding unnecessary re-renders. + +To learn more, see the [Custom Components Performance](../admin/custom-components#performance) docs. + +## Other Best Practices + +### Block references + +Use [Block References](../fields/blocks#block-references) to share the same block across multiple fields without bloating the config. This will reduce the number of fields to traverse when processing permissions, etc. and can significantly reduce the amount of data sent from the server to the client in the Admin Panel. + +For example, if you have a block that is used in multiple fields, you can define it once and reference it in each field. + +To do this, use the `blockReferences` option in your blocks field: + +```ts +import { buildConfig } from 'payload' + +const config = buildConfig({ + // ... + blocks: [ + { + slug: 'TextBlock', + fields: [ + { + name: 'text', + type: 'text', + }, + ], + }, + ], + collections: [ + { + slug: 'posts', + fields: [ + { + name: 'content', + type: 'blocks', + // highlight-start + blockReferences: ['TextBlock'], + blocks: [], // Required to be empty, for compatibility reasons + // highlight-end + }, + ], + }, + { + slug: 'pages', + fields: [ + { + name: 'content', + type: 'blocks', + // highlight-start + blockReferences: ['TextBlock'], + blocks: [], // Required to be empty, for compatibility reasons + // highlight-end + }, + ], + }, + ], +}) +``` + +### Using the cached Payload instance + +Ensure that you do not instantiate Payload unnecessarily. Instead, Payload provides a caching mechanism to reuse the same instance across your app. + +To do this, use the `getPayload` function to get the cached instance of Payload: + +```ts +import { getPayload } from 'payload' +import config from '@payload-config' + +const myFunction = async () => { + const payload = await getPayload({ config }) + + // use payload here +} +``` + +### When to make direct-to-db calls + + + **Warning:** Direct database calls bypass all hooks and validations. Only use + this method when you are certain that the operation is safe and does not + require any of these features. + + +Making direct database calls can significantly improve performance by bypassing much of the request lifecycle such as hooks, validations, and other overhead associated with Payload APIs. + +For example, this can be especially useful for the `update` operation, where Payload would otherwise need to make multiple API calls to fetch, update, and fetch again. Making a direct database call can reduce this to a single operation. + +To do this, use the `payload.db` methods: + +```ts +await payload.db.updateOne({ + collection: 'posts', + id: post.id, + data: { + title: 'New Title', + }, +}) +``` + + + **Note:** Direct database methods do not start a + [transaction](../database/transactions). You have to start that yourself. + + +#### Returning + +To prevent unnecessary database computation and reduce the size of the response, you can also set `returning: false` in your direct database calls if you don't need the updated document returned to you. + +```ts +await payload.db.updateOne({ + collection: 'posts', + id: post.id, + data: { title: 'New Title' }, // See note above ^ about Postgres + // highlight-start + returning: false, + // highlight-end +}) +``` + + + **Note:** The `returning` option is only available on direct-to-db methods. + E.g. those on the `payload.db` object. It is not exposed to the Local API. + + +### Avoid bundling the entire UI library in your front-end + +If your front-end imports from `@payloadcms/ui`, ensure that you do not bundle the entire package as this can significantly increase your bundle size. + +To do this, import using the full path to the specific component you need: + +```ts +import { Button } from '@payloadcms/ui/elements/Button' +``` + +Custom components within the Admin Panel, however, do not have this same restriction and can import directly from `@payloadcms/ui`: + +```ts +import { Button } from '@payloadcms/ui' +``` + + + **Tip:** Use + [`@next/bundle-analyzer`](https://nextjs.org/docs/app/guides/package-bundling) + to analyze your component tree and identify unnecessary re-renders or large + components that could be optimized. + + +## Optimizing local development + +Everything mentioned above applies to local development as well, but there are a few additional steps you can take to optimize your local development experience. + +### Enable Turbopack + + + **Note:** In the future this will be the default. Use as your own risk. + + +Add `--turbo` to your dev script to significantly speed up your local development server start time. + +```json +{ + "scripts": { + "dev": "next dev --turbo" + } +} +``` + +### Only bundle server packages in production + + + **Note:** This is enabled by default in `create-payload-app` since v3.28.0. If + you created your app after this version, you don't need to do anything. + + +By default, Next.js bundles both server and client code. However, during development, bundling certain server packages isn't necessary. + +Payload has thousands of modules, slowing down compilation. + +Setting this option skips bundling Payload server modules during development. Fewer files to compile means faster compilation speeds. + +To do this, add the `devBundleServerPackages` option to `withPayload` in your `next.config.js` file: + +```ts +const nextConfig = { + // your existing next config +} + +export default withPayload(nextConfig, { devBundleServerPackages: false }) +``` diff --git a/docs/plugins/form-builder.mdx b/docs/plugins/form-builder.mdx index 2643188c5c..5872c887d0 100644 --- a/docs/plugins/form-builder.mdx +++ b/docs/plugins/form-builder.mdx @@ -1,7 +1,7 @@ --- title: Form Builder Plugin label: Form Builder -order: 40 +order: 30 desc: Easily build and manage forms from the Admin Panel. Send dynamic, personalized emails and even accept and process payments. keywords: plugins, plugin, form, forms, form builder --- diff --git a/docs/plugins/import-export.mdx b/docs/plugins/import-export.mdx new file mode 100644 index 0000000000..63c2cb2159 --- /dev/null +++ b/docs/plugins/import-export.mdx @@ -0,0 +1,155 @@ +--- +title: Import Export Plugin +label: Import Export +order: 40 +desc: Add Import and export functionality to create CSV and JSON data exports +keywords: plugins, plugin, import, export, csv, JSON, data, ETL, download +--- + +![https://www.npmjs.com/package/@payloadcms/plugin-import-export](https://img.shields.io/npm/v/@payloadcms/plugin-import-export) + + + **Note**: This plugin is in **beta** as some aspects of it may change on any + minor releases. It is under development and currently only supports exporting + of collection data. + + +This plugin adds features that give admin users the ability to download or create export data as an upload collection and import it back into a project. + +## Core Features + +- Export data as CSV or JSON format via the admin UI +- Download the export directly through the browser +- Create a file upload of the export data +- Use the jobs queue for large exports +- (Coming soon) Import collection data + +## Installation + +Install the plugin using any JavaScript package manager like [pnpm](https://pnpm.io), [npm](https://npmjs.com), or [Yarn](https://yarnpkg.com): + +```bash +pnpm add @payloadcms/plugin-import-export +``` + +## Basic Usage + +In the `plugins` array of your [Payload Config](https://payloadcms.com/docs/configuration/overview), call the plugin with [options](#options): + +```ts +import { buildConfig } from 'payload' +import { importExportPlugin } from '@payloadcms/plugin-import-export' + +const config = buildConfig({ + collections: [Pages, Media], + plugins: [ + importExportPlugin({ + collections: ['users', 'pages'], + // see below for a list of available options + }), + ], +}) + +export default config +``` + +## Options + +| Property | Type | Description | +| -------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `collections` | string[] | Collections to include Import/Export controls in. Defaults to all collections. | +| `debug` | boolean | If true, enables debug logging. | +| `disableDownload` | boolean | If true, disables the download button in the export preview UI. | +| `disableJobsQueue` | boolean | If true, forces the export to run synchronously. | +| `disableSave` | boolean | If true, disables the save button in the export preview UI. | +| `format` | string | Forces a specific export format (`csv` or `json`), hides the format dropdown, and prevents the user from choosing the export format. | +| `overrideExportCollection` | function | Function to override the default export collection; takes the default export collection and allows you to modify and return it. | + +## Field Options + +In addition to the above plugin configuration options, you can granularly set the following field level options using the `custom['plugin-import-export']` properties in any of your collections. + +| Property | Type | Description | +| ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `disabled` | boolean | When `true` the field is completely excluded from the import-export plugin. | +| `toCSV` | function | Custom function used to modify the outgoing csv data by manipulating the data, siblingData or by returning the desired value. | + +### Customizing the output of CSV data + +To manipulate the data that a field exports you can add `toCSV` custom functions. This allows you to modify the outgoing csv data by manipulating the data, siblingData or by returning the desired value. + +The toCSV function argument is an object with the following properties: + +| Property | Type | Description | +| ------------ | ------- | ----------------------------------------------------------------- | +| `columnName` | string | The CSV column name given to the field. | +| `doc` | object | The top level document | +| `row` | object | The object data that can be manipulated to assign data to the CSV | +| `siblingDoc` | object | The document data at the level where it belongs | +| `value` | unknown | The data for the field. | + +Example function: + +```ts +const pages: CollectionConfig = { + slug: 'pages', + fields: [ + { + name: 'author', + type: 'relationship', + relationTo: 'users', + custom: { + 'plugin-import-export': { + toCSV: ({ value, columnName, row }) => { + // add both `author_id` and the `author_email` to the csv export + if ( + value && + typeof value === 'object' && + 'id' in value && + 'email' in value + ) { + row[`${columnName}_id`] = (value as { id: number | string }).id + row[`${columnName}_email`] = (value as { email: string }).email + } + }, + }, + }, + }, + ], +} +``` + +## Exporting Data + +There are four possible ways that the plugin allows for exporting documents, the first two are available in the admin UI from the list view of a collection: + +1. Direct download - Using a `POST` to `/api/exports/download` and streams the response as a file download +2. File storage - Goes to the `exports` collection as an uploads enabled collection +3. Local API - A create call to the uploads collection: `payload.create({ slug: 'uploads', ...parameters })` +4. Jobs Queue - `payload.jobs.queue({ task: 'createCollectionExport', input: parameters })` + +By default, a user can use the Export drawer to create a file download by choosing `Save` or stream a downloadable file directly without persisting it by using the `Download` button. Either option can be disabled to provide the export experience you desire for your use-case. + +The UI for creating exports provides options so that users can be selective about which documents to include and also which columns or fields to include. + +It is necessary to add access control to the uploads collection configuration using the `overrideExportCollection` function if you have enabled this plugin on collections with data that some authenticated users should not have access to. + + + **Note**: Users who have read access to the upload collection may be able to + download data that is normally not readable due to [access + control](../access-control/overview). + + +The following parameters are used by the export function to handle requests: + +| Property | Type | Description | +| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------- | +| `format` | text | Either `csv` or `json` to determine the shape of data exported | +| `limit` | number | The max number of documents to return | +| `sort` | select | The field to use for ordering documents | +| `locale` | string | The locale code to query documents or `all` | +| `draft` | string | Either `yes` or `no` to return documents with their newest drafts for drafts enabled collections | +| `fields` | string[] | Which collection fields are used to create the export, defaults to all | +| `collectionSlug` | string | The slug to query against | +| `where` | object | The WhereObject used to query documents to export. This is set by making selections or filters from the list view | +| `filename` | text | What to call the export being created | diff --git a/docs/plugins/multi-tenant.mdx b/docs/plugins/multi-tenant.mdx index 3b507aae8b..406e20a2f5 100644 --- a/docs/plugins/multi-tenant.mdx +++ b/docs/plugins/multi-tenant.mdx @@ -1,7 +1,7 @@ --- title: Multi-Tenant Plugin label: Multi-Tenant -order: 40 +order: 50 desc: Scaffolds multi-tenancy for your Payload application keywords: plugins, multi-tenant, multi-tenancy, plugin, payload, cms, seo, indexing, search, search engine --- @@ -54,7 +54,8 @@ The plugin accepts an object with the following properties: ```ts type MultiTenantPluginConfig = { /** - * After a tenant is deleted, the plugin will attempt to clean up related documents + * After a tenant is deleted, the plugin will attempt + * to clean up related documents * - removing documents with the tenant ID * - removing the tenant from users * @@ -67,19 +68,34 @@ type MultiTenantPluginConfig = { collections: { [key in CollectionSlug]?: { /** - * Set to `true` if you want the collection to behave as a global + * Set to `true` if you want the collection to + * behave as a global * * @default false */ isGlobal?: boolean /** - * Set to `false` if you want to manually apply the baseListFilter + * Set to `false` if you want to manually apply + * the baseFilter + * + * @default true + */ + useBaseFilter?: boolean + /** + * @deprecated Use `useBaseFilter` instead. If both are defined, + * `useBaseFilter` will take precedence. This property remains only + * for backward compatibility and may be removed in a future version. + * + * Originally, `baseListFilter` was intended to filter only the List View + * in the admin panel. However, base filtering is often required in other areas + * such as internal link relationships in the Lexical editor. * * @default true */ useBaseListFilter?: boolean /** - * Set to `false` if you want to handle collection access manually without the multi-tenant constraints applied + * Set to `false` if you want to handle collection access + * manually without the multi-tenant constraints applied * * @default true */ @@ -88,7 +104,8 @@ type MultiTenantPluginConfig = { } /** * Enables debug mode - * - Makes the tenant field visible in the admin UI within applicable collections + * - Makes the tenant field visible in the + * admin UI within applicable collections * * @default false */ @@ -100,22 +117,27 @@ type MultiTenantPluginConfig = { */ enabled?: boolean /** - * Field configuration for the field added to all tenant enabled collections + * Field configuration for the field added + * to all tenant enabled collections */ tenantField?: { access?: RelationshipField['access'] /** - * The name of the field added to all tenant enabled collections + * The name of the field added to all tenant + * enabled collections * * @default 'tenant' */ name?: string } /** - * Field configuration for the field added to the users collection + * Field configuration for the field added + * to the users collection * - * If `includeDefaultField` is `false`, you must include the field on your users collection manually - * This is useful if you want to customize the field or place the field in a specific location + * If `includeDefaultField` is `false`, you must + * include the field on your users collection manually + * This is useful if you want to customize the field + * or place the field in a specific location */ tenantsArrayField?: | { @@ -136,7 +158,8 @@ type MultiTenantPluginConfig = { */ arrayTenantFieldName?: string /** - * When `includeDefaultField` is `true`, the field will be added to the users collection automatically + * When `includeDefaultField` is `true`, the field will + * be added to the users collection automatically */ includeDefaultField?: true /** @@ -153,7 +176,8 @@ type MultiTenantPluginConfig = { arrayFieldName?: string arrayTenantFieldName?: string /** - * When `includeDefaultField` is `false`, you must include the field on your users collection manually + * When `includeDefaultField` is `false`, you must + * include the field on your users collection manually */ includeDefaultField?: false rowFields?: never @@ -162,7 +186,8 @@ type MultiTenantPluginConfig = { /** * Customize tenant selector label * - * Either a string or an object where the keys are i18n codes and the values are the string labels + * Either a string or an object where the keys are i18n + * codes and the values are the string labels */ tenantSelectorLabel?: | Partial<{ @@ -176,7 +201,8 @@ type MultiTenantPluginConfig = { */ tenantsSlug?: string /** - * Function that determines if a user has access to _all_ tenants + * Function that determines if a user has access + * to _all_ tenants * * Useful for super-admin type users */ @@ -184,15 +210,18 @@ type MultiTenantPluginConfig = { user: ConfigTypes extends { user: unknown } ? ConfigTypes['user'] : User, ) => boolean /** - * Opt out of adding access constraints to the tenants collection + * Opt out of adding access constraints to + * the tenants collection */ useTenantsCollectionAccess?: boolean /** - * Opt out including the baseListFilter to filter tenants by selected tenant + * Opt out including the baseFilter to filter + * tenants by selected tenant */ useTenantsListFilter?: boolean /** - * Opt out including the baseListFilter to filter users by selected tenant + * Opt out including the baseFilter to filter + * users by selected tenant */ useUsersTenantFilter?: boolean } @@ -212,15 +241,15 @@ const config = buildConfig({ { slug: 'tenants', admin: { - useAsTitle: 'name' - } + useAsTitle: 'name', + }, fields: [ // remember, you own these fields // these are merely suggestions/examples { - name: 'name', - type: 'text', - required: true, + name: 'name', + type: 'text', + required: true, }, { name: 'slug', @@ -231,7 +260,7 @@ const config = buildConfig({ name: 'domain', type: 'text', required: true, - } + }, ], }, ], @@ -241,7 +270,7 @@ const config = buildConfig({ pages: {}, navigation: { isGlobal: true, - } + }, }, }), ], @@ -327,14 +356,16 @@ type ContextType = { /** * Prevents a refresh when the tenant is changed * - * If not switching tenants while viewing a "global", set to true + * If not switching tenants while viewing a "global", + * set to true */ setPreventRefreshOnChange: React.Dispatch> /** * Sets the selected tenant ID * * @param args.id - The ID of the tenant to select - * @param args.refresh - Whether to refresh the page after changing the tenant + * @param args.refresh - Whether to refresh the page + * after changing the tenant */ setTenant: (args: { id: number | string | undefined diff --git a/docs/plugins/nested-docs.mdx b/docs/plugins/nested-docs.mdx index 5725bdabc2..60c3d43323 100644 --- a/docs/plugins/nested-docs.mdx +++ b/docs/plugins/nested-docs.mdx @@ -1,7 +1,7 @@ --- title: Nested Docs Plugin label: Nested Docs -order: 40 +order: 60 desc: Nested documents in a parent, child, and sibling relationship. keywords: plugins, nested, documents, parent, child, sibling, relationship --- diff --git a/docs/plugins/overview.mdx b/docs/plugins/overview.mdx index 96b2430896..d5bce425fd 100644 --- a/docs/plugins/overview.mdx +++ b/docs/plugins/overview.mdx @@ -55,6 +55,7 @@ Payload maintains a set of Official Plugins that solve for some of the common us - [Sentry](./sentry) - [SEO](./seo) - [Stripe](./stripe) +- [Import/Export](./import-export) You can also [build your own plugin](./build-your-own) to easily extend Payload's functionality in some other way. Once your plugin is ready, consider [sharing it with the community](#community-plugins). diff --git a/docs/plugins/redirects.mdx b/docs/plugins/redirects.mdx index 3fbc624d58..dae099a499 100644 --- a/docs/plugins/redirects.mdx +++ b/docs/plugins/redirects.mdx @@ -1,7 +1,7 @@ --- title: Redirects Plugin label: Redirects -order: 40 +order: 70 desc: Automatically create redirects for your Payload application keywords: plugins, redirects, redirect, plugin, payload, cms, seo, indexing, search, search engine --- diff --git a/docs/plugins/search.mdx b/docs/plugins/search.mdx index 868e87b5b7..8eee4073aa 100644 --- a/docs/plugins/search.mdx +++ b/docs/plugins/search.mdx @@ -1,7 +1,7 @@ --- title: Search Plugin label: Search -order: 40 +order: 80 desc: Generates records of your documents that are extremely fast to search on. keywords: plugins, search, search plugin, search engine, search index, search results, search bar, search box, search field, search form, search input --- diff --git a/docs/plugins/sentry.mdx b/docs/plugins/sentry.mdx index fc87f2e2de..ecd6826487 100644 --- a/docs/plugins/sentry.mdx +++ b/docs/plugins/sentry.mdx @@ -1,7 +1,7 @@ --- title: Sentry Plugin label: Sentry -order: 40 +order: 90 desc: Integrate Sentry error tracking into your Payload application keywords: plugins, sentry, error, tracking, monitoring, logging, bug, reporting, performance --- diff --git a/docs/plugins/seo.mdx b/docs/plugins/seo.mdx index c0fa06d0e0..b22e01c829 100644 --- a/docs/plugins/seo.mdx +++ b/docs/plugins/seo.mdx @@ -2,7 +2,7 @@ description: Manage SEO metadata from your Payload admin keywords: plugins, seo, meta, search, engine, ranking, google label: SEO -order: 30 +order: 100 title: SEO Plugin --- diff --git a/docs/plugins/stripe.mdx b/docs/plugins/stripe.mdx index 214267f0a2..79111274d7 100644 --- a/docs/plugins/stripe.mdx +++ b/docs/plugins/stripe.mdx @@ -1,7 +1,7 @@ --- title: Stripe Plugin label: Stripe -order: 40 +order: 110 desc: Easily accept payments with Stripe keywords: plugins, stripe, payments, ecommerce --- diff --git a/docs/production/building-without-a-db-connection.mdx b/docs/production/building-without-a-db-connection.mdx index fe5ae02994..7d7ce68014 100644 --- a/docs/production/building-without-a-db-connection.mdx +++ b/docs/production/building-without-a-db-connection.mdx @@ -14,7 +14,9 @@ Solutions: ## Using the experimental-build-mode Next.js build flag -You can run Next.js build using the `pnpx next build --experimental-build-mode compile` command to only compile the code without static generation, which does not require a DB connection. In that case, your pages will be rendered dynamically, but after that, you can still generate static pages using the `pnpx next build --experimental-build-mode generate` command when you have a DB connection. +You can run Next.js build using the `pnpm next build --experimental-build-mode compile` command to only compile the code without static generation, which does not require a DB connection. In that case, your pages will be rendered dynamically, but after that, you can still generate static pages using the `pnpm next build --experimental-build-mode generate` command when you have a DB connection. + +When running `pnpm next build --experimental-build-mode compile`, environment variables prefixed with `NEXT_PUBLIC` will not be inlined and will be `undefined` on the client. To make these variables available, either run `pnpm next build --experimental-build-mode generate` if a DB connection is available, or use `pnpm next build --experimental-build-mode generate-env` if you do not have a DB connection. [Next.js documentation](https://nextjs.org/docs/pages/api-reference/cli/next#next-build-options) diff --git a/docs/production/deployment.mdx b/docs/production/deployment.mdx index e86898f004..1865133ece 100644 --- a/docs/production/deployment.mdx +++ b/docs/production/deployment.mdx @@ -24,16 +24,6 @@ Payload can be deployed _anywhere that Next.js can run_ - including Vercel, Netl But it's important to remember that most Payload projects will also need a database, file storage, an email provider, and a CDN. Make sure you have all of the requirements that your project needs, no matter what deployment platform you choose. -Often, the easiest and fastest way to deploy Payload is to use [Payload Cloud](https://payloadcms.com/new) — where you get everything you need out of the box, including: - -1. A MongoDB Atlas database -1. S3 file storage -1. Resend email service -1. Cloudflare CDN -1. Blue / green deployments -1. Logs -1. And more - ## Basics Payload runs fully in Next.js, so the [Next.js build process](https://nextjs.org/docs/app/building-your-application/deploying) is used for building Payload. If you've used `create-payload-app` to create your project, executing the `build` diff --git a/docs/queries/depth.mdx b/docs/queries/depth.mdx index 0fe9a128e6..241f2e78e0 100644 --- a/docs/queries/depth.mdx +++ b/docs/queries/depth.mdx @@ -8,7 +8,7 @@ keywords: query, documents, pagination, documentation, Content Management System Documents in Payload can have relationships to other Documents. This is true for both [Collections](../configuration/collections) as well as [Globals](../configuration/globals). When you query a Document, you can specify the depth at which to populate any of its related Documents either as full objects, or only their IDs. -Depth will optimize the performance of your application by limiting the amount of processing made in the database and significantly reducing the amount of data returned. Since Documents can be infinitely nested or recursively related, it's important to be able to control how deep your API populates. +Since Documents can be infinitely nested or recursively related, it's important to be able to control how deep your API populates. Depth can impact the performance of your queries by affecting the load on the database and the size of the response. For example, when you specify a `depth` of `0`, the API response might look like this: @@ -48,7 +48,9 @@ import type { Payload } from 'payload' const getPosts = async (payload: Payload) => { const posts = await payload.find({ collection: 'posts', - depth: 2, // highlight-line + // highlight-start + depth: 2, + // highlight-end }) return posts @@ -65,7 +67,9 @@ const getPosts = async (payload: Payload) => { To specify depth in the [REST API](../rest-api/overview), you can use the `depth` parameter in your query: ```ts -fetch('https://localhost:3000/api/posts?depth=2') // highlight-line +// highlight-start +fetch('https://localhost:3000/api/posts?depth=2') + // highlight-end .then((res) => res.json()) .then((data) => console.log(data)) ``` @@ -75,6 +79,24 @@ fetch('https://localhost:3000/api/posts?depth=2') // highlight-line the `/api/globals` endpoint. +## Default Depth + +If no depth is specified in the request, Payload will use its default depth for all requests. By default, this is set to `2`. + +To change the default depth on the application level, you can use the `defaultDepth` option in your root Payload config: + +```ts +import { buildConfig } from 'payload/config' + +export default buildConfig({ + // ... + // highlight-start + defaultDepth: 1, + // highlight-end + // ... +}) +``` + ## Max Depth Fields like the [Relationship Field](../fields/relationship) or the [Upload Field](../fields/upload) can also set a maximum depth. If exceeded, this will limit the population depth regardless of what the depth might be on the request. @@ -89,7 +111,9 @@ To set a max depth for a field, use the `maxDepth` property in your field config name: 'author', type: 'relationship', relationTo: 'users', - maxDepth: 2, // highlight-line + // highlight-start + maxDepth: 2, + // highlight-end } ] } diff --git a/docs/queries/overview.mdx b/docs/queries/overview.mdx index b3354d65af..cd39f6d174 100644 --- a/docs/queries/overview.mdx +++ b/docs/queries/overview.mdx @@ -60,7 +60,7 @@ The following operators are available for use in queries: **Tip:** If you know your users will be querying on certain fields a lot, add `index: true` to the Field Config. This will speed up searches using that - field immensely. + field immensely. [More details](../database/indexes). ### And / Or Logic @@ -192,3 +192,130 @@ const getPosts = async () => { // Continue to handle the response below... } ``` + +## Performance + +There are several ways to optimize your queries. Many of these options directly impact overall database overhead, response sizes, and/or computational load and can significantly improve performance. + +When building queries, combine as many of these strategies together as possible to ensure your queries are as performant as they can be. + + + For more performance tips, see the [Performance + documentation](../performance/overview). + + +### Indexes + +Build [Indexes](../database/indexes) for fields that are often queried or sorted by. + +When your query runs, the database will not search the entire document to find that one field, but will instead use the index to quickly locate the data. + +This is done by adding `index: true` to the Field Config for that field: + +```ts +// In your collection configuration +{ + name: 'posts', + fields: [ + { + name: 'title', + type: 'text', + // highlight-start + index: true, // Add an index to the title field + // highlight-end + }, + // Other fields... + ], +} +``` + +To learn more, see the [Indexes documentation](../database/indexes). + +### Depth + +Set the [Depth](./depth) to only the level that you need to avoid populating unnecessary related documents. + +Relationships will only populate down to the specified depth, and any relationships beyond that depth will only return the ID of the related document. + +```ts +const posts = await payload.find({ + collection: 'posts', + where: { ... }, + // highlight-start + depth: 0, // Only return the IDs of related documents + // highlight-end +}) +``` + +To learn more, see the [Depth documentation](./depth). + +### Limit + +Set the [Limit](./pagination#limit) if you can reliably predict the number of matched documents, such as when querying on a unique field. + +```ts +const posts = await payload.find({ + collection: 'posts', + where: { + slug: { + equals: 'unique-post-slug', + }, + }, + // highlight-start + limit: 1, // Only expect one document to be returned + // highlight-end +}) +``` + + + **Tip:** Use in combination with `pagination: false` for best performance when + querying by unique fields. + + +To learn more, see the [Limit documentation](./pagination#limit). + +### Select + +Use the [Select API](./select) to only process and return the fields you need. + +This will reduce the amount of data returned from the request, and also skip processing of any fields that are not selected, such as running their field hooks. + +```ts +const posts = await payload.find({ + collection: 'posts', + where: { ... }, + // highlight-start + select: [{ + title: true, + }], + // highlight-end +``` + +This is a basic example, but there are many ways to use the Select API, including selecting specific fields, excluding fields, etc. + +To learn more, see the [Select documentation](./select). + +### Pagination + +[Disable Pagination](./pagination#disabling-pagination) if you can reliably predict the number of matched documents, such as when querying on a unique field. + +```ts +const posts = await payload.find({ + collection: 'posts', + where: { + slug: { + equals: 'unique-post-slug', + }, + }, + // highlight-start + pagination: false, // Return all matched documents without pagination + // highlight-end +}) +``` + + + **Tip:** Use in combination with `limit: 1` for best performance when querying + by unique fields. + + +To learn more, see the [Pagination documentation](./pagination). diff --git a/docs/queries/pagination.mdx b/docs/queries/pagination.mdx index a9f39137db..7ec1ae953a 100644 --- a/docs/queries/pagination.mdx +++ b/docs/queries/pagination.mdx @@ -6,9 +6,61 @@ desc: Payload queries are equipped with automatic pagination so you create pagin keywords: query, documents, pagination, documentation, Content Management System, cms, headless, javascript, node, react, nextjs --- -All collection `find` queries are paginated automatically. Responses are returned with top-level meta data related to pagination, and returned documents are nested within a `docs` array. +With Pagination you can limit the number of documents returned per page, and get a specific page of results. This is useful for creating paginated lists of documents within your application. -**`Find` response properties:** +All paginated responses include documents nested within a `docs` array, and return top-level meta data related to pagination such as `totalDocs`, `limit`, `totalPages`, `page`, and more. + + + **Note:** Collection `find` queries are paginated automatically. + + +## Options + +All Payload APIs support the pagination controls below. With them, you can create paginated lists of documents within your application: + +| Control | Default | Description | +| ------------ | ------- | ------------------------------------------------------------------------- | +| `limit` | `10` | Limits the number of documents returned per page. [More details](#limit). | +| `pagination` | `true` | Set to `false` to disable pagination and return all documents. | +| `page` | `1` | Get a specific page number. | + +## Local API + +To specify pagination controls in the [Local API](../local-api/overview), you can use the `limit`, `page`, and `pagination` options in your query: + +```ts +import type { Payload } from 'payload' + +const getPosts = async (payload: Payload) => { + const posts = await payload.find({ + collection: 'posts', + // highlight-start + limit: 10, + page: 2, + // highlight-end + }) + + return posts +} +``` + +## REST API + +With the [REST API](../rest-api/overview), you can use the pagination controls below as query strings: + +```ts +// highlight-start +fetch('https://localhost:3000/api/posts?limit=10&page=2') + // highlight-end + .then((res) => res.json()) + .then((data) => console.log(data)) +``` + +## Response + +All paginated responses include documents nested within a `docs` array, and return top-level meta data related to pagination. + +The `find` operation includes the following properties in its response: | Property | Description | | --------------- | --------------------------------------------------------- | @@ -51,16 +103,59 @@ All collection `find` queries are paginated automatically. Responses are returne } ``` -## Pagination controls +## Limit -All Payload APIs support the pagination controls below. With them, you can create paginated lists of documents within your application: +You can specify a `limit` to restrict the number of documents returned per page. -| Control | Default | Description | -| ------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `limit` | `10` | Limits the number of documents returned per page - set to `0` to show all documents, we automatically disabled pagination for you when `limit` is `0` for optimisation | -| `pagination` | `true` | Set to `false` to disable pagination and return all documents | -| `page` | `1` | Get a specific page number | + + **Reminder:** By default, any query with `limit: 0` will automatically + [disable pagination](#disabling-pagination). + -### Disabling pagination within Local API +#### Performance benefits + +If you are querying for a specific document and can reliably expect only one document to match, you can set a limit of `1` (or another low number) to reduce the number of database lookups and improve performance. + +For example, when querying a document by a unique field such as `slug`, you can set the limit to `1` since you know there will only be one document with that slug. + +To do this, set the `limit` option in your query: + +```ts +await payload.find({ + collection: 'posts', + where: { + slug: { + equals: 'post-1', + }, + }, + // highlight-start + limit: 1, + // highlight-end +}) +``` + +## Disabling pagination + +Disabling pagination can improve performance by reducing the overhead of pagination calculations and improve query speed. For `find` operations within the Local API, you can disable pagination to retrieve all documents from a collection by passing `pagination: false` to the `find` local operation. + +To do this, set `pagination: false` in your query: + +```ts +import type { Payload } from 'payload' + +const getPost = async (payload: Payload) => { + const posts = await payload.find({ + collection: 'posts', + where: { + title: { equals: 'My Post' }, + }, + // highlight-start + pagination: false, + // highlight-end + }) + + return posts +} +``` diff --git a/docs/queries/select.mdx b/docs/queries/select.mdx index abc8a516bb..fa5e760167 100644 --- a/docs/queries/select.mdx +++ b/docs/queries/select.mdx @@ -6,9 +6,9 @@ desc: Payload select determines which fields are selected to the result. keywords: query, documents, pagination, documentation, Content Management System, cms, headless, javascript, node, react, nextjs --- -By default, Payload's APIs will return _all fields_ for a given collection or global. But, you may not need all of that data for all of your queries. Sometimes, you might want just a few fields from the response, which can speed up the Payload API and reduce the amount of JSON that is sent to you from the API. +By default, Payload's APIs will return _all fields_ for a given collection or global. But, you may not need all of that data for all of your queries. Sometimes, you might want just a few fields from the response. -This is where Payload's `select` feature comes in. Here, you can define exactly which fields you'd like to retrieve from the API. +With the Select API, you can define exactly which fields you'd like to retrieve. This can impact the performance of your queries by affecting the load on the database and the size of the response. ## Local API @@ -21,6 +21,7 @@ import type { Payload } from 'payload' const getPosts = async (payload: Payload) => { const posts = await payload.find({ collection: 'posts', + // highlight-start select: { text: true, // select a specific field from group @@ -29,7 +30,8 @@ const getPosts = async (payload: Payload) => { }, // select all fields from array array: true, - }, // highlight-line + }, + // highlight-end }) return posts @@ -40,12 +42,14 @@ const getPosts = async (payload: Payload) => { const posts = await payload.find({ collection: 'posts', // Select everything except for array and group.number + // highlight-start select: { array: false, group: { number: false, }, - }, // highlight-line + }, + // highlight-end }) return posts @@ -67,8 +71,10 @@ To specify select in the [REST API](../rest-api/overview), you can use the `sele ```ts fetch( + // highlight-start 'https://localhost:3000/api/posts?select[color]=true&select[group][number]=true', -) // highlight-line + // highlight-end +) .then((res) => res.json()) .then((data) => console.log(data)) ``` @@ -149,7 +155,7 @@ export const Pages: CollectionConfig<'pages'> = { not be able to construct the correct file URL, instead returning `url: null`. -## populate +## Populate Setting `defaultPopulate` will enforce that each time Payload performs a "population" of a related document, only the fields specified will be queried and returned. However, you can override `defaultPopulate` with the `populate` property in the Local and REST API: diff --git a/docs/queries/sort.mdx b/docs/queries/sort.mdx index d9f46b2f91..5ff67f5821 100644 --- a/docs/queries/sort.mdx +++ b/docs/queries/sort.mdx @@ -6,13 +6,15 @@ desc: Payload sort allows you to order your documents by a field in ascending or keywords: query, documents, pagination, documentation, Content Management System, cms, headless, javascript, node, react, nextjs --- -Documents in Payload can be easily sorted by a specific [Field](../fields/overview). When querying Documents, you can pass the name of any top-level field, and the response will sort the Documents by that field in _ascending_ order. If prefixed with a minus symbol ("-"), they will be sorted in _descending_ order. In Local API multiple fields can be specified by using an array of strings. In REST API multiple fields can be specified by separating fields with comma. The minus symbol can be in front of individual fields. +Documents in Payload can be easily sorted by a specific [Field](../fields/overview). When querying Documents, you can pass the name of any top-level field, and the response will sort the Documents by that field in _ascending_ order. + +If prefixed with a minus symbol ("-"), they will be sorted in _descending_ order. In Local API multiple fields can be specified by using an array of strings. In REST API multiple fields can be specified by separating fields with comma. The minus symbol can be in front of individual fields. Because sorting is handled by the database, the field cannot be a [Virtual Field](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) unless it's [linked with a relationship field](/docs/fields/relationship#linking-virtual-fields-with-relationships). It must be stored in the database to be searchable. **Tip:** For performance reasons, it is recommended to enable `index: true` - for the fields that will be sorted upon. [More details](../fields/overview). + for the fields that will be sorted upon. [More details](../database/indexes). ## Local API diff --git a/docs/rich-text/custom-features.mdx b/docs/rich-text/custom-features.mdx index c29935dd83..217819ec03 100644 --- a/docs/rich-text/custom-features.mdx +++ b/docs/rich-text/custom-features.mdx @@ -474,11 +474,15 @@ const MyNodeComponent = React.lazy(() => ) /** - * This node is a DecoratorNode. DecoratorNodes allow you to render React components in the editor. + * This node is a DecoratorNode. DecoratorNodes allow + * you to render React components in the editor. * - * They need both createDom and decorate functions. createDom => outside of the html. decorate => React Component inside of the html. + * They need both createDom and decorate functions. + * createDom => outside of the html. + * decorate => React Component inside of the html. * - * If we used DecoratorBlockNode instead, we would only need a decorate method + * If we used DecoratorBlockNode instead, + * we would only need a decorate method */ export class MyNode extends DecoratorNode { static clone(node: MyNode): MyNode { @@ -490,9 +494,11 @@ export class MyNode extends DecoratorNode { } /** - * Defines what happens if you copy a div element from another page and paste it into the lexical editor + * Defines what happens if you copy a div element + * from another page and paste it into the lexical editor * - * This also determines the behavior of lexical's internal HTML -> Lexical converter + * This also determines the behavior of lexical's + * internal HTML -> Lexical converter */ static importDOM(): DOMConversionMap | null { return { @@ -504,14 +510,18 @@ export class MyNode extends DecoratorNode { } /** - * The data for this node is stored serialized as JSON. This is the "load function" of that node: it takes the saved data and converts it into a node. + * The data for this node is stored serialized as JSON. + * This is the "load function" of that node: it takes + * the saved data and converts it into a node. */ static importJSON(serializedNode: SerializedMyNode): MyNode { return $createMyNode() } /** - * Determines how the hr element is rendered in the lexical editor. This is only the "initial" / "outer" HTML element. + * Determines how the hr element is rendered in the + * lexical editor. This is only the "initial" / "outer" + * HTML element. */ createDOM(config: EditorConfig): HTMLElement { const element = document.createElement('div') @@ -519,22 +529,28 @@ export class MyNode extends DecoratorNode { } /** - * Allows you to render a React component within whatever createDOM returns. + * Allows you to render a React component within + * whatever createDOM returns. */ decorate(): React.ReactElement { return } /** - * Opposite of importDOM, this function defines what happens when you copy a div element from the lexical editor and paste it into another page. + * Opposite of importDOM, this function defines what + * happens when you copy a div element from the lexical + * editor and paste it into another page. * - * This also determines the behavior of lexical's internal Lexical -> HTML converter + * This also determines the behavior of lexical's + * internal Lexical -> HTML converter */ exportDOM(): DOMExportOutput { return { element: document.createElement('div') } } /** - * Opposite of importJSON. This determines what data is saved in the database / in the lexical editor state. + * Opposite of importJSON. This determines what + * data is saved in the database / in the lexical + * editor state. */ exportJSON(): SerializedLexicalNode { return { @@ -556,18 +572,23 @@ export class MyNode extends DecoratorNode { } } -// This is used in the importDOM method. Totally optional if you do not want your node to be created automatically when copy & pasting certain dom elements -// into your editor. +// This is used in the importDOM method. Totally optional +// if you do not want your node to be created automatically +// when copy & pasting certain dom elements into your editor. function $yourConversionMethod(): DOMConversionOutput { return { node: $createMyNode() } } -// This is a utility method to create a new MyNode. Utility methods prefixed with $ make it explicit that this should only be used within lexical +// This is a utility method to create a new MyNode. +// Utility methods prefixed with $ make it explicit +// that this should only be used within lexical export function $createMyNode(): MyNode { return $applyNodeReplacement(new MyNode()) } -// This is just a utility method you can use to check if a node is a MyNode. This also ensures correct typing. +// This is just a utility method you can use +// to check if a node is a MyNode. This also +// ensures correct typing. export function $isMyNode( node: LexicalNode | null | undefined, ): node is MyNode { @@ -626,10 +647,12 @@ export const INSERT_MYNODE_COMMAND: LexicalCommand = createCommand( ) /** - * Plugin which registers a lexical command to insert a new MyNode into the editor + * Plugin which registers a lexical command to + * insert a new MyNode into the editor */ export const MyNodePlugin: PluginComponent = () => { - // The useLexicalComposerContext hook can be used to access the lexical editor instance + // The useLexicalComposerContext hook can be used + // to access the lexical editor instance const [editor] = useLexicalComposerContext() useEffect(() => { diff --git a/docs/rich-text/official-features.mdx b/docs/rich-text/official-features.mdx new file mode 100644 index 0000000000..bffa4c0b2e --- /dev/null +++ b/docs/rich-text/official-features.mdx @@ -0,0 +1,494 @@ +--- +description: Features officially maintained by Payload. +keywords: lexical, rich text, editor, headless cms, official, features +label: Official Features +order: 35 +title: Official Features +--- + +Below are all the Rich Text Features Payload offers. Everything is customizable; you can [create your own features](/docs/rich-text/custom-features), modify ours and share them with the community. + +## Features Overview + +| Feature Name | Included by default | Description | +| ------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`BoldFeature`** | Yes | Adds support for bold text formatting. | +| **`ItalicFeature`** | Yes | Adds support for italic text formatting. | +| **`UnderlineFeature`** | Yes | Adds support for underlined text formatting. | +| **`StrikethroughFeature`** | Yes | Adds support for strikethrough text formatting. | +| **`SubscriptFeature`** | Yes | Adds support for subscript text formatting. | +| **`SuperscriptFeature`** | Yes | Adds support for superscript text formatting. | +| **`InlineCodeFeature`** | Yes | Adds support for inline code formatting. | +| **`ParagraphFeature`** | Yes | Provides entries in both the slash menu and toolbar dropdown for explicit paragraph creation or conversion. | +| **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) | +| **`AlignFeature`** | Yes | Adds support for text alignment (left, center, right, justify) | +| **`IndentFeature`** | Yes | Adds support for text indentation with toolbar buttons | +| **`UnorderedListFeature`** | Yes | Adds support for unordered lists (ul) | +| **`OrderedListFeature`** | Yes | Adds support for ordered lists (ol) | +| **`ChecklistFeature`** | Yes | Adds support for interactive checklists | +| **`LinkFeature`** | Yes | Allows you to create internal and external links | +| **`RelationshipFeature`** | Yes | Allows you to create block-level (not inline) relationships to other documents | +| **`BlockquoteFeature`** | Yes | Allows you to create block-level quotes | +| **`UploadFeature`** | Yes | Allows you to create block-level upload nodes - this supports all kinds of uploads, not just images | +| **`HorizontalRuleFeature`** | Yes | Adds support for horizontal rules / separators. Basically displays an `
` element | +| **`InlineToolbarFeature`** | Yes | Provides a floating toolbar which appears when you select text. This toolbar only contains actions relevant for selected text | +| **`FixedToolbarFeature`** | No | Provides a persistent toolbar pinned to the top and always visible. Both inline and fixed toolbars can be enabled at the same time. | +| **`BlocksFeature`** | No | Allows you to use Payload's [Blocks Field](../fields/blocks) directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. | +| **`TreeViewFeature`** | No | Provides a debug box under the editor, which allows you to see the current editor state live, the dom, as well as time travel. Very useful for debugging | +| **`EXPERIMENTAL_TableFeature`** | No | Adds support for tables. This feature may be removed or receive breaking changes in the future - even within a stable lexical release, without needing a major release. | +| **`TextStateFeature`** | No | Allows you to store key-value attributes within TextNodes and assign them inline styles. | + +## In depth + +### BoldFeature + +- Description: Adds support for bold text formatting, along with buttons to apply it in both fixed and inline toolbars. +- Included by default: Yes +- Markdown Support: `**bold**` or `__bold__` +- Keyboard Shortcut: Ctrl/Cmd + B + +### ItalicFeature + +- Description: Adds support for italic text formatting, along with buttons to apply it in both fixed and inline toolbars. +- Included by default: Yes +- Markdown Support: `*italic*` or `_italic_` +- Keyboard Shortcut: Ctrl/Cmd + I + +### UnderlineFeature + +- Description: Adds support for underlined text formatting, along with buttons to apply it in both fixed and inline toolbars. +- Included by default: Yes +- Keyboard Shortcut: Ctrl/Cmd + U + +### StrikethroughFeature + +- Description: Adds support for strikethrough text formatting, along with buttons to apply it in both fixed and inline toolbars. +- Included by default: Yes +- Markdown Support: `~~strikethrough~~` + +### SubscriptFeature + +- Description: Adds support for subscript text formatting, along with buttons to apply it in both fixed and inline toolbars. +- Included by default: Yes + +### SuperscriptFeature + +- Description: Adds support for superscript text formatting, along with buttons to apply it in both fixed and inline toolbars. +- Included by default: Yes + +### InlineCodeFeature + +- Description: Adds support for inline code formatting with distinct styling, along with buttons to apply it in both fixed and inline toolbars. +- Included by default: Yes +- Markdown Support: \`code\` + +### ParagraphFeature + +- Description: Provides entries in both the slash menu and toolbar dropdown for explicit paragraph creation or conversion. +- Included by default: Yes + +### HeadingFeature + +- Description: Adds support for heading nodes (H1-H6) with toolbar dropdown and slash menu entries for each enabled heading size. +- Included by default: Yes +- Markdown Support: `#`, `##`, `###`, ..., at start of line. +- Types: + +```ts +type HeadingFeatureProps = { + enabledHeadingSizes?: HeadingTagType[] // ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] +} +``` + +- Usage example: + +```ts +HeadingFeature({ + enabledHeadingSizes: ['h1', 'h2', 'h3'], // Default: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] +}) +``` + +### AlignFeature + +- Description: Allows text alignment (left, center, right, justify), along with buttons to apply it in both fixed and inline toolbars. +- Included by default: Yes +- Keyboard Shortcut: Ctrl/Cmd + Shift + L/E/R/J (left/center/right/justify) + +### IndentFeature + +- Description: Adds support for text indentation, along with buttons to apply it in both fixed and inline toolbars. +- Included by default: Yes +- Keyboard Shortcut: Tab (increase), Shift + Tab (decrease) +- Types: + +```ts +type IndentFeatureProps = { + /** + * The nodes that should not be indented. "type" + * property of the nodes you don't want to be indented. + * These can be: "paragraph", "heading", "listitem", + * "quote" or other indentable nodes if they exist. + */ + disabledNodes?: string[] + /** + * If true, pressing Tab in the middle of a block such + * as a paragraph or heading will not insert a tabNode. + * Instead, Tab will only be used for block-level indentation. + * @default false + */ + disableTabNode?: boolean +} +``` + +- Usage example: + +```ts +// Allow block-level indentation only +IndentFeature({ + disableTabNode: true, +}) +``` + +### UnorderedListFeature + +- Description: Adds support for unordered lists (bullet points) with toolbar dropdown and slash menu entries. +- Included by default: Yes +- Markdown Support: `-`, `*`, or `+` at start of line + +### OrderedListFeature + +- Description: Adds support for ordered lists (numbered lists) with toolbar dropdown and slash menu entries. +- Included by default: Yes +- Markdown Support: `1.` at start of line + +### ChecklistFeature + +- Description: Adds support for interactive checklists with toolbar dropdown and slash menu entries. +- Included by default: Yes +- Markdown Support: `- [ ]` (unchecked) or `- [x]` (checked) + +### LinkFeature + +- Description: Allows creation of internal and external links with toolbar buttons and automatic URL conversion. +- Included by default: Yes +- Markdown Support: `[anchor](url)` +- Types: + +```ts +type LinkFeatureServerProps = { + /** + * Disables the automatic creation of links + * from URLs typed or pasted into the editor, + * @default false + */ + disableAutoLinks?: 'creationOnly' | true + /** + * A function or array defining additional + * fields for the link feature. + * These will be displayed in the link editor drawer. + */ + fields?: + | ((args: { + config: SanitizedConfig + defaultFields: FieldAffectingData[] + }) => (Field | FieldAffectingData)[]) + | Field[] + /** + * Sets a maximum population depth for the internal + * doc default field of link, regardless of the + * remaining depth when the field is reached. + */ + maxDepth?: number +} & ExclusiveLinkCollectionsProps + +type ExclusiveLinkCollectionsProps = + | { + disabledCollections?: CollectionSlug[] + enabledCollections?: never + } + | { + disabledCollections?: never + enabledCollections?: CollectionSlug[] + } +``` + +- Usage example: + +```ts +LinkFeature({ + fields: ({ defaultFields }) => [ + ...defaultFields, + { + name: 'rel', + type: 'select', + options: ['noopener', 'noreferrer', 'nofollow'], + }, + ], + enabledCollections: ['pages', 'posts'], // Collections for internal links + maxDepth: 2, // Population depth for internal links + disableAutoLinks: false, // Allow auto-conversion of URLs +}) +``` + +### RelationshipFeature + +- Description: Allows creation of block-level relationships to other documents with toolbar button and slash menu entry. +- Included by default: Yes +- Types: + +```ts +type RelationshipFeatureProps = { + /** + * Sets a maximum population depth for this relationship, + * regardless of the remaining depth when the respective + * field is reached. + */ + maxDepth?: number +} & ExclusiveRelationshipFeatureProps + +type ExclusiveRelationshipFeatureProps = + | { + disabledCollections?: CollectionSlug[] + enabledCollections?: never + } + | { + disabledCollections?: never + enabledCollections?: CollectionSlug[] + } +``` + +- Usage example: + +```ts +RelationshipFeature({ + disabledCollections: ['users'], // Collections to exclude + maxDepth: 2, // Population depth for relationships +}) +``` + +### UploadFeature + +- Description: Allows creation of upload/media nodes with toolbar button and slash menu entry, supports all file types. +- Included by default: Yes +- Types: + +```ts +type UploadFeatureProps = { + collections?: { + [collection: CollectionSlug]: { + fields: Field[] + } + } + /** + * Sets a maximum population depth for this upload + * (not the fields for this upload), regardless of + * the remaining depth when the respective field is + * reached. + */ + maxDepth?: number +} +``` + +- Usage example: + +```ts +UploadFeature({ + collections: { + uploads: { + fields: [ + { + name: 'caption', + type: 'text', + label: 'Caption', + }, + { + name: 'alt', + type: 'text', + label: 'Alt Text', + }, + ], + }, + }, + maxDepth: 1, // Population depth for uploads +}) +``` + +### BlockquoteFeature + +- Description: Allows creation of blockquotes with toolbar button and slash menu entry. +- Included by default: Yes +- Markdown Support: `> quote text` + +### HorizontalRuleFeature + +- Description: Adds support for horizontal rules/separators with toolbar button and slash menu entry. +- Included by default: Yes +- Markdown Support: `---` + +### InlineToolbarFeature + +- Description: Provides a floating toolbar that appears when text is selected, containing formatting options relevant to selected text. +- Included by default: Yes + +### FixedToolbarFeature + +- Description: Provides a persistent toolbar pinned to the top of the editor that's always visible. +- Included by default: No +- Types: + +```ts +type FixedToolbarFeatureProps = { + /** + * @default false + * If this is enabled, the toolbar will apply + * to the focused editor, not the editor with + * the FixedToolbarFeature. + */ + applyToFocusedEditor?: boolean + /** + * Custom configurations for toolbar groups + * Key is the group key (e.g. 'format', 'indent', 'align') + * Value is a partial ToolbarGroup object that will + * be merged with the default configuration + */ + customGroups?: CustomGroups + /** + * @default false + * If there is a parent editor with a fixed toolbar, + * this will disable the toolbar for this editor. + */ + disableIfParentHasFixedToolbar?: boolean +} +``` + +- Usage example: + +```ts +FixedToolbarFeature({ + applyToFocusedEditor: false, // Apply to focused editor + customGroups: { + format: { + // Custom configuration for format group + }, + }, +}) +``` + +### BlocksFeature + +- Description: Allows use of Payload's Blocks Field directly in the editor with toolbar buttons and slash menu entries for each block type. +- Included by default: No +- Types: + +```ts +type BlocksFeatureProps = { + blocks?: (Block | BlockSlug)[] | Block[] + inlineBlocks?: (Block | BlockSlug)[] | Block[] +} +``` + +- Usage example: + +```ts +BlocksFeature({ + blocks: [ + { + slug: 'callout', + fields: [ + { + name: 'text', + type: 'text', + required: true, + }, + ], + }, + ], + inlineBlocks: [ + { + slug: 'mention', + fields: [ + { + name: 'name', + type: 'text', + required: true, + }, + ], + }, + ], +}) +``` + +### TreeViewFeature + +- Description: Provides a debug panel below the editor showing the editor's internal state, DOM tree, and time travel debugging. +- Included by default: No + +### EXPERIMENTAL_TableFeature + +- Description: Adds support for tables with toolbar button and slash menu entry for creation and editing. +- Included by default: No + +### TextStateFeature + +- Description: Allows storing key-value attributes in text nodes with inline styles and toolbar dropdown for style selection. +- Included by default: No +- Types: + +```ts +type TextStateFeatureProps = { + /** + * The keys of the top-level object (stateKeys) represent the attributes that the textNode can have (e.g., color). + * The values of the top-level object (stateValues) represent the values that the attribute can have (e.g., red, blue, etc.). + * Within the stateValue, you can define inline styles and labels. + */ + state: { [stateKey: string]: StateValues } +} + +type StateValues = { + [stateValue: string]: { + css: StyleObject + label: string + } +} + +type StyleObject = { + [K in keyof PropertiesHyphenFallback]?: + | Extract + | undefined +} +``` + +- Usage example: + +```ts +// We offer default colors that have good contrast and look good in dark and light mode. +import { defaultColors, TextStateFeature } from '@payloadcms/richtext-lexical' + +TextStateFeature({ + // prettier-ignore + state: { + color: { + ...defaultColors, + // fancy gradients! + galaxy: { label: 'Galaxy', css: { background: 'linear-gradient(to right, #0000ff, #ff0000)', color: 'white' } }, + sunset: { label: 'Sunset', css: { background: 'linear-gradient(to top, #ff5f6d, #6a3093)' } }, + }, + // You can have both colored and underlined text at the same time. + // If you don't want that, you should group them within the same key. + // (just like I did with defaultColors and my fancy gradients) + underline: { + 'solid': { label: 'Solid', css: { 'text-decoration': 'underline', 'text-underline-offset': '4px' } }, + // You'll probably want to use the CSS light-dark() utility. + 'yellow-dashed': { label: 'Yellow Dashed', css: { 'text-decoration': 'underline dashed', 'text-decoration-color': 'light-dark(#EAB308,yellow)', 'text-underline-offset': '4px' } }, + }, + }, +}), +``` + +This is what the example above will look like: + + diff --git a/docs/rich-text/overview.mdx b/docs/rich-text/overview.mdx index fda0cb0007..cd6a55d8a7 100644 --- a/docs/rich-text/overview.mdx +++ b/docs/rich-text/overview.mdx @@ -138,39 +138,9 @@ import { CallToAction } from '../blocks/CallToAction' | **`defaultFeatures`** | This opinionated array contains all "recommended" default features. You can see which features are included in the default features in the table below. | | **`rootFeatures`** | This array contains all features that are enabled in the root richText editor (the one defined in the payload.config.ts). If this field is the root richText editor, or if the root richText editor is not a lexical editor, this array will be empty. | -## Features overview +## Official Features -Here's an overview of all the included features: - -| Feature Name | Included by default | Description | -| ----------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`BoldFeature`** | Yes | Handles the bold text format | -| **`ItalicFeature`** | Yes | Handles the italic text format | -| **`UnderlineFeature`** | Yes | Handles the underline text format | -| **`StrikethroughFeature`** | Yes | Handles the strikethrough text format | -| **`SubscriptFeature`** | Yes | Handles the subscript text format | -| **`SuperscriptFeature`** | Yes | Handles the superscript text format | -| **`InlineCodeFeature`** | Yes | Handles the inline-code text format | -| **`ParagraphFeature`** | Yes | Handles paragraphs. Since they are already a key feature of lexical itself, this Feature mainly handles the Slash and Add-Block menu entries for paragraphs | -| **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) | -| **`AlignFeature`** | Yes | Allows you to align text left, centered and right | -| **`IndentFeature`** | Yes | Allows you to indent text with the tab key | -| **`UnorderedListFeature`** | Yes | Adds unordered lists (ul) | -| **`OrderedListFeature`** | Yes | Adds ordered lists (ol) | -| **`ChecklistFeature`** | Yes | Adds checklists | -| **`LinkFeature`** | Yes | Allows you to create internal and external links | -| **`RelationshipFeature`** | Yes | Allows you to create block-level (not inline) relationships to other documents | -| **`BlockquoteFeature`** | Yes | Allows you to create block-level quotes | -| **`UploadFeature`** | Yes | Allows you to create block-level upload nodes - this supports all kinds of uploads, not just images | -| **`HorizontalRuleFeature`** | Yes | Horizontal rules / separators. Basically displays an `
` element | -| **`InlineToolbarFeature`** | Yes | The inline toolbar is the floating toolbar which appears when you select text. This toolbar only contains actions relevant for selected text | -| **`FixedToolbarFeature`** | No | This classic toolbar is pinned to the top and always visible. Both inline and fixed toolbars can be enabled at the same time. | -| **`BlocksFeature`** | No | Allows you to use Payload's [Blocks Field](../fields/blocks) directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. | -| **`TreeViewFeature`** | No | Adds a debug box under the editor, which allows you to see the current editor state live, the dom, as well as time travel. Very useful for debugging | -| **`EXPERIMENTAL_TableFeature`** | No | Adds support for tables. This feature may be removed or receive breaking changes in the future - even within a stable lexical release, without needing a major release. | -| **`EXPERIMENTAL_TextStateFeature`** | No | Allows you to store key-value attributes within TextNodes and assign them inline styles. | - -Notice how even the toolbars are features? That's how extensible our lexical editor is - you could theoretically create your own toolbar if you wanted to! +You can find more information about the official features in our [official features docs](../rich-text/official-features). ## Creating your own, custom Feature diff --git a/docs/trash/overview.mdx b/docs/trash/overview.mdx new file mode 100644 index 0000000000..e20b6e7f77 --- /dev/null +++ b/docs/trash/overview.mdx @@ -0,0 +1,200 @@ +--- +title: Trash +label: Overview +order: 10 +desc: Enable soft deletes for your collections to mark documents as deleted without permanently removing them. +keywords: trash, soft delete, deletedAt, recovery, restore +--- + +Trash (also known as soft delete) allows documents to be marked as deleted without being permanently removed. When enabled on a collection, deleted documents will receive a `deletedAt` timestamp, making it possible to restore them later, view them in a dedicated Trash view, or permanently delete them. + +Soft delete is a safer way to manage content lifecycle, giving editors a chance to review and recover documents that may have been deleted by mistake. + + + **Note:** The Trash feature is currently in beta and may be subject to change + in minor version updates. + + +## Collection Configuration + +To enable soft deleting for a collection, set the `trash` property to `true`: + +```ts +import type { CollectionConfig } from 'payload' + +export const Posts: CollectionConfig = { + slug: 'posts', + trash: true, + fields: [ + { + name: 'title', + type: 'text', + }, + // other fields... + ], +} +``` + +When enabled, Payload automatically injects a deletedAt field into the collection's schema. This timestamp is set when a document is soft-deleted, and cleared when the document is restored. + +## Admin Panel behavior + +Once `trash` is enabled, the Admin Panel provides a dedicated Trash view for each collection: + +- A new route is added at `/collections/:collectionSlug/trash` +- The `Trash` view shows all documents that have a `deletedAt` timestamp + +From the Trash view, you can: + +- Use bulk actions to manage trashed documents: + + - **Restore** to clear the `deletedAt` timestamp and return documents to their original state + - **Delete** to permanently remove selected documents + - **Empty Trash** to select and permanently delete all trashed documents at once + +- Enter each document's **edit view**, just like in the main list view. While in the edit view of a trashed document: + - All fields are in a **read-only** state + - Standard document actions (e.g., Save, Publish, Restore Version) are hidden and disabled. + - The available actions are **Restore** and **Permanently Delete**. + - Access to the **API**, **Versions**, and **Preview** views is preserved. + +When deleting a document from the main collection List View, Payload will soft-delete the document by default. A checkbox in the delete confirmation modal allows users to skip the trash and permanently delete instead. + +## API Support + +Soft deletes are fully supported across all Payload APIs: **Local**, **REST**, and **GraphQL**. + +The following operations respect and support the `trash` functionality: + +- `find` +- `findByID` +- `update` +- `updateByID` +- `delete` +- `deleteByID` +- `findVersions` +- `findVersionByID` + +### Understanding `trash` Behavior + +Passing `trash: true` to these operations will **include soft-deleted documents** in the query results. + +To return _only_ soft-deleted documents, you must combine `trash: true` with a `where` clause that checks if `deletedAt` exists. + +### Examples + +#### Local API + +Return all documents including trashed: + +```ts +const result = await payload.find({ + collection: 'posts', + trash: true, +}) +``` + +Return only trashed documents: + +```ts +const result = await payload.find({ + collection: 'posts', + trash: true, + where: { + deletedAt: { + exists: true, + }, + }, +}) +``` + +Return only non-trashed documents: + +```ts +const result = await payload.find({ + collection: 'posts', + trash: false, +}) +``` + +#### REST + +Return **all** documents including trashed: + +```http +GET /api/posts?trash=true +``` + +Return **only trashed** documents: + +```http +GET /api/posts?trash=true&where[deletedAt][exists]=true +``` + +Return only non-trashed documents: + +```http +GET /api/posts?trash=false +``` + +#### GraphQL + +Return all documents including trashed: + +```ts +query { + Posts(trash: true) { + docs { + id + deletedAt + } + } +} +``` + +Return only trashed documents: + +```ts +query { + Posts( + trash: true + where: { deletedAt: { exists: true } } + ) { + docs { + id + deletedAt + } + } +} +``` + +Return only non-trashed documents: + +```ts +query { + Posts(trash: false) { + docs { + id + deletedAt + } + } +} +``` + +## Access Control + +All trash-related actions (delete, permanent delete) respect the `delete` access control defined in your collection config. + +This means: + +- If a user is denied delete access, they cannot soft delete or permanently delete documents + +## Versions and Trash + +When a document is soft-deleted: + +- It can no longer have a version **restored** until it is first restored from trash +- Attempting to restore a version while the document is in trash will result in an error +- This ensures consistency between the current document state and its version history + +However, versions are still fully **visible and accessible** from the **edit view** of a trashed document. You can view the full version history, but must restore the document itself before restoring any individual version. diff --git a/docs/troubleshooting/troubleshooting.mdx b/docs/troubleshooting/troubleshooting.mdx index 3137d930e0..cbe122ada4 100644 --- a/docs/troubleshooting/troubleshooting.mdx +++ b/docs/troubleshooting/troubleshooting.mdx @@ -6,9 +6,112 @@ desc: Troubleshooting Common Issues in Payload keywords: admin, components, custom, customize, documentation, Content Management System, cms, headless, javascript, node, react, nextjs, troubleshooting --- -## Common Issues +## Dependency mismatches -### "Unauthorized, you must be logged in to make this request" when attempting to log in +All `payload` and `@payloadcms/*` packages must be on exactly the same version and installed only once. + +When two copies—or two different versions—of any of these packages (or of `react` / `react-dom`) appear in your dependency graph, you can see puzzling runtime errors. The most frequent is a broken React context: + +```bash +TypeError: Cannot destructure property 'config' of... +``` + +This happens because one package imports a hook (most commonly `useConfig`) from _version A_ while the context provider comes from _version B_. The fix is always the same: make sure every Payload-related and React package resolves to the same module. + +### Confirm whether duplicates exist + +The first thing to do is to confirm whether duplicative dependencies do in fact exist. + +There are two ways to do this: + +1. Using pnpm's built-in inspection tool + +```bash +pnpm why @payloadcms/ui +``` + +This prints the dependency tree and shows which versions are being installed. If you see more than one distinct version—or the same version listed under different paths—you have duplication. + +2. Manual check (works with any package manager) + +```bash +find node_modules -name package.json \ + -exec grep -H '"name": "@payloadcms/ui"' {} \; +``` + +Most of these hits are likely symlinks created by pnpm. Edit the matching package.json files (temporarily add a comment or change a description) to confirm whether they point to the same physical folder or to multiple copies. + +Perform the same two checks for react and react-dom; a second copy of React can cause identical symptoms. + +#### If no duplicates are found + +`@payloadcms/ui` intentionally contains two bundles of itself, so you may see dual paths even when everything is correct. Inside the Payload Admin UI you must import only: + +- `@payloadcms/ui` +- `@payloadcms/ui/rsc` +- `@payloadcms/ui/shared` + +Any other deep import such as `@payloadcms/ui/elements/Button` should **only** be used in your own frontend, outside of the Payload Admin Panel. Those deep entries are published un-bundled to help you tree-shake and ship a smaller client bundle if you only need a few components from `@payloadcms/ui`. + +### Fixing depedendency issues + +These steps assume `pnpm`, which the Payload team recommends and uses internally. The principles apply to other package managers like npm and yarn as well. Do note that yarn 1.x is not supported by Payload. + +1. Pin every critical package to an exact version + +In package.json remove `^` or `~` from all versions of: + +- `payload` +- `@payloadcms/*` +- `react` +- `react-dom` + +Prefixes allow your package manager to float to a newer minor/patch release, causing mismatches. + +2. Delete node_modules + +Old packages often linger even after you change versions or removed them from your package.json. Deleting node_modules ensures a clean slate. + +3. Re-install dependencies + +```bash +pnpm install +``` + +#### If the error persists + +1. Clean the global store (pnpm only) + +```bash +pnpm store prune +``` + +2. Delete the lockfile + +Depending on your package manager, this could be `pnpm-lock.yaml`, `package-lock.json`, or `yarn.lock`. + +Make sure you delete the lockfile **and** the node_modules folder at the same time, then run `pnpm install`. This forces a fresh, consistent resolution for all packages. It will also update all packages with dynamic versions to the latest version. + +While it's best practice to manage dependencies in such a way where the lockfile can easily be re-generated (often this is the easiest way to resolve dependency issues), this may break your project if you have not tested the latest versions of your dependencies. + +If you are using a version control system, make sure to commit your lockfile after this step. + +3. Deduplicate anything that slipped through + +```bash +pnpm dedupe +``` + +**Still stuck?** + +- Switch to `pnpm` if you are on npm. Its symlinked store helps reducing accidental duplication. +- Inspect the lockfile directly for peer-dependency violations. +- Check project-level .npmrc / .pnpmfile.cjs overrides. +- Run [Syncpack](https://www.npmjs.com/package/syncpack) to enforce identical versions of every `@payloadcms/*`, `react`, and `react-dom` reference. + +Absolute last resort: add Webpack aliases so that all imports of a given package resolve to the same path (e.g. `resolve.alias['react'] = path.resolve('./node_modules/react')`). Keep this only until you can fix the underlying version skew. + +## "Unauthorized, you must be logged in to make this request" when attempting to log in This means that your auth cookie is not being set or accepted correctly upon logging in. To resolve check the following settings in your Payload Config: diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 07adf66bb0..6b0b058c38 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -90,32 +90,33 @@ export const Media: CollectionConfig = { _An asterisk denotes that an option is required._ -| Option | Description | -| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) | -| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true | -| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. | -| **`constructorOptions`** | An object passed to the the Sharp image library that accepts any Constructor options and applies them to the upload file. [More](https://sharp.pixelplumbing.com/api-constructor/) | -| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) | -| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) | -| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). | -| **`externalFileHeaderFilter`** | Accepts existing headers and returns the headers after filtering or modifying. | -| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. | -| **`filenameCompoundIndex`** | Field slugs to use for a compound index instead of the default filename index. | -| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the [Admin Panel](../admin/overview). The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) | -| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) | -| **`handlers`** | Array of Request handlers to execute when fetching a file, if a handler returns a Response it will be sent to the client. Otherwise Payload will retrieve and send back the file. | -| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) | -| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) | -| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) | -| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) | -| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. | -| **`allowRestrictedFileTypes`** | Set to `true` to allow restricted file types. If your Collection has defined [mimeTypes](#mimetypes), restricted file verification will be skipped. Defaults to `false`. [More](#restricted-file-types) | -| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug | -| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) | -| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. | -| **`hideFileInputOnCreate`** | Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. | -| **`hideRemoveFile`** | Set to `true` to prevent the admin UI having a way to remove an existing file while editing. | +| Option | Description | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) | +| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true | +| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. | +| **`constructorOptions`** | An object passed to the the Sharp image library that accepts any Constructor options and applies them to the upload file. [More](https://sharp.pixelplumbing.com/api-constructor/) | +| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) | +| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) | +| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). | +| **`externalFileHeaderFilter`** | Accepts existing headers and returns the headers after filtering or modifying. If using this option, you should handle the removal of any sensitive cookies (like payload-prefixed cookies) to prevent leaking session information to external services. By default, Payload automatically filters out payload-prefixed cookies when this option is not defined. | +| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. | +| **`filenameCompoundIndex`** | Field slugs to use for a compound index instead of the default filename index. | +| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the [Admin Panel](../admin/overview). The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) | +| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) | +| **`handlers`** | Array of Request handlers to execute when fetching a file, if a handler returns a Response it will be sent to the client. Otherwise Payload will retrieve and send back the file. | +| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) | +| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) | +| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) | +| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) | +| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. | +| **`allowRestrictedFileTypes`** | Set to `true` to allow restricted file types. If your Collection has defined [mimeTypes](#mimetypes), restricted file verification will be skipped. Defaults to `false`. [More](#restricted-file-types) | +| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug | +| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) | +| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. | +| **`hideFileInputOnCreate`** | Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. | +| **`hideRemoveFile`** | Set to `true` to prevent the admin UI having a way to remove an existing file while editing. | +| **`modifyResponseHeaders`** | Accepts an object with existing `headers` and allows you to manipulate the response headers for media files. [More](#modifying-response-headers) | ### Payload-wide Upload Options @@ -453,7 +454,7 @@ To fetch files from **restricted URLs** that would otherwise be blocked by CORS, Here’s how to configure the pasteURL option to control remote URL fetching: -``` +```ts import type { CollectionConfig } from 'payload' export const Media: CollectionConfig = { @@ -466,7 +467,7 @@ export const Media: CollectionConfig = { pathname: '', port: '', protocol: 'https', - search: '' + search: '', }, { hostname: 'example.com', @@ -519,3 +520,44 @@ _An asterisk denotes that an option is required._ ## Access Control All files that are uploaded to each Collection automatically support the `read` [Access Control](/docs/access-control/overview) function from the Collection itself. You can use this to control who should be allowed to see your uploads, and who should not. + +## Modifying response headers + +You can modify the response headers for files by specifying the `modifyResponseHeaders` option in your upload config. This option accepts an object with existing headers and allows you to manipulate the response headers for media files. + +### Modifying existing headers + +With this method you can directly interface with the `Headers` object and modify the existing headers to append or remove headers. + +```ts +import type { CollectionConfig } from 'payload' + +export const Media: CollectionConfig = { + slug: 'media', + upload: { + modifyResponseHeaders: ({ headers }) => { + headers.set('X-Frame-Options', 'DENY') // You can directly set headers without returning + }, + }, +} +``` + +### Return new headers + +You can also return a new `Headers` object with the modified headers. This is useful if you want to set new headers or remove existing ones. + +```ts +import type { CollectionConfig } from 'payload' + +export const Media: CollectionConfig = { + slug: 'media', + upload: { + modifyResponseHeaders: ({ headers }) => { + const newHeaders = new Headers(headers) // Copy existing headers + newHeaders.set('X-Frame-Options', 'DENY') // Set new header + + return newHeaders + }, + }, +} +``` diff --git a/docs/upload/storage-adapters.mdx b/docs/upload/storage-adapters.mdx index fa25571939..de6420d07b 100644 --- a/docs/upload/storage-adapters.mdx +++ b/docs/upload/storage-adapters.mdx @@ -292,7 +292,8 @@ Reference any of the existing storage adapters for guidance on how this should b ```ts export interface GeneratedAdapter { /** - * Additional fields to be injected into the base collection and image sizes + * Additional fields to be injected into the base + * collection and image sizes */ fields?: Field[] /** diff --git a/examples/auth/src/collections/Users.ts b/examples/auth/src/collections/Users.ts index 67f00ebdd4..a0f9d334a0 100644 --- a/examples/auth/src/collections/Users.ts +++ b/examples/auth/src/collections/Users.ts @@ -6,6 +6,8 @@ import { anyone } from './access/anyone' import { checkRole } from './access/checkRole' import { loginAfterCreate } from './hooks/loginAfterCreate' import { protectRoles } from './hooks/protectRoles' +import { access } from 'fs' +import { create } from 'domain' export const Users: CollectionConfig = { slug: 'users', @@ -32,6 +34,34 @@ export const Users: CollectionConfig = { afterChange: [loginAfterCreate], }, fields: [ + { + name: 'email', + type: 'email', + required: true, + unique: true, + access: { + read: adminsAndUser, + update: adminsAndUser, + }, + }, + { + name: 'password', + type: 'password', + required: true, + admin: { + description: 'Leave blank to keep the current password.', + }, + }, + { + name: 'resetPasswordToken', + type: 'text', + hidden: true, + }, + { + name: 'resetPasswordExpiration', + type: 'date', + hidden: true, + }, { name: 'firstName', type: 'text', @@ -45,6 +75,11 @@ export const Users: CollectionConfig = { type: 'select', hasMany: true, saveToJWT: true, + access: { + read: admins, + update: admins, + create: admins, + }, hooks: { beforeChange: [protectRoles], }, diff --git a/package.json b/package.json index dd3be1a889..b17e512594 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload-monorepo", - "version": "3.46.0", + "version": "3.50.0", "private": true, "type": "module", "workspaces": [ @@ -112,6 +112,7 @@ "test:e2e:prod:ci": "pnpm prepare-run-test-against-prod:ci && pnpm runts ./test/runE2E.ts --prod", "test:e2e:prod:ci:noturbo": "pnpm prepare-run-test-against-prod:ci && pnpm runts ./test/runE2E.ts --prod --no-turbo", "test:int": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" NODE_NO_WARNINGS=1 DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand", + "test:int:firestore": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" NODE_NO_WARNINGS=1 PAYLOAD_DATABASE=firestore DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand", "test:int:postgres": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" NODE_NO_WARNINGS=1 PAYLOAD_DATABASE=postgres DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand", "test:int:sqlite": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" NODE_NO_WARNINGS=1 PAYLOAD_DATABASE=sqlite DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand", "test:types": "tstyche", @@ -131,12 +132,12 @@ "devDependencies": { "@jest/globals": "29.7.0", "@libsql/client": "0.14.0", - "@next/bundle-analyzer": "15.3.2", + "@next/bundle-analyzer": "15.4.4", "@payloadcms/db-postgres": "workspace:*", "@payloadcms/eslint-config": "workspace:*", "@payloadcms/eslint-plugin": "workspace:*", "@payloadcms/live-preview-react": "workspace:*", - "@playwright/test": "1.50.0", + "@playwright/test": "1.54.1", "@sentry/nextjs": "^8.33.1", "@sentry/node": "^8.33.1", "@swc-node/register": "1.10.10", @@ -146,8 +147,8 @@ "@types/jest": "29.5.12", "@types/minimist": "1.2.5", "@types/node": "22.15.30", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "@types/shelljs": "0.8.15", "chalk": "^4.1.2", "comment-json": "^4.2.3", @@ -167,12 +168,12 @@ "lint-staged": "15.2.7", "minimist": "1.2.8", "mongodb-memory-server": "10.1.4", - "next": "15.3.2", + "next": "15.4.4", "open": "^10.1.0", "p-limit": "^5.0.0", "pg": "8.16.3", - "playwright": "1.50.0", - "playwright-core": "1.50.0", + "playwright": "1.54.1", + "playwright-core": "1.54.1", "prettier": "3.5.3", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/packages/admin-bar/package.json b/packages/admin-bar/package.json index eaa08e5598..8745c6e3fe 100644 --- a/packages/admin-bar/package.json +++ b/packages/admin-bar/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/admin-bar", - "version": "3.46.0", + "version": "3.50.0", "description": "An admin bar for React apps using Payload", "homepage": "https://payloadcms.com", "repository": { @@ -42,8 +42,8 @@ }, "devDependencies": { "@payloadcms/eslint-config": "workspace:*", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "payload": "workspace:*" }, "peerDependencies": { diff --git a/packages/create-payload-app/package.json b/packages/create-payload-app/package.json index 101f5a0a6e..1e3096d6a1 100644 --- a/packages/create-payload-app/package.json +++ b/packages/create-payload-app/package.json @@ -1,6 +1,6 @@ { "name": "create-payload-app", - "version": "3.46.0", + "version": "3.50.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/db-mongodb/package.json b/packages/db-mongodb/package.json index e142b0e1e7..da2b23257b 100644 --- a/packages/db-mongodb/package.json +++ b/packages/db-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-mongodb", - "version": "3.46.0", + "version": "3.50.0", "description": "The officially supported MongoDB database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-mongodb/src/connect.ts b/packages/db-mongodb/src/connect.ts index 6210bde286..ba2c9c4db3 100644 --- a/packages/db-mongodb/src/connect.ts +++ b/packages/db-mongodb/src/connect.ts @@ -36,6 +36,25 @@ export const connect: Connect = async function connect( try { this.connection = (await mongoose.connect(urlToConnect, connectionOptions)).connection + if (this.useAlternativeDropDatabase) { + if (this.connection.db) { + // Firestore doesn't support dropDatabase, so we monkey patch + // dropDatabase to delete all documents from all collections instead + this.connection.db.dropDatabase = async function (): Promise { + const existingCollections = await this.listCollections().toArray() + await Promise.all( + existingCollections.map(async (collectionInfo) => { + const collection = this.collection(collectionInfo.name) + await collection.deleteMany({}) + }), + ) + return true + } + this.connection.dropDatabase = async function () { + await this.db?.dropDatabase() + } + } + } // If we are running a replica set with MongoDB Memory Server, // wait until the replica set elects a primary before proceeding diff --git a/packages/db-mongodb/src/find.ts b/packages/db-mongodb/src/find.ts index 938940c513..6f1124e503 100644 --- a/packages/db-mongodb/src/find.ts +++ b/packages/db-mongodb/src/find.ts @@ -12,6 +12,7 @@ import { buildJoinAggregation } from './utilities/buildJoinAggregation.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getCollection } from './utilities/getEntity.js' import { getSession } from './utilities/getSession.js' +import { resolveJoins } from './utilities/resolveJoins.js' import { transform } from './utilities/transform.js' export const find: Find = async function find( @@ -155,6 +156,16 @@ export const find: Find = async function find( result = await Model.paginate(query, paginationOptions) } + if (!this.useJoinAggregations) { + await resolveJoins({ + adapter: this, + collectionSlug, + docs: result.docs as Record[], + joins, + locale, + }) + } + transform({ adapter: this, data: result.docs, diff --git a/packages/db-mongodb/src/findDistinct.ts b/packages/db-mongodb/src/findDistinct.ts new file mode 100644 index 0000000000..bc77a8cab4 --- /dev/null +++ b/packages/db-mongodb/src/findDistinct.ts @@ -0,0 +1,141 @@ +import type { PipelineStage } from 'mongoose' + +import { type FindDistinct, getFieldByPath } from 'payload' + +import type { MongooseAdapter } from './index.js' + +import { buildQuery } from './queries/buildQuery.js' +import { buildSortParam } from './queries/buildSortParam.js' +import { getCollection } from './utilities/getEntity.js' +import { getSession } from './utilities/getSession.js' + +export const findDistinct: FindDistinct = async function (this: MongooseAdapter, args) { + const { collectionConfig, Model } = getCollection({ + adapter: this, + collectionSlug: args.collection, + }) + + const session = await getSession(this, args.req) + + const { where = {} } = args + + const sortAggregation: PipelineStage[] = [] + + const sort = buildSortParam({ + adapter: this, + config: this.payload.config, + fields: collectionConfig.flattenedFields, + locale: args.locale, + sort: args.sort ?? args.field, + sortAggregation, + timestamps: true, + }) + + const query = await buildQuery({ + adapter: this, + collectionSlug: args.collection, + fields: collectionConfig.flattenedFields, + locale: args.locale, + where, + }) + + const fieldPathResult = getFieldByPath({ + fields: collectionConfig.flattenedFields, + path: args.field, + }) + let fieldPath = args.field + if (fieldPathResult?.pathHasLocalized && args.locale) { + fieldPath = fieldPathResult.localizedPath.replace('', args.locale) + } + + const page = args.page || 1 + + const sortProperty = Object.keys(sort)[0]! // assert because buildSortParam always returns at least 1 key. + const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1 + + const pipeline: PipelineStage[] = [ + { + $match: query, + }, + ...(sortAggregation.length > 0 ? sortAggregation : []), + + { + $group: { + _id: { + _field: `$${fieldPath}`, + ...(sortProperty === fieldPath + ? {} + : { + _sort: `$${sortProperty}`, + }), + }, + }, + }, + { + $sort: { + [sortProperty === fieldPath ? '_id._field' : '_id._sort']: sortDirection, + }, + }, + ] + + const getValues = async () => { + return Model.aggregate(pipeline, { session }).then((res) => + res.map((each) => ({ + [args.field]: JSON.parse(JSON.stringify(each._id._field)), + })), + ) + } + + if (args.limit) { + pipeline.push({ + $skip: (page - 1) * args.limit, + }) + pipeline.push({ $limit: args.limit }) + const totalDocs = await Model.aggregate( + [ + { + $match: query, + }, + { + $group: { + _id: `$${fieldPath}`, + }, + }, + { $count: 'count' }, + ], + { + session, + }, + ).then((res) => res[0]?.count ?? 0) + const totalPages = Math.ceil(totalDocs / args.limit) + const hasPrevPage = page > 1 + const hasNextPage = totalPages > page + const pagingCounter = (page - 1) * args.limit + 1 + + return { + hasNextPage, + hasPrevPage, + limit: args.limit, + nextPage: hasNextPage ? page + 1 : null, + page, + pagingCounter, + prevPage: hasPrevPage ? page - 1 : null, + totalDocs, + totalPages, + values: await getValues(), + } + } + + const values = await getValues() + + return { + hasNextPage: false, + hasPrevPage: false, + limit: 0, + page: 1, + pagingCounter: 1, + totalDocs: values.length, + totalPages: 1, + values, + } +} diff --git a/packages/db-mongodb/src/findOne.ts b/packages/db-mongodb/src/findOne.ts index 0ffe97b108..cf6edb34f0 100644 --- a/packages/db-mongodb/src/findOne.ts +++ b/packages/db-mongodb/src/findOne.ts @@ -10,6 +10,7 @@ import { buildJoinAggregation } from './utilities/buildJoinAggregation.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getCollection } from './utilities/getEntity.js' import { getSession } from './utilities/getSession.js' +import { resolveJoins } from './utilities/resolveJoins.js' import { transform } from './utilities/transform.js' export const findOne: FindOne = async function findOne( @@ -67,6 +68,16 @@ export const findOne: FindOne = async function findOne( doc = await Model.findOne(query, {}, options) } + if (doc && !this.useJoinAggregations) { + await resolveJoins({ + adapter: this, + collectionSlug, + docs: [doc] as Record[], + joins, + locale, + }) + } + if (!doc) { return null } diff --git a/packages/db-mongodb/src/index.ts b/packages/db-mongodb/src/index.ts index de2dc1c862..08c8e6cb6f 100644 --- a/packages/db-mongodb/src/index.ts +++ b/packages/db-mongodb/src/index.ts @@ -42,6 +42,7 @@ import { deleteOne } from './deleteOne.js' import { deleteVersions } from './deleteVersions.js' import { destroy } from './destroy.js' import { find } from './find.js' +import { findDistinct } from './findDistinct.js' import { findGlobal } from './findGlobal.js' import { findGlobalVersions } from './findGlobalVersions.js' import { findOne } from './findOne.js' @@ -143,6 +144,29 @@ export interface Args { /** The URL to connect to MongoDB or false to start payload and prevent connecting */ url: false | string + + /** + * Set to `true` to use an alternative `dropDatabase` implementation that calls `collection.deleteMany({})` on every collection instead of sending a raw `dropDatabase` command. + * Payload only uses `dropDatabase` for testing purposes. + * @default false + */ + useAlternativeDropDatabase?: boolean + /** + * Set to `true` to use `BigInt` for custom ID fields of type `'number'`. + * Useful for databases that don't support `double` or `int32` IDs. + * @default false + */ + useBigIntForNumberIDs?: boolean + /** + * Set to `false` to disable join aggregations (which use correlated subqueries) and instead populate join fields via multiple `find` queries. + * @default true + */ + useJoinAggregations?: boolean + /** + * Set to `false` to disable the use of `pipeline` in the `$lookup` aggregation in sorting. + * @default true + */ + usePipelineInSortLookup?: boolean } export type MongooseAdapter = { @@ -159,6 +183,10 @@ export type MongooseAdapter = { up: (args: MigrateUpArgs) => Promise }[] sessions: Record + useAlternativeDropDatabase: boolean + useBigIntForNumberIDs: boolean + useJoinAggregations: boolean + usePipelineInSortLookup: boolean versions: { [slug: string]: CollectionModel } @@ -194,6 +222,10 @@ declare module 'payload' { updateVersion: ( args: { options?: QueryOptions } & UpdateVersionArgs, ) => Promise> + useAlternativeDropDatabase: boolean + useBigIntForNumberIDs: boolean + useJoinAggregations: boolean + usePipelineInSortLookup: boolean versions: { [slug: string]: CollectionModel } @@ -214,6 +246,10 @@ export function mongooseAdapter({ prodMigrations, transactionOptions = {}, url, + useAlternativeDropDatabase = false, + useBigIntForNumberIDs = false, + useJoinAggregations = true, + usePipelineInSortLookup = true, }: Args): DatabaseAdapterObj { function adapter({ payload }: { payload: Payload }) { const migrationDir = findMigrationDir(migrationDirArg) @@ -262,6 +298,7 @@ export function mongooseAdapter({ destroy, disableFallbackSort, find, + findDistinct, findGlobal, findGlobalVersions, findOne, @@ -279,6 +316,10 @@ export function mongooseAdapter({ updateOne, updateVersion, upsert, + useAlternativeDropDatabase, + useBigIntForNumberIDs, + useJoinAggregations, + usePipelineInSortLookup, }) } @@ -290,6 +331,8 @@ export function mongooseAdapter({ } } +export { compatabilityOptions } from './utilities/compatabilityOptions.js' + /** * Attempt to find migrations directory. * diff --git a/packages/db-mongodb/src/models/buildSchema.ts b/packages/db-mongodb/src/models/buildSchema.ts index 56e2cf1130..719f474ef7 100644 --- a/packages/db-mongodb/src/models/buildSchema.ts +++ b/packages/db-mongodb/src/models/buildSchema.ts @@ -143,7 +143,12 @@ export const buildSchema = (args: { const idField = schemaFields.find((field) => fieldAffectsData(field) && field.name === 'id') if (idField) { fields = { - _id: idField.type === 'number' ? Number : String, + _id: + idField.type === 'number' + ? payload.db.useBigIntForNumberIDs + ? mongoose.Schema.Types.BigInt + : Number + : String, } schemaFields = schemaFields.filter( (field) => !(fieldAffectsData(field) && field.name === 'id'), @@ -900,7 +905,11 @@ const getRelationshipValueType = (field: RelationshipField | UploadField, payloa } if (customIDType === 'number') { - return mongoose.Schema.Types.Number + if (payload.db.useBigIntForNumberIDs) { + return mongoose.Schema.Types.BigInt + } else { + return mongoose.Schema.Types.Number + } } return mongoose.Schema.Types.String diff --git a/packages/db-mongodb/src/queries/buildSortParam.ts b/packages/db-mongodb/src/queries/buildSortParam.ts index 0133736932..551d5bfad7 100644 --- a/packages/db-mongodb/src/queries/buildSortParam.ts +++ b/packages/db-mongodb/src/queries/buildSortParam.ts @@ -99,31 +99,57 @@ const relationshipSort = ({ sortFieldPath = foreignFieldPath.localizedPath.replace('', locale) } - if ( - !sortAggregation.some((each) => { - return '$lookup' in each && each.$lookup.as === `__${path}` - }) - ) { + const as = `__${relationshipPath.replace(/\./g, '__')}` + + // If we have not already sorted on this relationship yet, we need to add a lookup stage + if (!sortAggregation.some((each) => '$lookup' in each && each.$lookup.as === as)) { + let localField = versions ? `version.${relationshipPath}` : relationshipPath + + if (adapter.usePipelineInSortLookup) { + const flattenedField = `__${localField.replace(/\./g, '__')}_lookup` + sortAggregation.push({ + $addFields: { + [flattenedField]: `$${localField}`, + }, + }) + localField = flattenedField + } + sortAggregation.push({ $lookup: { - as: `__${path}`, + as, foreignField: '_id', from: foreignCollection.Model.collection.name, - localField: versions ? `version.${relationshipPath}` : relationshipPath, - pipeline: [ - { - $project: { - [sortFieldPath]: true, + localField, + ...(!adapter.usePipelineInSortLookup && { + pipeline: [ + { + $project: { + [sortFieldPath]: true, + }, }, - }, - ], + ], + }), }, }) - sort[`__${path}.${sortFieldPath}`] = sortDirection - - return true + if (adapter.usePipelineInSortLookup) { + sortAggregation.push({ + $unset: localField, + }) + } } + + if (!adapter.usePipelineInSortLookup) { + const lookup = sortAggregation.find( + (each) => '$lookup' in each && each.$lookup.as === as, + ) as PipelineStage.Lookup + const pipeline = lookup.$lookup.pipeline![0] as PipelineStage.Project + pipeline.$project[sortFieldPath] = true + } + + sort[`${as}.${sortFieldPath}`] = sortDirection + return true } } diff --git a/packages/db-mongodb/src/queryDrafts.ts b/packages/db-mongodb/src/queryDrafts.ts index c43e0c52f4..1dd0e84daf 100644 --- a/packages/db-mongodb/src/queryDrafts.ts +++ b/packages/db-mongodb/src/queryDrafts.ts @@ -12,6 +12,7 @@ import { buildJoinAggregation } from './utilities/buildJoinAggregation.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getCollection } from './utilities/getEntity.js' import { getSession } from './utilities/getSession.js' +import { resolveJoins } from './utilities/resolveJoins.js' import { transform } from './utilities/transform.js' export const queryDrafts: QueryDrafts = async function queryDrafts( @@ -158,6 +159,17 @@ export const queryDrafts: QueryDrafts = async function queryDrafts( result = await Model.paginate(versionQuery, paginationOptions) } + if (!this.useJoinAggregations) { + await resolveJoins({ + adapter: this, + collectionSlug, + docs: result.docs as Record[], + joins, + locale, + versions: true, + }) + } + transform({ adapter: this, data: result.docs, diff --git a/packages/db-mongodb/src/updateOne.ts b/packages/db-mongodb/src/updateOne.ts index 3fd4a0a516..20816512ad 100644 --- a/packages/db-mongodb/src/updateOne.ts +++ b/packages/db-mongodb/src/updateOne.ts @@ -1,4 +1,4 @@ -import type { MongooseUpdateQueryOptions } from 'mongoose' +import type { MongooseUpdateQueryOptions, UpdateQuery } from 'mongoose' import type { UpdateOne } from 'payload' import type { MongooseAdapter } from './index.js' @@ -50,15 +50,20 @@ export const updateOne: UpdateOne = async function updateOne( let result - transform({ adapter: this, data, fields, operation: 'write' }) + const $inc: Record = {} + let updateData: UpdateQuery = data + transform({ $inc, adapter: this, data, fields, operation: 'write' }) + if (Object.keys($inc).length) { + updateData = { $inc, $set: updateData } + } try { if (returning === false) { - await Model.updateOne(query, data, options) + await Model.updateOne(query, updateData, options) transform({ adapter: this, data, fields, operation: 'read' }) return null } else { - result = await Model.findOneAndUpdate(query, data, options) + result = await Model.findOneAndUpdate(query, updateData, options) } } catch (error) { handleError({ collection: collectionSlug, error, req }) diff --git a/packages/db-mongodb/src/utilities/aggregatePaginate.ts b/packages/db-mongodb/src/utilities/aggregatePaginate.ts index 237d0a00c9..5e0b6d1de3 100644 --- a/packages/db-mongodb/src/utilities/aggregatePaginate.ts +++ b/packages/db-mongodb/src/utilities/aggregatePaginate.ts @@ -76,7 +76,11 @@ export const aggregatePaginate = async ({ countPromise = Model.estimatedDocumentCount(query) } else { const hint = adapter.disableIndexHints !== true ? { _id: 1 } : undefined - countPromise = Model.countDocuments(query, { collation, hint, session }) + countPromise = Model.countDocuments(query, { + collation, + session, + ...(hint ? { hint } : {}), + }) } } diff --git a/packages/db-mongodb/src/utilities/buildJoinAggregation.ts b/packages/db-mongodb/src/utilities/buildJoinAggregation.ts index 0d8afb3688..da737d62fc 100644 --- a/packages/db-mongodb/src/utilities/buildJoinAggregation.ts +++ b/packages/db-mongodb/src/utilities/buildJoinAggregation.ts @@ -44,6 +44,9 @@ export const buildJoinAggregation = async ({ projection, versions, }: BuildJoinAggregationArgs): Promise => { + if (!adapter.useJoinAggregations) { + return + } if ( (Object.keys(collectionConfig.joins).length === 0 && collectionConfig.polymorphicJoins.length == 0) || diff --git a/packages/db-mongodb/src/utilities/compatabilityOptions.ts b/packages/db-mongodb/src/utilities/compatabilityOptions.ts new file mode 100644 index 0000000000..bf797895b7 --- /dev/null +++ b/packages/db-mongodb/src/utilities/compatabilityOptions.ts @@ -0,0 +1,25 @@ +import type { Args } from '../index.js' + +/** + * Each key is a mongo-compatible database and the value + * is the recommended `mongooseAdapter` settings for compatability. + */ +export const compatabilityOptions = { + cosmosdb: { + transactionOptions: false, + useJoinAggregations: false, + usePipelineInSortLookup: false, + }, + documentdb: { + disableIndexHints: true, + }, + firestore: { + disableIndexHints: true, + ensureIndexes: false, + transactionOptions: false, + useAlternativeDropDatabase: true, + useBigIntForNumberIDs: true, + useJoinAggregations: false, + usePipelineInSortLookup: false, + }, +} satisfies Record> diff --git a/packages/db-mongodb/src/utilities/handleError.ts b/packages/db-mongodb/src/utilities/handleError.ts index d7a44656ef..172548ff6d 100644 --- a/packages/db-mongodb/src/utilities/handleError.ts +++ b/packages/db-mongodb/src/utilities/handleError.ts @@ -2,6 +2,15 @@ import type { PayloadRequest } from 'payload' import { ValidationError } from 'payload' +function extractFieldFromMessage(message: string) { + // eslint-disable-next-line regexp/no-super-linear-backtracking + const match = message.match(/index:\s*(.*?)_/) + if (match && match[1]) { + return match[1] // e.g., returns "email" from "index: email_1" + } + return null +} + export const handleError = ({ collection, error, @@ -18,20 +27,22 @@ export const handleError = ({ } // Handle uniqueness error from MongoDB - if ( - 'code' in error && - error.code === 11000 && - 'keyValue' in error && - error.keyValue && - typeof error.keyValue === 'object' - ) { + if ('code' in error && error.code === 11000) { + let path: null | string = null + + if ('keyValue' in error && error.keyValue && typeof error.keyValue === 'object') { + path = Object.keys(error.keyValue)[0] ?? '' + } else if ('message' in error && typeof error.message === 'string') { + path = extractFieldFromMessage(error.message) + } + throw new ValidationError( { collection, errors: [ { message: req?.t ? req.t('error:valueMustBeUnique') : 'Value must be unique', - path: Object.keys(error.keyValue)[0] ?? '', + path: path ?? '', }, ], global, diff --git a/packages/db-mongodb/src/utilities/resolveJoins.ts b/packages/db-mongodb/src/utilities/resolveJoins.ts new file mode 100644 index 0000000000..fa28c63d76 --- /dev/null +++ b/packages/db-mongodb/src/utilities/resolveJoins.ts @@ -0,0 +1,647 @@ +import type { JoinQuery, SanitizedJoins, Where } from 'payload' + +import { + appendVersionToQueryKey, + buildVersionCollectionFields, + combineQueries, + getQueryDraftsSort, +} from 'payload' +import { fieldShouldBeLocalized } from 'payload/shared' + +import type { MongooseAdapter } from '../index.js' + +import { buildQuery } from '../queries/buildQuery.js' +import { buildSortParam } from '../queries/buildSortParam.js' +import { transform } from './transform.js' + +export type ResolveJoinsArgs = { + /** The MongoDB adapter instance */ + adapter: MongooseAdapter + /** The slug of the collection being queried */ + collectionSlug: string + /** Array of documents to resolve joins for */ + docs: Record[] + /** Join query specifications (which joins to resolve and how) */ + joins?: JoinQuery + /** Optional locale for localized queries */ + locale?: string + /** Optional projection for the join query */ + projection?: Record + /** Whether to resolve versions instead of published documents */ + versions?: boolean +} + +/** + * Resolves join relationships for a collection of documents. + * This function fetches related documents based on join configurations and + * attaches them to the original documents with pagination support. + */ +export async function resolveJoins({ + adapter, + collectionSlug, + docs, + joins, + locale, + projection, + versions = false, +}: ResolveJoinsArgs): Promise { + // Early return if no joins are specified or no documents to process + if (!joins || docs.length === 0) { + return + } + + // Get the collection configuration from the adapter + const collectionConfig = adapter.payload.collections[collectionSlug]?.config + if (!collectionConfig) { + return + } + + // Build a map of join paths to their configurations for quick lookup + // This flattens the nested join structure into a single map keyed by join path + const joinMap: Record = {} + + // Add regular joins + for (const [target, joinList] of Object.entries(collectionConfig.joins)) { + for (const join of joinList) { + joinMap[join.joinPath] = { ...join, targetCollection: target } + } + } + + // Add polymorphic joins + for (const join of collectionConfig.polymorphicJoins || []) { + // For polymorphic joins, we use the collections array as the target + joinMap[join.joinPath] = { ...join, targetCollection: join.field.collection as string } + } + + // Process each requested join concurrently + const joinPromises = Object.entries(joins).map(async ([joinPath, joinQuery]) => { + if (!joinQuery) { + return null + } + + // If a projection is provided, and the join path is not in the projection, skip it + if (projection && !projection[joinPath]) { + return null + } + + // Get the join definition from our map + const joinDef = joinMap[joinPath] + if (!joinDef) { + return null + } + + // Normalize collections to always be an array for unified processing + const allCollections = Array.isArray(joinDef.field.collection) + ? joinDef.field.collection + : [joinDef.field.collection] + + // Use the provided locale or fall back to the default locale for localized fields + const localizationConfig = adapter.payload.config.localization + const effectiveLocale = + locale || + (typeof localizationConfig === 'object' && + localizationConfig && + localizationConfig.defaultLocale) + + // Extract relationTo filter from the where clause to determine which collections to query + const relationToFilter = extractRelationToFilter(joinQuery.where || {}) + + // Determine which collections to query based on relationTo filter + const collections = relationToFilter + ? allCollections.filter((col) => relationToFilter.includes(col)) + : allCollections + + // Check if this is a polymorphic collection join (where field.collection is an array) + const isPolymorphicJoin = Array.isArray(joinDef.field.collection) + + // Apply pagination settings + const limit = joinQuery.limit ?? joinDef.field.defaultLimit ?? 10 + const page = joinQuery.page ?? 1 + const skip = (page - 1) * limit + + // Process collections concurrently + const collectionPromises = collections.map(async (joinCollectionSlug) => { + const targetConfig = adapter.payload.collections[joinCollectionSlug]?.config + if (!targetConfig) { + return null + } + + const useDrafts = versions && Boolean(targetConfig.versions?.drafts) + let JoinModel + if (useDrafts) { + JoinModel = adapter.versions[targetConfig.slug] + } else { + JoinModel = adapter.collections[targetConfig.slug] + } + + if (!JoinModel) { + return null + } + + // Extract all parent document IDs to use in the join query + const parentIDs = docs.map((d) => (versions ? (d.parent ?? d._id ?? d.id) : (d._id ?? d.id))) + + // Build the base query + let whereQuery: null | Record = null + whereQuery = isPolymorphicJoin + ? filterWhereForCollection( + joinQuery.where || {}, + targetConfig.flattenedFields, + true, // exclude relationTo for individual collections + ) + : joinQuery.where || {} + + // Skip this collection if the WHERE clause cannot be satisfied for polymorphic collection joins + if (whereQuery === null) { + return null + } + whereQuery = useDrafts + ? await JoinModel.buildQuery({ + locale, + payload: adapter.payload, + where: combineQueries(appendVersionToQueryKey(whereQuery as Where), { + latest: { + equals: true, + }, + }), + }) + : await buildQuery({ + adapter, + collectionSlug: joinCollectionSlug, + fields: targetConfig.flattenedFields, + locale, + where: whereQuery as Where, + }) + + // Handle localized paths and version prefixes + let dbFieldName = joinDef.field.on + + if (effectiveLocale && typeof localizationConfig === 'object' && localizationConfig) { + const pathSegments = joinDef.field.on.split('.') + const transformedSegments: string[] = [] + const fields = useDrafts + ? buildVersionCollectionFields(adapter.payload.config, targetConfig, true) + : targetConfig.flattenedFields + + for (let i = 0; i < pathSegments.length; i++) { + const segment = pathSegments[i]! + transformedSegments.push(segment) + + // Check if this segment corresponds to a localized field + const fieldAtSegment = fields.find((f) => f.name === segment) + if (fieldAtSegment && fieldAtSegment.localized) { + transformedSegments.push(effectiveLocale) + } + } + + dbFieldName = transformedSegments.join('.') + } + + // Add version prefix for draft queries + if (useDrafts) { + dbFieldName = `version.${dbFieldName}` + } + + // Check if the target field is a polymorphic relationship + const isPolymorphic = joinDef.targetField + ? Array.isArray(joinDef.targetField.relationTo) + : false + + if (isPolymorphic) { + // For polymorphic relationships, we need to match both relationTo and value + whereQuery[`${dbFieldName}.relationTo`] = collectionSlug + whereQuery[`${dbFieldName}.value`] = { $in: parentIDs } + } else { + // For regular relationships and polymorphic collection joins + whereQuery[dbFieldName] = { $in: parentIDs } + } + + // Build the sort parameters for the query + const fields = useDrafts + ? buildVersionCollectionFields(adapter.payload.config, targetConfig, true) + : targetConfig.flattenedFields + + const sort = buildSortParam({ + adapter, + config: adapter.payload.config, + fields, + locale, + sort: useDrafts + ? getQueryDraftsSort({ + collectionConfig: targetConfig, + sort: joinQuery.sort || joinDef.field.defaultSort || targetConfig.defaultSort, + }) + : joinQuery.sort || joinDef.field.defaultSort || targetConfig.defaultSort, + timestamps: true, + }) + + const projection = buildJoinProjection(dbFieldName, useDrafts, sort) + + const [results, dbCount] = await Promise.all([ + JoinModel.find(whereQuery, projection, { + sort, + ...(isPolymorphicJoin ? {} : { limit, skip }), + }).lean(), + isPolymorphicJoin ? Promise.resolve(0) : JoinModel.countDocuments(whereQuery), + ]) + + const count = isPolymorphicJoin ? results.length : dbCount + + transform({ + adapter, + data: results, + fields: useDrafts + ? buildVersionCollectionFields(adapter.payload.config, targetConfig, false) + : targetConfig.fields, + operation: 'read', + }) + + // Return results with collection info for grouping + return { + collectionSlug: joinCollectionSlug, + count, + dbFieldName, + results, + sort, + useDrafts, + } + }) + + const collectionResults = await Promise.all(collectionPromises) + + // Group the results by parent ID + const grouped: Record< + string, + { + docs: Record[] + sort: Record + } + > = {} + + let totalCount = 0 + for (const collectionResult of collectionResults) { + if (!collectionResult) { + continue + } + + const { collectionSlug, count, dbFieldName, results, sort, useDrafts } = collectionResult + + totalCount += count + + for (const result of results) { + if (useDrafts) { + result.id = result.parent + } + + const parentValues = getByPathWithArrays(result, dbFieldName) as ( + | { relationTo: string; value: number | string } + | number + | string + )[] + + if (parentValues.length === 0) { + continue + } + + for (let parentValue of parentValues) { + if (!parentValue) { + continue + } + + if (typeof parentValue === 'object') { + parentValue = parentValue.value + } + + const joinData = { + relationTo: collectionSlug, + value: result.id, + } + + const parentKey = parentValue as string + if (!grouped[parentKey]) { + grouped[parentKey] = { + docs: [], + sort, + } + } + + // Always store the ObjectID reference in polymorphic format + grouped[parentKey].docs.push({ + ...result, + __joinData: joinData, + }) + } + } + } + + for (const results of Object.values(grouped)) { + results.docs.sort((a, b) => { + for (const [fieldName, sortOrder] of Object.entries(results.sort)) { + const sort = sortOrder === 'asc' ? 1 : -1 + const aValue = a[fieldName] as Date | number | string + const bValue = b[fieldName] as Date | number | string + if (aValue < bValue) { + return -1 * sort + } + if (aValue > bValue) { + return 1 * sort + } + } + return 0 + }) + results.docs = results.docs.map( + (doc) => (isPolymorphicJoin ? doc.__joinData : doc.id) as Record, + ) + } + + // Determine if the join field should be localized + const localeSuffix = + fieldShouldBeLocalized({ + field: joinDef.field, + parentIsLocalized: joinDef.parentIsLocalized, + }) && + adapter.payload.config.localization && + effectiveLocale + ? `.${effectiveLocale}` + : '' + + // Adjust the join path with locale suffix if needed + const localizedJoinPath = `${joinPath}${localeSuffix}` + + return { + grouped, + isPolymorphicJoin, + joinQuery, + limit, + localizedJoinPath, + page, + skip, + totalCount, + } + }) + + // Wait for all join operations to complete + const joinResults = await Promise.all(joinPromises) + + // Process the results and attach them to documents + for (const joinResult of joinResults) { + if (!joinResult) { + continue + } + + const { grouped, isPolymorphicJoin, joinQuery, limit, localizedJoinPath, skip, totalCount } = + joinResult + + // Attach the joined data to each parent document + for (const doc of docs) { + const id = (versions ? (doc.parent ?? doc._id ?? doc.id) : (doc._id ?? doc.id)) as string + const all = grouped[id]?.docs || [] + + // Calculate the slice for pagination + // When limit is 0, it means unlimited - return all results + const slice = isPolymorphicJoin + ? limit === 0 + ? all + : all.slice(skip, skip + limit) + : // For non-polymorphic joins, we assume that page and limit were applied at the database level + all + + // Create the join result object with pagination metadata + const value: Record = { + docs: slice, + hasNextPage: limit === 0 ? false : totalCount > skip + slice.length, + } + + // Include total count if requested + if (joinQuery.count) { + value.totalDocs = totalCount + } + + // Navigate to the correct nested location in the document and set the join data + // This handles nested join paths like "user.posts" by creating intermediate objects + const segments = localizedJoinPath.split('.') + let ref: Record + if (versions) { + if (!doc.version) { + doc.version = {} + } + ref = doc.version as Record + } else { + ref = doc + } + + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]! + if (!ref[seg]) { + ref[seg] = {} + } + ref = ref[seg] as Record + } + // Set the final join data at the target path + ref[segments[segments.length - 1]!] = value + } + } +} + +/** + * Extracts relationTo filter values from a WHERE clause + * @param where - The WHERE clause to search + * @returns Array of collection slugs if relationTo filter found, null otherwise + */ +function extractRelationToFilter(where: Record): null | string[] { + if (!where || typeof where !== 'object') { + return null + } + + // Check for direct relationTo conditions + if (where.relationTo && typeof where.relationTo === 'object') { + const relationTo = where.relationTo as Record + if (relationTo.in && Array.isArray(relationTo.in)) { + return relationTo.in as string[] + } + if (relationTo.equals) { + return [relationTo.equals as string] + } + } + + // Check for relationTo in logical operators + if (where.and && Array.isArray(where.and)) { + for (const condition of where.and) { + const result = extractRelationToFilter(condition) + if (result) { + return result + } + } + } + + if (where.or && Array.isArray(where.or)) { + for (const condition of where.or) { + const result = extractRelationToFilter(condition) + if (result) { + return result + } + } + } + + return null +} + +/** + * Filters a WHERE clause to only include fields that exist in the target collection + * This is needed for polymorphic joins where different collections have different fields + * @param where - The original WHERE clause + * @param availableFields - The fields available in the target collection + * @param excludeRelationTo - Whether to exclude relationTo field (for individual collections) + * @returns A filtered WHERE clause, or null if the query cannot match this collection + */ +function filterWhereForCollection( + where: Record, + availableFields: Array<{ name: string }>, + excludeRelationTo: boolean = false, +): null | Record { + if (!where || typeof where !== 'object') { + return where + } + + const fieldNames = new Set(availableFields.map((f) => f.name)) + // Add special fields that are available in polymorphic relationships + if (!excludeRelationTo) { + fieldNames.add('relationTo') + } + + const filtered: Record = {} + + for (const [key, value] of Object.entries(where)) { + if (key === 'and') { + // Handle AND operator - all conditions must be satisfiable + if (Array.isArray(value)) { + const filteredConditions: Record[] = [] + + for (const condition of value) { + const filteredCondition = filterWhereForCollection( + condition, + availableFields, + excludeRelationTo, + ) + + // If any condition in AND cannot be satisfied, the whole AND fails + if (filteredCondition === null) { + return null + } + + if (Object.keys(filteredCondition).length > 0) { + filteredConditions.push(filteredCondition) + } + } + + if (filteredConditions.length > 0) { + filtered[key] = filteredConditions + } + } + } else if (key === 'or') { + // Handle OR operator - at least one condition must be satisfiable + if (Array.isArray(value)) { + const filteredConditions = value + .map((condition) => + filterWhereForCollection(condition, availableFields, excludeRelationTo), + ) + .filter((condition) => condition !== null && Object.keys(condition).length > 0) + + if (filteredConditions.length > 0) { + filtered[key] = filteredConditions + } + // If no OR conditions can be satisfied, we still continue (OR is more permissive) + } + } else if (key === 'relationTo' && excludeRelationTo) { + // Skip relationTo field for non-polymorphic collections + continue + } else if (fieldNames.has(key)) { + // Include the condition if the field exists in this collection + filtered[key] = value + } else { + // Field doesn't exist in this collection - this makes the query unsatisfiable + return null + } + } + + return filtered +} + +type SanitizedJoin = SanitizedJoins[string][number] + +/** + * Builds projection for join queries + */ +function buildJoinProjection( + baseFieldName: string, + useDrafts: boolean, + sort: Record, +): Record { + const projection: Record = { + _id: 1, + [baseFieldName]: 1, + } + + if (useDrafts) { + projection.parent = 1 + } + + for (const fieldName of Object.keys(sort)) { + projection[fieldName] = 1 + } + + return projection +} + +/** + * Enhanced utility function to safely traverse nested object properties using dot notation + * Handles arrays by searching through array elements for matching values + * @param doc - The document to traverse + * @param path - Dot-separated path (e.g., "array.category") + * @returns Array of values found at the specified path (for arrays) or single value + */ +function getByPathWithArrays(doc: unknown, path: string): unknown[] { + const segments = path.split('.') + let current = doc + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]! + + if (current === undefined || current === null) { + return [] + } + + // Get the value at the current segment + const value = (current as Record)[segment] + + if (value === undefined || value === null) { + return [] + } + + // If this is the last segment, return the value(s) + if (i === segments.length - 1) { + return Array.isArray(value) ? value : [value] + } + + // If the value is an array and we have more segments to traverse + if (Array.isArray(value)) { + const remainingPath = segments.slice(i + 1).join('.') + const results: unknown[] = [] + + // Search through each array element + for (const item of value) { + if (item && typeof item === 'object') { + const subResults = getByPathWithArrays(item, remainingPath) + results.push(...subResults) + } + } + + return results + } + + // Continue traversing + current = value + } + + return [] +} diff --git a/packages/db-mongodb/src/utilities/transform.ts b/packages/db-mongodb/src/utilities/transform.ts index 74b6e7b93a..35a271877c 100644 --- a/packages/db-mongodb/src/utilities/transform.ts +++ b/packages/db-mongodb/src/utilities/transform.ts @@ -208,6 +208,7 @@ const sanitizeDate = ({ } type Args = { + $inc?: Record /** instance of the adapter */ adapter: MongooseAdapter /** data to transform, can be an array of documents or a single document */ @@ -396,6 +397,7 @@ const stripFields = ({ } export const transform = ({ + $inc, adapter, data, fields, @@ -404,9 +406,13 @@ export const transform = ({ parentIsLocalized = false, validateRelationships = true, }: Args) => { + if (!data) { + return null + } + if (Array.isArray(data)) { for (const item of data) { - transform({ adapter, data: item, fields, globalSlug, operation, validateRelationships }) + transform({ $inc, adapter, data: item, fields, globalSlug, operation, validateRelationships }) } return } @@ -424,6 +430,11 @@ export const transform = ({ data.id = data.id.toHexString() } + // Handle BigInt conversion for custom ID fields of type 'number' + if (adapter.useBigIntForNumberIDs && typeof data.id === 'bigint') { + data.id = Number(data.id) + } + if (!adapter.allowAdditionalKeys) { stripFields({ config, @@ -438,13 +449,27 @@ export const transform = ({ data.globalType = globalSlug } - const sanitize: TraverseFieldsCallback = ({ field, ref: incomingRef }) => { + const sanitize: TraverseFieldsCallback = ({ field, parentPath, ref: incomingRef }) => { if (!incomingRef || typeof incomingRef !== 'object') { return } const ref = incomingRef as Record + if ( + $inc && + field.type === 'number' && + operation === 'write' && + field.name in ref && + ref[field.name] + ) { + const value = ref[field.name] + if (value && typeof value === 'object' && '$inc' in value && typeof value.$inc === 'number') { + $inc[`${parentPath}${field.name}`] = value.$inc + delete ref[field.name] + } + } + if (field.type === 'date' && operation === 'read' && field.name in ref && ref[field.name]) { if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) { const fieldRef = ref[field.name] as Record diff --git a/packages/db-postgres/package.json b/packages/db-postgres/package.json index 156ed4e1b5..ac4da28599 100644 --- a/packages/db-postgres/package.json +++ b/packages/db-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-postgres", - "version": "3.46.0", + "version": "3.50.0", "description": "The officially supported Postgres database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-postgres/src/index.ts b/packages/db-postgres/src/index.ts index a6769cb735..df431424bd 100644 --- a/packages/db-postgres/src/index.ts +++ b/packages/db-postgres/src/index.ts @@ -17,6 +17,7 @@ import { deleteVersions, destroy, find, + findDistinct, findGlobal, findGlobalVersions, findMigrationDir, @@ -120,6 +121,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj json: true, }, fieldConstraints: {}, + findDistinct, generateSchema: createSchemaGenerator({ columnToCodeConverter, corePackageSuffix: 'pg-core', diff --git a/packages/db-sqlite/package.json b/packages/db-sqlite/package.json index b83642182c..5e28d12fff 100644 --- a/packages/db-sqlite/package.json +++ b/packages/db-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-sqlite", - "version": "3.46.0", + "version": "3.50.0", "description": "The officially supported SQLite database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-sqlite/src/countDistinct.ts b/packages/db-sqlite/src/countDistinct.ts index ae729138f0..4c0c1e45ce 100644 --- a/packages/db-sqlite/src/countDistinct.ts +++ b/packages/db-sqlite/src/countDistinct.ts @@ -6,13 +6,13 @@ import type { CountDistinct, SQLiteAdapter } from './types.js' export const countDistinct: CountDistinct = async function countDistinct( this: SQLiteAdapter, - { db, joins, tableName, where }, + { column, db, joins, tableName, where }, ) { // When we don't have any joins - use a simple COUNT(*) query. if (joins.length === 0) { const countResult = await db .select({ - count: count(), + count: column ? count(sql`DISTINCT ${column}`) : count(), }) .from(this.tables[tableName]) .where(where) @@ -25,12 +25,12 @@ export const countDistinct: CountDistinct = async function countDistinct( }) .from(this.tables[tableName]) .where(where) - .groupBy(this.tables[tableName].id) + .groupBy(column ?? this.tables[tableName].id) .limit(1) .$dynamic() - joins.forEach(({ condition, table }) => { - query = query.leftJoin(table, condition) + joins.forEach(({ type, condition, table }) => { + query = query[type ?? 'leftJoin'](table, condition) }) // When we have any joins, we need to count each individual ID only once. diff --git a/packages/db-sqlite/src/createJSONQuery/index.ts b/packages/db-sqlite/src/createJSONQuery/index.ts index 435ca62ce6..abcb709d44 100644 --- a/packages/db-sqlite/src/createJSONQuery/index.ts +++ b/packages/db-sqlite/src/createJSONQuery/index.ts @@ -60,6 +60,10 @@ const createConstraint = ({ formattedOperator = '=' } + if (pathSegments.length === 1) { + return `EXISTS (SELECT 1 FROM json_each("${pathSegments[0]}") AS ${newAlias} WHERE ${newAlias}.value ${formattedOperator} '${formattedValue}')` + } + return `EXISTS ( SELECT 1 FROM json_each(${alias}.value -> '${pathSegments[0]}') AS ${newAlias} @@ -68,21 +72,38 @@ const createConstraint = ({ } export const createJSONQuery = ({ + column, operator, pathSegments, + rawColumn, table, treatAsArray, + treatRootAsArray, value, }: CreateJSONQueryArgs): string => { + if ((operator === 'in' || operator === 'not_in') && Array.isArray(value)) { + let sql = '' + for (const [i, v] of value.entries()) { + sql = `${sql}${createJSONQuery({ column, operator: operator === 'in' ? 'equals' : 'not_equals', pathSegments, rawColumn, table, treatAsArray, treatRootAsArray, value: v })} ${i === value.length - 1 ? '' : ` ${operator === 'in' ? 'OR' : 'AND'} `}` + } + return sql + } + if (treatAsArray?.includes(pathSegments[1]!) && table) { return fromArray({ operator, pathSegments, table, treatAsArray, - value, + value: value as CreateConstraintArgs['value'], }) } - return createConstraint({ alias: table, operator, pathSegments, treatAsArray, value }) + return createConstraint({ + alias: table, + operator, + pathSegments, + treatAsArray, + value: value as CreateConstraintArgs['value'], + }) } diff --git a/packages/db-sqlite/src/index.ts b/packages/db-sqlite/src/index.ts index 015ce9ba92..0cae319680 100644 --- a/packages/db-sqlite/src/index.ts +++ b/packages/db-sqlite/src/index.ts @@ -18,6 +18,7 @@ import { deleteVersions, destroy, find, + findDistinct, findGlobal, findGlobalVersions, findMigrationDir, @@ -101,6 +102,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj { json: true, }, fieldConstraints: {}, + findDistinct, generateSchema: createSchemaGenerator({ columnToCodeConverter, corePackageSuffix: 'sqlite-core', diff --git a/packages/db-sqlite/src/types.ts b/packages/db-sqlite/src/types.ts index 568f3a4dc3..5aa84c9935 100644 --- a/packages/db-sqlite/src/types.ts +++ b/packages/db-sqlite/src/types.ts @@ -5,6 +5,7 @@ import type { DrizzleConfig, Relation, Relations, SQL } from 'drizzle-orm' import type { LibSQLDatabase } from 'drizzle-orm/libsql' import type { AnySQLiteColumn, + SQLiteColumn, SQLiteInsertOnConflictDoUpdateConfig, SQLiteTableWithColumns, SQLiteTransactionConfig, @@ -87,6 +88,7 @@ export type GenericTable = SQLiteTableWithColumns<{ export type GenericRelation = Relations>> export type CountDistinct = (args: { + column?: SQLiteColumn db: LibSQLDatabase joins: BuildQueryJoinAliases tableName: string diff --git a/packages/db-vercel-postgres/package.json b/packages/db-vercel-postgres/package.json index a830982fef..d8fa3dd596 100644 --- a/packages/db-vercel-postgres/package.json +++ b/packages/db-vercel-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-vercel-postgres", - "version": "3.46.0", + "version": "3.50.0", "description": "Vercel Postgres adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-vercel-postgres/src/index.ts b/packages/db-vercel-postgres/src/index.ts index a9fd65f63c..155bdc2a2d 100644 --- a/packages/db-vercel-postgres/src/index.ts +++ b/packages/db-vercel-postgres/src/index.ts @@ -18,6 +18,7 @@ import { deleteVersions, destroy, find, + findDistinct, findGlobal, findGlobalVersions, findMigrationDir, @@ -174,6 +175,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj { - ids.push(data.id) - }) - - if (ids.length > 0) { - await this.deleteWhere({ - db, + if (joins?.length) { + // Difficult to support joins (through where referencing other tables) in deleteMany. => 2 separate queries. + // We can look into supporting this using one single query (through a subquery) in the future, though that's difficult to do in a generic way. + const result = await findMany({ + adapter: this, + fields: collectionConfig.flattenedFields, + joins: false, + limit: 0, + locale: req?.locale, + page: 1, + pagination: false, + req, + select: { + id: true, + }, tableName, - where: inArray(this.tables[tableName].id, ids), + where: whereArg, }) + + whereToUse = inArray( + table.id, + result.docs.map((doc) => doc.id), + ) } + + await this.deleteWhere({ + db, + tableName, + where: whereToUse, + }) } diff --git a/packages/drizzle/src/find/buildFindManyArgs.ts b/packages/drizzle/src/find/buildFindManyArgs.ts index 4febf335d1..c45bff699f 100644 --- a/packages/drizzle/src/find/buildFindManyArgs.ts +++ b/packages/drizzle/src/find/buildFindManyArgs.ts @@ -44,7 +44,7 @@ export const buildFindManyArgs = ({ select, tableName, versions, -}: BuildFindQueryArgs): Record => { +}: BuildFindQueryArgs): Result => { const result: Result = { extras: {}, with: {}, @@ -134,5 +134,12 @@ export const buildFindManyArgs = ({ result.with._locales = _locales } + // Delete properties that are empty + for (const key of Object.keys(result)) { + if (!Object.keys(result[key]).length) { + delete result[key] + } + } + return result } diff --git a/packages/drizzle/src/find/traverseFields.ts b/packages/drizzle/src/find/traverseFields.ts index f81b729258..208bd93c87 100644 --- a/packages/drizzle/src/find/traverseFields.ts +++ b/packages/drizzle/src/find/traverseFields.ts @@ -1,12 +1,14 @@ +import type { SQL } from 'drizzle-orm' import type { LibSQLDatabase } from 'drizzle-orm/libsql' import type { SQLiteSelect, SQLiteSelectBase } from 'drizzle-orm/sqlite-core' -import { and, asc, count, desc, eq, or, sql } from 'drizzle-orm' +import { and, asc, count, desc, eq, getTableName, or, sql } from 'drizzle-orm' import { appendVersionToQueryKey, buildVersionCollectionFields, combineQueries, type FlattenedField, + getFieldByPath, getQueryDraftsSort, type JoinQuery, type SelectMode, @@ -31,7 +33,7 @@ import { resolveBlockTableName, } from '../utilities/validateExistingBlockIsIdentical.js' -const flattenAllWherePaths = (where: Where, paths: string[]) => { +const flattenAllWherePaths = (where: Where, paths: { path: string; ref: any }[]) => { for (const k in where) { if (['AND', 'OR'].includes(k.toUpperCase())) { if (Array.isArray(where[k])) { @@ -41,7 +43,7 @@ const flattenAllWherePaths = (where: Where, paths: string[]) => { } } else { // TODO: explore how to support arrays/relationship querying. - paths.push(k.split('.').join('_')) + paths.push({ path: k.split('.').join('_'), ref: where }) } } } @@ -59,7 +61,11 @@ const buildSQLWhere = (where: Where, alias: string) => { } } else { const payloadOperator = Object.keys(where[k])[0] + const value = where[k][payloadOperator] + if (payloadOperator === '$raw') { + return sql.raw(value) + } return operatorMap[payloadOperator](sql.raw(`"${alias}"."${k.split('.').join('_')}"`), value) } @@ -472,7 +478,7 @@ export const traverseFields = ({ const sortPath = sanitizedSort.split('.').join('_') - const wherePaths: string[] = [] + const wherePaths: { path: string; ref: any }[] = [] if (where) { flattenAllWherePaths(where, wherePaths) @@ -492,9 +498,50 @@ export const traverseFields = ({ sortPath: sql`${sortColumn ? sortColumn : null}`.as('sortPath'), } + const collectionQueryWhere: any[] = [] // Select for WHERE and Fallback NULL - for (const path of wherePaths) { - if (adapter.tables[joinCollectionTableName][path]) { + for (const { path, ref } of wherePaths) { + const collectioConfig = adapter.payload.collections[collection].config + const field = getFieldByPath({ fields: collectioConfig.flattenedFields, path }) + + if (field && field.field.type === 'select' && field.field.hasMany) { + let tableName = adapter.tableNameMap.get( + `${toSnakeCase(collection)}_${toSnakeCase(path)}`, + ) + let parentTable = getTableName(table) + + if (adapter.schemaName) { + tableName = `"${adapter.schemaName}"."${tableName}"` + parentTable = `"${adapter.schemaName}"."${parentTable}"` + } + + if (adapter.name === 'postgres') { + selectFields[path] = sql + .raw( + `(select jsonb_agg(${tableName}.value) from ${tableName} where ${tableName}.parent_id = ${parentTable}.id)`, + ) + .as(path) + } else { + selectFields[path] = sql + .raw( + `(select json_group_array(${tableName}.value) from ${tableName} where ${tableName}.parent_id = ${parentTable}.id)`, + ) + .as(path) + } + + const constraint = ref[path] + const operator = Object.keys(constraint)[0] + const value: any = Object.values(constraint)[0] + + const query = adapter.createJSONQuery({ + column: `"${path}"`, + operator, + pathSegments: [field.field.name], + table: parentTable, + value, + }) + ref[path] = { $raw: query } + } else if (adapter.tables[joinCollectionTableName][path]) { selectFields[path] = sql`${adapter.tables[joinCollectionTableName][path]}`.as(path) // Allow to filter by collectionSlug } else if (path !== 'relationTo') { @@ -502,7 +549,10 @@ export const traverseFields = ({ } } - const query = db.select(selectFields).from(adapter.tables[joinCollectionTableName]) + let query: any = db.select(selectFields).from(adapter.tables[joinCollectionTableName]) + if (collectionQueryWhere.length) { + query = query.where(and(...collectionQueryWhere)) + } if (currentQuery === null) { currentQuery = query as unknown as SQLSelect } else { diff --git a/packages/drizzle/src/findDistinct.ts b/packages/drizzle/src/findDistinct.ts new file mode 100644 index 0000000000..d19e0c1c7b --- /dev/null +++ b/packages/drizzle/src/findDistinct.ts @@ -0,0 +1,108 @@ +import type { FindDistinct, SanitizedCollectionConfig } from 'payload' + +import toSnakeCase from 'to-snake-case' + +import type { DrizzleAdapter, GenericColumn } from './types.js' + +import { buildQuery } from './queries/buildQuery.js' +import { selectDistinct } from './queries/selectDistinct.js' +import { getTransaction } from './utilities/getTransaction.js' +import { DistinctSymbol } from './utilities/rawConstraint.js' + +export const findDistinct: FindDistinct = async function (this: DrizzleAdapter, args) { + const db = await getTransaction(this, args.req) + const collectionConfig: SanitizedCollectionConfig = + this.payload.collections[args.collection].config + const page = args.page || 1 + const offset = args.limit ? (page - 1) * args.limit : undefined + const tableName = this.tableNameMap.get(toSnakeCase(collectionConfig.slug)) + + const { joins, orderBy, selectFields, where } = buildQuery({ + adapter: this, + fields: collectionConfig.flattenedFields, + locale: args.locale, + sort: args.sort ?? args.field, + tableName, + where: { + and: [ + args.where ?? {}, + { + [args.field]: { + equals: DistinctSymbol, + }, + }, + ], + }, + }) + + orderBy.pop() + + const selectDistinctResult = await selectDistinct({ + adapter: this, + db, + forceRun: true, + joins, + query: ({ query }) => { + query = query.orderBy(() => orderBy.map(({ column, order }) => order(column))) + + if (args.limit) { + if (offset) { + query = query.offset(offset) + } + + query = query.limit(args.limit) + } + + return query + }, + selectFields: { + _selected: selectFields['_selected'], + ...(orderBy[0].column === selectFields['_selected'] ? {} : { _order: orderBy[0].column }), + } as Record, + tableName, + where, + }) + + const values = selectDistinctResult.map((each) => ({ + [args.field]: (each as Record)._selected, + })) + + if (args.limit) { + const totalDocs = await this.countDistinct({ + column: selectFields['_selected'], + db, + joins, + tableName, + where, + }) + + const totalPages = Math.ceil(totalDocs / args.limit) + const hasPrevPage = page > 1 + const hasNextPage = totalPages > page + const pagingCounter = (page - 1) * args.limit + 1 + + return { + hasNextPage, + hasPrevPage, + limit: args.limit, + nextPage: hasNextPage ? page + 1 : null, + page, + pagingCounter, + prevPage: hasPrevPage ? page - 1 : null, + totalDocs, + totalPages, + values, + } + } + + return { + hasNextPage: false, + hasPrevPage: false, + limit: 0, + page: 1, + pagingCounter: 1, + totalDocs: values.length, + totalPages: 1, + values, + } +} diff --git a/packages/drizzle/src/index.ts b/packages/drizzle/src/index.ts index 6650b26178..dd1055bdfc 100644 --- a/packages/drizzle/src/index.ts +++ b/packages/drizzle/src/index.ts @@ -12,6 +12,7 @@ export { deleteVersions } from './deleteVersions.js' export { destroy } from './destroy.js' export { find } from './find.js' export { chainMethods } from './find/chainMethods.js' +export { findDistinct } from './findDistinct.js' export { findGlobal } from './findGlobal.js' export { findGlobalVersions } from './findGlobalVersions.js' export { findMigrationDir } from './findMigrationDir.js' diff --git a/packages/drizzle/src/postgres/countDistinct.ts b/packages/drizzle/src/postgres/countDistinct.ts index 04d7559fcf..e6e45c69a8 100644 --- a/packages/drizzle/src/postgres/countDistinct.ts +++ b/packages/drizzle/src/postgres/countDistinct.ts @@ -6,13 +6,13 @@ import type { BasePostgresAdapter, CountDistinct } from './types.js' export const countDistinct: CountDistinct = async function countDistinct( this: BasePostgresAdapter, - { db, joins, tableName, where }, + { column, db, joins, tableName, where }, ) { // When we don't have any joins - use a simple COUNT(*) query. if (joins.length === 0) { const countResult = await db .select({ - count: count(), + count: column ? count(sql`DISTINCT ${column}`) : count(), }) .from(this.tables[tableName]) .where(where) @@ -26,12 +26,12 @@ export const countDistinct: CountDistinct = async function countDistinct( }) .from(this.tables[tableName]) .where(where) - .groupBy(this.tables[tableName].id) + .groupBy(column || this.tables[tableName].id) .limit(1) .$dynamic() - joins.forEach(({ condition, table }) => { - query = query.leftJoin(table as PgTableWithColumns, condition) + joins.forEach(({ type, condition, table }) => { + query = query[type ?? 'leftJoin'](table as PgTableWithColumns, condition) }) // When we have any joins, we need to count each individual ID only once. diff --git a/packages/drizzle/src/postgres/createJSONQuery/index.ts b/packages/drizzle/src/postgres/createJSONQuery/index.ts index 88ac57b4ae..86d532cca3 100644 --- a/packages/drizzle/src/postgres/createJSONQuery/index.ts +++ b/packages/drizzle/src/postgres/createJSONQuery/index.ts @@ -28,6 +28,8 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat }) .join('.') + const fullPath = pathSegments.length === 1 ? '$[*]' : `$.${jsonPaths}` + let sql = '' if (['in', 'not_in'].includes(operator) && Array.isArray(value)) { @@ -35,13 +37,13 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat sql = `${sql}${createJSONQuery({ column, operator: operator === 'in' ? 'equals' : 'not_equals', pathSegments, value: item })}${i === value.length - 1 ? '' : ` ${operator === 'in' ? 'OR' : 'AND'} `}` }) } else if (operator === 'exists') { - sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '$.${jsonPaths}')` + sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '${fullPath}')` } else if (['not_like'].includes(operator)) { const mappedOperator = operatorMap[operator] - sql = `NOT jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${mappedOperator.substring(1)} ${sanitizeValue(value, operator)})')` + sql = `NOT jsonb_path_exists(${columnName}, '${fullPath} ? (@ ${mappedOperator.substring(1)} ${sanitizeValue(value, operator)})')` } else { - sql = `jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')` + sql = `jsonb_path_exists(${columnName}, '${fullPath} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')` } return sql diff --git a/packages/drizzle/src/postgres/types.ts b/packages/drizzle/src/postgres/types.ts index 696d13797d..60ed3a0749 100644 --- a/packages/drizzle/src/postgres/types.ts +++ b/packages/drizzle/src/postgres/types.ts @@ -20,6 +20,7 @@ import type { UniqueConstraintBuilder, } from 'drizzle-orm/pg-core' import type { PgTableFn } from 'drizzle-orm/pg-core/table' +import type { SQLiteColumn } from 'drizzle-orm/sqlite-core' import type { Payload, PayloadRequest } from 'payload' import type { ClientConfig, QueryResult } from 'pg' @@ -64,6 +65,7 @@ export type GenericRelation = Relations> export type PostgresDB = NodePgDatabase> export type CountDistinct = (args: { + column?: PgColumn | SQLiteColumn db: PostgresDB | TransactionPg joins: BuildQueryJoinAliases tableName: string diff --git a/packages/drizzle/src/queries/parseParams.ts b/packages/drizzle/src/queries/parseParams.ts index a5b88d4d74..9c12c69416 100644 --- a/packages/drizzle/src/queries/parseParams.ts +++ b/packages/drizzle/src/queries/parseParams.ts @@ -10,6 +10,7 @@ import type { DrizzleAdapter, GenericColumn } from '../types.js' import type { BuildQueryJoinAliases } from './buildQuery.js' import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js' +import { DistinctSymbol } from '../utilities/rawConstraint.js' import { buildAndOrConditions } from './buildAndOrConditions.js' import { getTableColumnFromPath } from './getTableColumnFromPath.js' import { sanitizeQueryValue } from './sanitizeQueryValue.js' @@ -108,6 +109,17 @@ export function parseParams({ value: val, }) + const resolvedColumn = + rawColumn || + (aliasTable && tableName === getNameFromDrizzleTable(table) + ? aliasTable[columnName] + : table[columnName]) + + if (val === DistinctSymbol) { + selectFields['_selected'] = resolvedColumn + break + } + queryConstraints.forEach(({ columnName: col, table: constraintTable, value }) => { if (typeof value === 'string' && value.indexOf('%') > -1) { constraints.push(adapter.operators.like(constraintTable[col], value)) @@ -207,7 +219,10 @@ export function parseParams({ if ( operator === 'like' && - (field.type === 'number' || table[columnName].columnType === 'PgUUID') + (field.type === 'number' || + field.type === 'relationship' || + field.type === 'upload' || + table[columnName].columnType === 'PgUUID') ) { operator = 'equals' } @@ -281,12 +296,6 @@ export function parseParams({ break } - const resolvedColumn = - rawColumn || - (aliasTable && tableName === getNameFromDrizzleTable(table) - ? aliasTable[columnName] - : table[columnName]) - if (queryOperator === 'not_equals' && queryValue !== null) { constraints.push( or( diff --git a/packages/drizzle/src/queries/sanitizeQueryValue.ts b/packages/drizzle/src/queries/sanitizeQueryValue.ts index 2a7cfc8c9d..72b1d5cbac 100644 --- a/packages/drizzle/src/queries/sanitizeQueryValue.ts +++ b/packages/drizzle/src/queries/sanitizeQueryValue.ts @@ -112,9 +112,14 @@ export const sanitizeQueryValue = ({ if (field.type === 'date' && operator !== 'exists') { if (typeof val === 'string') { - formattedValue = new Date(val).toISOString() - if (Number.isNaN(Date.parse(formattedValue))) { - return { operator, value: undefined } + if (val === 'null' || val === '') { + formattedValue = null + } else { + const date = new Date(val) + if (Number.isNaN(date.getTime())) { + return { operator, value: undefined } + } + formattedValue = date.toISOString() } } else if (typeof val === 'number') { formattedValue = new Date(val).toISOString() diff --git a/packages/drizzle/src/queries/selectDistinct.ts b/packages/drizzle/src/queries/selectDistinct.ts index 7cb6b5fc0f..fd4e54b402 100644 --- a/packages/drizzle/src/queries/selectDistinct.ts +++ b/packages/drizzle/src/queries/selectDistinct.ts @@ -14,6 +14,7 @@ import type { BuildQueryJoinAliases } from './buildQuery.js' type Args = { adapter: DrizzleAdapter db: DrizzleAdapter['drizzle'] | DrizzleTransaction + forceRun?: boolean joins: BuildQueryJoinAliases query?: (args: { query: SQLiteSelect }) => SQLiteSelect selectFields: Record @@ -27,13 +28,14 @@ type Args = { export const selectDistinct = ({ adapter, db, + forceRun, joins, query: queryModifier = ({ query }) => query, selectFields, tableName, where, }: Args): QueryPromise<{ id: number | string }[] & Record> => { - if (Object.keys(joins).length > 0) { + if (forceRun || Object.keys(joins).length > 0) { let query: SQLiteSelect const table = adapter.tables[tableName] @@ -54,8 +56,8 @@ export const selectDistinct = ({ query = query.where(where) } - joins.forEach(({ condition, table }) => { - query = query.leftJoin(table, condition) + joins.forEach(({ type, condition, table }) => { + query = query[type ?? 'leftJoin'](table, condition) }) return queryModifier({ diff --git a/packages/drizzle/src/transform/write/index.ts b/packages/drizzle/src/transform/write/index.ts index e70b91ff8c..5d875162da 100644 --- a/packages/drizzle/src/transform/write/index.ts +++ b/packages/drizzle/src/transform/write/index.ts @@ -8,6 +8,7 @@ import { traverseFields } from './traverseFields.js' type Args = { adapter: DrizzleAdapter data: Record + enableAtomicWrites?: boolean fields: FlattenedField[] parentIsLocalized?: boolean path?: string @@ -17,6 +18,7 @@ type Args = { export const transformForWrite = ({ adapter, data, + enableAtomicWrites, fields, parentIsLocalized, path = '', @@ -48,6 +50,7 @@ export const transformForWrite = ({ blocksToDelete: rowToInsert.blocksToDelete, columnPrefix: '', data, + enableAtomicWrites, fieldPrefix: '', fields, locales: rowToInsert.locales, diff --git a/packages/drizzle/src/transform/write/traverseFields.ts b/packages/drizzle/src/transform/write/traverseFields.ts index e815733efa..feb3b17662 100644 --- a/packages/drizzle/src/transform/write/traverseFields.ts +++ b/packages/drizzle/src/transform/write/traverseFields.ts @@ -1,6 +1,5 @@ -import type { FlattenedField } from 'payload' - import { sql } from 'drizzle-orm' +import { APIError, type FlattenedField } from 'payload' import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared' import toSnakeCase from 'to-snake-case' @@ -41,6 +40,7 @@ type Args = { */ columnPrefix: string data: Record + enableAtomicWrites?: boolean existingLocales?: Record[] /** * A prefix that will retain camel-case formatting, representing prior fields @@ -87,6 +87,7 @@ export const traverseFields = ({ blocksToDelete, columnPrefix, data, + enableAtomicWrites, existingLocales, fieldPrefix, fields, @@ -268,6 +269,7 @@ export const traverseFields = ({ blocksToDelete, columnPrefix: `${columnName}_`, data: localeData as Record, + enableAtomicWrites, existingLocales, fieldPrefix: `${fieldName}_`, fields: field.flattenedFields, @@ -553,6 +555,22 @@ export const traverseFields = ({ formattedValue = JSON.stringify(value) } + if ( + field.type === 'number' && + value && + typeof value === 'object' && + '$inc' in value && + typeof value.$inc === 'number' + ) { + if (!enableAtomicWrites) { + throw new APIError( + 'The passed data must not contain any nested fields for atomic writes', + ) + } + + formattedValue = sql.raw(`${columnName} + ${value.$inc}`) + } + if (field.type === 'date') { if (typeof value === 'number' && !Number.isNaN(value)) { formattedValue = new Date(value).toISOString() diff --git a/packages/drizzle/src/types.ts b/packages/drizzle/src/types.ts index 42f01b7ce8..9e34cb23f6 100644 --- a/packages/drizzle/src/types.ts +++ b/packages/drizzle/src/types.ts @@ -89,6 +89,7 @@ export type TransactionPg = PgTransaction< export type DrizzleTransaction = TransactionPg | TransactionSQLite export type CountDistinct = (args: { + column?: PgColumn | SQLiteColumn db: DrizzleTransaction | LibSQLDatabase | PostgresDB joins: BuildQueryJoinAliases tableName: string @@ -160,10 +161,11 @@ export type CreateJSONQueryArgs = { column?: Column | string operator: string pathSegments: string[] + rawColumn?: SQL table?: string treatAsArray?: string[] treatRootAsArray?: boolean - value: boolean | number | string + value: boolean | number | number[] | string | string[] } /** diff --git a/packages/drizzle/src/updateJobs.ts b/packages/drizzle/src/updateJobs.ts index 885a0a6581..6bfec911a2 100644 --- a/packages/drizzle/src/updateJobs.ts +++ b/packages/drizzle/src/updateJobs.ts @@ -6,6 +6,7 @@ import type { DrizzleAdapter } from './types.js' import { findMany } from './find/findMany.js' import { upsertRow } from './upsertRow/index.js' +import { shouldUseOptimizedUpsertRow } from './upsertRow/shouldUseOptimizedUpsertRow.js' import { getTransaction } from './utilities/getTransaction.js' export const updateJobs: UpdateJobs = async function updateMany( @@ -23,6 +24,27 @@ export const updateJobs: UpdateJobs = async function updateMany( const tableName = this.tableNameMap.get(toSnakeCase(collection.slug)) const sort = sortArg !== undefined && sortArg !== null ? sortArg : collection.defaultSort + const useOptimizedUpsertRow = shouldUseOptimizedUpsertRow({ + data, + fields: collection.flattenedFields, + }) + + if (useOptimizedUpsertRow && id) { + const result = await upsertRow({ + id, + adapter: this, + data, + db, + fields: collection.flattenedFields, + ignoreResult: returning === false, + operation: 'update', + req, + tableName, + }) + + return returning === false ? null : [result] + } + const jobs = await findMany({ adapter: this, collectionSlug: 'payload-jobs', @@ -42,10 +64,12 @@ export const updateJobs: UpdateJobs = async function updateMany( // TODO: We need to batch this to reduce the amount of db calls. This can get very slow if we are updating a lot of rows. for (const job of jobs.docs) { - const updateData = { - ...job, - ...data, - } + const updateData = useOptimizedUpsertRow + ? data + : { + ...job, + ...data, + } const result = await upsertRow({ id: job.id, diff --git a/packages/drizzle/src/upsertRow/index.ts b/packages/drizzle/src/upsertRow/index.ts index 34abf51075..52d686a55e 100644 --- a/packages/drizzle/src/upsertRow/index.ts +++ b/packages/drizzle/src/upsertRow/index.ts @@ -1,3 +1,5 @@ +import type { LibSQLDatabase } from 'drizzle-orm/libsql' +import type { SelectedFields } from 'drizzle-orm/sqlite-core' import type { TypeWithID } from 'payload' import { eq } from 'drizzle-orm' @@ -12,13 +14,14 @@ import { transformForWrite } from '../transform/write/index.js' import { deleteExistingArrayRows } from './deleteExistingArrayRows.js' import { deleteExistingRowsByPath } from './deleteExistingRowsByPath.js' import { insertArrays } from './insertArrays.js' +import { shouldUseOptimizedUpsertRow } from './shouldUseOptimizedUpsertRow.js' /** * If `id` is provided, it will update the row with that ID. * If `where` is provided, it will update the row that matches the `where` * If neither `id` nor `where` is provided, it will create a new row. * - * This function replaces the entire row and does not support partial updates. + * adapter function replaces the entire row and does not support partial updates. */ export const upsertRow = async | TypeWithID>({ id, @@ -39,19 +42,99 @@ export const upsertRow = async | TypeWithID>( upsertTarget, where, }: Args): Promise => { + let insertedRow: Record = { id } + if (id && shouldUseOptimizedUpsertRow({ data, fields })) { + const { row } = transformForWrite({ + adapter, + data, + enableAtomicWrites: true, + fields, + tableName, + }) + + const drizzle = db as LibSQLDatabase + + if (ignoreResult) { + await drizzle + .update(adapter.tables[tableName]) + .set(row) + .where(eq(adapter.tables[tableName].id, id)) + return ignoreResult === 'idOnly' ? ({ id } as T) : null + } + + const findManyArgs = buildFindManyArgs({ + adapter, + depth: 0, + fields, + joinQuery: false, + select, + tableName, + }) + + const findManyKeysLength = Object.keys(findManyArgs).length + const hasOnlyColumns = Object.keys(findManyArgs.columns || {}).length > 0 + + if (findManyKeysLength === 0 || hasOnlyColumns) { + // Optimization - No need for joins => can simply use returning(). This is optimal for very simple collections + // without complex fields that live in separate tables like blocks, arrays, relationships, etc. + + const selectedFields: SelectedFields = {} + if (hasOnlyColumns) { + for (const [column, enabled] of Object.entries(findManyArgs.columns)) { + if (enabled) { + selectedFields[column] = adapter.tables[tableName][column] + } + } + } + + const docs = await drizzle + .update(adapter.tables[tableName]) + .set(row) + .where(eq(adapter.tables[tableName].id, id)) + .returning(Object.keys(selectedFields).length ? selectedFields : undefined) + + return transform({ + adapter, + config: adapter.payload.config, + data: docs[0], + fields, + joinQuery: false, + tableName, + }) + } + + // DB Update that needs the result, potentially with joins => need to update first, then find. returning() does not work with joins. + + await drizzle + .update(adapter.tables[tableName]) + .set(row) + .where(eq(adapter.tables[tableName].id, id)) + + findManyArgs.where = eq(adapter.tables[tableName].id, insertedRow.id) + + const doc = await db.query[tableName].findFirst(findManyArgs) + + return transform({ + adapter, + config: adapter.payload.config, + data: doc, + fields, + joinQuery: false, + tableName, + }) + } // Split out the incoming data into the corresponding: // base row, locales, relationships, blocks, and arrays const rowToInsert = transformForWrite({ adapter, data, + enableAtomicWrites: false, fields, path, tableName, }) // First, we insert the main row - let insertedRow: Record - try { if (operation === 'update') { const target = upsertTarget || adapter.tables[tableName].id @@ -275,7 +358,7 @@ export const upsertRow = async | TypeWithID>( } } - // When versions are enabled, this is used to track mapping between blocks/arrays ObjectID to their numeric generated representation, then we use it for nested to arrays/blocks select hasMany in versions. + // When versions are enabled, adapter is used to track mapping between blocks/arrays ObjectID to their numeric generated representation, then we use it for nested to arrays/blocks select hasMany in versions. const arraysBlocksUUIDMap: Record = {} for (const [tableName, blockRows] of Object.entries(blocksToInsert)) { diff --git a/packages/drizzle/src/upsertRow/shouldUseOptimizedUpsertRow.ts b/packages/drizzle/src/upsertRow/shouldUseOptimizedUpsertRow.ts new file mode 100644 index 0000000000..096d22a5cf --- /dev/null +++ b/packages/drizzle/src/upsertRow/shouldUseOptimizedUpsertRow.ts @@ -0,0 +1,52 @@ +import type { FlattenedField } from 'payload' + +/** + * Checks whether we should use the upsertRow function for the passed data and otherwise use a simple SQL SET call. + * We need to use upsertRow only when the data has arrays, blocks, hasMany select/text/number, localized fields, complex relationships. + */ +export const shouldUseOptimizedUpsertRow = ({ + data, + fields, +}: { + data: Record + fields: FlattenedField[] +}) => { + for (const key in data) { + const value = data[key] + const field = fields.find((each) => each.name === key) + + if (!field) { + continue + } + + if ( + field.type === 'array' || + field.type === 'blocks' || + ((field.type === 'text' || + field.type === 'relationship' || + field.type === 'upload' || + field.type === 'select' || + field.type === 'number') && + field.hasMany) || + ((field.type === 'relationship' || field.type === 'upload') && + Array.isArray(field.relationTo)) || + field.localized + ) { + return false + } + + if ( + (field.type === 'group' || field.type === 'tab') && + value && + typeof value === 'object' && + !shouldUseOptimizedUpsertRow({ + data: value as Record, + fields: field.flattenedFields, + }) + ) { + return false + } + } + + return true +} diff --git a/packages/drizzle/src/utilities/createSchemaGenerator.ts b/packages/drizzle/src/utilities/createSchemaGenerator.ts index b979460b26..cc6c85656e 100644 --- a/packages/drizzle/src/utilities/createSchemaGenerator.ts +++ b/packages/drizzle/src/utilities/createSchemaGenerator.ts @@ -296,12 +296,13 @@ declare module '${this.packageName}' { if (prettify) { try { - const prettier = await import('prettier') + const prettier = await eval('import("prettier")') const configPath = await prettier.resolveConfigFile() const config = configPath ? await prettier.resolveConfig(configPath) : {} code = await prettier.format(code, { ...config, parser: 'typescript' }) - // eslint-disable-next-line no-empty - } catch {} + } catch { + /* empty */ + } } await writeFile(outputFile, code, 'utf-8') diff --git a/packages/drizzle/src/utilities/getNameFromDrizzleTable.ts b/packages/drizzle/src/utilities/getNameFromDrizzleTable.ts index 7395c46ab9..e8c4233f9a 100644 --- a/packages/drizzle/src/utilities/getNameFromDrizzleTable.ts +++ b/packages/drizzle/src/utilities/getNameFromDrizzleTable.ts @@ -1,9 +1,7 @@ import type { Table } from 'drizzle-orm' -export const getNameFromDrizzleTable = (table: Table): string => { - const symbol = Object.getOwnPropertySymbols(table).find((symb) => - symb.description.includes('Name'), - ) +import { getTableName } from 'drizzle-orm' - return table[symbol] +export const getNameFromDrizzleTable = (table: Table): string => { + return getTableName(table) } diff --git a/packages/drizzle/src/utilities/rawConstraint.ts b/packages/drizzle/src/utilities/rawConstraint.ts index f47ceed9c0..2105532e3b 100644 --- a/packages/drizzle/src/utilities/rawConstraint.ts +++ b/packages/drizzle/src/utilities/rawConstraint.ts @@ -1,5 +1,7 @@ const RawConstraintSymbol = Symbol('RawConstraint') +export const DistinctSymbol = Symbol('DistinctSymbol') + /** * You can use this to inject a raw query to where */ diff --git a/packages/email-nodemailer/package.json b/packages/email-nodemailer/package.json index e048299aa8..593a066a61 100644 --- a/packages/email-nodemailer/package.json +++ b/packages/email-nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-nodemailer", - "version": "3.46.0", + "version": "3.50.0", "description": "Payload Nodemailer Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/email-resend/package.json b/packages/email-resend/package.json index 900851ca2f..65449ce867 100644 --- a/packages/email-resend/package.json +++ b/packages/email-resend/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-resend", - "version": "3.46.0", + "version": "3.50.0", "description": "Payload Resend Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 20796426ce..99b9c1f173 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/graphql", - "version": "3.46.0", + "version": "3.50.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/graphql/src/resolvers/collections/count.ts b/packages/graphql/src/resolvers/collections/count.ts index 0a928b464b..793509c884 100644 --- a/packages/graphql/src/resolvers/collections/count.ts +++ b/packages/graphql/src/resolvers/collections/count.ts @@ -9,6 +9,7 @@ export type Resolver = ( args: { data: Record locale?: string + trash?: boolean where?: Where }, context: { @@ -30,6 +31,7 @@ export function countResolver(collection: Collection): Resolver { const options = { collection, req: isolateObjectProperty(req, 'transactionID'), + trash: args.trash, where: args.where, } diff --git a/packages/graphql/src/resolvers/collections/delete.ts b/packages/graphql/src/resolvers/collections/delete.ts index 2624d5c636..b033447b87 100644 --- a/packages/graphql/src/resolvers/collections/delete.ts +++ b/packages/graphql/src/resolvers/collections/delete.ts @@ -11,6 +11,7 @@ export type Resolver = ( fallbackLocale?: string id: number | string locale?: string + trash?: boolean }, context: { req: PayloadRequest @@ -49,6 +50,7 @@ export function getDeleteResolver( collection, depth: 0, req: isolateObjectProperty(req, 'transactionID'), + trash: args.trash, } const result = await deleteByIDOperation(options) diff --git a/packages/graphql/src/resolvers/collections/find.ts b/packages/graphql/src/resolvers/collections/find.ts index 3285e16c76..4bd567a96a 100644 --- a/packages/graphql/src/resolvers/collections/find.ts +++ b/packages/graphql/src/resolvers/collections/find.ts @@ -15,6 +15,7 @@ export type Resolver = ( page?: number pagination?: boolean sort?: string + trash?: boolean where?: Where }, context: { @@ -57,6 +58,7 @@ export function findResolver(collection: Collection): Resolver { pagination: args.pagination, req, sort: args.sort, + trash: args.trash, where: args.where, } diff --git a/packages/graphql/src/resolvers/collections/findByID.ts b/packages/graphql/src/resolvers/collections/findByID.ts index 22e8403cc0..72a1ac4241 100644 --- a/packages/graphql/src/resolvers/collections/findByID.ts +++ b/packages/graphql/src/resolvers/collections/findByID.ts @@ -11,6 +11,7 @@ export type Resolver = ( fallbackLocale?: string id: string locale?: string + trash?: boolean }, context: { req: PayloadRequest @@ -50,6 +51,7 @@ export function findByIDResolver( depth: 0, draft: args.draft, req: isolateObjectProperty(req, 'transactionID'), + trash: args.trash, } const result = await findByIDOperation(options) diff --git a/packages/graphql/src/resolvers/collections/findVersionByID.ts b/packages/graphql/src/resolvers/collections/findVersionByID.ts index 25b05f3293..933e9f8105 100644 --- a/packages/graphql/src/resolvers/collections/findVersionByID.ts +++ b/packages/graphql/src/resolvers/collections/findVersionByID.ts @@ -10,6 +10,7 @@ export type Resolver = ( fallbackLocale?: string id: number | string locale?: string + trash?: boolean }, context: { req: PayloadRequest @@ -33,6 +34,7 @@ export function findVersionByIDResolver(collection: Collection): Resolver { collection, depth: 0, req: isolateObjectProperty(req, 'transactionID'), + trash: args.trash, } const result = await findVersionByIDOperation(options) diff --git a/packages/graphql/src/resolvers/collections/findVersions.ts b/packages/graphql/src/resolvers/collections/findVersions.ts index c747bbdfdc..2b1eb906a9 100644 --- a/packages/graphql/src/resolvers/collections/findVersions.ts +++ b/packages/graphql/src/resolvers/collections/findVersions.ts @@ -14,6 +14,7 @@ export type Resolver = ( page?: number pagination?: boolean sort?: string + trash?: boolean where: Where }, context: { @@ -54,6 +55,7 @@ export function findVersionsResolver(collection: Collection): Resolver { pagination: args.pagination, req: isolateObjectProperty(req, 'transactionID'), sort: args.sort, + trash: args.trash, where: args.where, } diff --git a/packages/graphql/src/resolvers/collections/update.ts b/packages/graphql/src/resolvers/collections/update.ts index 5e8d894cf7..0feff36fb6 100644 --- a/packages/graphql/src/resolvers/collections/update.ts +++ b/packages/graphql/src/resolvers/collections/update.ts @@ -13,6 +13,7 @@ export type Resolver = ( fallbackLocale?: string id: number | string locale?: string + trash?: boolean }, context: { req: PayloadRequest @@ -54,6 +55,7 @@ export function updateResolver( depth: 0, draft: args.draft, req: isolateObjectProperty(req, 'transactionID'), + trash: args.trash, } const result = await updateByIDOperation(options) diff --git a/packages/graphql/src/schema/fieldToSchemaMap.ts b/packages/graphql/src/schema/fieldToSchemaMap.ts index 571bc61585..af560a40e2 100644 --- a/packages/graphql/src/schema/fieldToSchemaMap.ts +++ b/packages/graphql/src/schema/fieldToSchemaMap.ts @@ -379,9 +379,11 @@ export const fieldToSchemaMap: FieldToSchemaMap = { ), }, hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) }, + totalDocs: { type: GraphQLInt }, }, }), args: { + count: { type: GraphQLBoolean }, limit: { type: GraphQLInt, }, @@ -402,7 +404,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = { }, async resolve(parent, args, context: Context) { const { collection } = field - const { limit, page, sort, where } = args + const { count = false, limit, page, sort, where } = args const { req } = context const draft = Boolean(args.draft ?? context.req.query?.draft) @@ -429,7 +431,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = { throw new Error('GraphQL with array of join.field.collection is not implemented') } - const { docs } = await req.payload.find({ + const { docs, totalDocs } = await req.payload.find({ collection, depth: 0, draft, @@ -439,7 +441,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = { locale: req.locale, overrideAccess: false, page, - pagination: false, + pagination: count ? true : false, req, sort, where: fullWhere, @@ -454,6 +456,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = { return { docs: shouldSlice ? docs.slice(0, -1) : docs, hasNextPage: limit === 0 ? false : limit < docs.length, + ...(count ? { totalDocs } : {}), } }, } diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index 6ae09a692d..85893cd96c 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -205,6 +205,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ locale: { type: graphqlResult.types.localeInputType }, } : {}), + trash: { type: GraphQLBoolean }, }, resolve: findByIDResolver(collection), } @@ -224,6 +225,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ page: { type: GraphQLInt }, pagination: { type: GraphQLBoolean }, sort: { type: GraphQLString }, + trash: { type: GraphQLBoolean }, }, resolve: findResolver(collection), } @@ -237,6 +239,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ }), args: { draft: { type: GraphQLBoolean }, + trash: { type: GraphQLBoolean }, where: { type: collection.graphQL.whereInputType }, ...(config.localization ? { @@ -292,6 +295,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ locale: { type: graphqlResult.types.localeInputType }, } : {}), + trash: { type: GraphQLBoolean }, }, resolve: updateResolver(collection), } @@ -300,6 +304,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ type: collection.graphQL.type, args: { id: { type: new GraphQLNonNull(idType) }, + trash: { type: GraphQLBoolean }, }, resolve: getDeleteResolver(collection), } @@ -329,12 +334,12 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ { name: 'createdAt', type: 'date', - label: 'Created At', + label: ({ t }) => t('general:createdAt'), }, { name: 'updatedAt', type: 'date', - label: 'Updated At', + label: ({ t }) => t('general:updatedAt'), }, ] @@ -359,6 +364,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ locale: { type: graphqlResult.types.localeInputType }, } : {}), + trash: { type: GraphQLBoolean }, }, resolve: findVersionByIDResolver(collection), } @@ -385,6 +391,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ page: { type: GraphQLInt }, pagination: { type: GraphQLBoolean }, sort: { type: GraphQLString }, + trash: { type: GraphQLBoolean }, }, resolve: findVersionsResolver(collection), } diff --git a/packages/live-preview-react/package.json b/packages/live-preview-react/package.json index 65ce87a88f..e382d93a45 100644 --- a/packages/live-preview-react/package.json +++ b/packages/live-preview-react/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview-react", - "version": "3.46.0", + "version": "3.50.0", "description": "The official React SDK for Payload Live Preview", "homepage": "https://payloadcms.com", "repository": { @@ -46,8 +46,8 @@ }, "devDependencies": { "@payloadcms/eslint-config": "workspace:*", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "payload": "workspace:*" }, "peerDependencies": { diff --git a/packages/live-preview-vue/package.json b/packages/live-preview-vue/package.json index 036af381ab..413af47465 100644 --- a/packages/live-preview-vue/package.json +++ b/packages/live-preview-vue/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview-vue", - "version": "3.46.0", + "version": "3.50.0", "description": "The official Vue SDK for Payload Live Preview", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/live-preview/package.json b/packages/live-preview/package.json index ffb6603686..ce359471d1 100644 --- a/packages/live-preview/package.json +++ b/packages/live-preview/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview", - "version": "3.46.0", + "version": "3.50.0", "description": "The official live preview JavaScript SDK for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/next/package.json b/packages/next/package.json index 1312288126..d8c1a06151 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/next", - "version": "3.46.0", + "version": "3.50.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", @@ -117,11 +117,11 @@ "@babel/preset-env": "7.27.2", "@babel/preset-react": "7.27.1", "@babel/preset-typescript": "7.27.1", - "@next/eslint-plugin-next": "15.3.2", + "@next/eslint-plugin-next": "15.4.4", "@payloadcms/eslint-config": "workspace:*", "@types/busboy": "1.5.4", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "@types/uuid": "10.0.0", "babel-plugin-react-compiler": "19.1.0-rc.2", "esbuild": "0.25.5", diff --git a/packages/next/src/auth/login.ts b/packages/next/src/auth/login.ts index 5bed900a94..9b5a7cb74d 100644 --- a/packages/next/src/auth/login.ts +++ b/packages/next/src/auth/login.ts @@ -27,7 +27,7 @@ export async function login({ collection, config, email, password, username }: L token?: string user: any }> { - const payload = await getPayload({ config }) + const payload = await getPayload({ config, cron: true }) const authConfig = payload.collections[collection]?.config.auth diff --git a/packages/next/src/auth/logout.ts b/packages/next/src/auth/logout.ts index 192e293580..f1684dd507 100644 --- a/packages/next/src/auth/logout.ts +++ b/packages/next/src/auth/logout.ts @@ -14,7 +14,7 @@ export async function logout({ allSessions?: boolean config: Promise | SanitizedConfig }) { - const payload = await getPayload({ config }) + const payload = await getPayload({ config, cron: true }) const headers = await nextHeaders() const authResult = await payload.auth({ headers }) diff --git a/packages/next/src/auth/refresh.ts b/packages/next/src/auth/refresh.ts index 9ece3e97c7..05fc5964d1 100644 --- a/packages/next/src/auth/refresh.ts +++ b/packages/next/src/auth/refresh.ts @@ -9,7 +9,7 @@ import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js' import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js' export async function refresh({ config }: { config: any }) { - const payload = await getPayload({ config }) + const payload = await getPayload({ config, cron: true }) const headers = await nextHeaders() const result = await payload.auth({ headers }) diff --git a/packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx b/packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx index 8647ce297f..fd89093712 100644 --- a/packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx +++ b/packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx @@ -38,9 +38,12 @@ export const DocumentTabLink: React.FC<{ path: `/${isCollection ? 'collections' : 'globals'}/${entitySlug}`, }) - if (isCollection && segmentThree) { - // doc ID - docPath += `/${segmentThree}` + if (isCollection) { + if (segmentThree === 'trash' && segmentFour) { + docPath += `/trash/${segmentFour}` + } else if (segmentThree) { + docPath += `/${segmentThree}` + } } const href = `${docPath}${hrefFromProps}` diff --git a/packages/next/src/elements/DocumentHeader/Tabs/Tab/index.tsx b/packages/next/src/elements/DocumentHeader/Tabs/Tab/index.tsx index a19ee4c388..d06cdefdd1 100644 --- a/packages/next/src/elements/DocumentHeader/Tabs/Tab/index.tsx +++ b/packages/next/src/elements/DocumentHeader/Tabs/Tab/index.tsx @@ -1,4 +1,11 @@ -import type { DocumentTabConfig, DocumentTabServerProps, ServerProps } from 'payload' +import type { + DocumentTabConfig, + DocumentTabServerPropsOnly, + PayloadRequest, + SanitizedCollectionConfig, + SanitizedGlobalConfig, + SanitizedPermissions, +} from 'payload' import type React from 'react' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' @@ -9,27 +16,24 @@ import './index.scss' export const baseClass = 'doc-tab' -export const DocumentTab: React.FC< - { readonly Pill_Component?: React.FC } & DocumentTabConfig & DocumentTabServerProps -> = (props) => { +export const DefaultDocumentTab: React.FC<{ + apiURL?: string + collectionConfig?: SanitizedCollectionConfig + globalConfig?: SanitizedGlobalConfig + path?: string + permissions?: SanitizedPermissions + req: PayloadRequest + tabConfig: { readonly Pill_Component?: React.FC } & DocumentTabConfig +}> = (props) => { const { apiURL, collectionConfig, globalConfig, - href: tabHref, - i18n, - isActive: tabIsActive, - label, - newTab, - payload, permissions, - Pill, - Pill_Component, + req, + tabConfig: { href: tabHref, isActive: tabIsActive, label, newTab, Pill, Pill_Component }, } = props - const { config } = payload - const { routes } = config - let href = typeof tabHref === 'string' ? tabHref : '' let isActive = typeof tabIsActive === 'boolean' ? tabIsActive : false @@ -38,7 +42,7 @@ export const DocumentTab: React.FC< apiURL, collection: collectionConfig, global: globalConfig, - routes, + routes: req.payload.config.routes, }) } @@ -51,13 +55,13 @@ export const DocumentTab: React.FC< const labelToRender = typeof label === 'function' ? label({ - t: i18n.t, + t: req.i18n.t, }) : label return ( ) : null} diff --git a/packages/next/src/elements/DocumentHeader/Tabs/index.tsx b/packages/next/src/elements/DocumentHeader/Tabs/index.tsx index 6efef9a5be..a994daf86b 100644 --- a/packages/next/src/elements/DocumentHeader/Tabs/index.tsx +++ b/packages/next/src/elements/DocumentHeader/Tabs/index.tsx @@ -1,8 +1,7 @@ -import type { I18n } from '@payloadcms/translations' import type { DocumentTabClientProps, DocumentTabServerPropsOnly, - Payload, + PayloadRequest, SanitizedCollectionConfig, SanitizedGlobalConfig, SanitizedPermissions, @@ -12,7 +11,7 @@ import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerCompo import React from 'react' import { ShouldRenderTabs } from './ShouldRenderTabs.js' -import { DocumentTab } from './Tab/index.js' +import { DefaultDocumentTab } from './Tab/index.js' import { getTabs } from './tabs/index.js' import './index.scss' @@ -21,12 +20,10 @@ const baseClass = 'doc-tabs' export const DocumentTabs: React.FC<{ collectionConfig: SanitizedCollectionConfig globalConfig: SanitizedGlobalConfig - i18n: I18n - payload: Payload permissions: SanitizedPermissions -}> = (props) => { - const { collectionConfig, globalConfig, i18n, payload, permissions } = props - const { config } = payload + req: PayloadRequest +}> = ({ collectionConfig, globalConfig, permissions, req }) => { + const { config } = req.payload const tabs = getTabs({ collectionConfig, @@ -38,42 +35,46 @@ export const DocumentTabs: React.FC<{
    - {tabs?.map(({ tab, viewPath }, index) => { - const { condition } = tab || {} + {tabs?.map(({ tab: tabConfig, viewPath }, index) => { + const { condition } = tabConfig || {} const meetsCondition = - !condition || condition({ collectionConfig, config, globalConfig, permissions }) + !condition || + condition({ collectionConfig, config, globalConfig, permissions, req }) if (!meetsCondition) { return null } - if (tab?.Component) { + if (tabConfig?.Component) { return RenderServerComponent({ clientProps: { path: viewPath, } satisfies DocumentTabClientProps, - Component: tab.Component, - importMap: payload.importMap, + Component: tabConfig.Component, + importMap: req.payload.importMap, key: `tab-${index}`, serverProps: { collectionConfig, globalConfig, - i18n, - payload, + i18n: req.i18n, + payload: req.payload, permissions, + req, + user: req.user, } satisfies DocumentTabServerPropsOnly, }) } return ( - ) })} diff --git a/packages/next/src/elements/DocumentHeader/index.tsx b/packages/next/src/elements/DocumentHeader/index.tsx index 399edf3021..f42c5c0ea1 100644 --- a/packages/next/src/elements/DocumentHeader/index.tsx +++ b/packages/next/src/elements/DocumentHeader/index.tsx @@ -1,6 +1,6 @@ import type { I18n } from '@payloadcms/translations' import type { - Payload, + PayloadRequest, SanitizedCollectionConfig, SanitizedGlobalConfig, SanitizedPermissions, @@ -18,11 +18,10 @@ export const DocumentHeader: React.FC<{ collectionConfig?: SanitizedCollectionConfig globalConfig?: SanitizedGlobalConfig hideTabs?: boolean - i18n: I18n - payload: Payload permissions: SanitizedPermissions + req: PayloadRequest }> = (props) => { - const { collectionConfig, globalConfig, hideTabs, i18n, payload, permissions } = props + const { collectionConfig, globalConfig, hideTabs, permissions, req } = props return ( @@ -31,9 +30,8 @@ export const DocumentHeader: React.FC<{ )} diff --git a/packages/next/src/utilities/initPage/handleAdminPage.ts b/packages/next/src/utilities/initPage/handleAdminPage.ts index d8683ea9f9..8fc3270559 100644 --- a/packages/next/src/utilities/initPage/handleAdminPage.ts +++ b/packages/next/src/utilities/initPage/handleAdminPage.ts @@ -5,8 +5,6 @@ import type { SanitizedGlobalConfig, } from 'payload' -import { fieldAffectsData } from 'payload/shared' - import { getRouteWithoutAdmin, isAdminRoute } from './shared.js' type Args = { @@ -35,7 +33,7 @@ export function getRouteInfo({ if (isAdminRoute({ adminRoute, config, route })) { const routeWithoutAdmin = getRouteWithoutAdmin({ adminRoute, route }) const routeSegments = routeWithoutAdmin.split('/').filter(Boolean) - const [entityType, entitySlug, createOrID] = routeSegments + const [entityType, entitySlug, segment3, segment4] = routeSegments const collectionSlug = entityType === 'collections' ? entitySlug : undefined const globalSlug = entityType === 'globals' ? entitySlug : undefined @@ -58,12 +56,17 @@ export function getRouteInfo({ } } - const docID = - collectionSlug && createOrID !== 'create' - ? idType === 'number' - ? Number(createOrID) - : createOrID - : undefined + let docID: number | string | undefined + + if (collectionSlug) { + if (segment3 === 'trash' && segment4) { + // /collections/:slug/trash/:id + docID = idType === 'number' ? Number(segment4) : segment4 + } else if (segment3 && segment3 !== 'create') { + // /collections/:slug/:id + docID = idType === 'number' ? Number(segment3) : segment3 + } + } return { collectionConfig, diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts index 62c6cd98dc..aa9b29b71c 100644 --- a/packages/next/src/utilities/initReq.ts +++ b/packages/next/src/utilities/initReq.ts @@ -66,7 +66,7 @@ export const initReq = async function ({ const partialResult = await partialReqCache.get(async () => { const config = await configPromise - const payload = await getPayload({ config, importMap }) + const payload = await getPayload({ config, cron: true, importMap }) const languageCode = getRequestLanguage({ config, cookies, diff --git a/packages/next/src/views/API/index.client.tsx b/packages/next/src/views/API/index.client.tsx index bf1c75e32f..95159878ed 100644 --- a/packages/next/src/views/API/index.client.tsx +++ b/packages/next/src/views/API/index.client.tsx @@ -15,16 +15,18 @@ import { useTranslation, } from '@payloadcms/ui' import { useSearchParams } from 'next/navigation.js' -import * as React from 'react' import './index.scss' + +import * as React from 'react' + import { LocaleSelector } from './LocaleSelector/index.js' import { RenderJSON } from './RenderJSON/index.js' const baseClass = 'query-inspector' export const APIViewClient: React.FC = () => { - const { id, collectionSlug, globalSlug, initialData } = useDocumentInfo() + const { id, collectionSlug, globalSlug, initialData, isTrashed } = useDocumentInfo() const searchParams = useSearchParams() const { i18n, t } = useTranslation() @@ -69,10 +71,13 @@ export const APIViewClient: React.FC = () => { const [authenticated, setAuthenticated] = React.useState(true) const [fullscreen, setFullscreen] = React.useState(false) + const trashParam = typeof initialData?.deletedAt === 'string' + const params = new URLSearchParams({ depth, draft: String(draft), locale, + trash: trashParam ? 'true' : 'false', }).toString() const fetchURL = `${serverURL}${apiRoute}${docEndpoint}?${params}` @@ -114,6 +119,7 @@ export const APIViewClient: React.FC = () => { globalLabel={globalConfig?.label} globalSlug={globalSlug} id={id} + isTrashed={isTrashed} pluralLabel={collectionConfig ? collectionConfig?.labels?.plural : undefined} useAsTitle={collectionConfig ? collectionConfig?.admin?.useAsTitle : undefined} view="API" diff --git a/packages/next/src/views/Account/index.tsx b/packages/next/src/views/Account/index.tsx index 7153665096..1ffc791508 100644 --- a/packages/next/src/views/Account/index.tsx +++ b/packages/next/src/views/Account/index.tsx @@ -137,9 +137,8 @@ export async function Account({ initPageResult, params, searchParams }: AdminVie {RenderServerComponent({ diff --git a/packages/next/src/views/BrowseByFolder/buildView.tsx b/packages/next/src/views/BrowseByFolder/buildView.tsx index b57da8a60a..59775ad7d3 100644 --- a/packages/next/src/views/BrowseByFolder/buildView.tsx +++ b/packages/next/src/views/BrowseByFolder/buildView.tsx @@ -58,20 +58,45 @@ export const buildBrowseByFolderView = async ( throw new Error('not-found') } - const browseByFolderSlugs = browseByFolderSlugsFromArgs.filter( + const foldersSlug = config.folders.slug + + /** + * All visiible folder enabled collection slugs that the user has read permissions for. + */ + const allowReadCollectionSlugs = browseByFolderSlugsFromArgs.filter( (collectionSlug) => permissions?.collections?.[collectionSlug]?.read && visibleEntities.collections.includes(collectionSlug), ) - const query = queryFromArgs || queryFromReq - const activeCollectionFolderSlugs: string[] = - Array.isArray(query?.relationTo) && query.relationTo.length - ? query.relationTo.filter( - (slug) => - browseByFolderSlugs.includes(slug) || (config.folders && slug === config.folders.slug), - ) - : [...browseByFolderSlugs, config.folders.slug] + const query = + queryFromArgs || + ((queryFromReq + ? { + ...queryFromReq, + relationTo: + typeof queryFromReq?.relationTo === 'string' + ? JSON.parse(queryFromReq.relationTo) + : undefined, + } + : {}) as ListQuery) + + /** + * If a folderID is provided and the relationTo query param exists, + * we filter the collection slugs to only those that are allowed to be read. + * + * If no folderID is provided, only folders should be active and displayed (the root view). + */ + let collectionsToDisplay: string[] = [] + if (folderID && Array.isArray(query?.relationTo)) { + collectionsToDisplay = query.relationTo.filter( + (slug) => allowReadCollectionSlugs.includes(slug) || slug === foldersSlug, + ) + } else if (folderID) { + collectionsToDisplay = [...allowReadCollectionSlugs, foldersSlug] + } else { + collectionsToDisplay = [foldersSlug] + } const { routes: { admin: adminRoute }, @@ -93,14 +118,15 @@ export const buildBrowseByFolderView = async ( }, }) - const sortPreference: FolderSortKeys = browseByFolderPreferences?.sort || '_folderOrDocumentTitle' + const sortPreference: FolderSortKeys = browseByFolderPreferences?.sort || 'name' const viewPreference = browseByFolderPreferences?.viewPreference || 'grid' - const { breadcrumbs, documents, FolderResultsComponent, subfolders } = + const { breadcrumbs, documents, folderAssignedCollections, FolderResultsComponent, subfolders } = await getFolderResultsComponentAndData({ - activeCollectionSlugs: activeCollectionFolderSlugs, - browseByFolder: false, + browseByFolder: true, + collectionsToDisplay, displayAs: viewPreference, + folderAssignedCollections: collectionsToDisplay.filter((slug) => slug !== foldersSlug) || [], folderID, req: initPageResult.req, sort: sortPreference, @@ -142,10 +168,33 @@ export const buildBrowseByFolderView = async ( // serverProps, // }) - // documents cannot be created without a parent folder in this view - const allowCreateCollectionSlugs = resolvedFolderID - ? [config.folders.slug, ...browseByFolderSlugs] - : [config.folders.slug] + // Filter down allCollectionFolderSlugs by the ones the current folder is assingned to + const allAvailableCollectionSlugs = + folderID && Array.isArray(folderAssignedCollections) && folderAssignedCollections.length + ? allowReadCollectionSlugs.filter((slug) => folderAssignedCollections.includes(slug)) + : allowReadCollectionSlugs + + // Filter down activeCollectionFolderSlugs by the ones the current folder is assingned to + const availableActiveCollectionFolderSlugs = collectionsToDisplay.filter((slug) => { + if (slug === foldersSlug) { + return permissions?.collections?.[foldersSlug]?.read + } else { + return !folderAssignedCollections || folderAssignedCollections.includes(slug) + } + }) + + // Documents cannot be created without a parent folder in this view + const allowCreateCollectionSlugs = ( + resolvedFolderID ? [foldersSlug, ...allAvailableCollectionSlugs] : [foldersSlug] + ).filter((collectionSlug) => { + if (collectionSlug === foldersSlug) { + return permissions?.collections?.[foldersSlug]?.create + } + return ( + permissions?.collections?.[collectionSlug]?.create && + visibleEntities.collections.includes(collectionSlug) + ) + }) return { View: ( @@ -154,8 +203,8 @@ export const buildBrowseByFolderView = async ( {RenderServerComponent({ clientProps: { // ...folderViewSlots, - activeCollectionFolderSlugs, - allCollectionFolderSlugs: browseByFolderSlugs, + activeCollectionFolderSlugs: availableActiveCollectionFolderSlugs, + allCollectionFolderSlugs: allAvailableCollectionSlugs, allowCreateCollectionSlugs, baseFolderPath: `/browse-by-folder`, breadcrumbs, @@ -163,6 +212,7 @@ export const buildBrowseByFolderView = async ( disableBulkEdit, documents, enableRowSelections, + folderAssignedCollections, folderFieldName: config.folders.fieldName, folderID: resolvedFolderID || null, FolderResultsComponent, diff --git a/packages/next/src/views/CollectionFolders/buildView.tsx b/packages/next/src/views/CollectionFolders/buildView.tsx index e06bfcc2c0..8a110f20f7 100644 --- a/packages/next/src/views/CollectionFolders/buildView.tsx +++ b/packages/next/src/views/CollectionFolders/buildView.tsx @@ -97,23 +97,28 @@ export const buildCollectionFolderView = async ( }, }) - const sortPreference: FolderSortKeys = - collectionFolderPreferences?.sort || '_folderOrDocumentTitle' + const sortPreference: FolderSortKeys = collectionFolderPreferences?.sort || 'name' const viewPreference = collectionFolderPreferences?.viewPreference || 'grid' const { routes: { admin: adminRoute }, } = config - const { breadcrumbs, documents, FolderResultsComponent, subfolders } = - await getFolderResultsComponentAndData({ - activeCollectionSlugs: [config.folders.slug, collectionSlug], - browseByFolder: false, - displayAs: viewPreference, - folderID, - req: initPageResult.req, - sort: sortPreference, - }) + const { + breadcrumbs, + documents, + folderAssignedCollections, + FolderResultsComponent, + subfolders, + } = await getFolderResultsComponentAndData({ + browseByFolder: false, + collectionsToDisplay: [config.folders.slug, collectionSlug], + displayAs: viewPreference, + folderAssignedCollections: [collectionSlug], + folderID, + req: initPageResult.req, + sort: sortPreference, + }) const resolvedFolderID = breadcrumbs[breadcrumbs.length - 1]?.id @@ -182,6 +187,7 @@ export const buildCollectionFolderView = async ( disableBulkEdit, documents, enableRowSelections, + folderAssignedCollections, folderFieldName: config.folders.fieldName, folderID: resolvedFolderID || null, FolderResultsComponent, diff --git a/packages/next/src/views/CollectionTrash/index.tsx b/packages/next/src/views/CollectionTrash/index.tsx new file mode 100644 index 0000000000..3501fbf831 --- /dev/null +++ b/packages/next/src/views/CollectionTrash/index.tsx @@ -0,0 +1,40 @@ +import type { AdminViewServerProps, ListQuery } from 'payload' +import type React from 'react' + +import { notFound } from 'next/navigation.js' + +import { renderListView } from '../List/index.js' + +type RenderTrashViewArgs = { + customCellProps?: Record + disableBulkDelete?: boolean + disableBulkEdit?: boolean + disableQueryPresets?: boolean + drawerSlug?: string + enableRowSelections: boolean + overrideEntityVisibility?: boolean + query: ListQuery + redirectAfterDelete?: boolean + redirectAfterDuplicate?: boolean + redirectAfterRestore?: boolean +} & AdminViewServerProps + +export const TrashView: React.FC> = async ( + args, +) => { + try { + const { List: TrashList } = await renderListView({ + ...args, + enableRowSelections: true, + trash: true, + viewType: 'trash', + }) + + return TrashList + } catch (error) { + if (error.message === 'not-found') { + notFound() + } + console.error(error) // eslint-disable-line no-console + } +} diff --git a/packages/next/src/views/CollectionTrash/metadata.ts b/packages/next/src/views/CollectionTrash/metadata.ts new file mode 100644 index 0000000000..83f5a67ea9 --- /dev/null +++ b/packages/next/src/views/CollectionTrash/metadata.ts @@ -0,0 +1,35 @@ +import type { Metadata } from 'next' +import type { SanitizedCollectionConfig } from 'payload' + +import { getTranslation } from '@payloadcms/translations' + +import type { GenerateViewMetadata } from '../Root/index.js' + +import { generateMetadata } from '../../utilities/meta.js' + +export const generateCollectionTrashMetadata = async ( + args: { + collectionConfig: SanitizedCollectionConfig + } & Parameters[0], +): Promise => { + const { collectionConfig, config, i18n } = args + + let title: string = '' + const description: string = '' + const keywords: string = '' + + if (collectionConfig) { + title = getTranslation(collectionConfig.labels.plural, i18n) + } + + title = `${title ? `${title} ` : title}${i18n.t('general:trash')}` + + return generateMetadata({ + ...(config.admin.meta || {}), + description, + keywords, + serverURL: config.serverURL, + title, + ...(collectionConfig?.admin?.meta || {}), + }) +} diff --git a/packages/next/src/views/CreateFirstUser/index.client.tsx b/packages/next/src/views/CreateFirstUser/index.client.tsx index caaabdaa09..d1462cfae5 100644 --- a/packages/next/src/views/CreateFirstUser/index.client.tsx +++ b/packages/next/src/views/CreateFirstUser/index.client.tsx @@ -85,7 +85,14 @@ export const CreateFirstUserClient: React.FC<{ return (
    | TypeWithID> => { const id = sanitizeID(idArg) let resolvedData: Record | TypeWithID = null const { transactionID, ...rest } = req + const isTrashedDoc = segments?.[2] === 'trash' && typeof segments?.[3] === 'string' // id exists at segment 3 + try { if (collectionSlug && id) { resolvedData = await payload.findByID({ @@ -44,6 +48,7 @@ export const getDocumentData = async ({ req: { ...rest, }, + trash: isTrashedDoc ? true : false, user, }) } diff --git a/packages/next/src/views/Document/getDocumentView.tsx b/packages/next/src/views/Document/getDocumentView.tsx index f97937cd8a..7056af04ed 100644 --- a/packages/next/src/views/Document/getDocumentView.tsx +++ b/packages/next/src/views/Document/getDocumentView.tsx @@ -113,7 +113,13 @@ export const getDocumentView = ({ // --> /collections/:collectionSlug/:id/api // --> /collections/:collectionSlug/:id/versions // --> /collections/:collectionSlug/:id/ + // --> /collections/:collectionSlug/trash/:id case 4: { + // --> /collections/:collectionSlug/trash/:id + if (segment3 === 'trash' && segment4) { + View = getCustomViewByKey(views, 'default') || DefaultEditView + break + } switch (segment4) { // --> /collections/:collectionSlug/:id/api case 'api': { @@ -167,18 +173,86 @@ export const getDocumentView = ({ break } + // --> /collections/:collectionSlug/trash/:id/api + // --> /collections/:collectionSlug/trash/:id/versions + // --> /collections/:collectionSlug/trash/:id/ // --> /collections/:collectionSlug/:id/versions/:version - // --> /collections/:collectionSlug/:id// - default: { - // --> /collections/:collectionSlug/:id/versions/:version - if (segment4 === 'versions') { + case 5: { + // --> /collections/:slug/trash/:id/api + if (segment3 === 'trash') { + switch (segment5) { + case 'api': { + if (collectionConfig?.admin?.hideAPIURL !== true) { + View = getCustomViewByKey(views, 'api') || DefaultAPIView + } + break + } + // --> /collections/:slug/trash/:id/versions + case 'versions': { + if (docPermissions?.readVersions) { + View = getCustomViewByKey(views, 'versions') || DefaultVersionsView + } else { + View = UnauthorizedViewWithGutter + } + break + } + + default: { + View = getCustomViewByKey(views, 'default') || DefaultEditView + break + } + } + // --> /collections/:collectionSlug/:id/versions/:version + } else if (segment4 === 'versions') { if (docPermissions?.readVersions) { View = getCustomViewByKey(views, 'version') || DefaultVersionView } else { View = UnauthorizedViewWithGutter } } else { - // --> /collections/:collectionSlug/:id// + // --> /collections/:collectionSlug/:id// + const baseRoute = [ + adminRoute !== '/' && adminRoute, + collectionEntity, + collectionSlug, + segment3, + ] + .filter(Boolean) + .join('/') + + const currentRoute = [baseRoute, segment4, segment5, ...remainingSegments] + .filter(Boolean) + .join('/') + + const { Component: CustomViewComponent, viewKey: customViewKey } = getCustomViewByRoute({ + baseRoute, + currentRoute, + views, + }) + + if (customViewKey) { + viewKey = customViewKey + View = CustomViewComponent + } + } + + break + } + + // --> /collections/:collectionSlug/trash/:id/versions/:version + // --> /collections/:collectionSlug/:id/// + default: { + // --> /collections/:collectionSlug/trash/:id/versions/:version + const isTrashedVersionView = segment3 === 'trash' && segment5 === 'versions' + + if (isTrashedVersionView) { + if (docPermissions?.readVersions) { + View = getCustomViewByKey(views, 'version') || DefaultVersionView + } else { + View = UnauthorizedViewWithGutter + } + } else { + // --> /collections/:collectionSlug/:id/// const baseRoute = [ adminRoute !== '/' && adminRoute, collectionEntity, diff --git a/packages/next/src/views/Document/getMetaBySegment.tsx b/packages/next/src/views/Document/getMetaBySegment.tsx index 3462363e24..4725368f95 100644 --- a/packages/next/src/views/Document/getMetaBySegment.tsx +++ b/packages/next/src/views/Document/getMetaBySegment.tsx @@ -15,6 +15,7 @@ export type GenerateEditViewMetadata = ( args: { collectionConfig?: null | SanitizedCollectionConfig globalConfig?: null | SanitizedGlobalConfig + isReadOnly?: boolean view?: keyof EditConfig } & Parameters[0], ) => Promise @@ -42,6 +43,11 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({ fn = generateEditViewMetadata } + // `/collections/:collection/trash/:id` + if (segments.length === 4 && segments[2] === 'trash') { + fn = (args) => generateEditViewMetadata({ ...args, isReadOnly: true }) + } + // `/:collection/:id/:view` if (params.segments.length === 4) { switch (params.segments[3]) { @@ -69,6 +75,25 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({ break } } + + // `/collections/:collection/trash/:id/:view` + if (segments.length === 5 && segments[2] === 'trash') { + switch (segments[4]) { + case 'api': + fn = generateAPIViewMetadata + break + case 'versions': + fn = generateVersionsViewMetadata + break + default: + break + } + } + + // `/collections/:collection/trash/:id/versions/:versionID` + if (segments.length === 6 && segments[2] === 'trash' && segments[4] === 'versions') { + fn = generateVersionViewMetadata + } } if (isGlobal) { diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index 88e42680c4..c8641f345b 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -65,6 +65,7 @@ export const renderDocument = async ({ redirectAfterCreate, redirectAfterDelete, redirectAfterDuplicate, + redirectAfterRestore, searchParams, versions, viewType, @@ -74,6 +75,7 @@ export const renderDocument = async ({ readonly redirectAfterCreate?: boolean readonly redirectAfterDelete?: boolean readonly redirectAfterDuplicate?: boolean + readonly redirectAfterRestore?: boolean versions?: RenderDocumentVersionsProperties } & AdminViewServerProps): Promise<{ data: Data @@ -108,21 +110,36 @@ export const renderDocument = async ({ // Fetch the doc required for the view let doc = - initialData || - (await getDocumentData({ - id: idFromArgs, - collectionSlug, - globalSlug, - locale, - payload, - req, - user, - })) + !idFromArgs && !globalSlug + ? initialData || null + : await getDocumentData({ + id: idFromArgs, + collectionSlug, + globalSlug, + locale, + payload, + req, + segments, + user, + }) if (isEditing && !doc) { - throw new Error('not-found') + // If it's a collection document that doesn't exist, redirect to collection list + if (collectionSlug) { + const redirectURL = formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}?notFound=${encodeURIComponent(idFromArgs)}`, + serverURL, + }) + redirect(redirectURL) + } else { + // For globals or other cases, keep the 404 behavior + throw new Error('not-found') + } } + const isTrashedDoc = typeof doc?.deletedAt === 'string' + const [ docPreferences, { docPermissions, hasPublishPermission, hasSavePermission }, @@ -191,6 +208,7 @@ export const renderDocument = async ({ globalSlug, locale: locale?.code, operation, + readOnly: isTrashedDoc, renderAllFields: true, req, schemaPath: collectionSlug || globalSlug, @@ -378,12 +396,14 @@ export const renderDocument = async ({ initialState={formState} isEditing={isEditing} isLocked={isLocked} + isTrashed={isTrashedDoc} key={locale?.code} lastUpdateTime={lastUpdateTime} mostRecentVersionIsAutosaved={mostRecentVersionIsAutosaved} redirectAfterCreate={redirectAfterCreate} redirectAfterDelete={redirectAfterDelete} redirectAfterDuplicate={redirectAfterDuplicate} + redirectAfterRestore={redirectAfterRestore} unpublishedVersionCount={unpublishedVersionCount} versionCount={versionCount} > @@ -397,9 +417,8 @@ export const renderDocument = async ({ )} diff --git a/packages/next/src/views/Edit/metadata.ts b/packages/next/src/views/Edit/metadata.ts index db77155f7b..250b8ba143 100644 --- a/packages/next/src/views/Edit/metadata.ts +++ b/packages/next/src/views/Edit/metadata.ts @@ -16,6 +16,7 @@ export const generateEditViewMetadata: GenerateEditViewMetadata = async ({ globalConfig, i18n, isEditing, + isReadOnly = false, view = 'default', }): Promise => { const { t } = i18n @@ -26,11 +27,17 @@ export const generateEditViewMetadata: GenerateEditViewMetadata = async ({ ? getTranslation(globalConfig.label, i18n) : '' + const verb = isReadOnly + ? t('general:viewing') + : isEditing + ? t('general:editing') + : t('general:creating') + const metaToUse: MetaConfig = { ...(config.admin.meta || {}), - description: `${isEditing ? t('general:editing') : t('general:creating')} - ${entityLabel}`, + description: `${verb} - ${entityLabel}`, keywords: `${entityLabel}, Payload, CMS`, - title: `${isEditing ? t('general:editing') : t('general:creating')} - ${entityLabel}`, + title: `${verb} - ${entityLabel}`, } const ogToUse: MetaConfig['openGraph'] = { diff --git a/packages/next/src/views/List/handleGroupBy.ts b/packages/next/src/views/List/handleGroupBy.ts new file mode 100644 index 0000000000..f389c14628 --- /dev/null +++ b/packages/next/src/views/List/handleGroupBy.ts @@ -0,0 +1,218 @@ +import type { + ClientConfig, + Column, + ListQuery, + PaginatedDocs, + PayloadRequest, + SanitizedCollectionConfig, + ViewTypes, + Where, +} from 'payload' + +import { renderTable } from '@payloadcms/ui/rsc' +import { formatDate } from '@payloadcms/ui/shared' +import { flattenAllFields } from 'payload' + +export const handleGroupBy = async ({ + clientConfig, + collectionConfig, + collectionSlug, + columns, + customCellProps, + drawerSlug, + enableRowSelections, + query, + req, + trash = false, + user, + viewType, + where: whereWithMergedSearch, +}: { + clientConfig: ClientConfig + collectionConfig: SanitizedCollectionConfig + collectionSlug: string + columns: any[] + customCellProps?: Record + drawerSlug?: string + enableRowSelections?: boolean + query?: ListQuery + req: PayloadRequest + trash?: boolean + user: any + viewType?: ViewTypes + where: Where +}): Promise<{ + columnState: Column[] + data: PaginatedDocs + Table: null | React.ReactNode | React.ReactNode[] +}> => { + let Table: React.ReactNode | React.ReactNode[] = null + let columnState: Column[] + + const dataByGroup: Record = {} + const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug) + + // NOTE: is there a faster/better way to do this? + const flattenedFields = flattenAllFields({ fields: collectionConfig.fields }) + + const groupByFieldPath = query.groupBy.replace(/^-/, '') + + const groupByField = flattenedFields.find((f) => f.name === groupByFieldPath) + + const relationshipConfig = + groupByField?.type === 'relationship' + ? clientConfig.collections.find((c) => c.slug === groupByField.relationTo) + : undefined + + let populate + + if (groupByField?.type === 'relationship' && groupByField.relationTo) { + const relationTo = + typeof groupByField.relationTo === 'string' + ? [groupByField.relationTo] + : groupByField.relationTo + + if (Array.isArray(relationTo)) { + relationTo.forEach((rel) => { + if (!populate) { + populate = {} + } + populate[rel] = { [relationshipConfig?.admin.useAsTitle || 'id']: true } + }) + } + } + + const distinct = await req.payload.findDistinct({ + collection: collectionSlug, + depth: 1, + field: groupByFieldPath, + limit: query?.limit ? Number(query.limit) : undefined, + locale: req.locale, + overrideAccess: false, + page: query?.page ? Number(query.page) : undefined, + populate, + req, + sort: query?.groupBy, + trash, + where: whereWithMergedSearch, + }) + + const data = { + ...distinct, + docs: distinct.values?.map(() => ({})) || [], + values: undefined, + } + + await Promise.all( + distinct.values.map(async (distinctValue, i) => { + const potentiallyPopulatedRelationship = distinctValue[groupByFieldPath] + + const valueOrRelationshipID = + groupByField?.type === 'relationship' && + potentiallyPopulatedRelationship && + typeof potentiallyPopulatedRelationship === 'object' && + 'id' in potentiallyPopulatedRelationship + ? potentiallyPopulatedRelationship.id + : potentiallyPopulatedRelationship + + const groupData = await req.payload.find({ + collection: collectionSlug, + depth: 0, + draft: true, + fallbackLocale: false, + includeLockStatus: true, + limit: query?.queryByGroup?.[valueOrRelationshipID]?.limit + ? Number(query.queryByGroup[valueOrRelationshipID].limit) + : undefined, + locale: req.locale, + overrideAccess: false, + page: query?.queryByGroup?.[valueOrRelationshipID]?.page + ? Number(query.queryByGroup[valueOrRelationshipID].page) + : undefined, + req, + // Note: if we wanted to enable table-by-table sorting, we could use this: + // sort: query?.queryByGroup?.[valueOrRelationshipID]?.sort, + sort: query?.sort, + trash, + user, + where: { + ...(whereWithMergedSearch || {}), + [groupByFieldPath]: { + equals: valueOrRelationshipID, + }, + }, + }) + + let heading = valueOrRelationshipID + + if ( + groupByField?.type === 'relationship' && + potentiallyPopulatedRelationship && + typeof potentiallyPopulatedRelationship === 'object' + ) { + heading = + potentiallyPopulatedRelationship[relationshipConfig.admin.useAsTitle || 'id'] || + valueOrRelationshipID + } + + if (groupByField.type === 'date' && valueOrRelationshipID) { + heading = formatDate({ + date: String(valueOrRelationshipID), + i18n: req.i18n, + pattern: clientConfig.admin.dateFormat, + }) + } + + if (groupByField.type === 'checkbox') { + if (valueOrRelationshipID === true) { + heading = req.i18n.t('general:true') + } + + if (valueOrRelationshipID === false) { + heading = req.i18n.t('general:false') + } + } + + if (groupData.docs && groupData.docs.length > 0) { + const { columnState: newColumnState, Table: NewTable } = renderTable({ + clientCollectionConfig, + collectionConfig, + columns, + customCellProps, + data: groupData, + drawerSlug, + enableRowSelections, + groupByFieldPath, + groupByValue: valueOrRelationshipID, + heading: heading || req.i18n.t('general:noValue'), + i18n: req.i18n, + key: `table-${valueOrRelationshipID}`, + orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined, + payload: req.payload, + query, + useAsTitle: collectionConfig.admin.useAsTitle, + viewType, + }) + + // Only need to set `columnState` once, using the first table's column state + // This will avoid needing to generate column state explicitly for root context that wraps all tables + if (!columnState) { + columnState = newColumnState + } + + if (!Table) { + Table = [] + } + + dataByGroup[valueOrRelationshipID] = groupData + ;(Table as Array)[i] = NewTable + } + }), + ) + + return { + columnState, + data, + Table, + } +} diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index 3df0d9de82..4b07f17136 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -1,29 +1,31 @@ -import type { - AdminViewServerProps, - CollectionPreferences, - ColumnPreference, - DefaultDocumentIDType, - ListQuery, - ListViewClientProps, - ListViewServerPropsOnly, - QueryPreset, - SanitizedCollectionPermission, - Where, -} from 'payload' - import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' import { renderFilters, renderTable, upsertPreferences } from '@payloadcms/ui/rsc' import { notFound } from 'next/navigation.js' import { + type AdminViewServerProps, + type CollectionPreferences, + type Column, + type ColumnPreference, + type ListQuery, + type ListViewClientProps, + type ListViewServerPropsOnly, + type PaginatedDocs, + type QueryPreset, + type SanitizedCollectionPermission, +} from 'payload' +import { + combineWhereConstraints, formatAdminURL, isNumber, mergeListSearchAndWhere, transformColumnsToPreferences, + transformColumnsToSearchParams, } from 'payload/shared' import React, { Fragment } from 'react' import { getDocumentPermissions } from '../Document/getDocumentPermissions.js' +import { handleGroupBy } from './handleGroupBy.js' import { renderListViewSlots } from './renderListViewSlots.js' import { resolveAllFilterOptions } from './resolveAllFilterOptions.js' @@ -38,6 +40,10 @@ type RenderListViewArgs = { query: ListQuery redirectAfterDelete?: boolean redirectAfterDuplicate?: boolean + /** + * @experimental This prop is subject to change in future releases. + */ + trash?: boolean } & AdminViewServerProps /** @@ -64,6 +70,8 @@ export const renderListView = async ( params, query: queryFromArgs, searchParams, + trash, + viewType, } = args const { @@ -74,7 +82,6 @@ export const renderListView = async ( req, req: { i18n, - locale, payload, payload: { config }, query: queryFromReq, @@ -87,28 +94,41 @@ export const renderListView = async ( throw new Error('not-found') } - const query = queryFromArgs || queryFromReq + const query: ListQuery = queryFromArgs || queryFromReq - const columns: ColumnPreference[] = transformColumnsToPreferences( - query?.columns as ColumnPreference[] | string, - ) + const columnsFromQuery: ColumnPreference[] = transformColumnsToPreferences(query?.columns) + + query.queryByGroup = + query?.queryByGroup && typeof query.queryByGroup === 'string' + ? JSON.parse(query.queryByGroup) + : query?.queryByGroup - /** - * @todo: find a pattern to avoid setting preferences on hard navigation, i.e. direct links, page refresh, etc. - * This will ensure that prefs are only updated when explicitly set by the user - * This could potentially be done by injecting a `sessionID` into the params and comparing it against a session cookie - */ const collectionPreferences = await upsertPreferences({ key: `collection-${collectionSlug}`, req, value: { - columns, + columns: columnsFromQuery, + groupBy: query?.groupBy, limit: isNumber(query?.limit) ? Number(query.limit) : undefined, - preset: (query?.preset as DefaultDocumentIDType) || null, + preset: query?.preset, sort: query?.sort as string, }, }) + query.preset = collectionPreferences?.preset + + query.page = isNumber(query?.page) ? Number(query.page) : 0 + + query.limit = collectionPreferences?.limit || collectionConfig.admin.pagination.defaultLimit + + query.sort = + collectionPreferences?.sort || + (typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : undefined) + + query.groupBy = collectionPreferences?.groupBy + + query.columns = transformColumnsToSearchParams(collectionPreferences?.columns || []) + const { routes: { admin: adminRoute }, } = config @@ -118,38 +138,37 @@ export const renderListView = async ( throw new Error('not-found') } - const page = isNumber(query?.page) ? Number(query.page) : 0 - - const limit = collectionPreferences?.limit || collectionConfig.admin.pagination.defaultLimit - - const sort = - collectionPreferences?.sort || - (typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : undefined) - - let where = mergeListSearchAndWhere({ - collectionConfig, - search: typeof query?.search === 'string' ? query.search : undefined, - where: (query?.where as Where) || undefined, + const baseFilterConstraint = await ( + collectionConfig.admin?.baseFilter ?? collectionConfig.admin?.baseListFilter + )?.({ + limit: query.limit, + page: query.page, + req, + sort: query.sort, }) - if (typeof collectionConfig.admin?.baseListFilter === 'function') { - const baseListFilter = await collectionConfig.admin.baseListFilter({ - limit, - page, - req, - sort, - }) - - if (baseListFilter) { - where = { - and: [where, baseListFilter].filter(Boolean), - } - } - } - let queryPreset: QueryPreset | undefined let queryPresetPermissions: SanitizedCollectionPermission | undefined + let whereWithMergedSearch = mergeListSearchAndWhere({ + collectionConfig, + search: typeof query?.search === 'string' ? query.search : undefined, + where: combineWhereConstraints([query?.where, baseFilterConstraint]), + }) + + if (trash === true) { + whereWithMergedSearch = { + and: [ + whereWithMergedSearch, + { + deletedAt: { + exists: true, + }, + }, + ], + } + } + if (collectionPreferences?.preset) { try { queryPreset = (await payload.findByID({ @@ -173,38 +192,82 @@ export const renderListView = async ( } } - const data = await payload.find({ - collection: collectionSlug, - depth: 0, - draft: true, - fallbackLocale: false, - includeLockStatus: true, - limit, - locale, - overrideAccess: false, - page, - req, - sort, - user, - where: where || {}, - }) + let Table: React.ReactNode | React.ReactNode[] = null + let columnState: Column[] = [] + let data: PaginatedDocs = { + // no results default + docs: [], + hasNextPage: false, + hasPrevPage: false, + limit: query.limit, + nextPage: null, + page: 1, + pagingCounter: 0, + prevPage: null, + totalDocs: 0, + totalPages: 0, + } - const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug) - - const { columnState, Table } = renderTable({ - clientCollectionConfig, - collectionConfig, - columnPreferences: collectionPreferences?.columns, - columns, - customCellProps, - docs: data.docs, - drawerSlug, - enableRowSelections, - i18n: req.i18n, - orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined, - payload, - useAsTitle: collectionConfig.admin.useAsTitle, - }) + try { + if (collectionConfig.admin.groupBy && query.groupBy) { + ;({ columnState, data, Table } = await handleGroupBy({ + clientConfig, + collectionConfig, + collectionSlug, + columns: collectionPreferences?.columns, + customCellProps, + drawerSlug, + enableRowSelections, + query, + req, + trash, + user, + viewType, + where: whereWithMergedSearch, + })) + } else { + data = await req.payload.find({ + collection: collectionSlug, + depth: 0, + draft: true, + fallbackLocale: false, + includeLockStatus: true, + limit: query?.limit ? Number(query.limit) : undefined, + locale: req.locale, + overrideAccess: false, + page: query?.page ? Number(query.page) : undefined, + req, + sort: query?.sort, + trash, + user, + where: whereWithMergedSearch, + }) + ;({ columnState, Table } = renderTable({ + clientCollectionConfig: clientConfig.collections.find((c) => c.slug === collectionSlug), + collectionConfig, + columns: collectionPreferences?.columns, + customCellProps, + data, + drawerSlug, + enableRowSelections, + i18n: req.i18n, + orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined, + payload: req.payload, + query, + useAsTitle: collectionConfig.admin.useAsTitle, + viewType, + })) + } + } catch (err) { + if (err.name !== 'QueryError') { + // QueryErrors are expected when a user filters by a field they do not have access to + req.payload.logger.error({ + err, + msg: `There was an error fetching the list view data for collection ${collectionSlug}`, + }) + throw err + } + } const renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap) @@ -224,12 +287,16 @@ export const renderListView = async ( }) const hasCreatePermission = permissions?.collections?.[collectionSlug]?.create + const hasDeletePermission = permissions?.collections?.[collectionSlug]?.delete + + // Check if there's a notFound query parameter (document ID that wasn't found) + const notFoundDocId = typeof searchParams?.notFound === 'string' ? searchParams.notFound : null const serverProps: ListViewServerPropsOnly = { collectionConfig, data, i18n, - limit, + limit: query.limit, listPreferences: collectionPreferences, listSearchableFields: collectionConfig.admin.listSearchableFields, locale: fullLocale, @@ -244,29 +311,32 @@ export const renderListView = async ( clientProps: { collectionSlug, hasCreatePermission, + hasDeletePermission, newDocumentURL, }, collectionConfig, description: staticDescription, + notFoundDocId, payload, serverProps, }) const isInDrawer = Boolean(drawerSlug) + // Needed to prevent: Only plain objects can be passed to Client Components from Server Components. Objects with toJSON methods are not supported. Convert it manually to a simple value before passing it to props. + // Is there a way to avoid this? The `where` object is already seemingly plain, but is not bc it originates from the params. + query.where = query?.where ? JSON.parse(JSON.stringify(query?.where || {})) : undefined + return { List: ( {RenderServerComponent({ clientProps: { @@ -278,6 +348,7 @@ export const renderListView = async ( disableQueryPresets, enableRowSelections, hasCreatePermission, + hasDeletePermission, listPreferences: collectionPreferences, newDocumentURL, queryPreset, @@ -285,6 +356,7 @@ export const renderListView = async ( renderedFilters, resolvedFilterOptions, Table, + viewType, } satisfies ListViewClientProps, Component: collectionConfig?.admin?.components?.views?.list?.Component, Fallback: DefaultListView, diff --git a/packages/next/src/views/List/renderListViewSlots.tsx b/packages/next/src/views/List/renderListViewSlots.tsx index a0c36d016f..200fcf7f37 100644 --- a/packages/next/src/views/List/renderListViewSlots.tsx +++ b/packages/next/src/views/List/renderListViewSlots.tsx @@ -16,12 +16,15 @@ import type { ViewDescriptionServerPropsOnly, } from 'payload' +import { Banner } from '@payloadcms/ui/elements/Banner' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' +import React from 'react' type Args = { clientProps: ListViewSlotSharedClientProps collectionConfig: SanitizedCollectionConfig description?: StaticDescription + notFoundDocId?: null | string payload: Payload serverProps: ListViewServerPropsOnly } @@ -30,6 +33,7 @@ export const renderListViewSlots = ({ clientProps, collectionConfig, description, + notFoundDocId, payload, serverProps, }: Args): ListViewSlots => { @@ -75,13 +79,31 @@ export const renderListViewSlots = ({ }) } - if (collectionConfig.admin.components?.beforeListTable) { - result.BeforeListTable = RenderServerComponent({ - clientProps: clientProps satisfies BeforeListTableClientProps, - Component: collectionConfig.admin.components.beforeListTable, - importMap: payload.importMap, - serverProps: serverProps satisfies BeforeListTableServerPropsOnly, - }) + // Handle beforeListTable with optional banner + const existingBeforeListTable = collectionConfig.admin.components?.beforeListTable + ? RenderServerComponent({ + clientProps: clientProps satisfies BeforeListTableClientProps, + Component: collectionConfig.admin.components.beforeListTable, + importMap: payload.importMap, + serverProps: serverProps satisfies BeforeListTableServerPropsOnly, + }) + : null + + // Create banner for document not found + const notFoundBanner = notFoundDocId ? ( + + {serverProps.i18n.t('error:documentNotFound', { id: notFoundDocId })} + + ) : null + + // Combine banner and existing component + if (notFoundBanner || existingBeforeListTable) { + result.BeforeListTable = ( + + {notFoundBanner} + {existingBeforeListTable} + + ) } if (collectionConfig.admin.components?.Description) { diff --git a/packages/next/src/views/Root/getRouteData.ts b/packages/next/src/views/Root/getRouteData.ts index a7e5e787c7..fa3e2e5e5e 100644 --- a/packages/next/src/views/Root/getRouteData.ts +++ b/packages/next/src/views/Root/getRouteData.ts @@ -17,6 +17,7 @@ import type { initPage } from '../../utilities/initPage/index.js' import { Account } from '../Account/index.js' import { BrowseByFolder } from '../BrowseByFolder/index.js' import { CollectionFolderView } from '../CollectionFolders/index.js' +import { TrashView } from '../CollectionTrash/index.js' import { CreateFirstUserView } from '../CreateFirstUser/index.js' import { Dashboard } from '../Dashboard/index.js' import { Document as DocumentView } from '../Document/index.js' @@ -107,7 +108,7 @@ export const getRouteData = ({ searchParams, } - const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive] = segments + const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive, segmentSix] = segments const isGlobal = segmentOne === 'globals' const isCollection = segmentOne === 'collections' @@ -272,7 +273,50 @@ export const getRouteData = ({ viewType = 'verify' } else if (isCollection && matchedCollection) { initPageOptions.routeParams.collection = matchedCollection.slug - if (config.folders && segmentThree === config.folders.slug && matchedCollection.folders) { + + if (segmentThree === 'trash' && typeof segmentFour === 'string') { + // --> /collections/:collectionSlug/trash/:id (read-only) + // --> /collections/:collectionSlug/trash/:id/api + // --> /collections/:collectionSlug/trash/:id/preview + // --> /collections/:collectionSlug/trash/:id/versions + // --> /collections/:collectionSlug/trash/:id/versions/:versionID + initPageOptions.routeParams.id = segmentFour + initPageOptions.routeParams.versionID = segmentSix + + ViewToRender = { + Component: DocumentView, + } + + templateClassName = `collection-default-edit` + templateType = 'default' + + const viewInfo = getDocumentViewInfo([segmentFive, segmentSix]) + viewType = viewInfo.viewType + documentSubViewType = viewInfo.documentSubViewType + + attachViewActions({ + collectionOrGlobal: matchedCollection, + serverProps, + viewKeyArg: documentSubViewType, + }) + } else if (segmentThree === 'trash') { + // --> /collections/:collectionSlug/trash + ViewToRender = { + Component: TrashView, + } + + templateClassName = `${segmentTwo}-trash` + templateType = 'default' + viewType = 'trash' + + serverProps.viewActions = serverProps.viewActions.concat( + matchedCollection.admin.components?.views?.list?.actions ?? [], + ) + } else if ( + config.folders && + segmentThree === config.folders.slug && + matchedCollection.folders + ) { // Collection Folder Views // --> /collections/:collectionSlug/:folderCollectionSlug // --> /collections/:collectionSlug/:folderCollectionSlug/:folderID diff --git a/packages/next/src/views/Root/index.tsx b/packages/next/src/views/Root/index.tsx index be6a4c9567..7a32e7d42a 100644 --- a/packages/next/src/views/Root/index.tsx +++ b/packages/next/src/views/Root/index.tsx @@ -17,6 +17,7 @@ import React from 'react' import { DefaultTemplate } from '../../templates/Default/index.js' import { MinimalTemplate } from '../../templates/Minimal/index.js' import { initPage } from '../../utilities/initPage/index.js' +import { getCustomViewByRoute } from './getCustomViewByRoute.js' import { getRouteData } from './getRouteData.js' export type GenerateViewMetadata = (args: { @@ -62,6 +63,32 @@ export const RootPage = async ({ const searchParams = await searchParamsPromise + // Redirect `${adminRoute}/collections` to `${adminRoute}` + if (segments.length === 1 && segments[0] === 'collections') { + const { viewKey } = getCustomViewByRoute({ + config, + currentRoute: '/collections', + }) + + // Only redirect if there's NO custom view configured for /collections + if (!viewKey) { + redirect(adminRoute) + } + } + + // Redirect `${adminRoute}/globals` to `${adminRoute}` + if (segments.length === 1 && segments[0] === 'globals') { + const { viewKey } = getCustomViewByRoute({ + config, + currentRoute: '/globals', + }) + + // Only redirect if there's NO custom view configured for /globals + if (!viewKey) { + redirect(adminRoute) + } + } + const { browseByFolderSlugs, DefaultView, diff --git a/packages/next/src/views/Root/metadata.ts b/packages/next/src/views/Root/metadata.ts index 27ab9c2242..6ca7974a7a 100644 --- a/packages/next/src/views/Root/metadata.ts +++ b/packages/next/src/views/Root/metadata.ts @@ -5,6 +5,7 @@ import { getNextRequestI18n } from '../../utilities/getNextRequestI18n.js' import { generateAccountViewMetadata } from '../Account/metadata.js' import { generateBrowseByFolderMetadata } from '../BrowseByFolder/metadata.js' import { generateCollectionFolderMetadata } from '../CollectionFolders/metadata.js' +import { generateCollectionTrashMetadata } from '../CollectionTrash/metadata.js' import { generateCreateFirstUserViewMetadata } from '../CreateFirstUser/metadata.js' import { generateDashboardViewMetadata } from '../Dashboard/metadata.js' import { generateDocumentViewMetadata } from '../Document/metadata.js' @@ -129,7 +130,16 @@ export const generatePageMetadata = async ({ // --> /:collectionSlug/verify/:token meta = await generateVerifyViewMetadata({ config, i18n }) } else if (isCollection) { - if (config.folders && segmentThree === config.folders.slug) { + if (segmentThree === 'trash' && segments.length === 3 && collectionConfig) { + // Collection Trash Views + // --> /collections/:collectionSlug/trash + meta = await generateCollectionTrashMetadata({ + collectionConfig, + config, + i18n, + params, + }) + } else if (config.folders && segmentThree === config.folders.slug) { if (folderCollectionSlugs.includes(collectionConfig.slug)) { // Collection Folder Views // --> /collections/:collectionSlug/:folderCollectionSlug @@ -147,6 +157,7 @@ export const generatePageMetadata = async ({ // --> /collections/:collectionSlug/:id/versions // --> /collections/:collectionSlug/:id/versions/:version // --> /collections/:collectionSlug/:id/api + // --> /collections/:collectionSlug/trash/:id meta = await generateDocumentViewMetadata({ collectionConfig, config, i18n, params }) } } else if (isGlobal) { diff --git a/packages/next/src/views/Version/Default/SetStepNav.tsx b/packages/next/src/views/Version/Default/SetStepNav.tsx index cece22878b..b05994f22a 100644 --- a/packages/next/src/views/Version/Default/SetStepNav.tsx +++ b/packages/next/src/views/Version/Default/SetStepNav.tsx @@ -12,13 +12,15 @@ export const SetStepNav: React.FC<{ readonly collectionConfig?: ClientCollectionConfig readonly globalConfig?: ClientGlobalConfig readonly id?: number | string + readonly isTrashed?: boolean versionToCreatedAtFormatted?: string versionToID?: string - versionToUseAsTitle?: string + versionToUseAsTitle?: Record | string }> = ({ id, collectionConfig, globalConfig, + isTrashed, versionToCreatedAtFormatted, versionToID, versionToUseAsTitle, @@ -52,10 +54,14 @@ export const SetStepNav: React.FC<{ ? versionToUseAsTitle?.[locale.code] || docLabel : versionToUseAsTitle } else if (useAsTitle === 'id') { - docLabel = versionToID + docLabel = String(id) } - setStepNav([ + const docBasePath: `/${string}` = isTrashed + ? `/collections/${collectionSlug}/trash/${id}` + : `/collections/${collectionSlug}/${id}` + + const nav = [ { label: getTranslation(pluralLabel, i18n), url: formatAdminURL({ @@ -63,24 +69,40 @@ export const SetStepNav: React.FC<{ path: `/collections/${collectionSlug}`, }), }, + ] + + if (isTrashed) { + nav.push({ + label: t('general:trash'), + url: formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/trash`, + }), + }) + } + + nav.push( { label: docLabel, url: formatAdminURL({ adminRoute, - path: `/collections/${collectionSlug}/${id}`, + path: docBasePath, }), }, { label: 'Versions', url: formatAdminURL({ adminRoute, - path: `/collections/${collectionSlug}/${id}/versions`, + path: `${docBasePath}/versions`, }), }, { label: versionToCreatedAtFormatted, + url: undefined, }, - ]) + ) + + setStepNav(nav) return } @@ -111,6 +133,7 @@ export const SetStepNav: React.FC<{ config, setStepNav, id, + isTrashed, locale, t, i18n, diff --git a/packages/next/src/views/Version/Default/index.tsx b/packages/next/src/views/Version/Default/index.tsx index e31cc717a4..96e1e31969 100644 --- a/packages/next/src/views/Version/Default/index.tsx +++ b/packages/next/src/views/Version/Default/index.tsx @@ -67,7 +67,7 @@ export const DefaultVersionView: React.FC = ({ } }, [code, config.localization, selectedLocalesFromProps]) - const { id: originalDocID, collectionSlug, globalSlug } = useDocumentInfo() + const { id: originalDocID, collectionSlug, globalSlug, isTrashed } = useDocumentInfo() const { startRouteTransition } = useRouteTransition() const { collectionConfig, globalConfig } = useMemo(() => { @@ -252,7 +252,7 @@ export const DefaultVersionView: React.FC = ({
{VersionToCreatedAtLabel} - {canUpdate && ( + {canUpdate && !isTrashed && ( = ({ collectionConfig={collectionConfig} globalConfig={globalConfig} id={originalDocID} + isTrashed={isTrashed} versionToCreatedAtFormatted={versionToCreatedAtFormatted} versionToID={versionToID} versionToUseAsTitle={versionToUseAsTitle} diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx index a7aa6a614f..950ccae89d 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx @@ -17,7 +17,13 @@ import { type SanitizedFieldPermissions, type VersionField, } from 'payload' -import { fieldIsID, fieldShouldBeLocalized, getUniqueListBy, tabHasName } from 'payload/shared' +import { + fieldIsID, + fieldShouldBeLocalized, + getFieldPermissions, + getUniqueListBy, + tabHasName, +} from 'payload/shared' import { diffComponents } from './fields/index.js' import { getFieldPathsModified } from './utilities/getFieldPathsModified.js' @@ -223,21 +229,16 @@ const buildVersionField = ({ BuildVersionFieldsArgs, 'fields' | 'parentIndexPath' | 'versionFromSiblingData' | 'versionToSiblingData' >): BaseVersionField | null => { - const fieldName: null | string = 'name' in field ? field.name : null + const { permissions, read: hasReadPermission } = getFieldPermissions({ + field, + operation: 'read', + parentName: parentPath?.includes('.') + ? parentPath.split('.')[parentPath.split('.').length - 1] + : parentPath, + permissions: fieldPermissions, + }) - const hasPermission = - fieldPermissions === true || - !fieldName || - fieldPermissions?.[fieldName] === true || - fieldPermissions?.[fieldName]?.read - - const subFieldPermissions = - fieldPermissions === true || - !fieldName || - fieldPermissions?.[fieldName] === true || - fieldPermissions?.[fieldName]?.fields - - if (!hasPermission) { + if (!hasReadPermission) { return null } @@ -292,13 +293,29 @@ const buildVersionField = ({ parentPath, parentSchemaPath, }) + + let tabPermissions: typeof fieldPermissions = undefined + + if (typeof permissions === 'boolean') { + tabPermissions = permissions + } else if (permissions && typeof permissions === 'object') { + if ('name' in tab) { + tabPermissions = + typeof permissions.fields?.[tab.name] === 'object' + ? permissions.fields?.[tab.name].fields + : permissions.fields?.[tab.name] + } else { + tabPermissions = permissions.fields + } + } + const tabVersion = { name: 'name' in tab ? tab.name : null, fields: buildVersionFields({ clientSchemaMap, customDiffComponents, entitySlug, - fieldPermissions, + fieldPermissions: tabPermissions, fields: tab.fields, i18n, modifiedOnly, @@ -324,6 +341,13 @@ const buildVersionField = ({ } } // At this point, we are dealing with a `row`, `collapsible`, etc else if ('fields' in field) { + let subfieldPermissions: typeof fieldPermissions = undefined + + if (typeof permissions === 'boolean') { + subfieldPermissions = permissions + } else if (permissions && typeof permissions === 'object') { + subfieldPermissions = permissions.fields + } if (field.type === 'array' && (valueTo || valueFrom)) { const maxLength = Math.max( Array.isArray(valueTo) ? valueTo.length : 0, @@ -339,7 +363,7 @@ const buildVersionField = ({ clientSchemaMap, customDiffComponents, entitySlug, - fieldPermissions, + fieldPermissions: subfieldPermissions, fields: field.fields, i18n, modifiedOnly, @@ -363,7 +387,7 @@ const buildVersionField = ({ clientSchemaMap, customDiffComponents, entitySlug, - fieldPermissions, + fieldPermissions: subfieldPermissions, fields: field.fields, i18n, modifiedOnly, @@ -421,11 +445,24 @@ const buildVersionField = ({ } } + let blockPermissions: typeof fieldPermissions = undefined + + if (permissions === true) { + blockPermissions = true + } else { + const permissionsBlockSpecific = permissions?.blocks?.[blockSlugToMatch] + if (permissionsBlockSpecific === true) { + blockPermissions = true + } else { + blockPermissions = permissionsBlockSpecific?.fields + } + } + baseVersionField.rows[i] = buildVersionFields({ clientSchemaMap, customDiffComponents, entitySlug, - fieldPermissions, + fieldPermissions: blockPermissions, fields, i18n, modifiedOnly, @@ -459,7 +496,7 @@ const buildVersionField = ({ */ diffMethod: 'diffWordsWithSpace', field: clientField, - fieldPermissions: subFieldPermissions, + fieldPermissions: typeof permissions === 'object' ? permissions.fields : permissions, parentIsLocalized, nestingLevel: nestingLevel ? nestingLevel : undefined, diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts index 5f64dc1c97..b638ee424b 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts @@ -18,12 +18,12 @@ export const generateLabelFromValue = ({ value: PopulatedRelationshipValue }): string => { let relatedDoc: TypeWithID + let relationTo: string = field.relationTo as string let valueToReturn: string = '' - const relationTo: string = 'relationTo' in value ? value.relationTo : (field.relationTo as string) - if (typeof value === 'object' && 'relationTo' in value) { relatedDoc = value.value + relationTo = value.relationTo } else { // Non-polymorphic relationship relatedDoc = value diff --git a/packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx b/packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx index b5b8d34d80..c40b7abed9 100644 --- a/packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx +++ b/packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx @@ -3,6 +3,7 @@ import { Drawer, LoadingOverlay, toast, + useDocumentInfo, useEditDepth, useModal, useServerFunctions, @@ -30,6 +31,7 @@ export const VersionDrawerContent: React.FC<{ globalSlug?: string }> = (props) => { const { collectionSlug, docID, drawerSlug, globalSlug } = props + const { isTrashed } = useDocumentInfo() const { closeModal } = useModal() const searchParams = useSearchParams() const prevSearchParams = useRef(searchParams) @@ -58,6 +60,7 @@ export const VersionDrawerContent: React.FC<{ segments: [ isGlobal ? 'globals' : 'collections', entitySlug, + ...(isTrashed ? ['trash'] : []), isGlobal ? undefined : String(docID), 'versions', ].filter(Boolean), @@ -84,7 +87,16 @@ export const VersionDrawerContent: React.FC<{ void fetchDocumentView() }, - [closeModal, collectionSlug, globalSlug, drawerSlug, renderDocument, searchParams, t], + [ + closeModal, + collectionSlug, + drawerSlug, + globalSlug, + isTrashed, + renderDocument, + searchParams, + t, + ], ) useEffect(() => { diff --git a/packages/next/src/views/Version/VersionPillLabel/VersionPillLabel.tsx b/packages/next/src/views/Version/VersionPillLabel/VersionPillLabel.tsx index 1ca29a7f43..45a5cde220 100644 --- a/packages/next/src/views/Version/VersionPillLabel/VersionPillLabel.tsx +++ b/packages/next/src/views/Version/VersionPillLabel/VersionPillLabel.tsx @@ -116,7 +116,7 @@ export const VersionPillLabel: React.FC<{ )} )} - {localeLabel && {localeLabel}} + {localeLabel && {localeLabel}}
) } diff --git a/packages/next/src/views/Version/index.tsx b/packages/next/src/views/Version/index.tsx index d623430432..189213d52c 100644 --- a/packages/next/src/views/Version/index.tsx +++ b/packages/next/src/views/Version/index.tsx @@ -411,6 +411,11 @@ export async function VersionView(props: DocumentViewServerProps) { }) } + const useAsTitleFieldName = collectionConfig?.admin?.useAsTitle || 'id' + const versionToUseAsTitle = + useAsTitleFieldName === 'id' + ? String(versionTo.parent) + : versionTo.version?.[useAsTitleFieldName] return ( ) } diff --git a/packages/next/src/views/Versions/buildColumns.tsx b/packages/next/src/views/Versions/buildColumns.tsx index aebbf7b6e8..fd1f4f6c81 100644 --- a/packages/next/src/views/Versions/buildColumns.tsx +++ b/packages/next/src/views/Versions/buildColumns.tsx @@ -23,6 +23,7 @@ export const buildVersionColumns = ({ docs, globalConfig, i18n: { t }, + isTrashed, latestDraftVersion, }: { collectionConfig?: SanitizedCollectionConfig @@ -35,6 +36,7 @@ export const buildVersionColumns = ({ docs: PaginatedDocs>['docs'] globalConfig?: SanitizedGlobalConfig i18n: I18n + isTrashed?: boolean latestDraftVersion?: { id: number | string updatedAt: string @@ -59,6 +61,7 @@ export const buildVersionColumns = ({ collectionSlug={collectionConfig?.slug} docID={docID} globalSlug={globalConfig?.slug} + isTrashed={isTrashed} key={i} rowData={{ id: doc.id, diff --git a/packages/next/src/views/Versions/cells/CreatedAt/index.tsx b/packages/next/src/views/Versions/cells/CreatedAt/index.tsx index c75233a1ab..15abcd988f 100644 --- a/packages/next/src/views/Versions/cells/CreatedAt/index.tsx +++ b/packages/next/src/views/Versions/cells/CreatedAt/index.tsx @@ -8,6 +8,7 @@ export type CreatedAtCellProps = { collectionSlug?: string docID?: number | string globalSlug?: string + isTrashed?: boolean rowData?: { id: number | string updatedAt: Date | number | string @@ -18,6 +19,7 @@ export const CreatedAtCell: React.FC = ({ collectionSlug, docID, globalSlug, + isTrashed, rowData: { id, updatedAt } = {}, }) => { const { @@ -29,12 +31,14 @@ export const CreatedAtCell: React.FC = ({ const { i18n } = useTranslation() + const trashedDocPrefix = isTrashed ? 'trash/' : '' + let to: string if (collectionSlug) { to = formatAdminURL({ adminRoute, - path: `/collections/${collectionSlug}/${docID}/versions/${id}`, + path: `/collections/${collectionSlug}/${trashedDocPrefix}${docID}/versions/${id}`, }) } diff --git a/packages/next/src/views/Versions/index.tsx b/packages/next/src/views/Versions/index.tsx index c4a564cf44..afbad6828c 100644 --- a/packages/next/src/views/Versions/index.tsx +++ b/packages/next/src/views/Versions/index.tsx @@ -27,6 +27,7 @@ export async function VersionsView(props: DocumentViewServerProps) { user, }, }, + routeSegments: segments, searchParams: { limit, page, sort }, versions: { disableGutter = false, useVersionDrawerCreatedAtCell = false } = {}, } = props @@ -36,6 +37,8 @@ export async function VersionsView(props: DocumentViewServerProps) { const collectionSlug = collectionConfig?.slug const globalSlug = globalConfig?.slug + const isTrashed = segments[2] === 'trash' + const { localization, routes: { api: apiRoute }, @@ -124,6 +127,7 @@ export async function VersionsView(props: DocumentViewServerProps) { docs: versionsData?.docs, globalConfig, i18n, + isTrashed, latestDraftVersion, }) @@ -140,6 +144,7 @@ export async function VersionsView(props: DocumentViewServerProps) { collectionSlug={collectionSlug} globalSlug={globalSlug} id={id} + isTrashed={isTrashed} pluralLabel={pluralLabel} useAsTitle={collectionConfig?.admin?.useAsTitle || globalSlug} view={i18n.t('version:versions')} @@ -148,10 +153,12 @@ export async function VersionsView(props: DocumentViewServerProps) { @@ -82,6 +83,7 @@ export type DefaultCellComponentProps< rowData: RowData }) => void rowData: RowData + viewType?: ViewTypes } export type DefaultServerCellComponentProps< diff --git a/packages/payload/src/admin/forms/Field.ts b/packages/payload/src/admin/forms/Field.ts index 0f6f69023c..cba2f61fda 100644 --- a/packages/payload/src/admin/forms/Field.ts +++ b/packages/payload/src/admin/forms/Field.ts @@ -68,6 +68,9 @@ export type FieldPaths = { path: string } +/** + * TODO: This should be renamed to `FieldComponentServerProps` or similar + */ export type ServerComponentProps = { clientField: ClientFieldWithOptionalType clientFieldSchemaMap: ClientFieldSchemaMap diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts index 374d28e068..42e5cfebc9 100644 --- a/packages/payload/src/admin/forms/Form.ts +++ b/packages/payload/src/admin/forms/Form.ts @@ -113,6 +113,7 @@ export type BuildFormStateArgs = { */ mockRSCs?: boolean operation?: 'create' | 'update' + readOnly?: boolean /* If true, will render field components within their state object */ diff --git a/packages/payload/src/admin/functions/index.ts b/packages/payload/src/admin/functions/index.ts index e3676a10a7..aaa1106fe5 100644 --- a/packages/payload/src/admin/functions/index.ts +++ b/packages/payload/src/admin/functions/index.ts @@ -1,7 +1,7 @@ import type { ImportMap } from '../../bin/generateImportMap/index.js' import type { SanitizedConfig } from '../../config/types.js' import type { PaginatedDocs } from '../../database/types.js' -import type { CollectionSlug, ColumnPreference } from '../../index.js' +import type { CollectionSlug, ColumnPreference, FolderSortKeys } from '../../index.js' import type { PayloadRequest, Sort, Where } from '../../types/index.js' import type { ColumnsFromURL } from '../../utilities/transformColumnPreferences.js' @@ -45,9 +45,15 @@ export type ListQuery = { * Use `transformColumnsToPreferences` and `transformColumnsToSearchParams` to convert it back and forth */ columns?: ColumnsFromURL - limit?: string - page?: string + /* + * A string representing the field to group by, e.g. `category` + * A leading hyphen represents descending order, e.g. `-category` + */ + groupBy?: string + limit?: number + page?: number preset?: number | string + queryByGroup?: Record /* When provided, is automatically injected into the `where` object */ @@ -59,6 +65,10 @@ export type ListQuery = { export type BuildTableStateArgs = { collectionSlug: string | string[] columns?: ColumnPreference[] + data?: PaginatedDocs + /** + * @deprecated Use `data` instead + */ docs?: PaginatedDocs['docs'] enableRowSelections?: boolean orderableFieldName: string @@ -78,10 +88,36 @@ export type BuildCollectionFolderViewResult = { } export type GetFolderResultsComponentAndDataArgs = { - activeCollectionSlugs: CollectionSlug[] + /** + * If true and no folderID is provided, only folders will be returned. + * If false, the results will include documents from the active collections. + */ browseByFolder: boolean + /** + * Used to filter document types to include in the results/display. + * + * i.e. ['folders', 'posts'] will only include folders and posts in the results. + * + * collectionsToQuery? + */ + collectionsToDisplay: CollectionSlug[] + /** + * Used to determine how the results should be displayed. + */ displayAs: 'grid' | 'list' + /** + * Used to filter folders by the collections they are assigned to. + * + * i.e. ['posts'] will only include folders that are assigned to the posts collections. + */ + folderAssignedCollections: CollectionSlug[] + /** + * The ID of the folder to filter results by. + */ folderID: number | string | undefined req: PayloadRequest - sort: string + /** + * The sort order for the results. + */ + sort: FolderSortKeys } diff --git a/packages/payload/src/admin/views/document.ts b/packages/payload/src/admin/views/document.ts index 12073d6d97..40e7eeb5d0 100644 --- a/packages/payload/src/admin/views/document.ts +++ b/packages/payload/src/admin/views/document.ts @@ -2,6 +2,7 @@ import type { SanitizedPermissions } from '../../auth/types.js' import type { SanitizedCollectionConfig } from '../../collections/config/types.js' import type { PayloadComponent, SanitizedConfig, ServerProps } from '../../config/types.js' import type { SanitizedGlobalConfig } from '../../globals/config/types.js' +import type { PayloadRequest } from '../../types/index.js' import type { Data, DocumentSlots, FormState } from '../types.js' import type { InitPageResult, ViewTypes } from './index.js' @@ -50,6 +51,7 @@ export type DocumentTabServerPropsOnly = { readonly collectionConfig?: SanitizedCollectionConfig readonly globalConfig?: SanitizedGlobalConfig readonly permissions: SanitizedPermissions + readonly req: PayloadRequest } & ServerProps export type DocumentTabClientProps = { @@ -60,9 +62,13 @@ export type DocumentTabServerProps = DocumentTabClientProps & DocumentTabServerP export type DocumentTabCondition = (args: { collectionConfig: SanitizedCollectionConfig + /** + * @deprecated: Use `req.payload.config` instead. This will be removed in v4. + */ config: SanitizedConfig globalConfig: SanitizedGlobalConfig permissions: SanitizedPermissions + req: PayloadRequest }) => boolean // Everything is optional because we merge in the defaults diff --git a/packages/payload/src/admin/views/folderList.ts b/packages/payload/src/admin/views/folderList.ts index b1074fe467..18b9aac736 100644 --- a/packages/payload/src/admin/views/folderList.ts +++ b/packages/payload/src/admin/views/folderList.ts @@ -30,6 +30,7 @@ export type FolderListViewClientProps = { disableBulkEdit?: boolean documents: FolderOrDocument[] enableRowSelections?: boolean + folderAssignedCollections?: SanitizedCollectionConfig['slug'][] folderFieldName: string folderID: null | number | string FolderResultsComponent: React.ReactNode diff --git a/packages/payload/src/admin/views/index.ts b/packages/payload/src/admin/views/index.ts index 42976336a5..2cf1bcade8 100644 --- a/packages/payload/src/admin/views/index.ts +++ b/packages/payload/src/admin/views/index.ts @@ -53,6 +53,7 @@ export type AdminViewServerPropsOnly = { readonly redirectAfterCreate?: boolean readonly redirectAfterDelete?: boolean readonly redirectAfterDuplicate?: boolean + readonly redirectAfterRestore?: boolean } & ServerProps export type AdminViewServerProps = AdminViewClientProps & AdminViewServerPropsOnly @@ -92,6 +93,7 @@ export type ViewTypes = | 'folders' | 'list' | 'reset' + | 'trash' | 'verify' | 'version' diff --git a/packages/payload/src/admin/views/list.ts b/packages/payload/src/admin/views/list.ts index 7097e0bd41..b02601abb9 100644 --- a/packages/payload/src/admin/views/list.ts +++ b/packages/payload/src/admin/views/list.ts @@ -8,7 +8,7 @@ import type { CollectionPreferences } from '../../preferences/types.js' import type { QueryPreset } from '../../query-presets/types.js' import type { ResolvedFilterOptions } from '../../types/index.js' import type { Column } from '../elements/Table.js' -import type { Data } from '../types.js' +import type { Data, ViewTypes } from '../types.js' export type ListViewSlots = { AfterList?: React.ReactNode @@ -17,7 +17,7 @@ export type ListViewSlots = { BeforeListTable?: React.ReactNode Description?: React.ReactNode listMenuItems?: React.ReactNode[] - Table: React.ReactNode + Table: React.ReactNode | React.ReactNode[] } /** @@ -45,6 +45,7 @@ export type ListViewClientProps = { disableQueryPresets?: boolean enableRowSelections?: boolean hasCreatePermission: boolean + hasDeletePermission?: boolean /** * @deprecated */ @@ -58,11 +59,13 @@ export type ListViewClientProps = { queryPresetPermissions?: SanitizedCollectionPermission renderedFilters?: Map resolvedFilterOptions?: Map + viewType: ViewTypes } & ListViewSlots export type ListViewSlotSharedClientProps = { collectionSlug: SanitizedCollectionConfig['slug'] hasCreatePermission: boolean + hasDeletePermission?: boolean newDocumentURL: string } diff --git a/packages/payload/src/auth/endpoints/me.ts b/packages/payload/src/auth/endpoints/me.ts index d224ea97a7..c022ba7b02 100644 --- a/packages/payload/src/auth/endpoints/me.ts +++ b/packages/payload/src/auth/endpoints/me.ts @@ -1,20 +1,50 @@ import { status as httpStatus } from 'http-status' import type { PayloadHandler } from '../../config/types.js' +import type { JoinParams } from '../../utilities/sanitizeJoinParams.js' import { getRequestCollection } from '../../utilities/getRequestEntity.js' import { headersWithCors } from '../../utilities/headersWithCors.js' +import { isNumber } from '../../utilities/isNumber.js' +import { sanitizeJoinParams } from '../../utilities/sanitizeJoinParams.js' +import { sanitizePopulateParam } from '../../utilities/sanitizePopulateParam.js' +import { sanitizeSelectParam } from '../../utilities/sanitizeSelectParam.js' import { extractJWT } from '../extractJWT.js' import { meOperation } from '../operations/me.js' export const meHandler: PayloadHandler = async (req) => { + const { searchParams } = req const collection = getRequestCollection(req) const currentToken = extractJWT(req) + const depthFromSearchParams = searchParams.get('depth') + const draftFromSearchParams = searchParams.get('depth') + + const { + depth: depthFromQuery, + draft: draftFromQuery, + joins, + populate, + select, + } = req.query as { + depth?: string + draft?: string + joins?: JoinParams + populate?: Record + select?: Record + } + + const depth = depthFromQuery || depthFromSearchParams + const draft = draftFromQuery || draftFromSearchParams const result = await meOperation({ collection, currentToken: currentToken!, + depth: isNumber(depth) ? Number(depth) : undefined, + draft: draft === 'true', + joins: sanitizeJoinParams(joins), + populate: sanitizePopulateParam(populate), req, + select: sanitizeSelectParam(select), }) if (collection.config.auth.removeTokenFromResponses) { diff --git a/packages/payload/src/auth/isUserLocked.ts b/packages/payload/src/auth/isUserLocked.ts index a1ea9c2bf7..8a1b4c881d 100644 --- a/packages/payload/src/auth/isUserLocked.ts +++ b/packages/payload/src/auth/isUserLocked.ts @@ -1,6 +1,6 @@ -export const isUserLocked = (date: number): boolean => { +export const isUserLocked = (date: Date): boolean => { if (!date) { return false } - return date > Date.now() + return date.getTime() > Date.now() } diff --git a/packages/payload/src/auth/operations/forgotPassword.ts b/packages/payload/src/auth/operations/forgotPassword.ts index 5a709b18c3..4c81c17770 100644 --- a/packages/payload/src/auth/operations/forgotPassword.ts +++ b/packages/payload/src/auth/operations/forgotPassword.ts @@ -12,6 +12,7 @@ import type { PayloadRequest, Where } from '../../types/index.js' import { buildAfterOperation } from '../../collections/operations/utils.js' import { APIError } from '../../errors/index.js' import { Forbidden } from '../../index.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { initTransaction } from '../../utilities/initTransaction.js' @@ -123,6 +124,13 @@ export const forgotPasswordOperation = async ( } } + // Exclude trashed users unless `trash: true` + whereConstraint = appendNonTrashedFilter({ + enableTrash: collectionConfig.trash, + trash: false, + where: whereConstraint, + }) + let user = await payload.db.findOne({ collection: collectionConfig.slug, req, diff --git a/packages/payload/src/auth/operations/init.ts b/packages/payload/src/auth/operations/init.ts index 296e0d4623..d97876479e 100644 --- a/packages/payload/src/auth/operations/init.ts +++ b/packages/payload/src/auth/operations/init.ts @@ -1,4 +1,6 @@ -import type { PayloadRequest } from '../../types/index.js' +import type { PayloadRequest, Where } from '../../types/index.js' + +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' export const initOperation = async (args: { collection: string @@ -6,9 +8,19 @@ export const initOperation = async (args: { }): Promise => { const { collection: slug, req } = args + const collectionConfig = req.payload.config.collections?.find((c) => c.slug === slug) + + // Exclude trashed documents unless `trash: true` + const where: Where = appendNonTrashedFilter({ + enableTrash: Boolean(collectionConfig?.trash), + trash: false, + where: {}, + }) + const doc = await req.payload.db.findOne({ collection: slug, req, + where, }) return !!doc diff --git a/packages/payload/src/auth/operations/local/login.ts b/packages/payload/src/auth/operations/local/login.ts index 2480084e4a..89f6ee572d 100644 --- a/packages/payload/src/auth/operations/local/login.ts +++ b/packages/payload/src/auth/operations/local/login.ts @@ -22,6 +22,7 @@ export type Options = { overrideAccess?: boolean req?: Partial showHiddenFields?: boolean + trash?: boolean } export async function loginLocal( diff --git a/packages/payload/src/auth/operations/login.ts b/packages/payload/src/auth/operations/login.ts index 1e2d35fba1..190f097558 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -1,5 +1,3 @@ -import { v4 as uuid } from 'uuid' - import type { AuthOperationsFromCollectionSlug, Collection, @@ -17,13 +15,14 @@ import { } from '../../errors/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { Forbidden } from '../../index.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { killTransaction } from '../../utilities/killTransaction.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' import { getFieldsToSign } from '../getFieldsToSign.js' import { getLoginOptions } from '../getLoginOptions.js' import { isUserLocked } from '../isUserLocked.js' import { jwtSign } from '../jwt.js' -import { removeExpiredSessions } from '../removeExpiredSessions.js' +import { addSessionToUser } from '../sessions.js' import { authenticateLocalStrategy } from '../strategies/local/authenticate.js' import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts.js' import { resetLoginAttempts } from '../strategies/local/resetLoginAttempts.js' @@ -49,6 +48,11 @@ type CheckLoginPermissionArgs = { user: any } +/** + * Throws an error if the user is locked or does not exist. + * This does not check the login attempts, only the lock status. Whoever increments login attempts + * is responsible for locking the user properly, not whoever checks the login permission. + */ export const checkLoginPermission = ({ loggingInWithUsername, req, @@ -58,7 +62,7 @@ export const checkLoginPermission = ({ throw new AuthenticationError(req.t, Boolean(loggingInWithUsername)) } - if (isUserLocked(new Date(user.lockUntil).getTime())) { + if (isUserLocked(new Date(user.lockUntil))) { throw new LockedAuth(req.t) } } @@ -198,11 +202,18 @@ export const loginOperation = async ( whereConstraint = usernameConstraint } - let user = await payload.db.findOne({ + // Exclude trashed users + whereConstraint = appendNonTrashedFilter({ + enableTrash: collectionConfig.trash, + trash: false, + where: whereConstraint, + }) + + let user = (await payload.db.findOne({ collection: collectionConfig.slug, req, where: whereConstraint, - }) + })) as TypedUser checkLoginPermission({ loggingInWithUsername: Boolean(canLoginWithUsername && sanitizedUsername), @@ -214,7 +225,6 @@ export const loginOperation = async ( user._strategy = 'local-jwt' const authResult = await authenticateLocalStrategy({ doc: user, password }) - user = sanitizeInternalFields(user) const maxLoginAttemptsEnabled = args.collection.config.auth.maxLoginAttempts > 0 @@ -223,9 +233,16 @@ export const loginOperation = async ( if (maxLoginAttemptsEnabled) { await incrementLoginAttempts({ collection: collectionConfig, - doc: user, payload: req.payload, req, + user, + }) + + // Re-check login permissions and max attempts after incrementing attempts, in case parallel updates occurred + checkLoginPermission({ + loggingInWithUsername: Boolean(canLoginWithUsername && sanitizedUsername), + req, + user, }) } @@ -236,37 +253,45 @@ export const loginOperation = async ( throw new UnverifiedEmail({ t: req.t }) } + /* + * Correct password accepted - re‑check that the account didn't + * get locked by parallel bad attempts in the meantime. + */ + if (maxLoginAttemptsEnabled) { + const { lockUntil, loginAttempts } = (await payload.db.findOne({ + collection: collectionConfig.slug, + req, + select: { + lockUntil: true, + loginAttempts: true, + }, + where: { id: { equals: user.id } }, + }))! + + user.lockUntil = lockUntil + user.loginAttempts = loginAttempts + + checkLoginPermission({ + req, + user, + }) + } + const fieldsToSignArgs: Parameters[0] = { collectionConfig, email: sanitizedEmail!, user, } - if (collectionConfig.auth.useSessions) { - // Add session to user - const newSessionID = uuid() - const now = new Date() - const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000 - const expiresAt = new Date(now.getTime() + tokenExpInMs) + const { sid } = await addSessionToUser({ + collectionConfig, + payload, + req, + user, + }) - const session = { id: newSessionID, createdAt: now, expiresAt } - - if (!user.sessions?.length) { - user.sessions = [session] - } else { - user.sessions = removeExpiredSessions(user.sessions) - user.sessions.push(session) - } - - await payload.db.updateOne({ - id: user.id, - collection: collectionConfig.slug, - data: user, - req, - returning: false, - }) - - fieldsToSignArgs.sid = newSessionID + if (sid) { + fieldsToSignArgs.sid = sid } const fieldsToSign = getFieldsToSign(fieldsToSignArgs) diff --git a/packages/payload/src/auth/operations/logout.ts b/packages/payload/src/auth/operations/logout.ts index 1977a7ffa4..fddde87acb 100644 --- a/packages/payload/src/auth/operations/logout.ts +++ b/packages/payload/src/auth/operations/logout.ts @@ -4,6 +4,7 @@ import type { Collection } from '../../collections/config/types.js' import type { PayloadRequest } from '../../types/index.js' import { APIError } from '../../errors/index.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' export type Arguments = { allSessions?: boolean @@ -39,17 +40,23 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise } if (collectionConfig.auth.disableLocalStrategy !== true && collectionConfig.auth.useSessions) { + const where = appendNonTrashedFilter({ + enableTrash: Boolean(collectionConfig.trash), + trash: false, + where: { + id: { + equals: user.id, + }, + }, + }) + const userWithSessions = await req.payload.db.findOne<{ id: number | string sessions: { id: string }[] }>({ collection: collectionConfig.slug, req, - where: { - id: { - equals: user.id, - }, - }, + where, }) if (!userWithSessions) { diff --git a/packages/payload/src/auth/operations/me.ts b/packages/payload/src/auth/operations/me.ts index a5977dd1f5..bc6f96468e 100644 --- a/packages/payload/src/auth/operations/me.ts +++ b/packages/payload/src/auth/operations/me.ts @@ -2,7 +2,7 @@ import { decodeJwt } from 'jose' import type { Collection } from '../../collections/config/types.js' import type { TypedUser } from '../../index.js' -import type { PayloadRequest } from '../../types/index.js' +import type { JoinQuery, PayloadRequest, PopulateType, SelectType } from '../../types/index.js' import type { ClientUser } from '../types.js' export type MeOperationResult = { @@ -22,11 +22,16 @@ export type MeOperationResult = { export type Arguments = { collection: Collection currentToken?: string + depth?: number + draft?: boolean + joins?: JoinQuery + populate?: PopulateType req: PayloadRequest + select?: SelectType } export const meOperation = async (args: Arguments): Promise => { - const { collection, currentToken, req } = args + const { collection, currentToken, depth, draft, joins, populate, req, select } = args let result: MeOperationResult = { user: null!, @@ -39,9 +44,13 @@ export const meOperation = async (args: Arguments): Promise = const user = (await req.payload.findByID({ id: req.user.id, collection: collection.config.slug, - depth: isGraphQL ? 0 : collection.config.auth.depth, + depth: isGraphQL ? 0 : (depth ?? collection.config.auth.depth), + draft, + joins, overrideAccess: false, + populate, req, + select, showHiddenFields: false, })) as TypedUser diff --git a/packages/payload/src/auth/operations/refresh.ts b/packages/payload/src/auth/operations/refresh.ts index 969fa57e15..037bcc4da5 100644 --- a/packages/payload/src/auth/operations/refresh.ts +++ b/packages/payload/src/auth/operations/refresh.ts @@ -1,5 +1,4 @@ import url from 'url' -import { v4 as uuid } from 'uuid' import type { Collection } from '../../collections/config/types.js' import type { Document, PayloadRequest } from '../../types/index.js' @@ -11,7 +10,7 @@ import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' import { getFieldsToSign } from '../getFieldsToSign.js' import { jwtSign } from '../jwt.js' -import { removeExpiredSessions } from '../removeExpiredSessions.js' +import { removeExpiredSessions } from '../sessions.js' export type Result = { exp: number @@ -74,11 +73,10 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise const parsedURL = url.parse(args.req.url!) const isGraphQL = parsedURL.pathname === config.routes.graphQL - const user = await args.req.payload.findByID({ - id: args.req.user.id, - collection: args.req.user.collection, - depth: isGraphQL ? 0 : args.collection.config.auth.depth, - req: args.req, + let user = await req.payload.db.findOne({ + collection: collectionConfig.slug, + req, + where: { id: { equals: args.req.user.id } }, }) const sid = args.req.user._sid @@ -88,7 +86,7 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise throw new Forbidden(args.req.t) } - const existingSession = user.sessions.find(({ id }) => id === sid) + const existingSession = user.sessions.find(({ id }: { id: number }) => id === sid) const now = new Date() const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000 @@ -106,6 +104,13 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise }) } + user = await req.payload.findByID({ + id: user.id, + collection: collectionConfig.slug, + depth: isGraphQL ? 0 : args.collection.config.auth.depth, + req: args.req, + }) + if (user) { user.collection = args.req.user.collection user._strategy = args.req.user._strategy diff --git a/packages/payload/src/auth/operations/registerFirstUser.ts b/packages/payload/src/auth/operations/registerFirstUser.ts index 2f05e17de9..18ea393f1a 100644 --- a/packages/payload/src/auth/operations/registerFirstUser.ts +++ b/packages/payload/src/auth/operations/registerFirstUser.ts @@ -8,6 +8,7 @@ import type { CollectionSlug } from '../../index.js' import type { PayloadRequest, SelectType } from '../../types/index.js' import { Forbidden } from '../../errors/index.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -57,9 +58,16 @@ export const registerFirstUserOperation = async ( req, }) + const where = appendNonTrashedFilter({ + enableTrash: Boolean(config.trash), + trash: false, + where: {}, // no initial filter; just exclude trashed docs + }) + const doc = await payload.db.findOne({ collection: config.slug, req, + where, }) if (doc) { diff --git a/packages/payload/src/auth/operations/resetPassword.ts b/packages/payload/src/auth/operations/resetPassword.ts index d48e77de15..4cff6599f3 100644 --- a/packages/payload/src/auth/operations/resetPassword.ts +++ b/packages/payload/src/auth/operations/resetPassword.ts @@ -6,6 +6,7 @@ import type { PayloadRequest } from '../../types/index.js' import { buildAfterOperation } from '../../collections/operations/utils.js' import { APIError, Forbidden } from '../../errors/index.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -76,15 +77,21 @@ export const resetPasswordOperation = async ( // Reset Password // ///////////////////////////////////// - const user = await payload.db.findOne({ - collection: collectionConfig.slug, - req, + const where = appendNonTrashedFilter({ + enableTrash: Boolean(collectionConfig.trash), + trash: false, where: { resetPasswordExpiration: { greater_than: new Date().toISOString() }, resetPasswordToken: { equals: data.token }, }, }) + const user = await payload.db.findOne({ + collection: collectionConfig.slug, + req, + where, + }) + if (!user) { throw new APIError('Token is either invalid or has expired.', httpStatus.FORBIDDEN) } @@ -151,6 +158,7 @@ export const resetPasswordOperation = async ( depth, overrideAccess, req, + trash: false, }) if (shouldCommit) { diff --git a/packages/payload/src/auth/operations/unlock.ts b/packages/payload/src/auth/operations/unlock.ts index 439b952702..3ac5af3206 100644 --- a/packages/payload/src/auth/operations/unlock.ts +++ b/packages/payload/src/auth/operations/unlock.ts @@ -9,6 +9,7 @@ import type { PayloadRequest, Where } from '../../types/index.js' import { APIError } from '../../errors/index.js' import { Forbidden } from '../../index.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -86,6 +87,13 @@ export const unlockOperation = async ( } } + // Exclude trashed users unless `trash: true` + whereConstraint = appendNonTrashedFilter({ + enableTrash: Boolean(collectionConfig.trash), + trash: false, + where: whereConstraint, + }) + const user = await req.payload.db.findOne({ collection: collectionConfig.slug, locale: locale!, diff --git a/packages/payload/src/auth/operations/verifyEmail.ts b/packages/payload/src/auth/operations/verifyEmail.ts index 9591402063..69dd606449 100644 --- a/packages/payload/src/auth/operations/verifyEmail.ts +++ b/packages/payload/src/auth/operations/verifyEmail.ts @@ -4,6 +4,7 @@ import type { Collection } from '../../collections/config/types.js' import type { PayloadRequest } from '../../types/index.js' import { APIError, Forbidden } from '../../errors/index.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -27,14 +28,20 @@ export const verifyEmailOperation = async (args: Args): Promise => { try { const shouldCommit = await initTransaction(req) - const user = await req.payload.db.findOne({ - collection: collection.config.slug, - req, + const where = appendNonTrashedFilter({ + enableTrash: Boolean(collection.config.trash), + trash: false, where: { _verificationToken: { equals: token }, }, }) + const user = await req.payload.db.findOne({ + collection: collection.config.slug, + req, + where, + }) + if (!user) { throw new APIError('Verification token is invalid.', httpStatus.FORBIDDEN) } diff --git a/packages/payload/src/auth/removeExpiredSessions.ts b/packages/payload/src/auth/removeExpiredSessions.ts deleted file mode 100644 index c0dd7476da..0000000000 --- a/packages/payload/src/auth/removeExpiredSessions.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { UserSession } from './types.js' - -export const removeExpiredSessions = (sessions: UserSession[]) => { - const now = new Date() - - return sessions.filter(({ expiresAt }) => { - const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt) - return expiry > now - }) -} diff --git a/packages/payload/src/auth/sessions.ts b/packages/payload/src/auth/sessions.ts new file mode 100644 index 0000000000..1073599e29 --- /dev/null +++ b/packages/payload/src/auth/sessions.ts @@ -0,0 +1,67 @@ +import { v4 as uuid } from 'uuid' + +import type { SanitizedCollectionConfig } from '../collections/config/types.js' +import type { TypedUser } from '../index.js' +import type { Payload, PayloadRequest } from '../types/index.js' +import type { UserSession } from './types.js' + +/** + * Removes expired sessions from an array of sessions + */ +export const removeExpiredSessions = (sessions: UserSession[]) => { + const now = new Date() + + return sessions.filter(({ expiresAt }) => { + const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt) + return expiry > now + }) +} + +/** + * Adds a session to the user and removes expired sessions + * @returns The session ID (sid) if sessions are used + */ +export const addSessionToUser = async ({ + collectionConfig, + payload, + req, + user, +}: { + collectionConfig: SanitizedCollectionConfig + payload: Payload + req: PayloadRequest + user: TypedUser +}): Promise<{ sid?: string }> => { + let sid: string | undefined + if (collectionConfig.auth.useSessions) { + // Add session to user + sid = uuid() + const now = new Date() + const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000 + const expiresAt = new Date(now.getTime() + tokenExpInMs) + + const session = { id: sid, createdAt: now, expiresAt } + + if (!user.sessions?.length) { + user.sessions = [session] + } else { + user.sessions = removeExpiredSessions(user.sessions) + user.sessions.push(session) + } + + await payload.db.updateOne({ + id: user.id, + collection: collectionConfig.slug, + data: user, + req, + returning: false, + }) + + user.collection = collectionConfig.slug + user._strategy = 'local-jwt' + } + + return { + sid, + } +} diff --git a/packages/payload/src/auth/strategies/local/incrementLoginAttempts.ts b/packages/payload/src/auth/strategies/local/incrementLoginAttempts.ts index 3a227f65f6..4fb86bb5e1 100644 --- a/packages/payload/src/auth/strategies/local/incrementLoginAttempts.ts +++ b/packages/payload/src/auth/strategies/local/incrementLoginAttempts.ts @@ -1,59 +1,154 @@ -import type { SanitizedCollectionConfig, TypeWithID } from '../../../collections/config/types.js' -import type { JsonObject, Payload } from '../../../index.js' +import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { PayloadRequest } from '../../../types/index.js' +import { type JsonObject, type Payload, type TypedUser } from '../../../index.js' +import { isUserLocked } from '../../isUserLocked.js' + type Args = { collection: SanitizedCollectionConfig - doc: Record & TypeWithID payload: Payload req: PayloadRequest + user: TypedUser } +// Note: this function does not use req in most updates, as we want those to be visible in parallel requests that are on a different +// transaction. At the same time, we want updates from parallel requests to be visible here. export const incrementLoginAttempts = async ({ collection, - doc, payload, req, + user, }: Args): Promise => { const { auth: { lockTime, maxLoginAttempts }, } = collection - if ('lockUntil' in doc && typeof doc.lockUntil === 'string') { - const lockUntil = new Date(doc.lockUntil).getTime() + const currentTime = Date.now() + let updatedLockUntil: null | string = null + let updatedLoginAttempts: null | number = null + + if (user.lockUntil && !isUserLocked(new Date(user.lockUntil))) { // Expired lock, restart count at 1 - if (lockUntil < Date.now()) { - await payload.update({ - id: doc.id, - collection: collection.slug, - data: { - lockUntil: null, - loginAttempts: 1, - }, - depth: 0, - req, - }) + const updatedUser = await payload.db.updateOne({ + id: user.id, + collection: collection.slug, + data: { + lockUntil: null, + loginAttempts: 1, + }, + req, + select: { + lockUntil: true, + loginAttempts: true, + }, + }) + updatedLockUntil = updatedUser.lockUntil + updatedLoginAttempts = updatedUser.loginAttempts + user.lockUntil = updatedLockUntil + } else { + const data: JsonObject = { + loginAttempts: { + $inc: 1, + }, } - return + const willReachMaxAttempts = + typeof user.loginAttempts === 'number' && user.loginAttempts + 1 >= maxLoginAttempts + // Lock the account if at max attempts and not already locked + if (willReachMaxAttempts) { + const lockUntil = new Date(currentTime + lockTime).toISOString() + data.lockUntil = lockUntil + } + + const updatedUser = await payload.db.updateOne({ + id: user.id, + collection: collection.slug, + data, + select: { + lockUntil: true, + loginAttempts: true, + }, + }) + + updatedLockUntil = updatedUser.lockUntil + updatedLoginAttempts = updatedUser.loginAttempts } - const data: JsonObject = { - loginAttempts: Number(doc.loginAttempts) + 1, + if (updatedLoginAttempts === null) { + throw new Error('Failed to update login attempts or lockUntil for user') } - // Lock the account if at max attempts and not already locked - if (typeof doc.loginAttempts === 'number' && doc.loginAttempts + 1 >= maxLoginAttempts) { - const lockUntil = new Date(Date.now() + lockTime).toISOString() - data.lockUntil = lockUntil - } + // Check updated latest lockUntil and loginAttempts in case there were parallel updates + const reachedMaxAttemptsForCurrentUser = + typeof updatedLoginAttempts === 'number' && updatedLoginAttempts - 1 >= maxLoginAttempts - await payload.update({ - id: doc.id, - collection: collection.slug, - data, - depth: 0, - req, - }) + const reachedMaxAttemptsForNextUser = + typeof updatedLoginAttempts === 'number' && updatedLoginAttempts >= maxLoginAttempts + + if (reachedMaxAttemptsForCurrentUser) { + user.lockUntil = updatedLockUntil + } + user.loginAttempts = updatedLoginAttempts - 1 // -1, as the updated increment is applied for the *next* login attempt, not the current one + + if ( + reachedMaxAttemptsForNextUser && + (!updatedLockUntil || !isUserLocked(new Date(updatedLockUntil))) + ) { + // If lockUntil reached max login attempts due to multiple parallel attempts but user was not locked yet, + const newLockUntil = new Date(currentTime + lockTime).toISOString() + + await payload.db.updateOne({ + id: user.id, + collection: collection.slug, + data: { + lockUntil: newLockUntil, + }, + returning: false, + }) + + if (reachedMaxAttemptsForCurrentUser) { + user.lockUntil = newLockUntil + } + + if (collection.auth.useSessions) { + // Remove all active sessions that have been created in a 20 second window. This protects + // against brute force attacks - example: 99 incorrect, 1 correct parallel login attempts. + // The correct login attempt will be finished first, as it's faster due to not having to perform + // an additional db update here. + // However, this request (the incorrect login attempt request) can kill the successful login attempt here. + + // Fetch user sessions separately (do not do this in the updateOne select in order to preserve the returning: true db call optimization) + const currentUser = await payload.db.findOne({ + collection: collection.slug, + select: { + sessions: true, + }, + where: { + id: { + equals: user.id, + }, + }, + }) + if (currentUser?.sessions?.length) { + // Does not hurt also removing expired sessions + currentUser.sessions = currentUser.sessions.filter((session) => { + const sessionCreatedAt = new Date(session.createdAt) + const twentySecondsAgo = new Date(currentTime - 20000) + + // Remove sessions created within the last 20 seconds + return sessionCreatedAt <= twentySecondsAgo + }) + + user.sessions = currentUser.sessions + + await payload.db.updateOne({ + id: user.id, + collection: collection.slug, + data: user, + returning: false, + }) + } + } + } } diff --git a/packages/payload/src/auth/strategies/local/resetLoginAttempts.ts b/packages/payload/src/auth/strategies/local/resetLoginAttempts.ts index b03e608f2f..8865612d0a 100644 --- a/packages/payload/src/auth/strategies/local/resetLoginAttempts.ts +++ b/packages/payload/src/auth/strategies/local/resetLoginAttempts.ts @@ -21,15 +21,14 @@ export const resetLoginAttempts = async ({ ) { return } - await payload.update({ + await payload.db.updateOne({ id: doc.id, collection: collection.slug, data: { lockUntil: null, loginAttempts: 0, }, - depth: 0, - overrideAccess: true, req, + returning: false, }) } diff --git a/packages/payload/src/bin/index.ts b/packages/payload/src/bin/index.ts index 81d06a78bd..6bd604a373 100755 --- a/packages/payload/src/bin/index.ts +++ b/packages/payload/src/bin/index.ts @@ -107,7 +107,7 @@ export const bin = async () => { } if (script === 'jobs:run') { - const payload = await getPayload({ config }) + const payload = await getPayload({ config }) // Do not setup crons here - this bin script can set up its own crons const limit = args.limit ? parseInt(args.limit, 10) : undefined const queue = args.queue ? args.queue : undefined const allQueues = !!args.allQueues @@ -133,7 +133,7 @@ export const bin = async () => { await payload.destroy() // close database connections after running jobs so process can exit cleanly - return + process.exit(0) } } diff --git a/packages/payload/src/collections/config/client.ts b/packages/payload/src/collections/config/client.ts index ffd29798a4..fb2e03fc5c 100644 --- a/packages/payload/src/collections/config/client.ts +++ b/packages/payload/src/collections/config/client.ts @@ -29,7 +29,7 @@ export type ServerOnlyCollectionProperties = keyof Pick< export type ServerOnlyCollectionAdminProperties = keyof Pick< SanitizedCollectionConfig['admin'], - 'baseListFilter' | 'components' | 'hidden' + 'baseFilter' | 'baseListFilter' | 'components' | 'hidden' > export type ServerOnlyUploadProperties = keyof Pick< @@ -94,6 +94,7 @@ const serverOnlyUploadProperties: Partial[] = [ const serverOnlyCollectionAdminProperties: Partial[] = [ 'hidden', + 'baseFilter', 'baseListFilter', 'components', // 'preview' is handled separately diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index b73e150cdb..617d8cdb0d 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -97,6 +97,7 @@ export const sanitizeCollection = async ( // add default timestamps fields only as needed let hasUpdatedAt: boolean | null = null let hasCreatedAt: boolean | null = null + let hasDeletedAt: boolean | null = null sanitized.fields.some((field) => { if (fieldAffectsData(field)) { @@ -107,9 +108,13 @@ export const sanitizeCollection = async ( if (field.name === 'createdAt') { hasCreatedAt = true } + + if (field.name === 'deletedAt') { + hasDeletedAt = true + } } - return hasCreatedAt && hasUpdatedAt + return hasCreatedAt && hasUpdatedAt && (!sanitized.trash || hasDeletedAt) }) if (!hasUpdatedAt) { @@ -138,6 +143,19 @@ export const sanitizeCollection = async ( label: ({ t }) => t('general:createdAt'), }) } + + if (sanitized.trash && !hasDeletedAt) { + sanitized.fields.push({ + name: 'deletedAt', + type: 'date', + admin: { + disableBulkEdit: true, + hidden: true, + }, + index: true, + label: ({ t }) => t('general:deletedAt'), + }) + } } sanitized.labels = sanitized.labels || formatLabels(sanitized.slug) diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 43f80ae91d..dc59bc9000 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -67,7 +67,7 @@ export type AuthOperationsFromCollectionSlug = export type RequiredDataFromCollection = MarkOptional< TData, - 'createdAt' | 'id' | 'sizes' | 'updatedAt' + 'createdAt' | 'deletedAt' | 'id' | 'sizes' | 'updatedAt' > export type RequiredDataFromCollectionSlug = @@ -82,8 +82,10 @@ export type HookOperationType = | 'forgotPassword' | 'login' | 'read' + | 'readDistinct' | 'refresh' | 'resetPassword' + | 'restoreVersion' | 'update' type CreateOrUpdateOperation = Extract @@ -268,7 +270,7 @@ export type EnableFoldersOptions = { debug?: boolean } -export type BaseListFilter = (args: { +export type BaseFilter = (args: { limit: number locale?: TypedLocale page: number @@ -276,7 +278,31 @@ export type BaseListFilter = (args: { sort: string }) => null | Promise | Where +/** + * @deprecated Use `BaseFilter` instead. + */ +export type BaseListFilter = BaseFilter + export type CollectionAdminOptions = { + /** + * Defines a default base filter which will be applied in the following parts of the admin panel: + * - List View + * - Relationship fields for internal links within the Lexical editor + * + * This is especially useful for plugins like multi-tenant. For example, + * a user may have access to multiple tenants, but should only see content + * related to the currently active or selected tenant in those places. + */ + baseFilter?: BaseFilter + /** + * @deprecated Use `baseFilter` instead. If both are defined, + * `baseFilter` will take precedence. This property remains only + * for backward compatibility and may be removed in a future version. + * + * Originally, `baseListFilter` was intended to filter only the List View + * in the admin panel. However, base filtering is often required in other areas + * such as internal link relationships in the Lexical editor. + */ baseListFilter?: BaseListFilter /** * Custom admin components @@ -366,6 +392,13 @@ export type CollectionAdminOptions = { * - Set to `false` to exclude the entity from the sidebar / dashboard without disabling its routes. */ group?: false | Record | string + /** + * @experimental This option is currently in beta and may change in future releases and/or contain bugs. + * Use at your own risk. + * @description Enable grouping by a field in the list view. + * Uses `payload.findDistinct` under the hood to populate the group-by options. + */ + groupBy?: boolean /** * Exclude the collection from the admin nav and routes */ @@ -552,11 +585,22 @@ export type CollectionConfig = { orderable?: boolean slug: string /** - * Add `createdAt` and `updatedAt` fields + * Add `createdAt`, `deletedAt` and `updatedAt` fields * * @default true */ timestamps?: boolean + /** + * Enables trash support for this collection. + * + * When enabled, documents will include a `deletedAt` timestamp field. + * This allows documents to be marked as deleted without being permanently removed. + * The `deletedAt` field will be set to the current date and time when a document is trashed. + * + * @experimental This is a beta feature and its behavior may be refined in future releases. + * @default false + */ + trash?: boolean /** * Options used in typescript generation */ @@ -668,6 +712,7 @@ export type AuthCollection = { } export type TypeWithID = { + deletedAt?: null | string docId?: any id: number | string } @@ -675,6 +720,7 @@ export type TypeWithID = { export type TypeWithTimestamps = { [key: string]: unknown createdAt: string + deletedAt?: null | string id: number | string updatedAt: string } diff --git a/packages/payload/src/collections/endpoints/count.ts b/packages/payload/src/collections/endpoints/count.ts index d0878b8ef3..5920e5f654 100644 --- a/packages/payload/src/collections/endpoints/count.ts +++ b/packages/payload/src/collections/endpoints/count.ts @@ -8,13 +8,15 @@ import { countOperation } from '../operations/count.js' export const countHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { where } = req.query as { + const { trash, where } = req.query as { + trash?: string where?: Where } const result = await countOperation({ collection, req, + trash: trash === 'true', where, }) diff --git a/packages/payload/src/collections/endpoints/create.ts b/packages/payload/src/collections/endpoints/create.ts index 9398eedd72..24641fab8b 100644 --- a/packages/payload/src/collections/endpoints/create.ts +++ b/packages/payload/src/collections/endpoints/create.ts @@ -16,6 +16,7 @@ export const createHandler: PayloadHandler = async (req) => { const autosave = searchParams.get('autosave') === 'true' const draft = searchParams.get('draft') === 'true' const depth = searchParams.get('depth') + const publishSpecificLocale = req.query.publishSpecificLocale as string | undefined const doc = await createOperation({ autosave, @@ -24,6 +25,7 @@ export const createHandler: PayloadHandler = async (req) => { depth: isNumber(depth) ? depth : undefined, draft, populate: sanitizePopulateParam(req.query.populate), + publishSpecificLocale, req, select: sanitizeSelectParam(req.query.select), }) diff --git a/packages/payload/src/collections/endpoints/delete.ts b/packages/payload/src/collections/endpoints/delete.ts index 4367420e32..be47ce0717 100644 --- a/packages/payload/src/collections/endpoints/delete.ts +++ b/packages/payload/src/collections/endpoints/delete.ts @@ -13,11 +13,12 @@ import { deleteOperation } from '../operations/delete.js' export const deleteHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { depth, overrideLock, populate, select, where } = req.query as { + const { depth, overrideLock, populate, select, trash, where } = req.query as { depth?: string overrideLock?: string populate?: Record select?: Record + trash?: string where?: Where } @@ -28,6 +29,7 @@ export const deleteHandler: PayloadHandler = async (req) => { populate: sanitizePopulateParam(populate), req, select: sanitizeSelectParam(select), + trash: trash === 'true', where: where!, }) diff --git a/packages/payload/src/collections/endpoints/deleteByID.ts b/packages/payload/src/collections/endpoints/deleteByID.ts index 1563ade65a..7faa13cd02 100644 --- a/packages/payload/src/collections/endpoints/deleteByID.ts +++ b/packages/payload/src/collections/endpoints/deleteByID.ts @@ -14,6 +14,7 @@ export const deleteByIDHandler: PayloadHandler = async (req) => { const { searchParams } = req const depth = searchParams.get('depth') const overrideLock = searchParams.get('overrideLock') + const trash = searchParams.get('trash') === 'true' const doc = await deleteByIDOperation({ id, @@ -23,6 +24,7 @@ export const deleteByIDHandler: PayloadHandler = async (req) => { populate: sanitizePopulateParam(req.query.populate), req, select: sanitizeSelectParam(req.query.select), + trash, }) const headers = headersWithCors({ diff --git a/packages/payload/src/collections/endpoints/find.ts b/packages/payload/src/collections/endpoints/find.ts index 30a3608919..0d532c4bd5 100644 --- a/packages/payload/src/collections/endpoints/find.ts +++ b/packages/payload/src/collections/endpoints/find.ts @@ -14,7 +14,7 @@ import { findOperation } from '../operations/find.js' export const findHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { depth, draft, joins, limit, page, pagination, populate, select, sort, where } = + const { depth, draft, joins, limit, page, pagination, populate, select, sort, trash, where } = req.query as { depth?: string draft?: string @@ -25,6 +25,7 @@ export const findHandler: PayloadHandler = async (req) => { populate?: Record select?: Record sort?: string + trash?: string where?: Where } @@ -40,6 +41,7 @@ export const findHandler: PayloadHandler = async (req) => { req, select: sanitizeSelectParam(select), sort: typeof sort === 'string' ? sort.split(',') : undefined, + trash: trash === 'true', where, }) diff --git a/packages/payload/src/collections/endpoints/findByID.ts b/packages/payload/src/collections/endpoints/findByID.ts index fbdb9b6f27..70413c770a 100644 --- a/packages/payload/src/collections/endpoints/findByID.ts +++ b/packages/payload/src/collections/endpoints/findByID.ts @@ -14,6 +14,7 @@ export const findByIDHandler: PayloadHandler = async (req) => { const { searchParams } = req const { id, collection } = getRequestCollectionWithID(req) const depth = searchParams.get('depth') + const trash = searchParams.get('trash') === 'true' const result = await findByIDOperation({ id, @@ -24,6 +25,7 @@ export const findByIDHandler: PayloadHandler = async (req) => { populate: sanitizePopulateParam(req.query.populate), req, select: sanitizeSelectParam(req.query.select), + trash, }) return Response.json(result, { diff --git a/packages/payload/src/collections/endpoints/findDistinct.ts b/packages/payload/src/collections/endpoints/findDistinct.ts new file mode 100644 index 0000000000..3e5a3d1606 --- /dev/null +++ b/packages/payload/src/collections/endpoints/findDistinct.ts @@ -0,0 +1,48 @@ +import { status as httpStatus } from 'http-status' + +import type { PayloadHandler } from '../../config/types.js' +import type { Where } from '../../types/index.js' + +import { APIError } from '../../errors/APIError.js' +import { getRequestCollection } from '../../utilities/getRequestEntity.js' +import { headersWithCors } from '../../utilities/headersWithCors.js' +import { isNumber } from '../../utilities/isNumber.js' +import { findDistinctOperation } from '../operations/findDistinct.js' + +export const findDistinctHandler: PayloadHandler = async (req) => { + const collection = getRequestCollection(req) + const { depth, field, limit, page, sort, trash, where } = req.query as { + depth?: string + field?: string + limit?: string + page?: string + sort?: string + sortOrder?: string + trash?: string + where?: Where + } + + if (!field) { + throw new APIError('field must be specified', httpStatus.BAD_REQUEST) + } + + const result = await findDistinctOperation({ + collection, + depth: isNumber(depth) ? Number(depth) : undefined, + field, + limit: isNumber(limit) ? Number(limit) : undefined, + page: isNumber(page) ? Number(page) : undefined, + req, + sort: typeof sort === 'string' ? sort.split(',') : undefined, + trash: trash === 'true', + where, + }) + + return Response.json(result, { + headers: headersWithCors({ + headers: new Headers(), + req, + }), + status: httpStatus.OK, + }) +} diff --git a/packages/payload/src/collections/endpoints/findVersionByID.ts b/packages/payload/src/collections/endpoints/findVersionByID.ts index 737705d939..701c855f85 100644 --- a/packages/payload/src/collections/endpoints/findVersionByID.ts +++ b/packages/payload/src/collections/endpoints/findVersionByID.ts @@ -12,6 +12,7 @@ import { findVersionByIDOperation } from '../operations/findVersionByID.js' export const findVersionByIDHandler: PayloadHandler = async (req) => { const { searchParams } = req const depth = searchParams.get('depth') + const trash = searchParams.get('trash') === 'true' const { id, collection } = getRequestCollectionWithID(req) @@ -22,6 +23,7 @@ export const findVersionByIDHandler: PayloadHandler = async (req) => { populate: sanitizePopulateParam(req.query.populate), req, select: sanitizeSelectParam(req.query.select), + trash, }) return Response.json(result, { diff --git a/packages/payload/src/collections/endpoints/findVersions.ts b/packages/payload/src/collections/endpoints/findVersions.ts index 57507c27bf..b4c2d896b7 100644 --- a/packages/payload/src/collections/endpoints/findVersions.ts +++ b/packages/payload/src/collections/endpoints/findVersions.ts @@ -12,7 +12,7 @@ import { findVersionsOperation } from '../operations/findVersions.js' export const findVersionsHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { depth, limit, page, pagination, populate, select, sort, where } = req.query as { + const { depth, limit, page, pagination, populate, select, sort, trash, where } = req.query as { depth?: string limit?: string page?: string @@ -20,6 +20,7 @@ export const findVersionsHandler: PayloadHandler = async (req) => { populate?: Record select?: Record sort?: string + trash?: string where?: Where } @@ -33,6 +34,7 @@ export const findVersionsHandler: PayloadHandler = async (req) => { req, select: sanitizeSelectParam(select), sort: typeof sort === 'string' ? sort.split(',') : undefined, + trash: trash === 'true', where, }) diff --git a/packages/payload/src/collections/endpoints/index.ts b/packages/payload/src/collections/endpoints/index.ts index bab76e2db5..b522170b6a 100644 --- a/packages/payload/src/collections/endpoints/index.ts +++ b/packages/payload/src/collections/endpoints/index.ts @@ -9,6 +9,7 @@ import { docAccessHandler } from './docAccess.js' import { duplicateHandler } from './duplicate.js' import { findHandler } from './find.js' import { findByIDHandler } from './findByID.js' +// import { findDistinctHandler } from './findDistinct.js' import { findVersionByIDHandler } from './findVersionByID.js' import { findVersionsHandler } from './findVersions.js' import { previewHandler } from './preview.js' @@ -48,6 +49,12 @@ export const defaultCollectionEndpoints: Endpoint[] = [ method: 'get', path: '/versions', }, + // Might be uncommented in the future + // { + // handler: findDistinctHandler, + // method: 'get', + // path: '/distinct', + // }, { handler: duplicateHandler, method: 'post', diff --git a/packages/payload/src/collections/endpoints/preview.ts b/packages/payload/src/collections/endpoints/preview.ts index 20789ed93d..758c34b03f 100644 --- a/packages/payload/src/collections/endpoints/preview.ts +++ b/packages/payload/src/collections/endpoints/preview.ts @@ -19,6 +19,7 @@ export const previewHandler: PayloadHandler = async (req) => { depth: isNumber(depth) ? Number(depth) : undefined, draft: searchParams.get('draft') === 'true', req, + trash: true, }) let previewURL!: string diff --git a/packages/payload/src/collections/endpoints/update.ts b/packages/payload/src/collections/endpoints/update.ts index 543c383654..9db2d54883 100644 --- a/packages/payload/src/collections/endpoints/update.ts +++ b/packages/payload/src/collections/endpoints/update.ts @@ -13,7 +13,7 @@ import { updateOperation } from '../operations/update.js' export const updateHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { depth, draft, limit, overrideLock, populate, select, sort, where } = req.query as { + const { depth, draft, limit, overrideLock, populate, select, sort, trash, where } = req.query as { depth?: string draft?: string limit?: string @@ -21,6 +21,7 @@ export const updateHandler: PayloadHandler = async (req) => { populate?: Record select?: Record sort?: string + trash?: string where?: Where } @@ -35,6 +36,7 @@ export const updateHandler: PayloadHandler = async (req) => { req, select: sanitizeSelectParam(select), sort: typeof sort === 'string' ? sort.split(',') : undefined, + trash: trash === 'true', where: where!, }) diff --git a/packages/payload/src/collections/endpoints/updateByID.ts b/packages/payload/src/collections/endpoints/updateByID.ts index df2f7a4f6e..78d2f74ada 100644 --- a/packages/payload/src/collections/endpoints/updateByID.ts +++ b/packages/payload/src/collections/endpoints/updateByID.ts @@ -16,6 +16,7 @@ export const updateByIDHandler: PayloadHandler = async (req) => { const autosave = searchParams.get('autosave') === 'true' const draft = searchParams.get('draft') === 'true' const overrideLock = searchParams.get('overrideLock') + const trash = searchParams.get('trash') === 'true' const publishSpecificLocale = req.query.publishSpecificLocale as string | undefined const doc = await updateByIDOperation({ @@ -30,6 +31,7 @@ export const updateByIDHandler: PayloadHandler = async (req) => { publishSpecificLocale, req, select: sanitizeSelectParam(req.query.select), + trash, }) let message = req.t('general:updatedSuccessfully') diff --git a/packages/payload/src/collections/operations/count.ts b/packages/payload/src/collections/operations/count.ts index f7d8661798..57bcf29dd5 100644 --- a/packages/payload/src/collections/operations/count.ts +++ b/packages/payload/src/collections/operations/count.ts @@ -7,6 +7,7 @@ import { executeAccess } from '../../auth/executeAccess.js' import { combineQueries } from '../../database/combineQueries.js' import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js' import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { killTransaction } from '../../utilities/killTransaction.js' import { buildAfterOperation } from './utils.js' @@ -15,6 +16,7 @@ export type Arguments = { disableErrors?: boolean overrideAccess?: boolean req?: PayloadRequest + trash?: boolean where?: Where } @@ -47,6 +49,7 @@ export const countOperation = async ( disableErrors, overrideAccess, req, + trash = false, where, } = args @@ -71,9 +74,16 @@ export const countOperation = async ( let result: { totalDocs: number } - const fullWhere = combineQueries(where!, accessResult!) + let fullWhere = combineQueries(where!, accessResult!) sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere }) + // Exclude trashed documents when trash: false + fullWhere = appendNonTrashedFilter({ + enableTrash: collectionConfig.trash, + trash, + where: fullWhere, + }) + await validateQueryPaths({ collectionConfig, overrideAccess: overrideAccess!, diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index cc09c1ad6a..b360f4eab2 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -47,6 +47,7 @@ export type Arguments = { overrideAccess?: boolean overwriteExistingFiles?: boolean populate?: PopulateType + publishSpecificLocale?: string req: PayloadRequest select?: SelectType showHiddenFields?: boolean @@ -88,6 +89,10 @@ export const createOperation = async < } } + if (args.publishSpecificLocale) { + args.req.locale = args.publishSpecificLocale + } + const { autosave = false, collection: { config: collectionConfig }, @@ -99,6 +104,7 @@ export const createOperation = async < overrideAccess, overwriteExistingFiles = false, populate, + publishSpecificLocale, req: { fallbackLocale, locale, @@ -285,7 +291,9 @@ export const createOperation = async < autosave, collection: collectionConfig, docWithLocales: result, + operation: 'create', payload, + publishSpecificLocale, req, }) } diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index 660a70d3c0..3ad006b19c 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -18,6 +18,7 @@ import { APIError } from '../../errors/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { deleteUserPreferences } from '../../preferences/deleteUserPreferences.js' import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { checkDocumentLockStatus } from '../../utilities/checkDocumentLockStatus.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' @@ -37,6 +38,7 @@ export type Arguments = { req: PayloadRequest select?: SelectType showHiddenFields?: boolean + trash?: boolean where: Where } @@ -82,6 +84,7 @@ export const deleteOperation = async < req, select: incomingSelect, showHiddenFields, + trash = false, where, } = args @@ -106,7 +109,14 @@ export const deleteOperation = async < where, }) - const fullWhere = combineQueries(where, accessResult!) + let fullWhere = combineQueries(where, accessResult!) + + // Exclude trashed documents when trash: false + fullWhere = appendNonTrashedFilter({ + enableTrash: collectionConfig.trash, + trash, + where: fullWhere, + }) sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere }) diff --git a/packages/payload/src/collections/operations/deleteByID.ts b/packages/payload/src/collections/operations/deleteByID.ts index 5ba5470243..11210c1829 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -14,6 +14,7 @@ import { Forbidden, NotFound } from '../../errors/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { deleteUserPreferences } from '../../preferences/deleteUserPreferences.js' import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { checkDocumentLockStatus } from '../../utilities/checkDocumentLockStatus.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' @@ -34,6 +35,7 @@ export type Arguments = { req: PayloadRequest select?: SelectType showHiddenFields?: boolean + trash?: boolean } export const deleteByIDOperation = async ( @@ -77,6 +79,7 @@ export const deleteByIDOperation = async >> => { + let args = incomingArgs + + try { + // ///////////////////////////////////// + // beforeOperation - Collection + // ///////////////////////////////////// + + if (args.collection.config.hooks?.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { + args = + (await hook({ + args, + collection: args.collection.config, + context: args.req!.context, + operation: 'readDistinct', + req: args.req!, + })) || args + } + } + + const { + collection: { config: collectionConfig }, + disableErrors, + overrideAccess, + populate, + showHiddenFields = false, + trash = false, + where, + } = args + + const req = args.req! + const { locale, payload } = req + + // ///////////////////////////////////// + // Access + // ///////////////////////////////////// + + let accessResult: AccessResult + + if (!overrideAccess) { + accessResult = await executeAccess({ disableErrors, req }, collectionConfig.access.read) + + // If errors are disabled, and access returns false, return empty results + if (accessResult === false) { + return { + hasNextPage: false, + hasPrevPage: false, + limit: args.limit || 0, + nextPage: null, + page: 1, + pagingCounter: 1, + prevPage: null, + totalDocs: 0, + totalPages: 0, + values: [], + } + } + } + + // ///////////////////////////////////// + // Find Distinct + // ///////////////////////////////////// + + let fullWhere = combineQueries(where!, accessResult!) + sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere }) + + // Exclude trashed documents when trash: false + fullWhere = appendNonTrashedFilter({ + enableTrash: collectionConfig.trash, + trash, + where: fullWhere, + }) + + await validateQueryPaths({ + collectionConfig, + overrideAccess: overrideAccess!, + req, + where: where ?? {}, + }) + + const fieldResult = getFieldByPath({ + fields: collectionConfig.flattenedFields, + path: args.field, + }) + + if (!fieldResult) { + throw new APIError( + `Field ${args.field} was not found in the collection ${collectionConfig.slug}`, + httpStatus.BAD_REQUEST, + ) + } + + if (fieldResult.field.hidden && !showHiddenFields) { + throw new Forbidden(req.t) + } + + if (fieldResult.field.access?.read) { + const hasAccess = await fieldResult.field.access.read({ req }) + if (!hasAccess) { + throw new Forbidden(req.t) + } + } + + let result = await payload.db.findDistinct({ + collection: collectionConfig.slug, + field: args.field, + limit: args.limit, + locale: locale!, + page: args.page, + req, + sort: args.sort, + where: fullWhere, + }) + + if ( + (fieldResult.field.type === 'relationship' || fieldResult.field.type === 'upload') && + args.depth + ) { + const populationPromises: Promise[] = [] + for (const doc of result.values) { + populationPromises.push( + relationshipPopulationPromise({ + currentDepth: 0, + depth: args.depth, + draft: false, + fallbackLocale: req.fallbackLocale || null, + field: fieldResult.field, + locale: req.locale || null, + overrideAccess: args.overrideAccess ?? true, + parentIsLocalized: false, + populate, + req, + showHiddenFields: false, + siblingDoc: doc, + }), + ) + } + await Promise.all(populationPromises) + } + + // ///////////////////////////////////// + // afterOperation - Collection + // ///////////////////////////////////// + + result = await buildAfterOperation({ + args, + collection: collectionConfig, + operation: 'findDistinct', + result, + }) + + // ///////////////////////////////////// + // Return results + // ///////////////////////////////////// + + return result + } catch (error: unknown) { + await killTransaction(args.req!) + throw error + } +} diff --git a/packages/payload/src/collections/operations/findVersionByID.ts b/packages/payload/src/collections/operations/findVersionByID.ts index eb7e316e13..1a358b3aee 100644 --- a/packages/payload/src/collections/operations/findVersionByID.ts +++ b/packages/payload/src/collections/operations/findVersionByID.ts @@ -8,6 +8,7 @@ import { executeAccess } from '../../auth/executeAccess.js' import { combineQueries } from '../../database/combineQueries.js' import { APIError, Forbidden, NotFound } from '../../errors/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { killTransaction } from '../../utilities/killTransaction.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js' @@ -24,6 +25,7 @@ export type Arguments = { req: PayloadRequest select?: SelectType showHiddenFields?: boolean + trash?: boolean } export const findVersionByIDOperation = async ( @@ -41,6 +43,7 @@ export const findVersionByIDOperation = async ( req, select: incomingSelect, showHiddenFields, + trash = false, } = args if (!id) { @@ -63,7 +66,16 @@ export const findVersionByIDOperation = async ( const hasWhereAccess = typeof accessResults === 'object' - const fullWhere = combineQueries({ id: { equals: id } }, accessResults) + const where = { id: { equals: id } } + + let fullWhere = combineQueries(where, accessResults) + + fullWhere = appendNonTrashedFilter({ + deletedAtPath: 'version.deletedAt', + enableTrash: collectionConfig.trash, + trash, + where: fullWhere, + }) // ///////////////////////////////////// // Find by ID diff --git a/packages/payload/src/collections/operations/findVersions.ts b/packages/payload/src/collections/operations/findVersions.ts index 7bd20a285a..b44960509e 100644 --- a/packages/payload/src/collections/operations/findVersions.ts +++ b/packages/payload/src/collections/operations/findVersions.ts @@ -9,6 +9,7 @@ import { combineQueries } from '../../database/combineQueries.js' import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js' import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { killTransaction } from '../../utilities/killTransaction.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' @@ -27,6 +28,7 @@ export type Arguments = { select?: SelectType showHiddenFields?: boolean sort?: Sort + trash?: boolean where?: Where } @@ -44,6 +46,7 @@ export const findVersionsOperation = async select: incomingSelect, showHiddenFields, sort, + trash = false, where, } = args @@ -71,7 +74,16 @@ export const findVersionsOperation = async where: where!, }) - const fullWhere = combineQueries(where!, accessResults) + let fullWhere = combineQueries(where!, accessResults) + + // Exclude trashed documents when trash: false + fullWhere = appendNonTrashedFilter({ + deletedAtPath: 'version.deletedAt', + enableTrash: collectionConfig.trash, + trash, + where: fullWhere, + }) + sanitizeWhereQuery({ fields: versionFields, payload, where: fullWhere }) const select = sanitizeSelect({ diff --git a/packages/payload/src/collections/operations/local/count.ts b/packages/payload/src/collections/operations/local/count.ts index 7bf767aea1..f5b5fd41a8 100644 --- a/packages/payload/src/collections/operations/local/count.ts +++ b/packages/payload/src/collections/operations/local/count.ts @@ -41,6 +41,15 @@ export type Options = { * Recommended to pass when using the Local API from hooks, as usually you want to execute the operation within the current transaction. */ req?: Partial + /** + * When set to `true`, the query will include both normal and trashed documents. + * To query only trashed documents, pass `trash: true` and combine with a `where` clause filtering by `deletedAt`. + * By default (`false`), the query will only include normal documents and exclude those with a `deletedAt` field. + * + * This argument has no effect unless `trash` is enabled on the collection. + * @default false + */ + trash?: boolean /** * If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. */ @@ -55,7 +64,13 @@ export async function countLocal( payload: Payload, options: Options, ): Promise<{ totalDocs: number }> { - const { collection: collectionSlug, disableErrors, overrideAccess = true, where } = options + const { + collection: collectionSlug, + disableErrors, + overrideAccess = true, + trash = false, + where, + } = options const collection = payload.collections[collectionSlug] @@ -70,6 +85,7 @@ export async function countLocal( disableErrors, overrideAccess, req: await createLocalReq(options as CreateLocalReqOptions, payload), + trash, where, }) } diff --git a/packages/payload/src/collections/operations/local/delete.ts b/packages/payload/src/collections/operations/local/delete.ts index abcb1c4e74..8731377e98 100644 --- a/packages/payload/src/collections/operations/local/delete.ts +++ b/packages/payload/src/collections/operations/local/delete.ts @@ -73,6 +73,14 @@ export type BaseOptions = * @example ['group', '-createdAt'] // sort by 2 fields, ASC group and DESC createdAt */ sort?: Sort + /** + * When set to `true`, the query will include both normal and trashed documents. + * To query only trashed documents, pass `trash: true` and combine with a `where` clause filtering by `deletedAt`. + * By default (`false`), the query will only include normal documents and exclude those with a `deletedAt` field. + * + * This argument has no effect unless `trash` is enabled on the collection. + * @default false + */ + trash?: boolean /** * If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. */ @@ -147,6 +156,7 @@ export async function findLocal< select, showHiddenFields, sort, + trash = false, where, } = options @@ -175,6 +185,7 @@ export async function findLocal< select, showHiddenFields, sort, + trash, where, }) } diff --git a/packages/payload/src/collections/operations/local/findByID.ts b/packages/payload/src/collections/operations/local/findByID.ts index 11bc5ba8c6..879243371a 100644 --- a/packages/payload/src/collections/operations/local/findByID.ts +++ b/packages/payload/src/collections/operations/local/findByID.ts @@ -99,6 +99,15 @@ export type Options< * @default false */ showHiddenFields?: boolean + /** + * When set to `true`, the operation will return a document by ID, even if it is trashed (soft-deleted). + * By default (`false`), the operation will exclude trashed documents. + * To fetch a trashed document, set `trash: true`. + * + * This argument has no effect unless `trash` is enabled on the collection. + * @default false + */ + trash?: boolean /** * If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. */ @@ -126,6 +135,7 @@ export async function findByIDLocal< populate, select, showHiddenFields, + trash = false, } = options const collection = payload.collections[collectionSlug] @@ -150,5 +160,6 @@ export async function findByIDLocal< req: await createLocalReq(options as CreateLocalReqOptions, payload), select, showHiddenFields, + trash, }) } diff --git a/packages/payload/src/collections/operations/local/findDistinct.ts b/packages/payload/src/collections/operations/local/findDistinct.ts new file mode 100644 index 0000000000..d0443fb437 --- /dev/null +++ b/packages/payload/src/collections/operations/local/findDistinct.ts @@ -0,0 +1,149 @@ +import type { + CollectionSlug, + DataFromCollectionSlug, + Document, + PaginatedDistinctDocs, + Payload, + PayloadRequest, + PopulateType, + RequestContext, + Sort, + TypedLocale, + Where, +} from '../../../index.js' +import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' + +import { APIError, createLocalReq } from '../../../index.js' +import { findDistinctOperation } from '../findDistinct.js' + +export type Options< + TSlug extends CollectionSlug, + TField extends keyof DataFromCollectionSlug, +> = { + /** + * the Collection slug to operate against. + */ + collection: TSlug + /** + * [Context](https://payloadcms.com/docs/hooks/context), which will then be passed to `context` and `req.context`, + * which can be read by hooks. Useful if you want to pass additional information to the hooks which + * shouldn't be necessarily part of the document, for example a `triggerBeforeChange` option which can be read by the BeforeChange hook + * to determine if it should run or not. + */ + context?: RequestContext + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + /** + * When set to `true`, errors will not be thrown. + */ + disableErrors?: boolean + /** + * The field to get distinct values for + */ + field: TField + /** + * The maximum distinct field values to be returned. + * By default the operation returns all the values. + */ + limit?: number + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * Skip access control. + * Set to `false` if you want to respect Access Control for the operation, for example when fetching data for the fron-end. + * @default true + */ + overrideAccess?: boolean + /** + * Get a specific page number (if limit is specified) + * @default 1 + */ + page?: number + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * The `PayloadRequest` object. You can pass it to thread the current [transaction](https://payloadcms.com/docs/database/transactions), user and locale to the operation. + * Recommended to pass when using the Local API from hooks, as usually you want to execute the operation within the current transaction. + */ + req?: Partial + /** + * Opt-in to receiving hidden fields. By default, they are hidden from returned documents in accordance to your config. + * @default false + */ + showHiddenFields?: boolean + /** + * Sort the documents, can be a string or an array of strings + * @example '-createdAt' // Sort DESC by createdAt + * @example ['group', '-createdAt'] // sort by 2 fields, ASC group and DESC createdAt + */ + sort?: Sort + /** + * When set to `true`, the query will include both normal and trashed documents. + * To query only trashed documents, pass `trash: true` and combine with a `where` clause filtering by `deletedAt`. + * By default (`false`), the query will only include normal documents and exclude those with a `deletedAt` field. + * + * This argument has no effect unless `trash` is enabled on the collection. + * @default false + */ + trash?: boolean + /** + * If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. + */ + user?: Document + /** + * A filter [query](https://payloadcms.com/docs/queries/overview) + */ + where?: Where +} + +export async function findDistinct< + TSlug extends CollectionSlug, + TField extends keyof DataFromCollectionSlug & string, +>( + payload: Payload, + options: Options, +): Promise[TField]>>> { + const { + collection: collectionSlug, + depth = 0, + disableErrors, + field, + limit, + overrideAccess = true, + page, + populate, + showHiddenFields, + sort, + trash = false, + where, + } = options + const collection = payload.collections[collectionSlug] + + if (!collection) { + throw new APIError( + `The collection with slug ${String(collectionSlug)} can't be found. Find Operation.`, + ) + } + + return findDistinctOperation({ + collection, + depth, + disableErrors, + field, + limit, + overrideAccess, + page, + populate, + req: await createLocalReq(options as CreateLocalReqOptions, payload), + showHiddenFields, + sort, + trash, + where, + }) as Promise[TField]>>> +} diff --git a/packages/payload/src/collections/operations/local/findVersionByID.ts b/packages/payload/src/collections/operations/local/findVersionByID.ts index 11a10e335f..358cc8db53 100644 --- a/packages/payload/src/collections/operations/local/findVersionByID.ts +++ b/packages/payload/src/collections/operations/local/findVersionByID.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-exports */ import type { CollectionSlug, Payload, RequestContext, TypedLocale } from '../../../index.js' import type { Document, PayloadRequest, PopulateType, SelectType } from '../../../types/index.js' import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' @@ -69,6 +70,15 @@ export type Options = { * @default false */ showHiddenFields?: boolean + /** + * When set to `true`, the operation will return a document by ID, even if it is trashed (soft-deleted). + * By default (`false`), the operation will exclude trashed documents. + * To fetch a trashed document, set `trash: true`. + * + * This argument has no effect unless `trash` is enabled on the collection. + * @default false + */ + trash?: boolean /** * If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. */ @@ -88,6 +98,7 @@ export async function findVersionByIDLocal( populate, select, showHiddenFields, + trash = false, } = options const collection = payload.collections[collectionSlug] @@ -110,5 +121,6 @@ export async function findVersionByIDLocal( req: await createLocalReq(options as CreateLocalReqOptions, payload), select, showHiddenFields, + trash, }) } diff --git a/packages/payload/src/collections/operations/local/findVersions.ts b/packages/payload/src/collections/operations/local/findVersions.ts index fb0fbd51e6..beea8c025e 100644 --- a/packages/payload/src/collections/operations/local/findVersions.ts +++ b/packages/payload/src/collections/operations/local/findVersions.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-exports */ import type { PaginatedDocs } from '../../../database/types.js' import type { CollectionSlug, Payload, RequestContext, TypedLocale } from '../../../index.js' import type { @@ -85,6 +86,15 @@ export type Options = { * @example ['version.group', '-version.createdAt'] // sort by 2 fields, ASC group and DESC createdAt */ sort?: Sort + /** + * When set to `true`, the query will include both normal and trashed (soft-deleted) documents. + * To query only trashed documents, pass `trash: true` and combine with a `where` clause filtering by `deletedAt`. + * By default (`false`), the query will only include normal documents and exclude those with a `deletedAt` field. + * + * This argument has no effect unless `trash` is enabled on the collection. + * @default false + */ + trash?: boolean /** * If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. */ @@ -109,6 +119,7 @@ export async function findVersionsLocal( select, showHiddenFields, sort, + trash = false, where, } = options @@ -131,6 +142,7 @@ export async function findVersionsLocal( select, showHiddenFields, sort, + trash, where, }) } diff --git a/packages/payload/src/collections/operations/local/update.ts b/packages/payload/src/collections/operations/local/update.ts index 1e739e4b29..a29e268de3 100644 --- a/packages/payload/src/collections/operations/local/update.ts +++ b/packages/payload/src/collections/operations/local/update.ts @@ -113,6 +113,13 @@ export type BaseOptions( id, collection: { config: collectionConfig }, depth, - draft, + draft: draftArg = false, overrideAccess = false, populate, req, @@ -45,6 +53,25 @@ export const restoreVersionOperation = async ( } = args try { + const shouldCommit = !args.disableTransaction && (await initTransaction(args.req)) + + // ///////////////////////////////////// + // beforeOperation - Collection + // ///////////////////////////////////// + + if (args.collection.config.hooks?.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { + args = + (await hook({ + args, + collection: args.collection.config, + context: args.req.context, + operation: 'restoreVersion', + req: args.req, + })) || args + } + } + if (!id) { throw new APIError('Missing ID of version to restore.', httpStatus.BAD_REQUEST) } @@ -68,7 +95,7 @@ export const restoreVersionOperation = async ( throw new NotFound(req.t) } - const parentDocID = rawVersion.parent + const { parent: parentDocID, version: versionToRestoreWithLocales } = rawVersion // ///////////////////////////////////// // Access @@ -90,6 +117,7 @@ export const restoreVersionOperation = async ( where: combineQueries({ id: { equals: parentDocID } }, accessResults), } + // Get the document from the non versioned collection const doc = await req.payload.db.findOne(findOneArgs) if (!doc && !hasWherePolicy) { @@ -99,10 +127,16 @@ export const restoreVersionOperation = async ( throw new Forbidden(req.t) } + if (collectionConfig.trash && doc?.deletedAt) { + throw new APIError( + `Cannot restore a version of a trashed document (ID: ${parentDocID}). Restore the document first.`, + httpStatus.FORBIDDEN, + ) + } + // ///////////////////////////////////// // fetch previousDoc // ///////////////////////////////////// - const prevDocWithLocales = await getLatestCollectionVersion({ id: parentDocID, config: collectionConfig, @@ -111,6 +145,109 @@ export const restoreVersionOperation = async ( req, }) + // originalDoc with hoisted localized data + const originalDoc = await afterRead({ + collection: collectionConfig, + context: req.context, + depth: 0, + doc: deepCopyObjectSimple(prevDocWithLocales), + draft: draftArg, + fallbackLocale: null, + global: null, + locale: locale!, + overrideAccess: true, + req, + showHiddenFields: true, + }) + + // version data with hoisted localized data + const prevVersionDoc = await afterRead({ + collection: collectionConfig, + context: req.context, + depth: 0, + doc: deepCopyObjectSimple(versionToRestoreWithLocales), + draft: draftArg, + fallbackLocale: null, + global: null, + locale: locale!, + overrideAccess: true, + req, + showHiddenFields: true, + }) + + let data = deepCopyObjectSimple(prevVersionDoc) + + // ///////////////////////////////////// + // beforeValidate - Fields + // ///////////////////////////////////// + + data = await beforeValidate({ + id: parentDocID, + collection: collectionConfig, + context: req.context, + data, + doc: originalDoc, + global: null, + operation: 'update', + overrideAccess, + req, + }) + + // ///////////////////////////////////// + // beforeValidate - Collection + // ///////////////////////////////////// + + if (collectionConfig.hooks?.beforeValidate?.length) { + for (const hook of collectionConfig.hooks.beforeValidate) { + data = + (await hook({ + collection: collectionConfig, + context: req.context, + data, + operation: 'update', + originalDoc, + req, + })) || data + } + } + + // ///////////////////////////////////// + // beforeChange - Collection + // ///////////////////////////////////// + + if (collectionConfig.hooks?.beforeChange?.length) { + for (const hook of collectionConfig.hooks.beforeChange) { + data = + (await hook({ + collection: collectionConfig, + context: req.context, + data, + operation: 'update', + originalDoc, + req, + })) || data + } + } + + // ///////////////////////////////////// + // beforeChange - Fields + // ///////////////////////////////////// + + let result = await beforeChange({ + id: parentDocID, + collection: collectionConfig, + context: req.context, + data: { ...data, id: parentDocID }, + doc: originalDoc, + docWithLocales: versionToRestoreWithLocales, + global: null, + operation: 'update', + overrideAccess, + req, + skipValidation: + draftArg && collectionConfig.versions.drafts && !collectionConfig.versions.drafts.validate, + }) + // ///////////////////////////////////// // Update // ///////////////////////////////////// @@ -121,10 +258,10 @@ export const restoreVersionOperation = async ( select: incomingSelect, }) - let result = await req.payload.db.updateOne({ + result = await req.payload.db.updateOne({ id: parentDocID, collection: collectionConfig.slug, - data: rawVersion.version, + data: result, req, select, }) @@ -133,18 +270,16 @@ export const restoreVersionOperation = async ( // Save `previousDoc` as a version after restoring // ///////////////////////////////////// - const prevVersion = { ...prevDocWithLocales } - - delete prevVersion.id - - await payload.db.createVersion({ + result = await saveVersion({ + id: parentDocID, autosave: false, - collectionSlug: collectionConfig.slug, - createdAt: prevVersion.createdAt, - parent: parentDocID, + collection: collectionConfig, + docWithLocales: result, + draft: draftArg, + operation: 'restoreVersion', + payload, req, - updatedAt: new Date().toISOString(), - versionData: draft ? { ...rawVersion.version, _status: 'draft' } : rawVersion.version, + select, }) // ///////////////////////////////////// @@ -218,6 +353,21 @@ export const restoreVersionOperation = async ( } } + // ///////////////////////////////////// + // afterOperation - Collection + // ///////////////////////////////////// + + result = await buildAfterOperation({ + args, + collection: collectionConfig, + operation: 'restoreVersion', + result, + }) + + if (shouldCommit) { + await commitTransaction(req) + } + return result } catch (error: unknown) { await killTransaction(req) diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index 57551af155..be735b4f1c 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -20,6 +20,7 @@ import { APIError } from '../../errors/index.js' import { type CollectionSlug, deepCopyObjectSimple } from '../../index.js' import { generateFileData } from '../../uploads/generateFileData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -53,6 +54,7 @@ export type Arguments = { * @example ['group', '-createdAt'] // sort by 2 fields, ASC group and DESC createdAt */ sort?: Sort + trash?: boolean where: Where } @@ -109,6 +111,7 @@ export const updateOperation = async < select: incomingSelect, showHiddenFields, sort: incomingSort, + trash = false, where, } = args @@ -139,7 +142,27 @@ export const updateOperation = async < // Retrieve documents // ///////////////////////////////////// - const fullWhere = combineQueries(where, accessResult!) + let fullWhere = combineQueries(where, accessResult!) + + const isTrashAttempt = + collectionConfig.trash && + typeof bulkUpdateData === 'object' && + bulkUpdateData !== null && + 'deletedAt' in bulkUpdateData && + bulkUpdateData.deletedAt != null + + // Enforce delete access if performing a soft-delete (trash) + if (isTrashAttempt && !overrideAccess) { + const deleteAccessResult = await executeAccess({ req }, collectionConfig.access.delete) + fullWhere = combineQueries(fullWhere, deleteAccessResult) + } + + // Exclude trashed documents when trash: false + fullWhere = appendNonTrashedFilter({ + enableTrash: collectionConfig.trash, + trash, + where: fullWhere, + }) sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere }) diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index 8dac3bb420..5b5dfb0478 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -22,6 +22,7 @@ import { APIError, Forbidden, NotFound } from '../../errors/index.js' import { type CollectionSlug, deepCopyObjectSimple } from '../../index.js' import { generateFileData } from '../../uploads/generateFileData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -47,6 +48,7 @@ export type Arguments = { req: PayloadRequest select?: SelectType showHiddenFields?: boolean + trash?: boolean } export const updateByIDOperation = async < @@ -102,6 +104,7 @@ export const updateByIDOperation = async < req, select: incomingSelect, showHiddenFields, + trash = false, } = args if (!id) { @@ -123,11 +126,34 @@ export const updateByIDOperation = async < // Retrieve document // ///////////////////////////////////// + const where = { id: { equals: id } } + + let fullWhere = combineQueries(where, accessResults) + + const isTrashAttempt = + collectionConfig.trash && + typeof data === 'object' && + data !== null && + 'deletedAt' in data && + data.deletedAt != null + + if (isTrashAttempt && !overrideAccess) { + const deleteAccessResult = await executeAccess({ req }, collectionConfig.access.delete) + fullWhere = combineQueries(fullWhere, deleteAccessResult) + } + + // Exclude trashed documents when trash: false + fullWhere = appendNonTrashedFilter({ + enableTrash: collectionConfig.trash, + trash, + where: fullWhere, + }) + const findOneArgs: FindOneArgs = { collection: collectionConfig.slug, locale: locale!, req, - where: combineQueries({ id: { equals: id } }, accessResults), + where: fullWhere, } const docWithLocales = await getLatestCollectionVersion({ diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 01cd589427..c9a0f9f283 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -314,6 +314,7 @@ export const updateDocument = async < collection: collectionConfig, docWithLocales: result, draft: shouldSaveDraft, + operation: 'update', payload, publishSpecificLocale, req, diff --git a/packages/payload/src/collections/operations/utils.ts b/packages/payload/src/collections/operations/utils.ts index 6ea8497248..05cdadd8d7 100644 --- a/packages/payload/src/collections/operations/utils.ts +++ b/packages/payload/src/collections/operations/utils.ts @@ -2,7 +2,7 @@ import type { forgotPasswordOperation } from '../../auth/operations/forgotPasswo import type { loginOperation } from '../../auth/operations/login.js' import type { refreshOperation } from '../../auth/operations/refresh.js' import type { resetPasswordOperation } from '../../auth/operations/resetPassword.js' -import type { CollectionSlug } from '../../index.js' +import type { CollectionSlug, restoreVersionOperation } from '../../index.js' import type { PayloadRequest } from '../../types/index.js' import type { SanitizedCollectionConfig, SelectFromCollectionSlug } from '../config/types.js' import type { countOperation } from './count.js' @@ -12,6 +12,7 @@ import type { deleteOperation } from './delete.js' import type { deleteByIDOperation } from './deleteByID.js' import type { findOperation } from './find.js' import type { findByIDOperation } from './findByID.js' +import type { findDistinctOperation } from './findDistinct.js' import type { updateOperation } from './update.js' import type { updateByIDOperation } from './updateByID.js' @@ -30,10 +31,12 @@ export type AfterOperationMap = { boolean, SelectFromCollectionSlug > + findDistinct: typeof findDistinctOperation forgotPassword: typeof forgotPasswordOperation login: typeof loginOperation refresh: typeof refreshOperation resetPassword: typeof resetPasswordOperation + restoreVersion: typeof restoreVersionOperation update: typeof updateOperation> updateByID: typeof updateByIDOperation< TOperationGeneric, @@ -81,6 +84,11 @@ export type AfterOperationArg = { operation: 'findByID' result: Awaited['findByID']>> } + | { + args: Parameters['findDistinct']>[0] + operation: 'findDistinct' + result: Awaited['findDistinct']>> + } | { args: Parameters['forgotPassword']>[0] operation: 'forgotPassword' @@ -101,6 +109,11 @@ export type AfterOperationArg = { operation: 'resetPassword' result: Awaited['resetPassword']>> } + | { + args: Parameters['restoreVersion']>[0] + operation: 'restoreVersion' + result: Awaited['restoreVersion']>> + } | { args: Parameters['update']>[0] operation: 'update' diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts index b5a4063bb3..77dc94677c 100644 --- a/packages/payload/src/config/defaults.ts +++ b/packages/payload/src/config/defaults.ts @@ -163,14 +163,17 @@ export const addDefaultsToConfig = (config: Config): Config => { ...(config.auth || {}), } - const hasFolderCollections = config.collections.some((collection) => Boolean(collection.folders)) - if (hasFolderCollections) { + if ( + config.folders !== false && + config.collections.some((collection) => Boolean(collection.folders)) + ) { config.folders = { - slug: foldersSlug, - browseByFolder: true, - debug: false, - fieldName: parentFolderFieldName, - ...(config.folders || {}), + slug: config.folders?.slug ?? foldersSlug, + browseByFolder: config.folders?.browseByFolder ?? true, + collectionOverrides: config.folders?.collectionOverrides || undefined, + collectionSpecific: config.folders?.collectionSpecific ?? true, + debug: config.folders?.debug ?? false, + fieldName: config.folders?.fieldName ?? parentFolderFieldName, } } else { config.folders = false diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index d79b2d73fc..043bb34d3d 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -3,6 +3,7 @@ import type { AcceptedLanguages } from '@payloadcms/translations' import { en } from '@payloadcms/translations/languages/en' import { deepMergeSimple } from '@payloadcms/translations/utilities' +import type { CollectionSlug, GlobalSlug, SanitizedCollectionConfig } from '../index.js' import type { SanitizedJobsConfig } from '../queues/config/types/index.js' import type { Config, @@ -18,22 +19,18 @@ import { sanitizeCollection } from '../collections/config/sanitize.js' import { migrationsCollection } from '../database/migrations/migrationsCollection.js' import { DuplicateCollection, InvalidConfiguration } from '../errors/index.js' import { defaultTimezones } from '../fields/baseFields/timezone/defaultTimezones.js' -import { addFolderCollections } from '../folders/addFolderCollections.js' +import { addFolderCollection } from '../folders/addFolderCollection.js' +import { addFolderFieldToCollection } from '../folders/addFolderFieldToCollection.js' import { sanitizeGlobal } from '../globals/config/sanitize.js' -import { - baseBlockFields, - type CollectionSlug, - formatLabels, - type GlobalSlug, - sanitizeFields, -} from '../index.js' +import { baseBlockFields, formatLabels, sanitizeFields } from '../index.js' import { getLockedDocumentsCollection, lockedDocumentsCollectionSlug, } from '../locked-documents/config.js' import { getPreferencesCollection, preferencesCollectionSlug } from '../preferences/config.js' import { getQueryPresetsConfig, queryPresetsCollectionSlug } from '../query-presets/config.js' -import { getDefaultJobsCollection, jobsCollectionSlug } from '../queues/config/index.js' +import { getDefaultJobsCollection, jobsCollectionSlug } from '../queues/config/collection.js' +import { getJobStatsGlobal } from '../queues/config/global.js' import { flattenBlock } from '../utilities/flattenAllFields.js' import { getSchedulePublishTask } from '../versions/schedule/job.js' import { addDefaultsToConfig } from './defaults.js' @@ -191,8 +188,6 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise() - await addFolderCollections(config as unknown as Config) - const validRelationships = [ ...(config.collections?.map((c) => c.slug) ?? []), jobsCollectionSlug, @@ -200,6 +195,10 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise 0) { @@ -300,7 +314,28 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise task.schedule)) || + (config?.jobs?.workflows?.length && + config.jobs.workflows.some((workflow) => workflow.schedule)) + + if (hasScheduleProperty) { + config.jobs.scheduling = true + // Add payload-jobs-stats global for tracking when a job of a specific slug was last run + ;(config.globals ??= []).push( + await sanitizeGlobal( + config as unknown as Config, + getJobStatsGlobal(config as unknown as Config), + richTextSanitizationPromises, + validRelationships, + ), + ) + + config.jobs.stats = true + } + + let defaultJobsCollection = getDefaultJobsCollection(config.jobs) if (typeof config.jobs.jobsCollectionOverrides === 'function') { defaultJobsCollection = config.jobs.jobsCollectionOverrides({ @@ -329,7 +364,17 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise | SanitizedConfig + /** + * If set to `true`, payload will initialize crons for things like autorunning jobs on initialization. + * + * @default false + */ + cron?: boolean + /** * Disable connect to the database on init */ @@ -268,7 +275,6 @@ export type InitOptions = { disableOnInit?: boolean importMap?: ImportMap - /** * A function that is called immediately following startup that receives the Payload instance as it's only argument. */ diff --git a/packages/payload/src/database/defaultUpdateJobs.ts b/packages/payload/src/database/defaultUpdateJobs.ts index 260851ada2..189b1d34f9 100644 --- a/packages/payload/src/database/defaultUpdateJobs.ts +++ b/packages/payload/src/database/defaultUpdateJobs.ts @@ -1,7 +1,7 @@ import type { DatabaseAdapter, Job } from '../index.js' import type { UpdateJobs } from './types.js' -import { jobsCollectionSlug } from '../queues/config/index.js' +import { jobsCollectionSlug } from '../queues/config/collection.js' export const defaultUpdateJobs: UpdateJobs = async function updateMany( this: DatabaseAdapter, diff --git a/packages/payload/src/database/getLocalizedPaths.ts b/packages/payload/src/database/getLocalizedPaths.ts index c419315414..ad9aecca94 100644 --- a/packages/payload/src/database/getLocalizedPaths.ts +++ b/packages/payload/src/database/getLocalizedPaths.ts @@ -157,18 +157,11 @@ export function getLocalizedPaths({ // If this is a polymorphic relation, // We only support querying directly (no nested querying) if (matchedField.type !== 'join' && typeof matchedField.relationTo !== 'string') { - const lastSegmentIsValid = - ['relationTo', 'value'].includes(pathSegments[pathSegments.length - 1]!) || - pathSegments.length === 1 || - (pathSegments.length === 2 && pathSegments[0] === 'version') - lastIncompletePath.path = pathSegments.join('.') - - if (lastSegmentIsValid) { - lastIncompletePath.complete = true - } else { + if (![matchedField.name, 'relationTo', 'value'].includes(pathSegments.at(-1)!)) { lastIncompletePath.invalid = true - return paths + } else { + lastIncompletePath.complete = true } } else { lastIncompletePath.complete = true diff --git a/packages/payload/src/database/queryValidation/validateSearchParams.ts b/packages/payload/src/database/queryValidation/validateSearchParams.ts index 7a11b7c55f..154e3dee55 100644 --- a/packages/payload/src/database/queryValidation/validateSearchParams.ts +++ b/packages/payload/src/database/queryValidation/validateSearchParams.ts @@ -160,32 +160,29 @@ export async function validateSearchParam({ let fieldAccess: any if (versionFields) { - fieldAccess = policies[entityType]![entitySlug]! + fieldAccess = policies[entityType]![entitySlug]!.fields - if (segments[0] === 'parent' || segments[0] === 'version') { + if (segments[0] === 'parent' || segments[0] === 'version' || segments[0] === 'snapshot') { segments.shift() } } else { fieldAccess = policies[entityType]![entitySlug]!.fields } - segments.forEach((segment) => { - if (fieldAccess[segment]) { - if ('fields' in fieldAccess[segment]) { - fieldAccess = fieldAccess[segment].fields - } else if ( - 'blocks' in fieldAccess[segment] || - 'blockReferences' in fieldAccess[segment] - ) { - fieldAccess = fieldAccess[segment] - } else { - fieldAccess = fieldAccess[segment] + if (segments.length) { + segments.forEach((segment) => { + if (fieldAccess[segment]) { + if ('fields' in fieldAccess[segment]) { + fieldAccess = fieldAccess[segment].fields + } else { + fieldAccess = fieldAccess[segment] + } } - } - }) + }) - if (!fieldAccess?.read?.permission) { - errors.push({ path: fieldPath }) + if (!fieldAccess?.read?.permission) { + errors.push({ path: fieldPath }) + } } } diff --git a/packages/payload/src/database/types.ts b/packages/payload/src/database/types.ts index ca94f76997..1451025145 100644 --- a/packages/payload/src/database/types.ts +++ b/packages/payload/src/database/types.ts @@ -63,6 +63,8 @@ export interface BaseDatabaseAdapter { find: Find + findDistinct: FindDistinct + findGlobal: FindGlobal findGlobalVersions: FindGlobalVersions @@ -82,16 +84,15 @@ export interface BaseDatabaseAdapter { * Run any migration up functions that have not yet been performed and update the status */ migrate: (args?: { migrations?: Migration[] }) => Promise - /** * Run any migration down functions that have been performed */ migrateDown: () => Promise + /** * Drop the current database and run all migrate up functions */ migrateFresh: (args: { forceAcceptWarning?: boolean }) => Promise - /** * Run all migration down functions before running up */ @@ -104,6 +105,7 @@ export interface BaseDatabaseAdapter { * Read the current state of migrations and output the result to show which have been run */ migrateStatus: () => Promise + /** * Path to read and write migration files from */ @@ -113,7 +115,6 @@ export interface BaseDatabaseAdapter { * The name of the database adapter */ name: string - /** * Full package name of the database adapter * @@ -124,6 +125,7 @@ export interface BaseDatabaseAdapter { * reference to the instance of payload */ payload: Payload + queryDrafts: QueryDrafts /** @@ -142,6 +144,9 @@ export interface BaseDatabaseAdapter { } } + /** + * Updates a global that exists. If the global doesn't exist yet, this will not work - you should use `createGlobal` instead. + */ updateGlobal: UpdateGlobal updateGlobalVersion: UpdateGlobalVersion @@ -151,7 +156,6 @@ export interface BaseDatabaseAdapter { updateMany: UpdateMany updateOne: UpdateOne - updateVersion: UpdateVersion upsert: Upsert } @@ -481,6 +485,34 @@ export type CreateArgs = { select?: SelectType } +export type FindDistinctArgs = { + collection: CollectionSlug + field: string + limit?: number + locale?: string + page?: number + req?: Partial + sort?: Sort + where?: Where +} + +export type PaginatedDistinctDocs> = { + hasNextPage: boolean + hasPrevPage: boolean + limit: number + nextPage?: null | number | undefined + page: number + pagingCounter: number + prevPage?: null | number | undefined + totalDocs: number + totalPages: number + values: T[] +} + +export type FindDistinct = ( + args: FindDistinctArgs, +) => Promise>> + export type Create = (args: CreateArgs) => Promise export type UpdateOneArgs = { diff --git a/packages/payload/src/exports/i18n/id.ts b/packages/payload/src/exports/i18n/id.ts new file mode 100644 index 0000000000..96c2377297 --- /dev/null +++ b/packages/payload/src/exports/i18n/id.ts @@ -0,0 +1 @@ +export { id } from '@payloadcms/translations/languages/id' diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index 92c50e9916..8bdc3ead91 100644 --- a/packages/payload/src/exports/shared.ts +++ b/packages/payload/src/exports/shared.ts @@ -6,6 +6,7 @@ export { parseCookies, } from '../auth/cookies.js' export { getLoginOptions } from '../auth/getLoginOptions.js' +export { addSessionToUser, removeExpiredSessions } from '../auth/sessions.js' export { getFromImportMap } from '../bin/generateImportMap/utilities/getFromImportMap.js' export { parsePayloadComponent } from '../bin/generateImportMap/utilities/parsePayloadComponent.js' export { defaults as collectionDefaults } from '../collections/config/defaults.js' diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 8ac63a8849..14f1fc7fae 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -142,6 +142,7 @@ import type { JsonObject, Operation, PayloadRequest, + PickPreserveOptional, Where, } from '../../types/index.js' import type { @@ -632,8 +633,8 @@ export type TextField = { Omit export type TextFieldClient = { - // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve - admin?: AdminClient & Pick + admin?: AdminClient & + PickPreserveOptional, 'autoComplete' | 'placeholder' | 'rtl'> } & FieldBaseClient & Pick @@ -653,8 +654,8 @@ export type EmailField = { } & Omit export type EmailFieldClient = { - // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve - admin?: AdminClient & Pick + admin?: AdminClient & + PickPreserveOptional, 'autoComplete' | 'placeholder'> } & FieldBaseClient & Pick @@ -677,8 +678,8 @@ export type TextareaField = { } & Omit export type TextareaFieldClient = { - // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve - admin?: AdminClient & Pick + admin?: AdminClient & + PickPreserveOptional, 'placeholder' | 'rows' | 'rtl'> } & FieldBaseClient & Pick diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts index 23010c6116..ecbc930870 100644 --- a/packages/payload/src/fields/hooks/afterChange/promise.ts +++ b/packages/payload/src/fields/hooks/afterChange/promise.ts @@ -88,12 +88,12 @@ export const promise = async ({ path: pathSegments, previousDoc, previousSiblingDoc, - previousValue: previousDoc[field.name!], + previousValue: previousDoc?.[field.name!], req, schemaPath: schemaPathSegments, siblingData, siblingFields: siblingFields!, - value: siblingDoc[field.name!], + value: siblingDoc?.[field.name!], }) if (hookedValue !== undefined) { @@ -226,10 +226,10 @@ export const promise = async ({ parentPath: path, parentSchemaPath: schemaPath, previousDoc, - previousSiblingDoc: previousDoc[field.name] as JsonObject, + previousSiblingDoc: (previousDoc?.[field.name] as JsonObject) || {}, req, siblingData: (siblingData?.[field.name] as JsonObject) || {}, - siblingDoc: siblingDoc[field.name] as JsonObject, + siblingDoc: (siblingDoc?.[field.name] as JsonObject) || {}, }) } else { await traverseFields({ @@ -282,11 +282,11 @@ export const promise = async ({ path: pathSegments, previousDoc, previousSiblingDoc, - previousValue: previousDoc[field.name], + previousValue: previousDoc?.[field.name], req, schemaPath: schemaPathSegments, siblingData, - value: siblingDoc[field.name], + value: siblingDoc?.[field.name], }) if (hookedValue !== undefined) { @@ -305,9 +305,9 @@ export const promise = async ({ const isNamedTab = tabHasName(field) if (isNamedTab) { - tabSiblingData = (siblingData[field.name] as JsonObject) ?? {} - tabSiblingDoc = (siblingDoc[field.name] as JsonObject) ?? {} - tabPreviousSiblingDoc = (previousDoc[field.name] as JsonObject) ?? {} + tabSiblingData = (siblingData?.[field.name] ?? {}) as JsonObject + tabSiblingDoc = (siblingDoc?.[field.name] ?? {}) as JsonObject + tabPreviousSiblingDoc = (previousDoc?.[field.name] ?? {}) as JsonObject } await traverseFields({ diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 3ceb856d73..bad55561ca 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -111,13 +111,14 @@ export const promise = async ({ parentSchemaPath, }) + const fieldAffectsDataResult = fieldAffectsData(field) const pathSegments = path ? path.split('.') : [] const schemaPathSegments = schemaPath ? schemaPath.split('.') : [] const indexPathSegments = indexPath ? indexPath.split('-').filter(Boolean)?.map(Number) : [] let removedFieldValue = false if ( - fieldAffectsData(field) && + fieldAffectsDataResult && field.hidden && typeof siblingDoc[field.name!] !== 'undefined' && !showHiddenFields @@ -139,16 +140,17 @@ export const promise = async ({ } } - const shouldHoistLocalizedValue = + const shouldHoistLocalizedValue: boolean = Boolean( flattenLocales && - fieldAffectsData(field) && - typeof siblingDoc[field.name!] === 'object' && - siblingDoc[field.name!] !== null && - fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! }) && - locale !== 'all' && - req.payload.config.localization + fieldAffectsDataResult && + typeof siblingDoc[field.name!] === 'object' && + siblingDoc[field.name!] !== null && + fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! }) && + locale !== 'all' && + req.payload.config.localization, + ) - if (shouldHoistLocalizedValue) { + if (fieldAffectsDataResult && shouldHoistLocalizedValue) { // replace actual value with localized value before sanitizing // { [locale]: fields } -> fields const value = siblingDoc[field.name!][locale!] @@ -187,7 +189,7 @@ export const promise = async ({ case 'group': { // Fill groups with empty objects so fields with hooks within groups can populate // themselves virtually as necessary - if (fieldAffectsData(field) && typeof siblingDoc[field.name] === 'undefined') { + if (fieldAffectsDataResult && typeof siblingDoc[field.name] === 'undefined') { siblingDoc[field.name] = {} } @@ -234,7 +236,7 @@ export const promise = async ({ } } - if (fieldAffectsData(field)) { + if (fieldAffectsDataResult) { // Execute hooks if (triggerHooks && field.hooks?.afterRead) { for (const hook of field.hooks.afterRead) { @@ -400,7 +402,7 @@ export const promise = async ({ } } - if (Array.isArray(rows)) { + if (Array.isArray(rows) && rows.length > 0) { rows.forEach((row, rowIndex) => { traverseFields({ blockData, @@ -468,6 +470,8 @@ export const promise = async ({ }) } }) + } else if (shouldHoistLocalizedValue && (!rows || rows.length === 0)) { + siblingDoc[field.name] = null } else if (field.hidden !== true || showHiddenFields === true) { siblingDoc[field.name] = [] } @@ -477,7 +481,7 @@ export const promise = async ({ case 'blocks': { const rows = siblingDoc[field.name] - if (Array.isArray(rows)) { + if (Array.isArray(rows) && rows.length > 0) { rows.forEach((row, rowIndex) => { const blockTypeToMatch = (row as JsonObject).blockType @@ -573,6 +577,8 @@ export const promise = async ({ }) } }) + } else if (shouldHoistLocalizedValue && (!rows || rows.length === 0)) { + siblingDoc[field.name] = null } else if (field.hidden !== true || showHiddenFields === true) { siblingDoc[field.name] = [] } @@ -617,7 +623,7 @@ export const promise = async ({ } case 'group': { - if (fieldAffectsData(field)) { + if (fieldAffectsDataResult) { let groupDoc = siblingDoc[field.name] as JsonObject if (typeof siblingDoc[field.name] !== 'object') { diff --git a/packages/payload/src/fields/validations.spec.ts b/packages/payload/src/fields/validations.spec.ts index b3b822771d..4d3d3dd116 100644 --- a/packages/payload/src/fields/validations.spec.ts +++ b/packages/payload/src/fields/validations.spec.ts @@ -61,6 +61,11 @@ describe('Field Validations', () => { const result = text(val, { ...options, minLength: 10 }) expect(result).toBe(true) }) + it('should validate minLength with empty string', () => { + const val = '' + const result = text(val, { ...options, required: false, minLength: 1 }) + expect(result).toBe('validation:longerThanMin') + }) it('should validate an array of texts', async () => { const val = ['test'] const result = text(val, { ...options, hasMany: true }) diff --git a/packages/payload/src/fields/validations.ts b/packages/payload/src/fields/validations.ts index bd89a162ba..7dc86df952 100644 --- a/packages/payload/src/fields/validations.ts +++ b/packages/payload/src/fields/validations.ts @@ -61,7 +61,7 @@ export const text: TextFieldValidation = ( let maxLength!: number if (!required) { - if (!value) { + if (value === undefined || value === null) { return true } } diff --git a/packages/payload/src/folders/addFolderCollection.ts b/packages/payload/src/folders/addFolderCollection.ts new file mode 100644 index 0000000000..55f0b861ad --- /dev/null +++ b/packages/payload/src/folders/addFolderCollection.ts @@ -0,0 +1,51 @@ +import type { Config, SanitizedConfig } from '../config/types.js' +import type { CollectionConfig } from '../index.js' + +import { sanitizeCollection } from '../collections/config/sanitize.js' +import { createFolderCollection } from './createFolderCollection.js' + +export async function addFolderCollection({ + collectionSpecific, + config, + folderEnabledCollections, + richTextSanitizationPromises = [], + validRelationships = [], +}: { + collectionSpecific: boolean + config: NonNullable + folderEnabledCollections: CollectionConfig[] + richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise> + validRelationships?: string[] +}): Promise { + if (config.folders === false) { + return + } + + let folderCollectionConfig = createFolderCollection({ + slug: config.folders!.slug as string, + collectionSpecific, + debug: config.folders!.debug, + folderEnabledCollections, + folderFieldName: config.folders!.fieldName as string, + }) + + const collectionIndex = config.collections!.push(folderCollectionConfig) + + if ( + Array.isArray(config.folders?.collectionOverrides) && + config?.folders.collectionOverrides.length + ) { + for (const override of config.folders.collectionOverrides) { + folderCollectionConfig = await override({ collection: folderCollectionConfig }) + } + } + + const sanitizedCollectionWithOverrides = await sanitizeCollection( + config as unknown as Config, + folderCollectionConfig, + richTextSanitizationPromises, + validRelationships, + ) + + config.collections![collectionIndex - 1] = sanitizedCollectionWithOverrides +} diff --git a/packages/payload/src/folders/addFolderCollections.ts b/packages/payload/src/folders/addFolderCollections.ts deleted file mode 100644 index deb9323197..0000000000 --- a/packages/payload/src/folders/addFolderCollections.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Config } from '../config/types.js' -import type { CollectionSlug } from '../index.js' - -import { createFolderCollection } from './createFolderCollection.js' - -export async function addFolderCollections(config: NonNullable): Promise { - if (!config.collections || !config.folders) { - return - } - - const enabledCollectionSlugs: CollectionSlug[] = [] - const debug = Boolean(config?.folders?.debug) - const folderFieldName = config?.folders?.fieldName as unknown as string - const folderSlug = config?.folders?.slug as unknown as CollectionSlug - - for (let i = 0; i < config.collections.length; i++) { - const collection = config.collections[i] - if (collection && collection?.folders) { - collection.fields.push({ - name: folderFieldName, - type: 'relationship', - admin: { - allowCreate: false, - allowEdit: false, - components: { - Cell: '@payloadcms/ui/rsc#FolderTableCell', - Field: '@payloadcms/ui/rsc#FolderEditField', - }, - }, - index: true, - label: 'Folder', - relationTo: folderSlug, - }) - enabledCollectionSlugs.push(collection.slug) - } - } - - if (enabledCollectionSlugs.length) { - let folderCollection = createFolderCollection({ - slug: folderSlug, - collectionSlugs: enabledCollectionSlugs, - debug, - folderFieldName, - }) - - if ( - Array.isArray(config?.folders?.collectionOverrides) && - config?.folders.collectionOverrides.length - ) { - for (const override of config.folders.collectionOverrides) { - folderCollection = await override({ collection: folderCollection }) - } - } - config.collections.push(folderCollection) - } -} diff --git a/packages/payload/src/folders/addFolderFieldToCollection.ts b/packages/payload/src/folders/addFolderFieldToCollection.ts new file mode 100644 index 0000000000..a4aa6c6860 --- /dev/null +++ b/packages/payload/src/folders/addFolderFieldToCollection.ts @@ -0,0 +1,33 @@ +import type { SanitizedCollectionConfig } from '../index.js' + +import { buildFolderField } from './buildFolderField.js' + +export const addFolderFieldToCollection = ({ + collection, + collectionSpecific, + folderFieldName, + folderSlug, +}: { + collection: SanitizedCollectionConfig + collectionSpecific: boolean + folderFieldName: string + folderSlug: string +}): void => { + collection.fields.push( + buildFolderField({ + collectionSpecific, + folderFieldName, + folderSlug, + overrides: { + admin: { + allowCreate: false, + allowEdit: false, + components: { + Cell: '@payloadcms/ui/rsc#FolderTableCell', + Field: '@payloadcms/ui/rsc#FolderField', + }, + }, + }, + }), + ) +} diff --git a/packages/payload/src/folders/buildFolderField.ts b/packages/payload/src/folders/buildFolderField.ts new file mode 100644 index 0000000000..c3920a4d58 --- /dev/null +++ b/packages/payload/src/folders/buildFolderField.ts @@ -0,0 +1,108 @@ +import type { SingleRelationshipField } from '../fields/config/types.js' +import type { Document } from '../types/index.js' + +import { extractID } from '../utilities/extractID.js' + +export const buildFolderField = ({ + collectionSpecific, + folderFieldName, + folderSlug, + overrides = {}, +}: { + collectionSpecific: boolean + folderFieldName: string + folderSlug: string + overrides?: Partial +}): SingleRelationshipField => { + const field: SingleRelationshipField = { + name: folderFieldName, + type: 'relationship', + admin: {}, + hasMany: false, + index: true, + label: 'Folder', + relationTo: folderSlug, + validate: async (value, { collectionSlug, data, overrideAccess, previousValue, req }) => { + if (!collectionSpecific) { + // if collection scoping is not enabled, no validation required since folders can contain any type of document + return true + } + + if (!value) { + // no folder, no validation required + return true + } + + const newID = extractID(value) + if (previousValue && extractID(previousValue) === newID) { + // value did not change, no validation required + return true + } else { + // need to validat the folder value allows this collection type + let parentFolder: Document = null + if (typeof value === 'string' || typeof value === 'number') { + // need to populate the value with the document + parentFolder = await req.payload.findByID({ + id: newID, + collection: folderSlug, + depth: 0, // no need to populate nested folders + overrideAccess, + req, + select: { + folderType: true, // only need to check folderType + }, + user: req.user, + }) + } + + if (parentFolder && collectionSlug) { + const parentFolderTypes: string[] = (parentFolder.folderType as string[]) || [] + + // if the parent folder has no folder types, it accepts all collections + if (parentFolderTypes.length === 0) { + return true + } + + // validation for a folder document + if (collectionSlug === folderSlug) { + // ensure the parent accepts ALL folder types + const folderTypes: string[] = 'folderType' in data ? (data.folderType as string[]) : [] + const invalidSlugs = folderTypes.filter((validCollectionSlug: string) => { + return !parentFolderTypes.includes(validCollectionSlug) + }) + if (invalidSlugs.length === 0) { + return true + } else { + return `Folder with ID ${newID} does not allow documents of type ${invalidSlugs.join(', ')}` + } + } + + // validation for a non-folder document + if (parentFolderTypes.includes(collectionSlug)) { + return true + } else { + return `Folder with ID ${newID} does not allow documents of type ${collectionSlug}` + } + } else { + return `Folder with ID ${newID} not found in collection ${folderSlug}` + } + } + }, + } + + if (overrides?.admin) { + field.admin = { + ...field.admin, + ...(overrides.admin || {}), + } + + if (overrides.admin.components) { + field.admin.components = { + ...field.admin.components, + ...(overrides.admin.components || {}), + } + } + } + + return field +} diff --git a/packages/payload/src/folders/createFolderCollection.ts b/packages/payload/src/folders/createFolderCollection.ts index 4da3e3bee7..9e1b8e93cd 100644 --- a/packages/payload/src/folders/createFolderCollection.ts +++ b/packages/payload/src/folders/createFolderCollection.ts @@ -1,74 +1,129 @@ import type { CollectionConfig } from '../collections/config/types.js' +import type { Field, Option, SelectField } from '../fields/config/types.js' -import { populateFolderDataEndpoint } from './endpoints/populateFolderData.js' +import { defaultAccess } from '../auth/defaultAccess.js' +import { buildFolderField } from './buildFolderField.js' +import { foldersSlug } from './constants.js' import { deleteSubfoldersBeforeDelete } from './hooks/deleteSubfoldersAfterDelete.js' import { dissasociateAfterDelete } from './hooks/dissasociateAfterDelete.js' +import { ensureSafeCollectionsChange } from './hooks/ensureSafeCollectionsChange.js' import { reparentChildFolder } from './hooks/reparentChildFolder.js' type CreateFolderCollectionArgs = { - collectionSlugs: string[] + collectionSpecific: boolean debug?: boolean + folderEnabledCollections: CollectionConfig[] folderFieldName: string slug: string } export const createFolderCollection = ({ slug, - collectionSlugs, + collectionSpecific, debug, + folderEnabledCollections, folderFieldName, -}: CreateFolderCollectionArgs): CollectionConfig => ({ - slug, - admin: { - hidden: !debug, - useAsTitle: 'name', - }, - endpoints: [populateFolderDataEndpoint], - fields: [ - { - name: 'name', - type: 'text', - index: true, - required: true, +}: CreateFolderCollectionArgs): CollectionConfig => { + const { collectionOptions, collectionSlugs } = folderEnabledCollections.reduce( + (acc, collection: CollectionConfig) => { + acc.collectionSlugs.push(collection.slug) + acc.collectionOptions.push({ + label: collection.labels?.plural || collection.slug, + value: collection.slug, + }) + + return acc }, { - name: folderFieldName, - type: 'relationship', - admin: { - hidden: !debug, + collectionOptions: [] as Option[], + collectionSlugs: [] as string[], + }, + ) + + return { + slug, + access: { + create: defaultAccess, + delete: defaultAccess, + read: defaultAccess, + readVersions: defaultAccess, + update: defaultAccess, + }, + admin: { + hidden: !debug, + useAsTitle: 'name', + }, + fields: [ + { + name: 'name', + type: 'text', + index: true, + required: true, }, - index: true, - relationTo: slug, - }, - { - name: 'documentsAndFolders', - type: 'join', - admin: { - hidden: !debug, + buildFolderField({ + collectionSpecific, + folderFieldName, + folderSlug: slug, + overrides: { + admin: { + hidden: !debug, + }, + }, + }), + { + name: 'documentsAndFolders', + type: 'join', + admin: { + hidden: !debug, + }, + collection: [slug, ...collectionSlugs], + hasMany: true, + on: folderFieldName, }, - collection: [slug, ...collectionSlugs], - hasMany: true, - on: folderFieldName, + ...(collectionSpecific + ? [ + { + name: 'folderType', + type: 'select', + admin: { + components: { + Field: { + clientProps: { + options: collectionOptions, + }, + path: '@payloadcms/ui#FolderTypeField', + }, + }, + position: 'sidebar', + }, + hasMany: true, + options: collectionOptions, + } satisfies SelectField, + ] + : ([] as Field[])), + ], + hooks: { + afterChange: [ + reparentChildFolder({ + folderFieldName, + }), + ], + afterDelete: [ + dissasociateAfterDelete({ + collectionSlugs, + folderFieldName, + }), + ], + beforeDelete: [deleteSubfoldersBeforeDelete({ folderFieldName, folderSlug: slug })], + beforeValidate: [ + ...(collectionSpecific ? [ensureSafeCollectionsChange({ foldersSlug })] : []), + ], }, - ], - hooks: { - afterChange: [ - reparentChildFolder({ - folderFieldName, - }), - ], - afterDelete: [ - dissasociateAfterDelete({ - collectionSlugs, - folderFieldName, - }), - ], - beforeDelete: [deleteSubfoldersBeforeDelete({ folderFieldName, folderSlug: slug })], - }, - labels: { - plural: 'Folders', - singular: 'Folder', - }, - typescript: { - interface: 'FolderInterface', - }, -}) + labels: { + plural: 'Folders', + singular: 'Folder', + }, + typescript: { + interface: 'FolderInterface', + }, + } +} diff --git a/packages/payload/src/folders/endpoints/populateFolderData.ts b/packages/payload/src/folders/endpoints/populateFolderData.ts deleted file mode 100644 index 9347602a9e..0000000000 --- a/packages/payload/src/folders/endpoints/populateFolderData.ts +++ /dev/null @@ -1,135 +0,0 @@ -import httpStatus from 'http-status' - -import type { Endpoint, Where } from '../../index.js' - -import { buildFolderWhereConstraints } from '../utils/buildFolderWhereConstraints.js' -import { getFolderData } from '../utils/getFolderData.js' - -export const populateFolderDataEndpoint: Endpoint = { - handler: async (req) => { - if (!req?.user) { - return Response.json( - { - message: 'Unauthorized request.', - }, - { - status: httpStatus.UNAUTHORIZED, - }, - ) - } - - if ( - !( - req.payload.config.folders && - Boolean(req.payload.collections?.[req.payload.config.folders.slug]) - ) - ) { - return Response.json( - { - message: 'Folders are not configured', - }, - { - status: httpStatus.NOT_FOUND, - }, - ) - } - - // if collectionSlug exists, we need to create constraints for that _specific collection_ and the folder collection - // if collectionSlug does not exist, we need to create constraints for _all folder enabled collections_ and the folder collection - let documentWhere: undefined | Where - let folderWhere: undefined | Where - const collectionSlug = req.searchParams?.get('collectionSlug') - - if (collectionSlug) { - const collectionConfig = req.payload.collections?.[collectionSlug]?.config - - if (!collectionConfig) { - return Response.json( - { - message: `Collection with slug "${collectionSlug}" not found`, - }, - { - status: httpStatus.NOT_FOUND, - }, - ) - } - - const collectionConstraints = await buildFolderWhereConstraints({ - collectionConfig, - folderID: req.searchParams?.get('folderID') || undefined, - localeCode: typeof req?.locale === 'string' ? req.locale : undefined, - req, - search: req.searchParams?.get('search') || undefined, - sort: req.searchParams?.get('sort') || undefined, - }) - - if (collectionConstraints) { - documentWhere = collectionConstraints - } - } else { - // loop over all folder enabled collections and build constraints for each - for (const collectionSlug of Object.keys(req.payload.collections)) { - const collectionConfig = req.payload.collections[collectionSlug]?.config - - if (collectionConfig?.folders) { - const collectionConstraints = await buildFolderWhereConstraints({ - collectionConfig, - folderID: req.searchParams?.get('folderID') || undefined, - localeCode: typeof req?.locale === 'string' ? req.locale : undefined, - req, - search: req.searchParams?.get('search') || undefined, - }) - - if (collectionConstraints) { - if (!documentWhere) { - documentWhere = { or: [] } - } - if (!Array.isArray(documentWhere.or)) { - documentWhere.or = [documentWhere] - } else if (Array.isArray(documentWhere.or)) { - documentWhere.or.push(collectionConstraints) - } - } - } - } - } - - const folderCollectionConfig = - req.payload.collections?.[req.payload.config.folders.slug]?.config - - if (!folderCollectionConfig) { - return Response.json( - { - message: 'Folder collection not found', - }, - { - status: httpStatus.NOT_FOUND, - }, - ) - } - - const folderConstraints = await buildFolderWhereConstraints({ - collectionConfig: folderCollectionConfig, - folderID: req.searchParams?.get('folderID') || undefined, - localeCode: typeof req?.locale === 'string' ? req.locale : undefined, - req, - search: req.searchParams?.get('search') || undefined, - }) - - if (folderConstraints) { - folderWhere = folderConstraints - } - - const data = await getFolderData({ - collectionSlug: req.searchParams?.get('collectionSlug') || undefined, - documentWhere: documentWhere ? documentWhere : undefined, - folderID: req.searchParams?.get('folderID') || undefined, - folderWhere, - req, - }) - - return Response.json(data) - }, - method: 'get', - path: '/populate-folder-data', -} diff --git a/packages/payload/src/folders/hooks/ensureSafeCollectionsChange.ts b/packages/payload/src/folders/hooks/ensureSafeCollectionsChange.ts new file mode 100644 index 0000000000..cd8e87858f --- /dev/null +++ b/packages/payload/src/folders/hooks/ensureSafeCollectionsChange.ts @@ -0,0 +1,144 @@ +import { APIError, type CollectionBeforeValidateHook, type CollectionSlug } from '../../index.js' +import { extractID } from '../../utilities/extractID.js' +import { getTranslatedLabel } from '../../utilities/getTranslatedLabel.js' + +export const ensureSafeCollectionsChange = + ({ foldersSlug }: { foldersSlug: CollectionSlug }): CollectionBeforeValidateHook => + async ({ data, originalDoc, req }) => { + const currentFolderID = extractID(originalDoc || {}) + const parentFolderID = extractID(data?.folder || originalDoc?.folder || {}) + if (Array.isArray(data?.folderType) && data.folderType.length > 0) { + const folderType = data.folderType as string[] + const currentlyAssignedCollections: string[] | undefined = + Array.isArray(originalDoc?.folderType) && originalDoc.folderType.length > 0 + ? originalDoc.folderType + : undefined + /** + * Check if the assigned collections have changed. + * example: + * - originalAssignedCollections: ['posts', 'pages'] + * - folderType: ['posts'] + * + * The user is narrowing the types of documents that can be associated with this folder. + * If the user is only expanding the types of documents that can be associated with this folder, + * we do not need to do anything. + */ + const newCollections = currentlyAssignedCollections + ? // user is narrowing the current scope of the folder + currentlyAssignedCollections.filter((c) => !folderType.includes(c)) + : // user is adding a scope to the folder + folderType + + if (newCollections && newCollections.length > 0) { + let hasDependentDocuments = false + if (typeof currentFolderID === 'string' || typeof currentFolderID === 'number') { + const childDocumentsResult = await req.payload.findByID({ + id: currentFolderID, + collection: foldersSlug, + joins: { + documentsAndFolders: { + limit: 100_000_000, + where: { + or: [ + { + relationTo: { + in: newCollections, + }, + }, + ], + }, + }, + }, + overrideAccess: true, + req, + }) + + hasDependentDocuments = childDocumentsResult.documentsAndFolders.docs.length > 0 + } + + // matches folders that are directly related to the removed collections + let hasDependentFolders = false + if ( + !hasDependentDocuments && + (typeof currentFolderID === 'string' || typeof currentFolderID === 'number') + ) { + const childFoldersResult = await req.payload.find({ + collection: foldersSlug, + limit: 1, + req, + where: { + and: [ + { + folderType: { + in: newCollections, + }, + }, + { + folder: { + equals: currentFolderID, + }, + }, + ], + }, + }) + hasDependentFolders = childFoldersResult.totalDocs > 0 + } + + if (hasDependentDocuments || hasDependentFolders) { + const translatedLabels = newCollections.map((collectionSlug) => { + if (req.payload.collections[collectionSlug]?.config.labels.singular) { + return getTranslatedLabel( + req.payload.collections[collectionSlug]?.config.labels.plural, + req.i18n, + ) + } + return collectionSlug + }) + + throw new APIError( + `The folder "${data.name || originalDoc.name}" contains ${hasDependentDocuments ? 'documents' : 'folders'} that still belong to the following collections: ${translatedLabels.join(', ')}`, + 400, + ) + } + return data + } + } else if ( + (data?.folderType === null || + (Array.isArray(data?.folderType) && data?.folderType.length === 0)) && + parentFolderID + ) { + // attempting to set the folderType to catch-all, so we need to ensure that the parent allows this + let parentFolder + if (typeof parentFolderID === 'string' || typeof parentFolderID === 'number') { + try { + parentFolder = await req.payload.findByID({ + id: parentFolderID, + collection: foldersSlug, + overrideAccess: true, + req, + select: { + name: true, + folderType: true, + }, + user: req.user, + }) + } catch (_) { + // parent folder does not exist + } + } + + if ( + parentFolder && + parentFolder?.folderType && + Array.isArray(parentFolder.folderType) && + parentFolder.folderType.length > 0 + ) { + throw new APIError( + `The folder "${data?.name || originalDoc.name}" must have folder-type set since its parent folder ${parentFolder?.name ? `"${parentFolder?.name}" ` : ''}has a folder-type set.`, + 400, + ) + } + } + + return data + } diff --git a/packages/payload/src/folders/types.ts b/packages/payload/src/folders/types.ts index 3b7b23793e..6ec48abef1 100644 --- a/packages/payload/src/folders/types.ts +++ b/packages/payload/src/folders/types.ts @@ -10,10 +10,12 @@ export type FolderInterface = { }[] } folder?: FolderInterface | (number | string | undefined) + folderType: CollectionSlug[] name: string } & TypeWithID export type FolderBreadcrumb = { + folderType?: CollectionSlug[] id: null | number | string name: string } @@ -58,6 +60,7 @@ export type FolderOrDocument = { _folderOrDocumentTitle: string createdAt?: string folderID?: number | string + folderType: CollectionSlug[] id: number | string updatedAt?: string } & DocumentMediaData @@ -66,6 +69,7 @@ export type FolderOrDocument = { export type GetFolderDataResult = { breadcrumbs: FolderBreadcrumb[] | null documents: FolderOrDocument[] + folderAssignedCollections: CollectionSlug[] | undefined subfolders: FolderOrDocument[] } @@ -85,6 +89,12 @@ export type RootFoldersConfiguration = { }: { collection: CollectionConfig }) => CollectionConfig | Promise)[] + /** + * If true, you can scope folders to specific collections. + * + * @default true + */ + collectionSpecific?: boolean /** * Ability to view hidden fields and collections related to folders * @@ -114,9 +124,6 @@ export type CollectionFoldersConfiguration = { browseByFolder?: boolean } -type BaseFolderSortKeys = keyof Pick< - FolderOrDocument['value'], - '_folderOrDocumentTitle' | 'createdAt' | 'updatedAt' -> +type BaseFolderSortKeys = 'createdAt' | 'name' | 'updatedAt' export type FolderSortKeys = `-${BaseFolderSortKeys}` | BaseFolderSortKeys diff --git a/packages/payload/src/folders/utils/buildFolderWhereConstraints.ts b/packages/payload/src/folders/utils/buildFolderWhereConstraints.ts index 667b91ab78..272ca9fe34 100644 --- a/packages/payload/src/folders/utils/buildFolderWhereConstraints.ts +++ b/packages/payload/src/folders/utils/buildFolderWhereConstraints.ts @@ -28,20 +28,20 @@ export async function buildFolderWhereConstraints({ }), ] - if (typeof collectionConfig.admin?.baseListFilter === 'function') { - const baseListFilterConstraint = await collectionConfig.admin.baseListFilter({ - limit: 0, - locale: localeCode, - page: 1, - req, - sort: - sort || - (typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : 'id'), - }) + const baseFilterConstraint = await ( + collectionConfig.admin?.baseFilter ?? collectionConfig.admin?.baseListFilter + )?.({ + limit: 0, + locale: localeCode, + page: 1, + req, + sort: + sort || + (typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : 'id'), + }) - if (baseListFilterConstraint) { - constraints.push(baseListFilterConstraint) - } + if (baseFilterConstraint) { + constraints.push(baseFilterConstraint) } if (folderID) { diff --git a/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts b/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts index 825dbb9545..b4aea95e83 100644 --- a/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts +++ b/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts @@ -23,21 +23,24 @@ export function formatFolderOrDocumentItem({ _folderOrDocumentTitle: String((useAsTitle && value?.[useAsTitle]) || value['id']), createdAt: value?.createdAt, folderID: value?.[folderFieldName], + folderType: value?.folderType || [], updatedAt: value?.updatedAt, } if (isUpload) { itemValue.filename = value.filename itemValue.mimeType = value.mimeType - itemValue.url = isImage(value.mimeType) - ? getBestFitFromSizes({ - sizes: value.sizes, - targetSizeMax: 520, - targetSizeMin: 300, - url: value.url, - width: value.width, - }) - : undefined + itemValue.url = + value.thumbnailURL || + (isImage(value.mimeType) + ? getBestFitFromSizes({ + sizes: value.sizes, + targetSizeMax: 520, + targetSizeMin: 300, + url: value.url, + width: value.width, + }) + : undefined) } return { diff --git a/packages/payload/src/folders/utils/getFolderBreadcrumbs.ts b/packages/payload/src/folders/utils/getFolderBreadcrumbs.ts index c2cb4c097a..5e9c2a0102 100644 --- a/packages/payload/src/folders/utils/getFolderBreadcrumbs.ts +++ b/packages/payload/src/folders/utils/getFolderBreadcrumbs.ts @@ -27,6 +27,7 @@ export const getFolderBreadcrumbs = async ({ select: { name: true, [folderFieldName]: true, + folderType: true, }, user, where: { @@ -42,6 +43,7 @@ export const getFolderBreadcrumbs = async ({ breadcrumbs.push({ id: folder.id, name: folder.name, + folderType: folder.folderType, }) if (folder[folderFieldName]) { return getFolderBreadcrumbs({ diff --git a/packages/payload/src/folders/utils/getFolderData.ts b/packages/payload/src/folders/utils/getFolderData.ts index d5efa40ef0..6acfcf49bb 100644 --- a/packages/payload/src/folders/utils/getFolderData.ts +++ b/packages/payload/src/folders/utils/getFolderData.ts @@ -1,6 +1,6 @@ import type { CollectionSlug } from '../../index.js' import type { PayloadRequest, Where } from '../../types/index.js' -import type { GetFolderDataResult } from '../types.js' +import type { FolderOrDocument, FolderSortKeys, GetFolderDataResult } from '../types.js' import { parseDocumentID } from '../../index.js' import { getFolderBreadcrumbs } from './getFolderBreadcrumbs.js' @@ -29,6 +29,7 @@ type Args = { */ folderWhere?: Where req: PayloadRequest + sort: FolderSortKeys } /** * Query for documents, subfolders and breadcrumbs for a given folder @@ -39,6 +40,7 @@ export const getFolderData = async ({ folderID: _folderID, folderWhere, req, + sort = 'name', }: Args): Promise => { const { payload } = req @@ -65,15 +67,16 @@ export const getFolderData = async ({ parentFolderID, req, }) - const [breadcrumbs, documentsAndSubfolders] = await Promise.all([ + const [breadcrumbs, result] = await Promise.all([ breadcrumbsPromise, documentAndSubfolderPromise, ]) return { breadcrumbs, - documents: documentsAndSubfolders.documents, - subfolders: documentsAndSubfolders.subfolders, + documents: sortDocs({ docs: result.documents, sort }), + folderAssignedCollections: result.folderAssignedCollections, + subfolders: sortDocs({ docs: result.subfolders, sort }), } } else { // subfolders and documents are queried separately @@ -96,10 +99,40 @@ export const getFolderData = async ({ subfoldersPromise, documentsPromise, ]) + return { breadcrumbs, - documents, - subfolders, + documents: sortDocs({ docs: documents, sort }), + folderAssignedCollections: collectionSlug ? [collectionSlug] : undefined, + subfolders: sortDocs({ docs: subfolders, sort }), } } } + +function sortDocs({ + docs, + sort, +}: { + docs: FolderOrDocument[] + sort?: FolderSortKeys +}): FolderOrDocument[] { + if (!sort) { + return docs + } + const isDesc = typeof sort === 'string' && sort.startsWith('-') + const sortKey = (isDesc ? sort.slice(1) : sort) as FolderSortKeys + + return docs.sort((a, b) => { + let result = 0 + if (sortKey === 'name') { + result = a.value._folderOrDocumentTitle.localeCompare(b.value._folderOrDocumentTitle) + } else if (sortKey === 'createdAt') { + result = + new Date(a.value.createdAt || '').getTime() - new Date(b.value.createdAt || '').getTime() + } else if (sortKey === 'updatedAt') { + result = + new Date(a.value.updatedAt || '').getTime() - new Date(b.value.updatedAt || '').getTime() + } + return isDesc ? -result : result + }) +} diff --git a/packages/payload/src/folders/utils/getFoldersAndDocumentsFromJoin.ts b/packages/payload/src/folders/utils/getFoldersAndDocumentsFromJoin.ts index ea3ef47af9..98b40276c4 100644 --- a/packages/payload/src/folders/utils/getFoldersAndDocumentsFromJoin.ts +++ b/packages/payload/src/folders/utils/getFoldersAndDocumentsFromJoin.ts @@ -1,4 +1,5 @@ import type { PaginatedDocs } from '../../database/types.js' +import type { CollectionSlug } from '../../index.js' import type { Document, PayloadRequest, Where } from '../../types/index.js' import type { FolderOrDocument } from '../types.js' @@ -8,6 +9,7 @@ import { formatFolderOrDocumentItem } from './formatFolderOrDocumentItem.js' type QueryDocumentsAndFoldersResults = { documents: FolderOrDocument[] + folderAssignedCollections: CollectionSlug[] subfolders: FolderOrDocument[] } type QueryDocumentsAndFoldersArgs = { @@ -85,5 +87,9 @@ export async function queryDocumentsAndFoldersFromJoin({ }, ) - return results + return { + documents: results.documents, + folderAssignedCollections: subfolderDoc?.docs[0]?.folderType || [], + subfolders: results.subfolders, + } } diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 0f3f3869f7..ed10f361b6 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -282,6 +282,7 @@ export const updateOperation = async < docWithLocales: result, draft: shouldSaveDraft, global: globalConfig, + operation: 'update', payload, publishSpecificLocale, req, diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 9d293a4234..a780a3128d 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -40,7 +40,7 @@ import { } from './auth/operations/local/verifyEmail.js' export type { FieldState } from './admin/forms/Form.js' import type { InitOptions, SanitizedConfig } from './config/types.js' -import type { BaseDatabaseAdapter, PaginatedDocs } from './database/types.js' +import type { BaseDatabaseAdapter, PaginatedDistinctDocs, PaginatedDocs } from './database/types.js' import type { InitializedEmailAdapter } from './email/types.js' import type { DataFromGlobalSlug, Globals, SelectFromGlobalSlug } from './globals/config/types.js' import type { @@ -72,6 +72,10 @@ import { findByIDLocal, type Options as FindByIDOptions, } from './collections/operations/local/findByID.js' +import { + findDistinct as findDistinctLocal, + type Options as FindDistinctOptions, +} from './collections/operations/local/findDistinct.js' import { findVersionByIDLocal, type Options as FindVersionByIDOptions, @@ -133,6 +137,7 @@ import { countVersionsLocal } from './collections/operations/local/countVersions import { consoleEmailAdapter } from './email/consoleEmailAdapter.js' import { fieldAffectsData, type FlattenedBlock } from './fields/config/types.js' import { getJobsLocalAPI } from './queues/localAPI.js' +import { _internal_jobSystemGlobals } from './queues/utilities/getCurrentDate.js' import { isNextBuild } from './utilities/isNextBuild.js' import { getLogger } from './utilities/logger.js' import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js' @@ -156,7 +161,6 @@ export { extractAccessFromPermission } from './auth/extractAccessFromPermission. export { getAccessResults } from './auth/getAccessResults.js' export { getFieldsToSign } from './auth/getFieldsToSign.js' export { getLoginOptions } from './auth/getLoginOptions.js' - export interface GeneratedTypes { authUntyped: { [slug: string]: { @@ -465,6 +469,20 @@ export class BasePayload { return findByIDLocal(this, options) } + /** + * @description Find distinct field values + * @param options + * @returns result with distinct field values + */ + findDistinct = async < + TSlug extends CollectionSlug, + TField extends keyof DataFromCollectionSlug & string, + >( + options: FindDistinctOptions, + ): Promise[TField]>>> => { + return findDistinctLocal(this, options) + } + findGlobal = async >( options: FindGlobalOptions, ): Promise> => { @@ -837,7 +855,7 @@ export class BasePayload { throw error } - if (this.config.jobs.enabled && this.config.jobs.autoRun && !isNextBuild()) { + if (this.config.jobs.enabled && this.config.jobs.autoRun && !isNextBuild() && options.cron) { const DEFAULT_CRON = '* * * * *' const DEFAULT_LIMIT = 10 @@ -848,24 +866,38 @@ export class BasePayload { await Promise.all( cronJobs.map((cronConfig) => { - const job = new Cron(cronConfig.cron ?? DEFAULT_CRON, async () => { + const jobAutorunCron = new Cron(cronConfig.cron ?? DEFAULT_CRON, async () => { + if ( + _internal_jobSystemGlobals.shouldAutoSchedule && + !cronConfig.disableScheduling && + this.config.jobs.scheduling + ) { + await this.jobs.handleSchedules({ + queue: cronConfig.queue, + }) + } + + if (!_internal_jobSystemGlobals.shouldAutoRun) { + return + } + if (typeof this.config.jobs.shouldAutoRun === 'function') { const shouldAutoRun = await this.config.jobs.shouldAutoRun(this) if (!shouldAutoRun) { - job.stop() - - return false + jobAutorunCron.stop() + return } } await this.jobs.run({ limit: cronConfig.limit ?? DEFAULT_LIMIT, queue: cronConfig.queue, + silent: cronConfig.silent, }) }) - this.crons.push(job) + this.crons.push(jobAutorunCron) }), ) } @@ -914,8 +946,10 @@ export const reload = async ( payload: Payload, skipImportMapGeneration?: boolean, ): Promise => { - await payload.destroy() - + if (typeof payload.db.destroy === 'function') { + // Only destroy db, as we then later only call payload.db.init and not payload.init + await payload.db.destroy() + } payload.config = config payload.collections = config.collections.reduce( @@ -975,7 +1009,7 @@ export const reload = async ( } export const getPayload = async ( - options: Pick, + options: Pick, ): Promise => { if (!options?.config) { throw new Error('Error: the payload config is required for getPayload to work.') @@ -1110,6 +1144,8 @@ export { generateImportMap } from './bin/generateImportMap/index.js' export type { ImportMap } from './bin/generateImportMap/index.js' export { genImportMapIterateFields } from './bin/generateImportMap/iterateFields.js' +export { migrate as migrateCLI } from './bin/migrate.js' + export { type ClientCollectionConfig, createClientCollectionConfig, @@ -1132,6 +1168,7 @@ export type { AfterRefreshHook as CollectionAfterRefreshHook, AuthCollection, AuthOperationsFromCollectionSlug, + BaseFilter, BaseListFilter, BeforeChangeHook as CollectionBeforeChangeHook, BeforeDeleteHook as CollectionBeforeDeleteHook, @@ -1156,8 +1193,8 @@ export type { } from './collections/config/types.js' export type { CompoundIndex } from './collections/config/types.js' - export type { SanitizedCompoundIndex } from './collections/config/types.js' + export { createDataloaderCacheKey, getDataLoader } from './collections/dataloader.js' export { countOperation } from './collections/operations/count.js' export { createOperation } from './collections/operations/create.js' @@ -1173,7 +1210,6 @@ export { restoreVersionOperation } from './collections/operations/restoreVersion export { updateOperation } from './collections/operations/update.js' export { updateByIDOperation } from './collections/operations/updateByID.js' export { buildConfig } from './config/build.js' - export { type ClientConfig, createClientConfig, @@ -1181,8 +1217,8 @@ export { serverOnlyConfigProperties, type UnsanitizedClientConfig, } from './config/client.js' - export { defaults } from './config/defaults.js' + export { type OrderableEndpointBody } from './config/orderable/index.js' export { sanitizeConfig } from './config/sanitize.js' export type * from './config/types.js' @@ -1237,6 +1273,7 @@ export type { Destroy, Find, FindArgs, + FindDistinct, FindGlobal, FindGlobalArgs, FindGlobalVersions, @@ -1250,6 +1287,7 @@ export type { Migration, MigrationData, MigrationTemplateArgs, + PaginatedDistinctDocs, PaginatedDocs, QueryDrafts, QueryDraftsArgs, @@ -1298,10 +1336,12 @@ export { ValidationError, ValidationErrorName, } from './errors/index.js' -export type { ValidationFieldError } from './errors/index.js' +export type { ValidationFieldError } from './errors/index.js' export { baseBlockFields } from './fields/baseFields/baseBlockFields.js' + export { baseIDField } from './fields/baseFields/baseIDField.js' + export { createClientField, createClientFields, @@ -1309,10 +1349,10 @@ export { type ServerOnlyFieldProperties, } from './fields/config/client.js' -export { sanitizeFields } from './fields/config/sanitize.js' - export interface FieldCustom extends Record {} +export { sanitizeFields } from './fields/config/sanitize.js' + export type { AdminClient, ArrayField, @@ -1422,15 +1462,16 @@ export type { } from './fields/config/types.js' export { getDefaultValue } from './fields/getDefaultValue.js' - export { traverseFields as afterChangeTraverseFields } from './fields/hooks/afterChange/traverseFields.js' + export { promise as afterReadPromise } from './fields/hooks/afterRead/promise.js' export { traverseFields as afterReadTraverseFields } from './fields/hooks/afterRead/traverseFields.js' export { traverseFields as beforeChangeTraverseFields } from './fields/hooks/beforeChange/traverseFields.js' export { traverseFields as beforeValidateTraverseFields } from './fields/hooks/beforeValidate/traverseFields.js' -export { sortableFieldTypes } from './fields/sortableFieldTypes.js' +export { sortableFieldTypes } from './fields/sortableFieldTypes.js' export { validations } from './fields/validations.js' + export type { ArrayFieldValidation, BlocksFieldValidation, @@ -1482,9 +1523,10 @@ export type { GlobalConfig, SanitizedGlobalConfig, } from './globals/config/types.js' -export { docAccessOperation as docAccessOperationGlobal } from './globals/operations/docAccess.js' +export { docAccessOperation as docAccessOperationGlobal } from './globals/operations/docAccess.js' export { findOneOperation } from './globals/operations/findOne.js' + export { findVersionByIDOperation as findVersionByIDOperationGlobal } from './globals/operations/findVersionByID.js' export { findVersionsOperation as findVersionsOperationGlobal } from './globals/operations/findVersions.js' export { restoreVersionOperation as restoreVersionOperationGlobal } from './globals/operations/restoreVersion.js' @@ -1505,9 +1547,8 @@ export type { TabsPreferences, } from './preferences/types.js' export type { QueryPreset } from './query-presets/types.js' -export { jobAfterRead } from './queues/config/index.js' +export { jobAfterRead } from './queues/config/collection.js' export type { JobsConfig, RunJobAccess, RunJobAccessArgs } from './queues/config/types/index.js' - export type { RunInlineTaskFunction, RunTaskFunction, @@ -1521,6 +1562,7 @@ export type { TaskOutput, TaskType, } from './queues/config/types/taskTypes.js' + export type { BaseJob, JobLog, @@ -1531,14 +1573,21 @@ export type { WorkflowHandler, WorkflowTypes, } from './queues/config/types/workflowTypes.js' +export { countRunnableOrActiveJobsForQueue } from './queues/operations/handleSchedules/countRunnableOrActiveJobsForQueue.js' export { importHandlerPath } from './queues/operations/runJobs/runJob/importHandlerPath.js' +export { + _internal_jobSystemGlobals, + _internal_resetJobSystemGlobals, + getCurrentDate, +} from './queues/utilities/getCurrentDate.js' export { getLocalI18n } from './translations/getLocalI18n.js' export * from './types/index.js' export { getFileByPath } from './uploads/getFileByPath.js' +export { _internal_safeFetchGlobal } from './uploads/safeFetch.js' + export type * from './uploads/types.js' export { addDataAndFileToRequest } from './utilities/addDataAndFileToRequest.js' - export { addLocalesToRequestFromData, sanitizeLocales } from './utilities/addLocalesToRequest.js' export { commitTransaction } from './utilities/commitTransaction.js' export { @@ -1609,9 +1658,9 @@ export { versionDefaults } from './versions/defaults.js' export { deleteCollectionVersions } from './versions/deleteCollectionVersions.js' export { appendVersionToQueryKey } from './versions/drafts/appendVersionToQueryKey.js' export { getQueryDraftsSort } from './versions/drafts/getQueryDraftsSort.js' + export { enforceMaxVersions } from './versions/enforceMaxVersions.js' export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js' - export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js' export { saveVersion } from './versions/saveVersion.js' export type { SchedulePublishTaskInput } from './versions/schedule/types.js' diff --git a/packages/payload/src/preferences/types.ts b/packages/payload/src/preferences/types.ts index 0e4a137a39..245fd2ad63 100644 --- a/packages/payload/src/preferences/types.ts +++ b/packages/payload/src/preferences/types.ts @@ -37,6 +37,7 @@ export type ColumnPreference = { export type CollectionPreferences = { columns?: ColumnPreference[] editViewType?: 'default' | 'live-preview' + groupBy?: string limit?: number preset?: DefaultDocumentIDType sort?: string diff --git a/packages/payload/src/queues/config/index.ts b/packages/payload/src/queues/config/collection.ts similarity index 84% rename from packages/payload/src/queues/config/index.ts rename to packages/payload/src/queues/config/collection.ts index 9628a29bff..e9e925d804 100644 --- a/packages/payload/src/queues/config/index.ts +++ b/packages/payload/src/queues/config/collection.ts @@ -1,25 +1,28 @@ import type { CollectionConfig } from '../../collections/config/types.js' -import type { Config, SanitizedConfig } from '../../config/types.js' +import type { SanitizedConfig } from '../../config/types.js' import type { Field } from '../../fields/config/types.js' import type { Job } from '../../index.js' -import { runJobsEndpoint } from '../restEndpointRun.js' +import { handleSchedulesJobsEndpoint } from '../endpoints/handleSchedules.js' +import { runJobsEndpoint } from '../endpoints/run.js' import { getJobTaskStatus } from '../utilities/getJobTaskStatus.js' export const jobsCollectionSlug = 'payload-jobs' -export const getDefaultJobsCollection: (config: Config) => CollectionConfig = (config) => { +export const getDefaultJobsCollection: (jobsConfig: SanitizedConfig['jobs']) => CollectionConfig = ( + jobsConfig, +) => { const workflowSlugs: Set = new Set() const taskSlugs: Set = new Set(['inline']) - if (config.jobs?.workflows?.length) { - config.jobs?.workflows.forEach((workflow) => { + if (jobsConfig.workflows?.length) { + jobsConfig.workflows.forEach((workflow) => { workflowSlugs.add(workflow.slug) }) } - if (config.jobs?.tasks?.length) { - config.jobs.tasks.forEach((task) => { + if (jobsConfig.tasks?.length) { + jobsConfig.tasks.forEach((task) => { if (workflowSlugs.has(task.slug)) { throw new Error( `Task slug "${task.slug}" is already used by a workflow. No tasks are allowed to have the same slug as a workflow.`, @@ -78,7 +81,7 @@ export const getDefaultJobsCollection: (config: Config) => CollectionConfig = (c }, ] - if (config?.jobs?.addParentToTaskLog) { + if (jobsConfig.addParentToTaskLog) { logFields.push({ name: 'parent', type: 'group', @@ -102,7 +105,7 @@ export const getDefaultJobsCollection: (config: Config) => CollectionConfig = (c group: 'System', hidden: true, }, - endpoints: [runJobsEndpoint], + endpoints: [runJobsEndpoint, handleSchedulesJobsEndpoint], fields: [ { name: 'input', @@ -198,6 +201,9 @@ export const getDefaultJobsCollection: (config: Config) => CollectionConfig = (c { name: 'waitUntil', type: 'date', + admin: { + date: { pickerAppearance: 'dayAndTime' }, + }, index: true, }, { @@ -237,6 +243,15 @@ export const getDefaultJobsCollection: (config: Config) => CollectionConfig = (c lockDocuments: false, } + if (jobsConfig.stats) { + // TODO: In 4.0, this should be added by default. + // The meta field can be used to store arbitrary data about the job. The scheduling system uses this to store + // `scheduled: true` to indicate that the job was queued by the scheduling system. + jobsCollection.fields.push({ + name: 'meta', + type: 'json', + }) + } return jobsCollection } diff --git a/packages/payload/src/queues/config/global.ts b/packages/payload/src/queues/config/global.ts new file mode 100644 index 0000000000..55c87d247f --- /dev/null +++ b/packages/payload/src/queues/config/global.ts @@ -0,0 +1,45 @@ +import type { Config } from '../../config/types.js' +import type { GlobalConfig } from '../../globals/config/types.js' +import type { TaskType } from './types/taskTypes.js' +import type { WorkflowTypes } from './types/workflowTypes.js' + +export const jobStatsGlobalSlug = 'payload-jobs-stats' + +/** + * Type for data stored in the payload-jobs-stats global. + */ +export type JobStats = { + stats?: { + scheduledRuns?: { + queues?: { + [queueSlug: string]: { + tasks?: { + [taskSlug: TaskType]: { + lastScheduledRun: string + } + } + workflows?: { + [workflowSlug: WorkflowTypes]: { + lastScheduledRun: string + } + } + } + } + } + } +} + +/** + * Global config for job statistics. + */ +export const getJobStatsGlobal: (config: Config) => GlobalConfig = (config) => { + return { + slug: jobStatsGlobalSlug, + fields: [ + { + name: 'stats', + type: 'json', + }, + ], + } +} diff --git a/packages/payload/src/queues/config/types/index.ts b/packages/payload/src/queues/config/types/index.ts index 2e6eafc72f..9ea4ff2233 100644 --- a/packages/payload/src/queues/config/types/index.ts +++ b/packages/payload/src/queues/config/types/index.ts @@ -1,10 +1,12 @@ -import type { CollectionConfig } from '../../../index.js' +import type { CollectionConfig, Job } from '../../../index.js' import type { Payload, PayloadRequest, Sort } from '../../../types/index.js' +import type { RunJobsSilent } from '../../localAPI.js' import type { RunJobsArgs } from '../../operations/runJobs/index.js' +import type { JobStats } from '../global.js' import type { TaskConfig } from './taskTypes.js' import type { WorkflowConfig } from './workflowTypes.js' -export type CronConfig = { +export type AutorunCronConfig = { /** * The cron schedule for the job. * @default '* * * * *' (every minute). @@ -26,6 +28,15 @@ export type CronConfig = { * - '* * * * * *' every second */ cron?: string + /** + * By default, the autorun will attempt to schedule jobs for tasks and workflows that have a `schedule` property, given + * the queue name is the same. + * + * Set this to `true` to disable the scheduling of jobs automatically. + * + * @default false + */ + disableScheduling?: boolean /** * The limit for the job. This can be overridden by the user. Defaults to 10. */ @@ -34,6 +45,15 @@ export type CronConfig = { * The queue name for the job. */ queue?: string + /** + * If set to true, the job system will not log any output to the console (for both info and error logs). + * Can be an option for more granular control over logging. + * + * This will not automatically affect user-configured logs (e.g. if you call `console.log` or `payload.logger.info` in your job code). + * + * @default false + */ + silent?: RunJobsSilent } export type RunJobAccessArgs = { @@ -48,6 +68,16 @@ export type SanitizedJobsConfig = { * This property is automatically set during sanitization. */ enabled?: boolean + /** + * If set to `true`, at least one task or workflow has scheduling enabled. + * This property is automatically set during sanitization. + */ + scheduling?: boolean + /** + * If set to `true`, a payload-job-stats global exists. + * This property is automatically set during sanitization. + */ + stats?: boolean } & JobsConfig export type JobsConfig = { /** @@ -73,7 +103,9 @@ export type JobsConfig = { * * @remark this property should not be used on serverless platforms like Vercel */ - autoRun?: ((payload: Payload) => CronConfig[] | Promise) | CronConfig[] + autoRun?: + | ((payload: Payload) => AutorunCronConfig[] | Promise) + | AutorunCronConfig[] /** * Determine whether or not to delete a job after it has successfully completed. */ @@ -121,6 +153,7 @@ export type JobsConfig = { /** * A function that will be executed before Payload picks up jobs which are configured by the `jobs.autorun` function. * If this function returns true, jobs will be queried and picked up. If it returns false, jobs will not be run. + * @default undefined - if this function is not defined, jobs will be run - as if () => true was passed. * @param payload * @returns boolean */ @@ -134,3 +167,104 @@ export type JobsConfig = { */ workflows?: WorkflowConfig[] } + +export type Queueable = { + scheduleConfig: ScheduleConfig + taskConfig?: TaskConfig + // If not set, queue it immediately + waitUntil?: Date + workflowConfig?: WorkflowConfig +} + +type OptionalPromise = Promise | T + +export type BeforeScheduleFn = (args: { + defaultBeforeSchedule: BeforeScheduleFn + /** + * payload-job-stats global data + */ + jobStats: JobStats + queueable: Queueable + req: PayloadRequest +}) => OptionalPromise<{ + input?: object + shouldSchedule: boolean + waitUntil?: Date +}> + +export type AfterScheduleFn = ( + args: { + defaultAfterSchedule: AfterScheduleFn + /** + * payload-job-stats global data. If the global does not exist, it will be null. + */ + jobStats: JobStats | null + queueable: Queueable + req: PayloadRequest + } & ( + | { + error: Error + job?: never + status: 'error' + } + | { + error?: never + job: Job + status: 'success' + } + | { + error?: never + job?: never + /** + * If the beforeSchedule hook returned `shouldSchedule: false`, this will be called with status `skipped`. + */ + status: 'skipped' + } + ), +) => OptionalPromise + +export type ScheduleConfig = { + /** + * The cron for scheduling the job. + * + * @example + * ┌───────────── (optional) second (0 - 59) + * │ ┌───────────── minute (0 - 59) + * │ │ ┌───────────── hour (0 - 23) + * │ │ │ ┌───────────── day of the month (1 - 31) + * │ │ │ │ ┌───────────── month (1 - 12) + * │ │ │ │ │ ┌───────────── day of the week (0 - 6) (Sunday to Saturday) + * │ │ │ │ │ │ + * │ │ │ │ │ │ + * - '* 0 * * * *' every hour at minute 0 + * - '* 0 0 * * *' daily at midnight + * - '* 0 0 * * 0' weekly at midnight on Sundays + * - '* 0 0 1 * *' monthly at midnight on the 1st day of the month + * - '* 0/5 * * * *' every 5 minutes + * - '* * * * * *' every second + */ + cron: string + hooks?: { + /** + * Functions that will be executed after the job has been successfully scheduled. + * + * @default By default, global update?? Unless global update should happen before + */ + afterSchedule?: AfterScheduleFn + /** + * Functions that will be executed before the job is scheduled. + * You can use this to control whether or not the job should be scheduled, or what input + * data should be passed to the job. + * + * @default By default, this has one function that returns { shouldSchedule: true } if the following conditions are met: + * - There currently is no job of the same type in the specified queue that is currently running + * - There currently is no job of the same type in the specified queue that is scheduled to run in the future + * - There currently is no job of the same type in the specified queue that failed previously but can be retried + */ + beforeSchedule?: BeforeScheduleFn + } + /** + * Queue to which the scheduled job will be added. + */ + queue: string +} diff --git a/packages/payload/src/queues/config/types/taskTypes.ts b/packages/payload/src/queues/config/types/taskTypes.ts index fe9107b8f8..48edff6881 100644 --- a/packages/payload/src/queues/config/types/taskTypes.ts +++ b/packages/payload/src/queues/config/types/taskTypes.ts @@ -1,4 +1,5 @@ import type { Field, Job, PayloadRequest, StringKeyOf, TypedJobs } from '../../../index.js' +import type { ScheduleConfig } from './index.js' import type { SingleTaskStatus } from './workflowTypes.js' export type TaskInputOutput = { @@ -54,6 +55,9 @@ export type TaskHandler< args: TaskHandlerArgs, ) => Promise> | TaskHandlerResult +/** + * @todo rename to TaskSlug in 4.0, similar to CollectionSlug + */ export type TaskType = StringKeyOf // Extracts the type of `input` corresponding to each task @@ -233,6 +237,10 @@ export type TaskConfig< * @default By default, tasks are not retried and `retries` is `undefined`. */ retries?: number | RetryConfig | undefined + /** + * Allows automatically scheduling this task to run regularly at a specified interval. + */ + schedule?: ScheduleConfig[] /** * Define a slug-based name for this job. This slug needs to be unique among both tasks and workflows. */ diff --git a/packages/payload/src/queues/config/types/workflowTypes.ts b/packages/payload/src/queues/config/types/workflowTypes.ts index 6a4adc011b..8c16825102 100644 --- a/packages/payload/src/queues/config/types/workflowTypes.ts +++ b/packages/payload/src/queues/config/types/workflowTypes.ts @@ -7,6 +7,7 @@ import type { TypedJobs, } from '../../../index.js' import type { TaskParent } from '../../operations/runJobs/runJob/getRunTaskFunction.js' +import type { ScheduleConfig } from './index.js' import type { RetryConfig, RunInlineTaskFunction, @@ -53,6 +54,13 @@ export type BaseJob< ? TypedJobs['workflows'][TWorkflowSlugOrInput]['input'] : TWorkflowSlugOrInput log?: JobLog[] + meta?: { + [key: string]: unknown + /** + * If true, this job was queued by the scheduling system. + */ + scheduled?: boolean + } processing?: boolean queue?: string taskSlug?: null | TaskType @@ -63,6 +71,9 @@ export type BaseJob< workflowSlug?: null | WorkflowTypes } +/** + * @todo rename to WorkflowSlug in 4.0, similar to CollectionSlug + */ export type WorkflowTypes = StringKeyOf /** @@ -155,6 +166,10 @@ export type WorkflowConfig< * @default undefined. By default, workflows retries are defined by their tasks */ retries?: number | RetryConfig | undefined + /** + * Allows automatically scheduling this workflow to run regularly at a specified interval. + */ + schedule?: ScheduleConfig[] /** * Define a slug-based name for this job. */ diff --git a/packages/payload/src/queues/endpoints/handleSchedules.ts b/packages/payload/src/queues/endpoints/handleSchedules.ts new file mode 100644 index 0000000000..385cb496e9 --- /dev/null +++ b/packages/payload/src/queues/endpoints/handleSchedules.ts @@ -0,0 +1,66 @@ +import type { Endpoint } from '../../config/types.js' + +import { handleSchedules } from '../operations/handleSchedules/index.js' +import { configHasJobs } from './run.js' + +/** + * GET /api/payload-jobs/handle-schedules endpoint + * + * This endpoint is GET instead of POST to allow it to be used in a Vercel Cron. + */ +export const handleSchedulesJobsEndpoint: Endpoint = { + handler: async (req) => { + const jobsConfig = req.payload.config.jobs + + if (!configHasJobs(jobsConfig)) { + return Response.json( + { + message: 'No jobs to schedule.', + }, + { status: 200 }, + ) + } + + const accessFn = jobsConfig.access?.run ?? (() => true) + + const hasAccess = await accessFn({ req }) + + if (!hasAccess) { + return Response.json( + { + message: req.i18n.t('error:unauthorized'), + }, + { status: 401 }, + ) + } + + if (!jobsConfig.scheduling) { + // There is no reason to call the handleSchedules endpoint if the stats global is not enabled (= no schedules defined) + return Response.json( + { + message: + 'Cannot handle schedules because no tasks or workflows with schedules are defined.', + }, + { status: 500 }, + ) + } + + const { queue } = req.query as { + queue?: string + } + + const { errored, queued, skipped } = await handleSchedules({ queue, req }) + + return Response.json( + { + errored, + message: req.i18n.t('general:success'), + queued, + skipped, + }, + { status: 200 }, + ) + }, + method: 'get', + path: '/handle-schedules', +} diff --git a/packages/payload/src/queues/endpoints/run.ts b/packages/payload/src/queues/endpoints/run.ts new file mode 100644 index 0000000000..a362a7d2cc --- /dev/null +++ b/packages/payload/src/queues/endpoints/run.ts @@ -0,0 +1,118 @@ +import type { Endpoint } from '../../config/types.js' +import type { SanitizedJobsConfig } from '../config/types/index.js' + +import { runJobs, type RunJobsArgs } from '../operations/runJobs/index.js' + +/** + * /api/payload-jobs/run endpoint + * + * This endpoint is GET instead of POST to allow it to be used in a Vercel Cron. + */ +export const runJobsEndpoint: Endpoint = { + handler: async (req) => { + const jobsConfig = req.payload.config.jobs + + if (!configHasJobs(jobsConfig)) { + return Response.json( + { + message: 'No jobs to run.', + }, + { status: 200 }, + ) + } + + const accessFn = jobsConfig.access?.run ?? (() => true) + + const hasAccess = await accessFn({ req }) + + if (!hasAccess) { + return Response.json( + { + message: req.i18n.t('error:unauthorized'), + }, + { status: 401 }, + ) + } + + const { + allQueues, + disableScheduling: disableSchedulingParam, + limit, + queue, + silent: silentParam, + } = req.query as { + allQueues?: 'false' | 'true' + disableScheduling?: 'false' | 'true' + limit?: number + queue?: string + silent?: string + } + + const silent = silentParam === 'true' + + const shouldHandleSchedules = disableSchedulingParam !== 'true' + + const runAllQueues = allQueues && !(typeof allQueues === 'string' && allQueues === 'false') + + if (shouldHandleSchedules && jobsConfig.scheduling) { + // If should handle schedules and schedules are defined + await req.payload.jobs.handleSchedules({ queue: runAllQueues ? undefined : queue, req }) + } + + const runJobsArgs: RunJobsArgs = { + queue, + req, + // Access is validated above, so it's safe to override here + allQueues: runAllQueues, + overrideAccess: true, + silent, + } + + if (typeof queue === 'string') { + runJobsArgs.queue = queue + } + + const parsedLimit = Number(limit) + if (!isNaN(parsedLimit)) { + runJobsArgs.limit = parsedLimit + } + + let noJobsRemaining = false + let remainingJobsFromQueried = 0 + try { + const result = await runJobs(runJobsArgs) + noJobsRemaining = !!result.noJobsRemaining + remainingJobsFromQueried = result.remainingJobsFromQueried + } catch (err) { + req.payload.logger.error({ + err, + msg: 'There was an error running jobs:', + queue: runJobsArgs.queue, + }) + + return Response.json( + { + message: req.i18n.t('error:unknown'), + noJobsRemaining: true, + remainingJobsFromQueried, + }, + { status: 500 }, + ) + } + + return Response.json( + { + message: req.i18n.t('general:success'), + noJobsRemaining, + remainingJobsFromQueried, + }, + { status: 200 }, + ) + }, + method: 'get', + path: '/run', +} + +export const configHasJobs = (jobsConfig: SanitizedJobsConfig): boolean => { + return Boolean(jobsConfig.tasks?.length || jobsConfig.workflows?.length) +} diff --git a/packages/payload/src/queues/errors/calculateBackoffWaitUntil.ts b/packages/payload/src/queues/errors/calculateBackoffWaitUntil.ts index e8ff239e8e..0214ecd141 100644 --- a/packages/payload/src/queues/errors/calculateBackoffWaitUntil.ts +++ b/packages/payload/src/queues/errors/calculateBackoffWaitUntil.ts @@ -1,5 +1,7 @@ import type { RetryConfig } from '../config/types/taskTypes.js' +import { getCurrentDate } from '../utilities/getCurrentDate.js' + export function calculateBackoffWaitUntil({ retriesConfig, totalTried, @@ -7,23 +9,23 @@ export function calculateBackoffWaitUntil({ retriesConfig: number | RetryConfig totalTried: number }): Date { - let waitUntil: Date = new Date() + let waitUntil: Date = getCurrentDate() if (typeof retriesConfig === 'object') { if (retriesConfig.backoff) { if (retriesConfig.backoff.type === 'fixed') { waitUntil = retriesConfig.backoff.delay - ? new Date(new Date().getTime() + retriesConfig.backoff.delay) - : new Date() + ? new Date(getCurrentDate().getTime() + retriesConfig.backoff.delay) + : getCurrentDate() } else if (retriesConfig.backoff.type === 'exponential') { // 2 ^ (attempts - 1) * delay (current attempt is not included in totalTried, thus no need for -1) const delay = retriesConfig.backoff.delay ? retriesConfig.backoff.delay : 0 - waitUntil = new Date(new Date().getTime() + Math.pow(2, totalTried) * delay) + waitUntil = new Date(getCurrentDate().getTime() + Math.pow(2, totalTried) * delay) } } } /* - const differenceInMSBetweenNowAndWaitUntil = waitUntil.getTime() - new Date().getTime() + const differenceInMSBetweenNowAndWaitUntil = waitUntil.getTime() - getCurrentDate().getTime() const differenceInSBetweenNowAndWaitUntil = differenceInMSBetweenNowAndWaitUntil / 1000 console.log('Calculated backoff', { diff --git a/packages/payload/src/queues/errors/handleTaskError.ts b/packages/payload/src/queues/errors/handleTaskError.ts index 3d6b491c95..3366b0a15b 100644 --- a/packages/payload/src/queues/errors/handleTaskError.ts +++ b/packages/payload/src/queues/errors/handleTaskError.ts @@ -1,9 +1,11 @@ import ObjectIdImport from 'bson-objectid' import type { PayloadRequest } from '../../index.js' +import type { RunJobsSilent } from '../localAPI.js' import type { UpdateJobFunction } from '../operations/runJobs/runJob/getUpdateJobFunction.js' import type { TaskError } from './index.js' +import { getCurrentDate } from '../utilities/getCurrentDate.js' import { calculateBackoffWaitUntil } from './calculateBackoffWaitUntil.js' import { getWorkflowRetryBehavior } from './getWorkflowRetryBehavior.js' @@ -13,10 +15,20 @@ const ObjectId = (ObjectIdImport.default || export async function handleTaskError({ error, req, + silent = false, updateJob, }: { error: TaskError req: PayloadRequest + /** + * If set to true, the job system will not log any output to the console (for both info and error logs). + * Can be an option for more granular control over logging. + * + * This will not automatically affect user-configured logs (e.g. if you call `console.log` or `payload.logger.info` in your job code). + * + * @default false + */ + silent?: RunJobsSilent updateJob: UpdateJobFunction }): Promise<{ hasFinalError: boolean @@ -46,7 +58,7 @@ export async function handleTaskError({ stack: error.stack, } - const currentDate = new Date() + const currentDate = getCurrentDate() ;(job.log ??= []).push({ id: new ObjectId().toHexString(), @@ -102,12 +114,14 @@ export async function handleTaskError({ waitUntil: job.waitUntil, }) - req.payload.logger.error({ - err: error, - job, - msg: `Error running task ${taskID}. Attempt ${job.totalTried} - max retries reached`, - taskSlug, - }) + if (!silent || (typeof silent === 'object' && !silent.error)) { + req.payload.logger.error({ + err: error, + job, + msg: `Error running task ${taskID}. Attempt ${job.totalTried} - max retries reached`, + taskSlug, + }) + } return { hasFinalError: true, } @@ -135,12 +149,14 @@ export async function handleTaskError({ retriesConfig: workflowConfig.retries, }) - req.payload.logger.error({ - err: error, - job, - msg: `Error running task ${taskID}. Attempt ${job.totalTried + 1}${maxWorkflowRetries !== undefined ? '/' + (maxWorkflowRetries + 1) : ''}`, - taskSlug, - }) + if (!silent || (typeof silent === 'object' && !silent.error)) { + req.payload.logger.error({ + err: error, + job, + msg: `Error running task ${taskID}. Attempt ${job.totalTried + 1}${maxWorkflowRetries !== undefined ? '/' + (maxWorkflowRetries + 1) : ''}`, + taskSlug, + }) + } // Update job's waitUntil only if this waitUntil is later than the current one if (waitUntil && (!job.waitUntil || waitUntil > new Date(job.waitUntil))) { diff --git a/packages/payload/src/queues/errors/handleWorkflowError.ts b/packages/payload/src/queues/errors/handleWorkflowError.ts index 6c5fed8da2..2716aebdec 100644 --- a/packages/payload/src/queues/errors/handleWorkflowError.ts +++ b/packages/payload/src/queues/errors/handleWorkflowError.ts @@ -1,7 +1,9 @@ import type { PayloadRequest } from '../../index.js' +import type { RunJobsSilent } from '../localAPI.js' import type { UpdateJobFunction } from '../operations/runJobs/runJob/getUpdateJobFunction.js' import type { WorkflowError } from './index.js' +import { getCurrentDate } from '../utilities/getCurrentDate.js' import { getWorkflowRetryBehavior } from './getWorkflowRetryBehavior.js' /** @@ -15,10 +17,20 @@ import { getWorkflowRetryBehavior } from './getWorkflowRetryBehavior.js' export async function handleWorkflowError({ error, req, + silent = false, updateJob, }: { error: WorkflowError req: PayloadRequest + /** + * If set to true, the job system will not log any output to the console (for both info and error logs). + * Can be an option for more granular control over logging. + * + * This will not automatically affect user-configured logs (e.g. if you call `console.log` or `payload.logger.info` in your job code). + * + * @default false + */ + silent?: RunJobsSilent updateJob: UpdateJobFunction }): Promise<{ hasFinalError: boolean @@ -41,7 +53,7 @@ export async function handleWorkflowError({ if (job.waitUntil) { // Check if waitUntil is in the past const waitUntil = new Date(job.waitUntil) - if (waitUntil < new Date()) { + if (waitUntil < getCurrentDate()) { // Outdated waitUntil, remove it delete job.waitUntil } @@ -55,10 +67,12 @@ export async function handleWorkflowError({ const jobLabel = job.workflowSlug || `Task: ${job.taskSlug}` - req.payload.logger.error({ - err: error, - msg: `Error running job ${jobLabel} id: ${job.id} attempt ${job.totalTried + 1}${maxWorkflowRetries !== undefined ? '/' + (maxWorkflowRetries + 1) : ''}`, - }) + if (!silent || (typeof silent === 'object' && !silent.error)) { + req.payload.logger.error({ + err: error, + msg: `Error running job ${jobLabel} id: ${job.id} attempt ${job.totalTried + 1}${maxWorkflowRetries !== undefined ? '/' + (maxWorkflowRetries + 1) : ''}`, + }) + } // Tasks update the job if they error - but in case there is an unhandled error (e.g. in the workflow itself, not in a task) // we need to ensure the job is updated to reflect the error diff --git a/packages/payload/src/queues/localAPI.ts b/packages/payload/src/queues/localAPI.ts index f1449c5664..c38a64868f 100644 --- a/packages/payload/src/queues/localAPI.ts +++ b/packages/payload/src/queues/localAPI.ts @@ -1,4 +1,4 @@ -import type { RunningJobFromTask } from './config/types/workflowTypes.js' +import type { BaseJob, RunningJobFromTask } from './config/types/workflowTypes.js' import { createLocalReq, @@ -9,11 +9,37 @@ import { type TypedJobs, type Where, } from '../index.js' -import { jobAfterRead, jobsCollectionSlug } from './config/index.js' +import { jobAfterRead, jobsCollectionSlug } from './config/collection.js' +import { handleSchedules, type HandleSchedulesResult } from './operations/handleSchedules/index.js' import { runJobs } from './operations/runJobs/index.js' import { updateJob, updateJobs } from './utilities/updateJob.js' +export type RunJobsSilent = + | { + error?: boolean + info?: boolean + } + | boolean export const getJobsLocalAPI = (payload: Payload) => ({ + handleSchedules: async (args?: { + // By default, schedule all queues - only scheduling jobs scheduled to be added to the `default` queue would not make sense + // here, as you'd usually specify a different queue than `default` here, especially if this is used in combination with autorun. + // The `queue` property for setting up schedules is required, and not optional. + /** + * If you want to only schedule jobs that are set to schedule in a specific queue, set this to the queue name. + * + * @default all jobs for all queues will be scheduled. + */ + queue?: string + req?: PayloadRequest + }): Promise => { + const newReq: PayloadRequest = args?.req ?? (await createLocalReq({}, payload)) + + return await handleSchedules({ + queue: args?.queue, + req: newReq, + }) + }, queue: async < // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents TTaskOrWorkflowSlug extends keyof TypedJobs['tasks'] | keyof TypedJobs['workflows'], @@ -21,6 +47,7 @@ export const getJobsLocalAPI = (payload: Payload) => ({ args: | { input: TypedJobs['tasks'][TTaskOrWorkflowSlug]['input'] + meta?: BaseJob['meta'] queue?: string req?: PayloadRequest // TTaskOrWorkflowlug with keyof TypedJobs['workflows'] removed: @@ -30,6 +57,7 @@ export const getJobsLocalAPI = (payload: Payload) => ({ } | { input: TypedJobs['workflows'][TTaskOrWorkflowSlug]['input'] + meta?: BaseJob['meta'] queue?: string req?: PayloadRequest task?: never @@ -74,6 +102,10 @@ export const getJobsLocalAPI = (payload: Payload) => ({ data.taskSlug = args.task as string } + if (args.meta) { + data.meta = args.meta + } + type ReturnType = TTaskOrWorkflowSlug extends keyof TypedJobs['workflows'] ? Job : RunningJobFromTask // Type assertion is still needed here @@ -130,6 +162,15 @@ export const getJobsLocalAPI = (payload: Payload) => ({ * If you want to run them in sequence, set this to true. */ sequential?: boolean + /** + * If set to true, the job system will not log any output to the console (for both info and error logs). + * Can be an option for more granular control over logging. + * + * This will not automatically affect user-configured logs (e.g. if you call `console.log` or `payload.logger.info` in your job code). + * + * @default false + */ + silent?: RunJobsSilent where?: Where }): Promise> => { const newReq: PayloadRequest = args?.req ?? (await createLocalReq({}, payload)) @@ -142,6 +183,7 @@ export const getJobsLocalAPI = (payload: Payload) => ({ queue: args?.queue, req: newReq, sequential: args?.sequential, + silent: args?.silent, where: args?.where, }) }, @@ -150,6 +192,15 @@ export const getJobsLocalAPI = (payload: Payload) => ({ id: number | string overrideAccess?: boolean req?: PayloadRequest + /** + * If set to true, the job system will not log any output to the console (for both info and error logs). + * Can be an option for more granular control over logging. + * + * This will not automatically affect user-configured logs (e.g. if you call `console.log` or `payload.logger.info` in your job code). + * + * @default false + */ + silent?: RunJobsSilent }): Promise> => { const newReq: PayloadRequest = args.req ?? (await createLocalReq({}, payload)) @@ -157,6 +208,7 @@ export const getJobsLocalAPI = (payload: Payload) => ({ id: args.id, overrideAccess: args.overrideAccess !== false, req: newReq, + silent: args.silent, }) }, diff --git a/packages/payload/src/queues/operations/handleSchedules/countRunnableOrActiveJobsForQueue.ts b/packages/payload/src/queues/operations/handleSchedules/countRunnableOrActiveJobsForQueue.ts new file mode 100644 index 0000000000..713cfa2048 --- /dev/null +++ b/packages/payload/src/queues/operations/handleSchedules/countRunnableOrActiveJobsForQueue.ts @@ -0,0 +1,74 @@ +import type { PayloadRequest, Where } from '../../../types/index.js' +import type { TaskType } from '../../config/types/taskTypes.js' +import type { WorkflowTypes } from '../../config/types/workflowTypes.js' + +/** + * Gets all queued jobs that can be run. This means they either: + * - failed but do not have a definitive error => can be retried + * - are currently processing + * - have not been started yet + */ +export async function countRunnableOrActiveJobsForQueue({ + onlyScheduled = false, + queue, + req, + taskSlug, + workflowSlug, +}: { + /** + * If true, this counts only jobs that have been created through the scheduling system. + * + * @default false + */ + onlyScheduled?: boolean + queue: string + req: PayloadRequest + taskSlug?: TaskType + workflowSlug?: WorkflowTypes +}): Promise { + const and: Where[] = [ + { + queue: { + equals: queue, + }, + }, + + { + completedAt: { exists: false }, + }, + { + error: { exists: false }, + }, + ] + + if (taskSlug) { + and.push({ + taskSlug: { + equals: taskSlug, + }, + }) + } else if (workflowSlug) { + and.push({ + workflowSlug: { + equals: workflowSlug, + }, + }) + } + if (onlyScheduled) { + and.push({ + 'meta.scheduled': { + equals: true, + }, + }) + } + + const runnableOrActiveJobsForQueue = await req.payload.db.count({ + collection: 'payload-jobs', + req, + where: { + and, + }, + }) + + return runnableOrActiveJobsForQueue.totalDocs +} diff --git a/packages/payload/src/queues/operations/handleSchedules/defaultAfterSchedule.ts b/packages/payload/src/queues/operations/handleSchedules/defaultAfterSchedule.ts new file mode 100644 index 0000000000..4627c407c6 --- /dev/null +++ b/packages/payload/src/queues/operations/handleSchedules/defaultAfterSchedule.ts @@ -0,0 +1,64 @@ +import type { AfterScheduleFn } from '../../config/types/index.js' + +import { type JobStats, jobStatsGlobalSlug } from '../../config/global.js' +import { getCurrentDate } from '../../utilities/getCurrentDate.js' + +type JobStatsScheduledRuns = NonNullable< + NonNullable['scheduledRuns']>['queues'] +>[string] + +export const defaultAfterSchedule: AfterScheduleFn = async ({ jobStats, queueable, req }) => { + const existingQueuesConfig = + jobStats?.stats?.scheduledRuns?.queues?.[queueable.scheduleConfig.queue] || {} + + const queueConfig: JobStatsScheduledRuns = { + ...existingQueuesConfig, + } + if (queueable.taskConfig) { + ;(queueConfig.tasks ??= {})[queueable.taskConfig.slug] = { + lastScheduledRun: getCurrentDate().toISOString(), + } + } else if (queueable.workflowConfig) { + ;(queueConfig.workflows ??= {})[queueable.workflowConfig.slug] = { + lastScheduledRun: getCurrentDate().toISOString(), + } + } + + // Add to payload-jobs-stats global regardless of the status + if (jobStats) { + await req.payload.db.updateGlobal({ + slug: jobStatsGlobalSlug, + data: { + ...(jobStats || {}), + stats: { + ...(jobStats?.stats || {}), + scheduledRuns: { + ...(jobStats?.stats?.scheduledRuns || {}), + queues: { + ...(jobStats?.stats?.scheduledRuns?.queues || {}), + [queueable.scheduleConfig.queue]: queueConfig, + }, + }, + }, + } as JobStats, + req, + returning: false, + }) + } else { + await req.payload.db.createGlobal({ + slug: jobStatsGlobalSlug, + data: { + createdAt: getCurrentDate().toISOString(), + stats: { + scheduledRuns: { + queues: { + [queueable.scheduleConfig.queue]: queueConfig, + }, + }, + }, + } as JobStats, + req, + returning: false, + }) + } +} diff --git a/packages/payload/src/queues/operations/handleSchedules/defaultBeforeSchedule.ts b/packages/payload/src/queues/operations/handleSchedules/defaultBeforeSchedule.ts new file mode 100644 index 0000000000..96b8092258 --- /dev/null +++ b/packages/payload/src/queues/operations/handleSchedules/defaultBeforeSchedule.ts @@ -0,0 +1,20 @@ +import type { BeforeScheduleFn } from '../../config/types/index.js' + +import { countRunnableOrActiveJobsForQueue } from './countRunnableOrActiveJobsForQueue.js' + +export const defaultBeforeSchedule: BeforeScheduleFn = async ({ queueable, req }) => { + // All tasks in that queue that are either currently processing or can be run + const runnableOrActiveJobsForQueue = await countRunnableOrActiveJobsForQueue({ + onlyScheduled: true, + queue: queueable.scheduleConfig.queue, + req, + taskSlug: queueable.taskConfig?.slug, + workflowSlug: queueable.workflowConfig?.slug, + }) + + return { + input: {}, + shouldSchedule: runnableOrActiveJobsForQueue === 0, + waitUntil: queueable.waitUntil, + } +} diff --git a/packages/payload/src/queues/operations/handleSchedules/getQueuesWithSchedules.ts b/packages/payload/src/queues/operations/handleSchedules/getQueuesWithSchedules.ts new file mode 100644 index 0000000000..817de244f6 --- /dev/null +++ b/packages/payload/src/queues/operations/handleSchedules/getQueuesWithSchedules.ts @@ -0,0 +1,50 @@ +import type { SanitizedJobsConfig, ScheduleConfig } from '../../config/types/index.js' +import type { TaskConfig } from '../../config/types/taskTypes.js' +import type { WorkflowConfig } from '../../config/types/workflowTypes.js' + +type QueuesWithSchedules = { + [queue: string]: { + schedules: { + scheduleConfig: ScheduleConfig + taskConfig?: TaskConfig + workflowConfig?: WorkflowConfig + }[] + } +} + +export const getQueuesWithSchedules = ({ + jobsConfig, +}: { + jobsConfig: SanitizedJobsConfig +}): QueuesWithSchedules => { + const tasksWithSchedules = + jobsConfig.tasks?.filter((task) => { + return task.schedule?.length + }) ?? [] + + const workflowsWithSchedules = + jobsConfig.workflows?.filter((workflow) => { + return workflow.schedule?.length + }) ?? [] + + const queuesWithSchedules: QueuesWithSchedules = {} + + for (const task of tasksWithSchedules) { + for (const schedule of task.schedule ?? []) { + ;(queuesWithSchedules[schedule.queue] ??= { schedules: [] }).schedules.push({ + scheduleConfig: schedule, + taskConfig: task, + }) + } + } + for (const workflow of workflowsWithSchedules) { + for (const schedule of workflow.schedule ?? []) { + ;(queuesWithSchedules[schedule.queue] ??= { schedules: [] }).schedules.push({ + scheduleConfig: schedule, + workflowConfig: workflow, + }) + } + } + + return queuesWithSchedules +} diff --git a/packages/payload/src/queues/operations/handleSchedules/index.ts b/packages/payload/src/queues/operations/handleSchedules/index.ts new file mode 100644 index 0000000000..b5daefccb9 --- /dev/null +++ b/packages/payload/src/queues/operations/handleSchedules/index.ts @@ -0,0 +1,223 @@ +import { Cron } from 'croner' + +import type { Job, TaskConfig, WorkflowConfig } from '../../../index.js' +import type { PayloadRequest } from '../../../types/index.js' +import type { BeforeScheduleFn, Queueable, ScheduleConfig } from '../../config/types/index.js' + +import { type JobStats, jobStatsGlobalSlug } from '../../config/global.js' +import { defaultAfterSchedule } from './defaultAfterSchedule.js' +import { defaultBeforeSchedule } from './defaultBeforeSchedule.js' +import { getQueuesWithSchedules } from './getQueuesWithSchedules.js' + +export type HandleSchedulesResult = { + errored: Queueable[] + queued: Queueable[] + skipped: Queueable[] +} + +/** + * On vercel, we cannot auto-schedule jobs using a Cron - instead, we'll use this same endpoint that can + * also be called from Vercel Cron for auto-running jobs. + * + * The benefit of doing it like this instead of a separate endpoint is that we can run jobs immediately + * after they are scheduled + */ +export async function handleSchedules({ + queue, + req, +}: { + /** + * If you want to only schedule jobs that are set to schedule in a specific queue, set this to the queue name. + * + * @default all jobs for all queues will be scheduled. + */ + queue?: string + req: PayloadRequest +}): Promise { + const jobsConfig = req.payload.config.jobs + const queuesWithSchedules = getQueuesWithSchedules({ + jobsConfig, + }) + + const stats: JobStats = await req.payload.db.findGlobal({ + slug: jobStatsGlobalSlug, + req, + }) + + /** + * Almost last step! Tasks and Workflows added here just need to be constraint-checked (e.g max. 1 running task etc.), + * before we can queue them + */ + const queueables: Queueable[] = [] + + // Need to know when that particular job was last scheduled in that particular queue + + for (const [queueName, { schedules }] of Object.entries(queuesWithSchedules)) { + if (queue && queueName !== queue) { + // If a queue is specified, only schedule jobs for that queue + continue + } + for (const schedulable of schedules) { + const queuable = checkQueueableTimeConstraints({ + queue: queueName, + scheduleConfig: schedulable.scheduleConfig, + stats, + taskConfig: schedulable.taskConfig, + workflowConfig: schedulable.workflowConfig, + }) + if (queuable) { + queueables.push(queuable) + } + } + } + + const queued: Queueable[] = [] + const skipped: Queueable[] = [] + const errored: Queueable[] = [] + + /** + * Now queue, but check for constraints (= beforeSchedule) first. + * Default constraint (= defaultBeforeSchedule): max. 1 running / scheduled task or workflow per queue + */ + for (const queueable of queueables) { + const { status } = await scheduleQueueable({ + queueable, + req, + stats, + }) + switch (status) { + case 'error': + errored.push(queueable) + break + case 'skipped': + skipped.push(queueable) + break + case 'success': + queued.push(queueable) + break + } + } + return { + errored, + queued, + skipped, + } +} + +export function checkQueueableTimeConstraints({ + queue, + scheduleConfig, + stats, + taskConfig, + workflowConfig, +}: { + queue: string + scheduleConfig: ScheduleConfig + stats: JobStats + taskConfig?: TaskConfig + workflowConfig?: WorkflowConfig +}): false | Queueable { + const queueScheduleStats = stats?.stats?.scheduledRuns?.queues?.[queue] + + const lastScheduledRun = taskConfig + ? queueScheduleStats?.tasks?.[taskConfig.slug]?.lastScheduledRun + : queueScheduleStats?.workflows?.[workflowConfig?.slug ?? '']?.lastScheduledRun + + const nextRun = new Cron(scheduleConfig.cron).nextRun(lastScheduledRun ?? undefined) + + if (!nextRun) { + return false + } + return { + scheduleConfig, + taskConfig, + waitUntil: nextRun, + workflowConfig, + } +} + +export async function scheduleQueueable({ + queueable, + req, + stats, +}: { + queueable: Queueable + req: PayloadRequest + stats: JobStats +}): Promise<{ + job?: Job + status: 'error' | 'skipped' | 'success' +}> { + if (!queueable.taskConfig && !queueable.workflowConfig) { + return { + status: 'error', + } + } + + const beforeScheduleFn = queueable.scheduleConfig.hooks?.beforeSchedule + const afterScheduleFN = queueable.scheduleConfig.hooks?.afterSchedule + + try { + const beforeScheduleResult: Awaited> = await ( + beforeScheduleFn ?? defaultBeforeSchedule + )({ + // @ts-expect-error we know defaultBeforeSchedule will never call itself => pass null + defaultBeforeSchedule: beforeScheduleFn ? defaultBeforeSchedule : null, + jobStats: stats, + queueable, + req, + }) + + if (!beforeScheduleResult.shouldSchedule) { + await (afterScheduleFN ?? defaultAfterSchedule)({ + // @ts-expect-error we know defaultAfterchedule will never call itself => pass null + defaultAfterSchedule: afterScheduleFN ? defaultAfterSchedule : null, + jobStats: stats, + queueable, + req, + status: 'skipped', + }) + return { + status: 'skipped', + } + } + + const job = (await req.payload.jobs.queue({ + input: beforeScheduleResult.input ?? {}, + meta: { + scheduled: true, + }, + queue: queueable.scheduleConfig.queue, + req, + task: queueable?.taskConfig?.slug, + waitUntil: beforeScheduleResult.waitUntil, + workflow: queueable.workflowConfig?.slug, + } as Parameters[0])) as unknown as Job + + await (afterScheduleFN ?? defaultAfterSchedule)({ + // @ts-expect-error we know defaultAfterchedule will never call itself => pass null + defaultAfterSchedule: afterScheduleFN ? defaultAfterSchedule : null, + job, + jobStats: stats, + queueable, + req, + status: 'success', + }) + return { + status: 'success', + } + } catch (error) { + await (afterScheduleFN ?? defaultAfterSchedule)({ + // @ts-expect-error we know defaultAfterchedule will never call itself => pass null + defaultAfterSchedule: afterScheduleFN ? defaultAfterSchedule : null, + error: error as Error, + jobStats: stats, + queueable, + req, + status: 'error', + }) + return { + status: 'error', + } + } +} diff --git a/packages/payload/src/queues/operations/runJobs/index.ts b/packages/payload/src/queues/operations/runJobs/index.ts index c626103467..9530594788 100644 --- a/packages/payload/src/queues/operations/runJobs/index.ts +++ b/packages/payload/src/queues/operations/runJobs/index.ts @@ -2,12 +2,14 @@ import type { Job } from '../../../index.js' import type { PayloadRequest, Sort, Where } from '../../../types/index.js' import type { WorkflowJSON } from '../../config/types/workflowJSONTypes.js' import type { WorkflowConfig, WorkflowHandler } from '../../config/types/workflowTypes.js' +import type { RunJobsSilent } from '../../localAPI.js' import type { RunJobResult } from './runJob/index.js' import { Forbidden } from '../../../errors/Forbidden.js' import { isolateObjectProperty } from '../../../utilities/isolateObjectProperty.js' -import { jobsCollectionSlug } from '../../config/index.js' +import { jobsCollectionSlug } from '../../config/collection.js' import { JobCancelledError } from '../../errors/index.js' +import { getCurrentDate } from '../../utilities/getCurrentDate.js' import { updateJob, updateJobs } from '../../utilities/updateJob.js' import { getUpdateJobFunction } from './runJob/getUpdateJobFunction.js' import { importHandlerPath } from './runJob/importHandlerPath.js' @@ -53,6 +55,15 @@ export type RunJobsArgs = { * If you want to run them in sequence, set this to true. */ sequential?: boolean + /** + * If set to true, the job system will not log any output to the console (for both info and error logs). + * Can be an option for more granular control over logging. + * + * This will not automatically affect user-configured logs (e.g. if you call `console.log` or `payload.logger.info` in your job code). + * + * @default false + */ + silent?: RunJobsSilent where?: Where } @@ -84,6 +95,7 @@ export const runJobs = async (args: RunJobsArgs): Promise => { }, }, sequential, + silent = false, where: whereFromProps, } = args @@ -119,7 +131,7 @@ export const runJobs = async (args: RunJobsArgs): Promise => { }, { waitUntil: { - less_than: new Date().toISOString(), + less_than: getCurrentDate().toISOString(), }, }, ], @@ -219,11 +231,13 @@ export const runJobs = async (args: RunJobsArgs): Promise => { } } - payload.logger.info({ - msg: `Running ${jobs.length} jobs.`, - new: newJobs?.length, - retrying: existingJobs?.length, - }) + if (!silent || (typeof silent === 'object' && !silent.info)) { + payload.logger.info({ + msg: `Running ${jobs.length} jobs.`, + new: newJobs?.length, + retrying: existingJobs?.length, + }) + } const successfullyCompletedJobs: (number | string)[] = [] @@ -277,7 +291,9 @@ export const runJobs = async (args: RunJobsArgs): Promise => { if (!workflowHandler) { const jobLabel = job.workflowSlug || `Task: ${job.taskSlug}` const errorMessage = `Can't find runner while importing with the path ${workflowConfig.handler} in job type ${jobLabel}.` - payload.logger.error(errorMessage) + if (!silent || (typeof silent === 'object' && !silent.error)) { + payload.logger.error(errorMessage) + } await updateJob({ error: { @@ -300,6 +316,7 @@ export const runJobs = async (args: RunJobsArgs): Promise => { const result = await runJob({ job, req: jobReq, + silent, updateJob, workflowConfig, workflowHandler, @@ -314,6 +331,7 @@ export const runJobs = async (args: RunJobsArgs): Promise => { const result = await runJSONJob({ job, req: jobReq, + silent, updateJob, workflowConfig, workflowHandler, @@ -370,10 +388,12 @@ export const runJobs = async (args: RunJobsArgs): Promise => { }) } } catch (err) { - payload.logger.error({ - err, - msg: `Failed to delete jobs ${successfullyCompletedJobs.join(', ')} on complete`, - }) + if (!silent || (typeof silent === 'object' && !silent.error)) { + payload.logger.error({ + err, + msg: `Failed to delete jobs ${successfullyCompletedJobs.join(', ')} on complete`, + }) + } } } diff --git a/packages/payload/src/queues/operations/runJobs/runJSONJob/index.ts b/packages/payload/src/queues/operations/runJobs/runJSONJob/index.ts index 66b0156724..87f5995904 100644 --- a/packages/payload/src/queues/operations/runJobs/runJSONJob/index.ts +++ b/packages/payload/src/queues/operations/runJobs/runJSONJob/index.ts @@ -2,16 +2,27 @@ import type { Job } from '../../../../index.js' import type { PayloadRequest } from '../../../../types/index.js' import type { WorkflowJSON, WorkflowStep } from '../../../config/types/workflowJSONTypes.js' import type { WorkflowConfig } from '../../../config/types/workflowTypes.js' +import type { RunJobsSilent } from '../../../localAPI.js' import type { UpdateJobFunction } from '../runJob/getUpdateJobFunction.js' import type { JobRunStatus } from '../runJob/index.js' import { handleWorkflowError } from '../../../errors/handleWorkflowError.js' import { WorkflowError } from '../../../errors/index.js' +import { getCurrentDate } from '../../../utilities/getCurrentDate.js' import { getRunTaskFunction } from '../runJob/getRunTaskFunction.js' type Args = { job: Job req: PayloadRequest + /** + * If set to true, the job system will not log any output to the console (for both info and error logs). + * Can be an option for more granular control over logging. + * + * This will not automatically affect user-configured logs (e.g. if you call `console.log` or `payload.logger.info` in your job code). + * + * @default false + */ + silent?: RunJobsSilent updateJob: UpdateJobFunction workflowConfig: WorkflowConfig workflowHandler: WorkflowJSON @@ -24,6 +35,7 @@ export type RunJSONJobResult = { export const runJSONJob = async ({ job, req, + silent = false, updateJob, workflowConfig, workflowHandler, @@ -79,6 +91,7 @@ export const runJSONJob = async ({ : 'An unhandled error occurred', workflowConfig, }), + silent, req, updateJob, @@ -111,7 +124,7 @@ export const runJSONJob = async ({ if (workflowCompleted) { await updateJob({ - completedAt: new Date().toISOString(), + completedAt: getCurrentDate().toISOString(), processing: false, totalTried: (job.totalTried ?? 0) + 1, }) diff --git a/packages/payload/src/queues/operations/runJobs/runJob/getRunTaskFunction.ts b/packages/payload/src/queues/operations/runJobs/runJob/getRunTaskFunction.ts index 868ac5602d..aa9f171567 100644 --- a/packages/payload/src/queues/operations/runJobs/runJob/getRunTaskFunction.ts +++ b/packages/payload/src/queues/operations/runJobs/runJob/getRunTaskFunction.ts @@ -20,6 +20,7 @@ import type { import type { UpdateJobFunction } from './getUpdateJobFunction.js' import { TaskError } from '../../../errors/index.js' +import { getCurrentDate } from '../../../utilities/getCurrentDate.js' import { getTaskHandlerFromConfig } from './importHandlerPath.js' const ObjectId = (ObjectIdImport.default || @@ -54,7 +55,7 @@ export const getRunTaskFunction = ( task, }: Parameters[1] & Parameters>[1], ) => { - const executedAt = new Date() + const executedAt = getCurrentDate() let taskConfig: TaskConfig | undefined if (!isInline) { @@ -186,7 +187,7 @@ export const getRunTaskFunction = ( ;(job.log ??= []).push({ id: new ObjectId().toHexString(), - completedAt: new Date().toISOString(), + completedAt: getCurrentDate().toISOString(), executedAt: executedAt.toISOString(), input, output, diff --git a/packages/payload/src/queues/operations/runJobs/runJob/index.ts b/packages/payload/src/queues/operations/runJobs/runJob/index.ts index fe8f6256ef..2195c5e023 100644 --- a/packages/payload/src/queues/operations/runJobs/runJob/index.ts +++ b/packages/payload/src/queues/operations/runJobs/runJob/index.ts @@ -1,16 +1,27 @@ import type { Job } from '../../../../index.js' import type { PayloadRequest } from '../../../../types/index.js' import type { WorkflowConfig, WorkflowHandler } from '../../../config/types/workflowTypes.js' +import type { RunJobsSilent } from '../../../localAPI.js' import type { UpdateJobFunction } from './getUpdateJobFunction.js' import { handleTaskError } from '../../../errors/handleTaskError.js' import { handleWorkflowError } from '../../../errors/handleWorkflowError.js' import { JobCancelledError, TaskError, WorkflowError } from '../../../errors/index.js' +import { getCurrentDate } from '../../../utilities/getCurrentDate.js' import { getRunTaskFunction } from './getRunTaskFunction.js' type Args = { job: Job req: PayloadRequest + /** + * If set to true, the job system will not log any output to the console (for both info and error logs). + * Can be an option for more granular control over logging. + * + * This will not automatically affect user-configured logs (e.g. if you call `console.log` or `payload.logger.info` in your job code). + * + * @default false + */ + silent?: RunJobsSilent updateJob: UpdateJobFunction workflowConfig: WorkflowConfig workflowHandler: WorkflowHandler @@ -25,6 +36,7 @@ export type RunJobResult = { export const runJob = async ({ job, req, + silent, updateJob, workflowConfig, workflowHandler, @@ -45,6 +57,7 @@ export const runJob = async ({ const { hasFinalError } = await handleTaskError({ error, req, + silent, updateJob, }) @@ -66,6 +79,7 @@ export const runJob = async ({ workflowConfig, }), req, + silent, updateJob, }) @@ -75,9 +89,11 @@ export const runJob = async ({ } // Workflow has completed successfully + // Do not update the job log here, as that would result in unnecessary db calls when using postgres. + // Solely updating simple fields here will result in optimized db calls. + // Job log modifications are already updated at the end of the runTask function. await updateJob({ - completedAt: new Date().toISOString(), - log: job.log, + completedAt: getCurrentDate().toISOString(), processing: false, totalTried: (job.totalTried ?? 0) + 1, }) diff --git a/packages/payload/src/queues/restEndpointRun.ts b/packages/payload/src/queues/restEndpointRun.ts deleted file mode 100644 index 14c425a940..0000000000 --- a/packages/payload/src/queues/restEndpointRun.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { Endpoint, SanitizedConfig } from '../config/types.js' - -import { runJobs, type RunJobsArgs } from './operations/runJobs/index.js' - -const configHasJobs = (config: SanitizedConfig): boolean => { - return Boolean(config.jobs?.tasks?.length || config.jobs?.workflows?.length) -} - -/** - * /api/payload-jobs/run endpoint - */ -export const runJobsEndpoint: Endpoint = { - handler: async (req) => { - if (!configHasJobs(req.payload.config)) { - return Response.json( - { - message: 'No jobs to run.', - }, - { status: 200 }, - ) - } - - const accessFn = req.payload.config.jobs?.access?.run ?? (() => true) - - const hasAccess = await accessFn({ req }) - - if (!hasAccess) { - return Response.json( - { - message: req.i18n.t('error:unauthorized'), - }, - { status: 401 }, - ) - } - - const { allQueues, limit, queue } = req.query as { - allQueues?: boolean - limit?: number - queue?: string - } - - const runJobsArgs: RunJobsArgs = { - queue, - req, - // We are checking access above, so we can override it here - overrideAccess: true, - } - - if (typeof limit !== 'undefined') { - runJobsArgs.limit = Number(limit) - } - - if (allQueues && !(typeof allQueues === 'string' && allQueues === 'false')) { - runJobsArgs.allQueues = true - } - - let noJobsRemaining = false - let remainingJobsFromQueried = 0 - try { - const result = await runJobs(runJobsArgs) - noJobsRemaining = !!result.noJobsRemaining - remainingJobsFromQueried = result.remainingJobsFromQueried - } catch (err) { - req.payload.logger.error({ - err, - msg: 'There was an error running jobs:', - queue: runJobsArgs.queue, - }) - - return Response.json( - { - message: req.i18n.t('error:unknown'), - noJobsRemaining: true, - remainingJobsFromQueried, - }, - { status: 500 }, - ) - } - - return Response.json( - { - message: req.i18n.t('general:success'), - noJobsRemaining, - remainingJobsFromQueried, - }, - { status: 200 }, - ) - }, - method: 'get', - path: '/run', -} diff --git a/packages/payload/src/queues/utilities/getCurrentDate.ts b/packages/payload/src/queues/utilities/getCurrentDate.ts new file mode 100644 index 0000000000..6e0d67af3b --- /dev/null +++ b/packages/payload/src/queues/utilities/getCurrentDate.ts @@ -0,0 +1,21 @@ +/** + * Globals that are used by our integration tests to modify the behavior of the job system during runtime. + * This is useful to avoid having to wait for the cron jobs to run, or to pause auto-running jobs. + */ +export const _internal_jobSystemGlobals = { + getCurrentDate: () => { + return new Date() + }, + shouldAutoRun: true, + shouldAutoSchedule: true, +} + +export function _internal_resetJobSystemGlobals() { + _internal_jobSystemGlobals.getCurrentDate = () => new Date() + _internal_jobSystemGlobals.shouldAutoRun = true + _internal_jobSystemGlobals.shouldAutoSchedule = true +} + +export const getCurrentDate: () => Date = () => { + return _internal_jobSystemGlobals.getCurrentDate() +} diff --git a/packages/payload/src/queues/utilities/updateJob.ts b/packages/payload/src/queues/utilities/updateJob.ts index 6ce4479eaa..a8a4ff69ee 100644 --- a/packages/payload/src/queues/utilities/updateJob.ts +++ b/packages/payload/src/queues/utilities/updateJob.ts @@ -3,7 +3,7 @@ import type { UpdateJobsArgs } from '../../database/types.js' import type { Job } from '../../index.js' import type { PayloadRequest, Sort, Where } from '../../types/index.js' -import { jobAfterRead, jobsCollectionSlug } from '../config/index.js' +import { jobAfterRead, jobsCollectionSlug } from '../config/collection.js' type BaseArgs = { data: Partial diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index f10c14d4bf..b22bcbdde4 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -1,5 +1,6 @@ import type { I18n, TFunction } from '@payloadcms/translations' import type DataLoader from 'dataloader' +import type { OptionalKeys, RequiredKeys } from 'ts-essentials' import type { URL } from 'url' import type { @@ -262,3 +263,8 @@ export type TransformGlobalWithSelect< export type PopulateType = Partial export type ResolvedFilterOptions = { [collection: string]: Where } + +export type PickPreserveOptional = Partial< + Pick>> +> & + Pick>> diff --git a/packages/payload/src/uploads/checkFileRestrictions.ts b/packages/payload/src/uploads/checkFileRestrictions.ts index e4d19bd91c..c24401cd01 100644 --- a/packages/payload/src/uploads/checkFileRestrictions.ts +++ b/packages/payload/src/uploads/checkFileRestrictions.ts @@ -1,6 +1,10 @@ +import { fileTypeFromBuffer } from 'file-type' + import type { checkFileRestrictionsParams, FileAllowList } from './types.js' -import { APIError } from '../errors/index.js' +import { ValidationError } from '../errors/index.js' +import { validateMimeType } from '../utilities/validateMimeType.js' +import { detectSvgFromXml } from './detectSvgFromXml.js' /** * Restricted file types and their extensions. @@ -39,11 +43,12 @@ export const RESTRICTED_FILE_EXT_AND_TYPES: FileAllowList = [ { extensions: ['command'], mimeType: 'application/x-command' }, ] -export const checkFileRestrictions = ({ +export const checkFileRestrictions = async ({ collection, file, req, -}: checkFileRestrictionsParams): void => { +}: checkFileRestrictionsParams): Promise => { + const errors: string[] = [] const { upload: uploadConfig } = collection const configMimeTypes = uploadConfig && @@ -58,20 +63,48 @@ export const checkFileRestrictions = ({ ? (uploadConfig as { allowRestrictedFileTypes?: boolean }).allowRestrictedFileTypes : false - // Skip validation if `mimeTypes` are defined in the upload config, or `allowRestrictedFileTypes` are allowed - if (allowRestrictedFileTypes || configMimeTypes.length) { + // Skip validation if `allowRestrictedFileTypes` is true + if (allowRestrictedFileTypes) { return } - const isRestricted = RESTRICTED_FILE_EXT_AND_TYPES.some((type) => { - const hasRestrictedExt = type.extensions.some((ext) => file.name.toLowerCase().endsWith(ext)) - const hasRestrictedMime = type.mimeType === file.mimetype - return hasRestrictedExt || hasRestrictedMime - }) + // Secondary mimetype check to assess file type from buffer + if (configMimeTypes.length > 0) { + let detected = await fileTypeFromBuffer(file.data) - if (isRestricted) { - const errorMessage = `File type '${file.mimetype}' not allowed ${file.name}: Restricted file type detected -- set 'allowRestrictedFileTypes' to true to skip this check for this Collection.` - req.payload.logger.error(errorMessage) - throw new APIError(errorMessage) + // Handle SVG files that are detected as XML due to type.includes('image/') && (type.includes('svg') || type === 'image/*'), + ) && + detectSvgFromXml(file.data) + ) { + detected = { ext: 'svg' as any, mime: 'image/svg+xml' as any } + } + + const passesMimeTypeCheck = detected?.mime && validateMimeType(detected.mime, configMimeTypes) + + if (detected && !passesMimeTypeCheck) { + errors.push(`Invalid MIME type: ${detected.mime}.`) + } + } else { + const isRestricted = RESTRICTED_FILE_EXT_AND_TYPES.some((type) => { + const hasRestrictedExt = type.extensions.some((ext) => file.name.toLowerCase().endsWith(ext)) + const hasRestrictedMime = type.mimeType === file.mimetype + return hasRestrictedExt || hasRestrictedMime + }) + if (isRestricted) { + errors.push( + `File type '${file.mimetype}' not allowed ${file.name}: Restricted file type detected -- set 'allowRestrictedFileTypes' to true to skip this check for this Collection.`, + ) + } + } + + if (errors.length > 0) { + req.payload.logger.error(errors.join(', ')) + throw new ValidationError({ + errors: [{ message: errors.join(', '), path: 'file' }], + }) } } diff --git a/packages/payload/src/uploads/detectSvgFromXml.ts b/packages/payload/src/uploads/detectSvgFromXml.ts new file mode 100644 index 0000000000..ea6cf83ecf --- /dev/null +++ b/packages/payload/src/uploads/detectSvgFromXml.ts @@ -0,0 +1,49 @@ +/** + * Securely detect if an XML buffer contains a valid SVG document + */ +export function detectSvgFromXml(buffer: Buffer): boolean { + try { + // Limit buffer size to prevent processing large malicious files + const maxSize = 2048 + const content = buffer.toString('utf8', 0, Math.min(buffer.length, maxSize)) + + // Check for XML declaration and extract encoding if present + const xmlDeclMatch = content.match(/^<\?xml[^>]*encoding=["']([^"']+)["']/i) + const declaredEncoding = xmlDeclMatch?.[1]?.toLowerCase() + + // Only support safe encodings + if (declaredEncoding && !['ascii', 'utf-8', 'utf8'].includes(declaredEncoding)) { + return false + } + + // Remove XML declarations, comments, and processing instructions + const cleanContent = content + .replace(/<\?xml[^>]*\?>/gi, '') + .replace(//g, '') + .replace(/<\?[^>]*\?>/g, '') + .trim() + + // Find the first actual element (root element) + const rootElementMatch = cleanContent.match(/^<(\w+)(?:\s|>)/) + if (!rootElementMatch || rootElementMatch[1] !== 'svg') { + return false + } + + // Validate SVG namespace - must be present for valid SVG + const svgNamespaceRegex = /xmlns=["']http:\/\/www\.w3\.org\/2000\/svg["']/ + if (!svgNamespaceRegex.test(content)) { + return false + } + + // Additional validation: ensure it's not malformed + const svgOpenTag = content.match(/]/) + if (!svgOpenTag) { + return false + } + + return true + } catch (_error) { + // If any error occurs during parsing, treat as not SVG + return false + } +} diff --git a/packages/payload/src/uploads/endpoints/getFile.ts b/packages/payload/src/uploads/endpoints/getFile.ts index 69a3bfea13..8a316840fc 100644 --- a/packages/payload/src/uploads/endpoints/getFile.ts +++ b/packages/payload/src/uploads/endpoints/getFile.ts @@ -38,9 +38,12 @@ export const getFileHandler: PayloadHandler = async (req) => { if (collection.config.upload.handlers?.length) { let customResponse: null | Response | void = null + const headers = new Headers() + for (const handler of collection.config.upload.handlers) { customResponse = await handler(req, { doc: accessResult, + headers, params: { collection: collection.config.slug, filename, @@ -90,12 +93,17 @@ export const getFileHandler: PayloadHandler = async (req) => { const data = streamFile(filePath) const fileTypeResult = (await fileTypeFromFile(filePath)) || getFileTypeFallback(filePath) + let mimeType = fileTypeResult.mime + + if (filePath.endsWith('.svg') && fileTypeResult.mime === 'application/xml') { + mimeType = 'image/svg+xml' + } let headers = new Headers() - headers.set('Content-Type', fileTypeResult.mime) + headers.set('Content-Type', mimeType) headers.set('Content-Length', stats.size + '') headers = collection.config.upload?.modifyResponseHeaders - ? collection.config.upload.modifyResponseHeaders({ headers }) + ? collection.config.upload.modifyResponseHeaders({ headers }) || headers : headers return new Response(data, { diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index 92e3cf82c0..2a40765b74 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -123,7 +123,7 @@ export const generateFileData = async ({ } } - checkFileRestrictions({ + await checkFileRestrictions({ collection: collectionConfig, file, req, diff --git a/packages/payload/src/uploads/getExternalFile.ts b/packages/payload/src/uploads/getExternalFile.ts index 50239beaff..9cdafd68f8 100644 --- a/packages/payload/src/uploads/getExternalFile.ts +++ b/packages/payload/src/uploads/getExternalFile.ts @@ -22,7 +22,14 @@ export const getExternalFile = async ({ data, req, uploadConfig }: Args): Promis const headers = uploadConfig.externalFileHeaderFilter ? uploadConfig.externalFileHeaderFilter(Object.fromEntries(new Headers(req.headers))) - : { cookie: req.headers.get('cookie')! } + : { + cookie: + req.headers + .get('cookie') + ?.split(';') + .filter((cookie) => !cookie.trim().startsWith(req.payload.config.cookiePrefix)) + .join(';') || '', + } // Check if URL is allowed because of skipSafeFetch allowList const skipSafeFetch: boolean = diff --git a/packages/payload/src/uploads/safeFetch.ts b/packages/payload/src/uploads/safeFetch.ts index ee54bac3bd..4d0787e1c4 100644 --- a/packages/payload/src/uploads/safeFetch.ts +++ b/packages/payload/src/uploads/safeFetch.ts @@ -1,9 +1,16 @@ -import type { Dispatcher } from 'undici' +import type { LookupFunction } from 'net' -import { lookup } from 'dns/promises' +import { lookup } from 'dns' import ipaddr from 'ipaddr.js' import { Agent, fetch as undiciFetch } from 'undici' +/** + * @internal this is used to mock the IP `lookup` function in integration tests + */ +export const _internal_safeFetchGlobal = { + lookup, +} + const isSafeIp = (ip: string) => { try { if (!ip) { @@ -25,32 +32,31 @@ const isSafeIp = (ip: string) => { return true } -/** - * Checks if a hostname or IP address is safe to fetch from. - * @param hostname a hostname or IP address - * @returns - */ -const isSafe = async (hostname: string) => { - try { - if (ipaddr.isValid(hostname)) { - return isSafeIp(hostname) +const ssrfFilterInterceptor: LookupFunction = (hostname, options, callback) => { + _internal_safeFetchGlobal.lookup(hostname, options, (err, address, family) => { + if (err) { + callback(err, address, family) + } else { + let ips = [] as string[] + if (Array.isArray(address)) { + ips = address.map((a) => a.address) + } else { + ips = [address] + } + + if (ips.some((ip) => !isSafeIp(ip))) { + callback(new Error(`Blocked unsafe attempt to ${hostname}`), address, family) + return + } + + callback(null, address, family) } - - const { address } = await lookup(hostname) - return isSafeIp(address) - } catch (_ignore) { - return false - } + }) } -const ssrfFilterInterceptor: Dispatcher.DispatcherComposeInterceptor = (dispatch) => { - return (opts, handler) => { - return dispatch(opts, handler) - } -} - -const safeDispatcher = new Agent().compose(ssrfFilterInterceptor) - +const safeDispatcher = new Agent({ + connect: { lookup: ssrfFilterInterceptor }, +}) /** * A "safe" version of undici's fetch that prevents SSRF attacks. * @@ -64,11 +70,18 @@ export const safeFetch = async (...args: Parameters) => { try { const url = new URL(unverifiedUrl) - const isHostnameSafe = await isSafe(url.hostname) - if (!isHostnameSafe) { - throw new Error(`Blocked unsafe attempt to ${url.toString()}`) + let hostname = url.hostname + + // Strip brackets from IPv6 addresses (e.g., "[::1]" => "::1") + if (hostname.startsWith('[') && hostname.endsWith(']')) { + hostname = hostname.slice(1, -1) } + if (ipaddr.isValid(hostname)) { + if (!isSafeIp(hostname)) { + throw new Error(`Blocked unsafe attempt to ${hostname}`) + } + } return await undiciFetch(url, { ...options, dispatcher: safeDispatcher, diff --git a/packages/payload/src/uploads/types.ts b/packages/payload/src/uploads/types.ts index 5674f55889..60f17d4dd2 100644 --- a/packages/payload/src/uploads/types.ts +++ b/packages/payload/src/uploads/types.ts @@ -173,7 +173,12 @@ export type UploadConfig = { */ displayPreview?: boolean /** - * Ability to filter/modify Request Headers when fetching a file. + * + * Accepts existing headers and returns the headers after filtering or modifying. + * If using this option, you should handle the removal of any sensitive cookies + * (like payload-prefixed cookies) to prevent leaking session information to external + * services. By default, Payload automatically filters out payload-prefixed cookies + * when this option is NOT defined. * * Useful for adding custom headers to fetch from external providers. * @default undefined @@ -211,6 +216,7 @@ export type UploadConfig = { req: PayloadRequest, args: { doc: TypeWithID + headers?: Headers params: { clientUploadContext?: unknown; collection: string; filename: string } }, ) => Promise | Promise | Response | void)[] @@ -233,7 +239,7 @@ export type UploadConfig = { * Ability to modify the response headers fetching a file. * @default undefined */ - modifyResponseHeaders?: ({ headers }: { headers: Headers }) => Headers + modifyResponseHeaders?: ({ headers }: { headers: Headers }) => Headers | void /** * Controls the behavior of pasting/uploading files from URLs. * If set to `false`, fetching from remote URLs is disabled. diff --git a/packages/payload/src/utilities/appendNonTrashedFilter.ts b/packages/payload/src/utilities/appendNonTrashedFilter.ts new file mode 100644 index 0000000000..dc88ecc50e --- /dev/null +++ b/packages/payload/src/utilities/appendNonTrashedFilter.ts @@ -0,0 +1,32 @@ +import type { Where } from '../types/index.js' + +export const appendNonTrashedFilter = ({ + deletedAtPath = 'deletedAt', + enableTrash, + trash, + where, +}: { + deletedAtPath?: string + enableTrash: boolean + trash?: boolean + where: Where +}): Where => { + if (!enableTrash || trash) { + return where + } + + const notTrashedFilter = { + [deletedAtPath]: { exists: false }, + } + + if (where?.and) { + return { + ...where, + and: [...where.and, notTrashedFilter], + } + } + + return { + and: [notTrashedFilter, ...(where ? [where] : [])], + } +} diff --git a/packages/payload/src/utilities/combineWhereConstraints.spec.ts b/packages/payload/src/utilities/combineWhereConstraints.spec.ts new file mode 100644 index 0000000000..c852a9477b --- /dev/null +++ b/packages/payload/src/utilities/combineWhereConstraints.spec.ts @@ -0,0 +1,86 @@ +import { Where } from '../types/index.js' +import { combineWhereConstraints } from './combineWhereConstraints.js' + +describe('combineWhereConstraints', () => { + it('should merge matching constraint keys', async () => { + const constraint: Where = { + test: { + equals: 'value', + }, + } + + // should merge and queries + const andConstraint: Where = { + and: [constraint], + } + expect(combineWhereConstraints([andConstraint], 'and')).toEqual(andConstraint) + // should merge multiple and queries + expect(combineWhereConstraints([andConstraint, andConstraint], 'and')).toEqual({ + and: [constraint, constraint], + }) + + // should merge or queries + const orConstraint: Where = { + or: [constraint], + } + expect(combineWhereConstraints([orConstraint], 'or')).toEqual(orConstraint) + // should merge multiple or queries + expect(combineWhereConstraints([orConstraint, orConstraint], 'or')).toEqual({ + or: [constraint, constraint], + }) + }) + + it('should push mismatching constraints keys into `as` key', async () => { + const constraint: Where = { + test: { + equals: 'value', + }, + } + + // should push `and` into `or` key + const andConstraint: Where = { + and: [constraint], + } + expect(combineWhereConstraints([andConstraint], 'or')).toEqual({ + or: [andConstraint], + }) + + // should push `or` into `and` key + const orConstraint: Where = { + or: [constraint], + } + expect(combineWhereConstraints([orConstraint], 'and')).toEqual({ + and: [orConstraint], + }) + + // should merge `and` but push `or` into `and` key + expect(combineWhereConstraints([andConstraint, orConstraint], 'and')).toEqual({ + and: [constraint, orConstraint], + }) + }) + + it('should push non and/or constraint key into `as` key', async () => { + const basicConstraint: Where = { + test: { + equals: 'value', + }, + } + + expect(combineWhereConstraints([basicConstraint], 'and')).toEqual({ + and: [basicConstraint], + }) + expect(combineWhereConstraints([basicConstraint], 'or')).toEqual({ + or: [basicConstraint], + }) + }) + + it('should return an empty object when no constraints are provided', async () => { + expect(combineWhereConstraints([], 'and')).toEqual({}) + expect(combineWhereConstraints([], 'or')).toEqual({}) + }) + + it('should return an empty object when all constraints are empty', async () => { + expect(combineWhereConstraints([{}, {}, undefined], 'and')).toEqual({}) + expect(combineWhereConstraints([{}, {}, undefined], 'or')).toEqual({}) + }) +}) diff --git a/packages/payload/src/utilities/combineWhereConstraints.ts b/packages/payload/src/utilities/combineWhereConstraints.ts index 4363835aee..2a1b979b04 100644 --- a/packages/payload/src/utilities/combineWhereConstraints.ts +++ b/packages/payload/src/utilities/combineWhereConstraints.ts @@ -8,12 +8,27 @@ export function combineWhereConstraints( return {} } - return { - [as]: constraints.filter((constraint): constraint is Where => { + const reducedConstraints = constraints.reduce>( + (acc: Partial, constraint) => { if (constraint && typeof constraint === 'object' && Object.keys(constraint).length > 0) { - return true + if (as in constraint) { + // merge the objects under the shared key + acc[as] = [...(acc[as] as Where[]), ...(constraint[as] as Where[])] + } else { + // the constraint does not share the key + acc[as]?.push(constraint) + } } - return false - }), + + return acc + }, + { [as]: [] } satisfies Where, + ) + + if (reducedConstraints[as]?.length === 0) { + // If there are no constraints, return an empty object + return {} } + + return reducedConstraints as Where } diff --git a/packages/payload/src/utilities/createPayloadRequest.ts b/packages/payload/src/utilities/createPayloadRequest.ts index f87550cf70..ac262ce8a6 100644 --- a/packages/payload/src/utilities/createPayloadRequest.ts +++ b/packages/payload/src/utilities/createPayloadRequest.ts @@ -27,7 +27,7 @@ export const createPayloadRequest = async ({ request, }: Args): Promise => { const cookies = parseCookies(request.headers) - const payload = await getPayload({ config: configPromise }) + const payload = await getPayload({ config: configPromise, cron: true }) const { config } = payload const localization = config.localization diff --git a/packages/payload/src/utilities/getEntityPolicies.ts b/packages/payload/src/utilities/getEntityPolicies.ts index 1db7a16f8c..9009d31e25 100644 --- a/packages/payload/src/utilities/getEntityPolicies.ts +++ b/packages/payload/src/utilities/getEntityPolicies.ts @@ -103,6 +103,7 @@ export async function getEntityPolicies(args: T): Promise { + if (!columns) { + return undefined + } + let columnsToTransform = columns // Columns that originate from the URL are a stringified JSON array and need to be parsed first @@ -44,5 +48,5 @@ export const transformColumnsToPreferences = ( export const transformColumnsToSearchParams = ( columns: Column[] | ColumnPreference[], ): ColumnsFromURL => { - return columns.map((col) => (col.active ? col.accessor : `-${col.accessor}`)) + return columns?.map((col) => (col.active ? col.accessor : `-${col.accessor}`)) } diff --git a/packages/payload/src/utilities/traverseFields.ts b/packages/payload/src/utilities/traverseFields.ts index 4f8408735f..9e7fb8b1d0 100644 --- a/packages/payload/src/utilities/traverseFields.ts +++ b/packages/payload/src/utilities/traverseFields.ts @@ -5,6 +5,7 @@ import { fieldAffectsData, fieldHasSubFields, fieldShouldBeLocalized, + tabHasName, } from '../fields/config/types.js' const traverseArrayOrBlocksField = ({ @@ -16,6 +17,7 @@ const traverseArrayOrBlocksField = ({ fillEmpty, leavesFirst, parentIsLocalized, + parentPath, parentRef, }: { callback: TraverseFieldsCallback @@ -26,6 +28,7 @@ const traverseArrayOrBlocksField = ({ fillEmpty: boolean leavesFirst: boolean parentIsLocalized: boolean + parentPath: string parentRef?: unknown }) => { if (fillEmpty) { @@ -38,6 +41,7 @@ const traverseArrayOrBlocksField = ({ isTopLevel: false, leavesFirst, parentIsLocalized: parentIsLocalized || field.localized, + parentPath: `${parentPath}${field.name}.`, parentRef, }) } @@ -55,6 +59,7 @@ const traverseArrayOrBlocksField = ({ isTopLevel: false, leavesFirst, parentIsLocalized: parentIsLocalized || field.localized, + parentPath: `${parentPath}${field.name}.`, parentRef, }) } @@ -88,6 +93,7 @@ const traverseArrayOrBlocksField = ({ isTopLevel: false, leavesFirst, parentIsLocalized: parentIsLocalized || field.localized, + parentPath: `${parentPath}${field.name}.`, parentRef, ref, }) @@ -105,6 +111,7 @@ export type TraverseFieldsCallback = (args: { */ next?: () => void parentIsLocalized: boolean + parentPath: string /** * The parent reference object */ @@ -130,6 +137,7 @@ type TraverseFieldsArgs = { */ leavesFirst?: boolean parentIsLocalized?: boolean + parentPath?: string parentRef?: Record | unknown ref?: Record | unknown } @@ -152,6 +160,7 @@ export const traverseFields = ({ isTopLevel = true, leavesFirst = false, parentIsLocalized, + parentPath = '', parentRef = {}, ref = {}, }: TraverseFieldsArgs): void => { @@ -172,12 +181,19 @@ export const traverseFields = ({ if ( !leavesFirst && callback && - callback({ field, next, parentIsLocalized: parentIsLocalized!, parentRef, ref }) + callback({ field, next, parentIsLocalized: parentIsLocalized!, parentPath, parentRef, ref }) ) { return true } else if (leavesFirst) { callbackStack.push(() => - callback({ field, next, parentIsLocalized: parentIsLocalized!, parentRef, ref }), + callback({ + field, + next, + parentIsLocalized: parentIsLocalized!, + parentPath, + parentRef, + ref, + }), ) } @@ -220,6 +236,7 @@ export const traverseFields = ({ field: { ...tab, type: 'tab' }, next, parentIsLocalized: parentIsLocalized!, + parentPath, parentRef: currentParentRef, ref: tabRef, }) @@ -231,6 +248,7 @@ export const traverseFields = ({ field: { ...tab, type: 'tab' }, next, parentIsLocalized: parentIsLocalized!, + parentPath, parentRef: currentParentRef, ref: tabRef, }), @@ -254,6 +272,7 @@ export const traverseFields = ({ isTopLevel: false, leavesFirst, parentIsLocalized: true, + parentPath: `${parentPath}${tab.name}.`, parentRef: currentParentRef, ref: tabRef[key as keyof typeof tabRef], }) @@ -268,6 +287,7 @@ export const traverseFields = ({ field: { ...tab, type: 'tab' }, next, parentIsLocalized: parentIsLocalized!, + parentPath, parentRef: currentParentRef, ref: tabRef, }) @@ -279,6 +299,7 @@ export const traverseFields = ({ field: { ...tab, type: 'tab' }, next, parentIsLocalized: parentIsLocalized!, + parentPath, parentRef: currentParentRef, ref: tabRef, }), @@ -296,6 +317,7 @@ export const traverseFields = ({ isTopLevel: false, leavesFirst, parentIsLocalized: false, + parentPath: tabHasName(tab) ? `${parentPath}${tab.name}` : parentPath, parentRef: currentParentRef, ref: tabRef, }) @@ -352,6 +374,7 @@ export const traverseFields = ({ isTopLevel: false, leavesFirst, parentIsLocalized: true, + parentPath: field.name ? `${parentPath}${field.name}` : parentPath, parentRef: currentParentRef, ref: currentRef[key as keyof typeof currentRef], }) @@ -380,7 +403,18 @@ export const traverseFields = ({ currentRef && typeof currentRef === 'object' ) { - if (fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! })) { + // TODO: `?? field.localized ?? false` shouldn't be necessary, but right now it + // is so that all fields are correctly traversed in copyToLocale and + // therefore pass the localization integration tests. + // I tried replacing the `!parentIsLocalized` condition with `parentIsLocalized === false` + // in `fieldShouldBeLocalized`, but several tests failed. We must be calling it with incorrect + // parameters somewhere. + if ( + fieldShouldBeLocalized({ + field, + parentIsLocalized: parentIsLocalized ?? field.localized ?? false, + }) + ) { if (Array.isArray(currentRef)) { return } @@ -400,6 +434,7 @@ export const traverseFields = ({ fillEmpty, leavesFirst, parentIsLocalized: true, + parentPath, parentRef: currentParentRef, }) } @@ -413,6 +448,7 @@ export const traverseFields = ({ fillEmpty, leavesFirst, parentIsLocalized: parentIsLocalized!, + parentPath, parentRef: currentParentRef, }) } @@ -426,6 +462,7 @@ export const traverseFields = ({ isTopLevel: false, leavesFirst, parentIsLocalized, + parentPath, parentRef: currentParentRef, ref: currentRef, }) diff --git a/packages/payload/src/utilities/validateWhereQuery.ts b/packages/payload/src/utilities/validateWhereQuery.ts index cb920db1a9..720aa66a11 100644 --- a/packages/payload/src/utilities/validateWhereQuery.ts +++ b/packages/payload/src/utilities/validateWhereQuery.ts @@ -13,9 +13,10 @@ import { validOperatorSet } from '../types/constants.js' export const validateWhereQuery = (whereQuery: Where): whereQuery is Where => { if ( whereQuery?.or && - whereQuery?.or?.length > 0 && - whereQuery?.or?.[0]?.and && - whereQuery?.or?.[0]?.and?.length > 0 + (whereQuery?.or?.length === 0 || + (whereQuery?.or?.length > 0 && + whereQuery?.or?.[0]?.and && + whereQuery?.or?.[0]?.and?.length > 0)) ) { // At this point we know that the whereQuery has 'or' and 'and' fields, // now let's check the structure and content of these fields. diff --git a/packages/payload/src/versions/deleteScheduledPublishJobs.ts b/packages/payload/src/versions/deleteScheduledPublishJobs.ts index 4020ad4fd6..6ce4199f8c 100644 --- a/packages/payload/src/versions/deleteScheduledPublishJobs.ts +++ b/packages/payload/src/versions/deleteScheduledPublishJobs.ts @@ -1,7 +1,7 @@ import type { PayloadRequest } from '../types/index.js' import { type Payload } from '../index.js' -import { jobsCollectionSlug } from '../queues/config/index.js' +import { jobsCollectionSlug } from '../queues/config/collection.js' type Args = { id?: number | string diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index 43df69ede8..24b6feef3e 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -16,6 +16,7 @@ type Args = { draft?: boolean global?: SanitizedGlobalConfig id?: number | string + operation?: 'create' | 'restoreVersion' | 'update' payload: Payload publishSpecificLocale?: string req?: PayloadRequest @@ -30,6 +31,7 @@ export const saveVersion = async ({ docWithLocales: doc, draft, global, + operation, payload, publishSpecificLocale, req, @@ -126,7 +128,7 @@ export const saveVersion = async ({ const createVersionArgs = { autosave: Boolean(autosave), collectionSlug: undefined as string | undefined, - createdAt: now, + createdAt: operation === 'restoreVersion' ? versionData.createdAt : now, globalSlug: undefined as string | undefined, parent: collection ? id : undefined, publishedLocale: publishSpecificLocale || undefined, diff --git a/packages/plugin-cloud-storage/package.json b/packages/plugin-cloud-storage/package.json index 32a337a47e..76e62db230 100644 --- a/packages/plugin-cloud-storage/package.json +++ b/packages/plugin-cloud-storage/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-cloud-storage", - "version": "3.46.0", + "version": "3.50.0", "description": "The official cloud storage plugin for Payload CMS", "homepage": "https://payloadcms.com", "repository": { @@ -65,8 +65,8 @@ }, "devDependencies": { "@types/find-node-modules": "^2.1.2", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "payload": "workspace:*" }, "peerDependencies": { diff --git a/packages/plugin-cloud-storage/src/types.ts b/packages/plugin-cloud-storage/src/types.ts index 8558ccf728..8b88231130 100644 --- a/packages/plugin-cloud-storage/src/types.ts +++ b/packages/plugin-cloud-storage/src/types.ts @@ -58,6 +58,7 @@ export type StaticHandler = ( req: PayloadRequest, args: { doc?: TypeWithID + headers?: Headers params: { clientUploadContext?: unknown; collection: string; filename: string } }, ) => Promise | Response diff --git a/packages/plugin-form-builder/package.json b/packages/plugin-form-builder/package.json index e1841b6421..ebaaf276a0 100644 --- a/packages/plugin-form-builder/package.json +++ b/packages/plugin-form-builder/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-form-builder", - "version": "3.46.0", + "version": "3.50.0", "description": "Form builder plugin for Payload CMS", "keywords": [ "payload", @@ -68,8 +68,8 @@ "devDependencies": { "@payloadcms/eslint-config": "workspace:*", "@types/escape-html": "^1.0.4", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "payload": "workspace:*" diff --git a/packages/plugin-import-export/package.json b/packages/plugin-import-export/package.json index 82834387e0..ec93697c4e 100644 --- a/packages/plugin-import-export/package.json +++ b/packages/plugin-import-export/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-import-export", - "version": "3.46.0", + "version": "3.50.0", "description": "Import-Export plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-import-export/src/components/ExportSaveButton/index.tsx b/packages/plugin-import-export/src/components/ExportSaveButton/index.tsx index e5e0e71846..cf8d0d3dc6 100644 --- a/packages/plugin-import-export/src/components/ExportSaveButton/index.tsx +++ b/packages/plugin-import-export/src/components/ExportSaveButton/index.tsx @@ -1,6 +1,15 @@ 'use client' -import { Button, SaveButton, Translation, useConfig, useForm, useTranslation } from '@payloadcms/ui' +import { + Button, + SaveButton, + toast, + Translation, + useConfig, + useForm, + useFormModified, + useTranslation, +} from '@payloadcms/ui' import React from 'react' import type { @@ -15,15 +24,33 @@ export const ExportSaveButton: React.FC = () => { routes: { api }, serverURL, }, + getEntityConfig, } = useConfig() - const { getData } = useForm() + const { getData, setModified } = useForm() + const modified = useFormModified() + + const exportsCollectionConfig = getEntityConfig({ collectionSlug: 'exports' }) + + const disableSave = exportsCollectionConfig?.admin?.custom?.disableSave === true + + const disableDownload = exportsCollectionConfig?.admin?.custom?.disableDownload === true const label = t('general:save') const handleDownload = async () => { + let timeoutID: null | ReturnType = null + let toastID: null | number | string = null + try { + setModified(false) // Reset modified state const data = getData() + + // Set a timeout to show toast if the request takes longer than 200ms + timeoutID = setTimeout(() => { + toastID = toast.success('Your export is being processed...') + }, 200) + const response = await fetch(`${serverURL}${api}/exports/download`, { body: JSON.stringify({ data, @@ -35,6 +62,16 @@ export const ExportSaveButton: React.FC = () => { method: 'POST', }) + // Clear the timeout if fetch completes quickly + if (timeoutID) { + clearTimeout(timeoutID) + } + + // Dismiss the toast if it was shown + if (toastID) { + toast.dismiss(toastID) + } + if (!response.ok) { throw new Error('Failed to download file') } @@ -63,15 +100,18 @@ export const ExportSaveButton: React.FC = () => { URL.revokeObjectURL(url) } catch (error) { console.error('Error downloading file:', error) + toast.error('Error downloading file') } } return ( - - + {!disableSave && } + {!disableDownload && ( + + )} ) } diff --git a/packages/plugin-import-export/src/components/FieldsToExport/index.tsx b/packages/plugin-import-export/src/components/FieldsToExport/index.tsx index e3c32e1870..085346fe4c 100644 --- a/packages/plugin-import-export/src/components/FieldsToExport/index.tsx +++ b/packages/plugin-import-export/src/components/FieldsToExport/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { CollectionPreferences, SelectFieldClientComponent } from 'payload' +import type { SelectFieldClientComponent } from 'payload' import type { ReactNode } from 'react' import { @@ -9,9 +9,9 @@ import { useConfig, useDocumentInfo, useField, - usePreferences, + useListQuery, } from '@payloadcms/ui' -import React, { useEffect, useState } from 'react' +import React, { useEffect } from 'react' import { useImportExport } from '../ImportExportProvider/index.js' import { reduceFields } from './reduceFields.js' @@ -24,62 +24,48 @@ export const FieldsToExport: SelectFieldClientComponent = (props) => { const { value: collectionSlug } = useField({ path: 'collectionSlug' }) const { getEntityConfig } = useConfig() const { collection } = useImportExport() - const { getPreference } = usePreferences() - const [displayedValue, setDisplayedValue] = useState< - { id: string; label: ReactNode; value: string }[] - >([]) + const { query } = useListQuery() const collectionConfig = getEntityConfig({ collectionSlug: collectionSlug ?? collection }) - const fieldOptions = reduceFields({ fields: collectionConfig?.fields }) - useEffect(() => { - if (value && value.length > 0) { - setDisplayedValue((prevDisplayedValue) => { - if (prevDisplayedValue.length > 0) { - return prevDisplayedValue - } // Prevent unnecessary updates + const disabledFields = + collectionConfig?.admin?.custom?.['plugin-import-export']?.disabledFields ?? [] - return value.map((field) => { - const match = fieldOptions.find((option) => option.value === field) - return match ? { ...match, id: field } : { id: field, label: field, value: field } - }) - }) - } - }, [value, fieldOptions]) + const fieldOptions = reduceFields({ + disabledFields, + fields: collectionConfig?.fields, + }) useEffect(() => { if (id || !collectionSlug) { return } - const doAsync = async () => { - const currentPreferences = await getPreference<{ - columns: CollectionPreferences['columns'] - }>(`collection-${collectionSlug}`) + const queryColumns = query?.columns - const columns = currentPreferences?.columns?.filter((a) => a.active).map((b) => b.accessor) - setValue(columns ?? collectionConfig?.admin?.defaultColumns ?? []) + if (Array.isArray(queryColumns)) { + const cleanColumns = queryColumns.filter( + (col): col is string => typeof col === 'string' && !col.startsWith('-'), + ) + // If columns are specified in the query, use them + setValue(cleanColumns) + } else { + // Fallback if no columns in query + setValue(collectionConfig?.admin?.defaultColumns ?? []) } + }, [id, collectionSlug, query?.columns, collectionConfig?.admin?.defaultColumns, setValue]) - void doAsync() - }, [ - getPreference, - collection, - setValue, - collectionSlug, - id, - collectionConfig?.admin?.defaultColumns, - ]) const onChange = (options: { id: string; label: ReactNode; value: string }[]) => { if (!options) { setValue([]) return } - const updatedValue = options?.map((option) => + + const updatedValue = options.map((option) => typeof option === 'object' ? option.value : option, ) + setValue(updatedValue) - setDisplayedValue(options) } return ( @@ -96,7 +82,14 @@ export const FieldsToExport: SelectFieldClientComponent = (props) => { // @ts-expect-error react select option onChange={onChange} options={fieldOptions} - value={displayedValue} + value={ + Array.isArray(value) + ? value.map((val) => { + const match = fieldOptions.find((opt) => opt.value === val) + return match ? { ...match, id: val } : { id: val, label: val, value: val } + }) + : [] + } />
) diff --git a/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx b/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx index 9d71530511..a20568b436 100644 --- a/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx +++ b/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx @@ -43,10 +43,12 @@ const combineLabel = ({ } export const reduceFields = ({ + disabledFields = [], fields, labelPrefix = null, path = '', }: { + disabledFields?: string[] fields: ClientField[] labelPrefix?: React.ReactNode path?: string @@ -66,6 +68,7 @@ export const reduceFields = ({ return [ ...fieldsToUse, ...reduceFields({ + disabledFields, fields: field.fields, labelPrefix: combineLabel({ field, prefix: labelPrefix }), path: createNestedClientFieldPath(path, field), @@ -80,12 +83,24 @@ export const reduceFields = ({ (tabFields, tab) => { if ('fields' in tab) { const isNamedTab = 'name' in tab && tab.name + + const newPath = isNamedTab ? `${path}${path ? '.' : ''}${tab.name}` : path + return [ ...tabFields, ...reduceFields({ + disabledFields, fields: tab.fields, - labelPrefix, - path: isNamedTab ? createNestedClientFieldPath(path, field) : path, + labelPrefix: isNamedTab + ? combineLabel({ + field: { + name: tab.name, + label: tab.label ?? tab.name, + } as any, + prefix: labelPrefix, + }) + : labelPrefix, + path: newPath, }), ] } @@ -98,6 +113,15 @@ export const reduceFields = ({ const val = createNestedClientFieldPath(path, field) + // If the field is disabled, skip it + if ( + disabledFields.some( + (disabledField) => val === disabledField || val.startsWith(`${disabledField}.`), + ) + ) { + return fieldsToUse + } + const formattedField = { id: val, label: combineLabel({ field, prefix: labelPrefix }), diff --git a/packages/plugin-import-export/src/components/Preview/index.tsx b/packages/plugin-import-export/src/components/Preview/index.tsx index af99f817ff..3dbdd9a417 100644 --- a/packages/plugin-import-export/src/components/Preview/index.tsx +++ b/packages/plugin-import-export/src/components/Preview/index.tsx @@ -18,8 +18,9 @@ import type { PluginImportExportTranslations, } from '../../translations/index.js' -import { useImportExport } from '../ImportExportProvider/index.js' +import { buildDisabledFieldRegex } from '../../utilities/buildDisabledFieldRegex.js' import './index.scss' +import { useImportExport } from '../ImportExportProvider/index.js' const baseClass = 'preview' @@ -46,6 +47,13 @@ export const Preview = () => { (collection) => collection.slug === collectionSlug, ) + const disabledFieldRegexes: RegExp[] = React.useMemo(() => { + const disabledFieldPaths = + collectionConfig?.admin?.custom?.['plugin-import-export']?.disabledFields ?? [] + + return disabledFieldPaths.map(buildDisabledFieldRegex) + }, [collectionConfig]) + const isCSV = format === 'csv' React.useEffect(() => { @@ -60,6 +68,7 @@ export const Preview = () => { collectionSlug, draft, fields, + format, limit, locale, sort, @@ -93,14 +102,27 @@ export const Preview = () => { Array.isArray(fields) && fields.length > 0 ? fields.flatMap((field) => { const regex = fieldToRegex(field) - return allKeys.filter((key) => regex.test(key)) + return allKeys.filter( + (key) => + regex.test(key) && + !disabledFieldRegexes.some((disabledRegex) => disabledRegex.test(key)), + ) }) - : allKeys.filter((key) => !defaultMetaFields.includes(key)) + : allKeys.filter( + (key) => + !defaultMetaFields.includes(key) && + !disabledFieldRegexes.some((regex) => regex.test(key)), + ) const fieldKeys = Array.isArray(fields) && fields.length > 0 - ? selectedKeys // strictly only what was selected - : [...selectedKeys, ...defaultMetaFields.filter((key) => allKeys.includes(key))] + ? selectedKeys // strictly use selected fields only + : [ + ...selectedKeys, + ...defaultMetaFields.filter( + (key) => allKeys.includes(key) && !selectedKeys.includes(key), + ), + ] // Build columns based on flattened keys const newColumns: Column[] = fieldKeys.map((key) => ({ @@ -136,7 +158,19 @@ export const Preview = () => { } void fetchData() - }, [collectionConfig, collectionSlug, draft, fields, i18n, limit, locale, sort, where]) + }, [ + collectionConfig, + collectionSlug, + disabledFieldRegexes, + draft, + fields, + format, + i18n, + limit, + locale, + sort, + where, + ]) return (
diff --git a/packages/plugin-import-export/src/components/SelectionToUseField/index.tsx b/packages/plugin-import-export/src/components/SelectionToUseField/index.tsx new file mode 100644 index 0000000000..9a6d359178 --- /dev/null +++ b/packages/plugin-import-export/src/components/SelectionToUseField/index.tsx @@ -0,0 +1,130 @@ +'use client' + +import type { Where } from 'payload' + +import { + RadioGroupField, + useDocumentInfo, + useField, + useListQuery, + useSelection, + useTranslation, +} from '@payloadcms/ui' +import React, { useEffect, useMemo } from 'react' + +const isWhereEmpty = (where: Where): boolean => { + if (!where || typeof where !== 'object') { + return true + } + + // Flatten one level of OR/AND wrappers + if (Array.isArray(where.and)) { + return where.and.length === 0 + } + if (Array.isArray(where.or)) { + return where.or.length === 0 + } + + return Object.keys(where).length === 0 +} + +export const SelectionToUseField: React.FC = () => { + const { id } = useDocumentInfo() + const { query } = useListQuery() + const { selectAll, selected } = useSelection() + const { t } = useTranslation() + + const { setValue: setSelectionToUseValue, value: selectionToUseValue } = useField({ + path: 'selectionToUse', + }) + + const { setValue: setWhere } = useField({ + path: 'where', + }) + + const hasMeaningfulFilters = query?.where && !isWhereEmpty(query.where) + + const availableOptions = useMemo(() => { + const options = [ + { + // @ts-expect-error - this is not correctly typed in plugins right now + label: t('plugin-import-export:selectionToUse-allDocuments'), + value: 'all', + }, + ] + + if (hasMeaningfulFilters) { + options.unshift({ + // @ts-expect-error - this is not correctly typed in plugins right now + label: t('plugin-import-export:selectionToUse-currentFilters'), + value: 'currentFilters', + }) + } + + if (['allInPage', 'some'].includes(selectAll)) { + options.unshift({ + // @ts-expect-error - this is not correctly typed in plugins right now + label: t('plugin-import-export:selectionToUse-currentSelection'), + value: 'currentSelection', + }) + } + + return options + }, [hasMeaningfulFilters, selectAll, t]) + + // Auto-set default + useEffect(() => { + if (id) { + return + } + + let defaultSelection: 'all' | 'currentFilters' | 'currentSelection' = 'all' + + if (['allInPage', 'some'].includes(selectAll)) { + defaultSelection = 'currentSelection' + } else if (query?.where) { + defaultSelection = 'currentFilters' + } + + setSelectionToUseValue(defaultSelection) + }, [id, selectAll, query?.where, setSelectionToUseValue]) + + // Sync where clause with selected option + useEffect(() => { + if (id) { + return + } + + if (selectionToUseValue === 'currentFilters' && query?.where) { + setWhere(query.where) + } else if (selectionToUseValue === 'currentSelection' && selected) { + const ids = [...selected.entries()].filter(([_, isSelected]) => isSelected).map(([id]) => id) + + setWhere({ id: { in: ids } }) + } else if (selectionToUseValue === 'all') { + setWhere({}) + } + }, [id, selectionToUseValue, query?.where, selected, setWhere]) + + // Hide component if no other options besides "all" are available + if (availableOptions.length <= 1) { + return null + } + + return ( + + ) +} diff --git a/packages/plugin-import-export/src/components/SortBy/index.tsx b/packages/plugin-import-export/src/components/SortBy/index.tsx index 7680ed82c1..8952ba4d95 100644 --- a/packages/plugin-import-export/src/components/SortBy/index.tsx +++ b/packages/plugin-import-export/src/components/SortBy/index.tsx @@ -46,7 +46,7 @@ export const SortBy: SelectFieldClientComponent = (props) => { if (option && (!displayedValue || displayedValue.value !== value)) { setDisplayedValue(option) } - }, [value, fieldOptions]) + }, [displayedValue, fieldOptions, value]) useEffect(() => { if (id || !query?.sort || value) { diff --git a/packages/plugin-import-export/src/components/WhereField/index.scss b/packages/plugin-import-export/src/components/WhereField/index.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/plugin-import-export/src/components/WhereField/index.tsx b/packages/plugin-import-export/src/components/WhereField/index.tsx deleted file mode 100644 index 68f4daf653..0000000000 --- a/packages/plugin-import-export/src/components/WhereField/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client' - -import type React from 'react' - -import { useDocumentInfo, useField, useListQuery, useSelection } from '@payloadcms/ui' -import { useEffect } from 'react' - -import './index.scss' - -export const WhereField: React.FC = () => { - const { setValue: setSelectionToUseValue, value: selectionToUseValue } = useField({ - path: 'selectionToUse', - }) - - const { setValue } = useField({ path: 'where' }) - const { selectAll, selected } = useSelection() - const { query } = useListQuery() - const { id } = useDocumentInfo() - - // setValue based on selectionToUseValue - useEffect(() => { - if (id) { - return - } - - if (selectionToUseValue === 'currentFilters' && query && query?.where) { - setValue(query.where) - } - - if (selectionToUseValue === 'currentSelection' && selected) { - const ids = [] - - for (const [key, value] of selected) { - if (value) { - ids.push(key) - } - } - - setValue({ - id: { - in: ids, - }, - }) - } - - if (selectionToUseValue === 'all' && selected) { - setValue({}) - } - - // Selected set a where query with IDs - }, [id, selectionToUseValue, query, selected, setValue]) - - // handles default value of selectionToUse - useEffect(() => { - if (id) { - return - } - let defaultSelection: 'all' | 'currentFilters' | 'currentSelection' = 'all' - - if (['allInPage', 'some'].includes(selectAll)) { - defaultSelection = 'currentSelection' - } - - if (defaultSelection === 'all' && query?.where) { - defaultSelection = 'currentFilters' - } - - setSelectionToUseValue(defaultSelection) - }, [id, query, selectAll, setSelectionToUseValue]) - - return null -} diff --git a/packages/plugin-import-export/src/export/createExport.ts b/packages/plugin-import-export/src/export/createExport.ts index c06fc4bd6d..fba5e11a0c 100644 --- a/packages/plugin-import-export/src/export/createExport.ts +++ b/packages/plugin-import-export/src/export/createExport.ts @@ -5,6 +5,7 @@ import { stringify } from 'csv-stringify/sync' import { APIError } from 'payload' import { Readable } from 'stream' +import { buildDisabledFieldRegex } from '../utilities/buildDisabledFieldRegex.js' import { flattenObject } from './flattenObject.js' import { getCustomFieldFunctions } from './getCustomFieldFunctions.js' import { getFilename } from './getFilename.js' @@ -63,7 +64,7 @@ export const createExport = async (args: CreateExportArgs) => { } = args if (debug) { - req.payload.logger.info({ + req.payload.logger.debug({ message: 'Starting export process with args:', collectionSlug, drafts, @@ -83,7 +84,7 @@ export const createExport = async (args: CreateExportArgs) => { const select = Array.isArray(fields) && fields.length > 0 ? getSelect(fields) : undefined if (debug) { - req.payload.logger.info({ message: 'Export configuration:', name, isCSV, locale }) + req.payload.logger.debug({ message: 'Export configuration:', name, isCSV, locale }) } const findArgs = { @@ -101,43 +102,87 @@ export const createExport = async (args: CreateExportArgs) => { } if (debug) { - req.payload.logger.info({ message: 'Find arguments:', findArgs }) + req.payload.logger.debug({ message: 'Find arguments:', findArgs }) } const toCSVFunctions = getCustomFieldFunctions({ fields: collectionConfig.flattenedFields, - select, }) + const disabledFields = + collectionConfig.admin?.custom?.['plugin-import-export']?.disabledFields ?? [] + + const disabledRegexes: RegExp[] = disabledFields.map(buildDisabledFieldRegex) + + const filterDisabledCSV = (row: Record): Record => { + const filtered: Record = {} + + for (const [key, value] of Object.entries(row)) { + const isDisabled = disabledRegexes.some((regex) => regex.test(key)) + if (!isDisabled) { + filtered[key] = value + } + } + + return filtered + } + + const filterDisabledJSON = (doc: any, parentPath = ''): any => { + if (Array.isArray(doc)) { + return doc.map((item) => filterDisabledJSON(item, parentPath)) + } + + if (typeof doc !== 'object' || doc === null) { + return doc + } + + const filtered: Record = {} + for (const [key, value] of Object.entries(doc)) { + const currentPath = parentPath ? `${parentPath}.${key}` : key + + // Only remove if this exact path is disabled + const isDisabled = disabledFields.includes(currentPath) + + if (!isDisabled) { + filtered[key] = filterDisabledJSON(value, currentPath) + } + } + + return filtered + } + if (download) { if (debug) { - req.payload.logger.info('Pre-scanning all columns before streaming') + req.payload.logger.debug('Pre-scanning all columns before streaming') } - const allColumnsSet = new Set() const allColumns: string[] = [] - let scanPage = 1 - let hasMore = true - while (hasMore) { - const result = await payload.find({ ...findArgs, page: scanPage }) + if (isCSV) { + const allColumnsSet = new Set() + let scanPage = 1 + let hasMore = true - result.docs.forEach((doc) => { - const flat = flattenObject({ doc, fields, toCSVFunctions }) - Object.keys(flat).forEach((key) => { - if (!allColumnsSet.has(key)) { - allColumnsSet.add(key) - allColumns.push(key) - } + while (hasMore) { + const result = await payload.find({ ...findArgs, page: scanPage }) + + result.docs.forEach((doc) => { + const flat = filterDisabledCSV(flattenObject({ doc, fields, toCSVFunctions })) + Object.keys(flat).forEach((key) => { + if (!allColumnsSet.has(key)) { + allColumnsSet.add(key) + allColumns.push(key) + } + }) }) - }) - hasMore = result.hasNextPage - scanPage += 1 - } + hasMore = result.hasNextPage + scanPage += 1 + } - if (debug) { - req.payload.logger.info(`Discovered ${allColumns.length} columns`) + if (debug) { + req.payload.logger.debug(`Discovered ${allColumns.length} columns`) + } } const encoder = new TextEncoder() @@ -149,36 +194,61 @@ export const createExport = async (args: CreateExportArgs) => { const result = await payload.find({ ...findArgs, page: streamPage }) if (debug) { - req.payload.logger.info(`Streaming batch ${streamPage} with ${result.docs.length} docs`) + req.payload.logger.debug(`Streaming batch ${streamPage} with ${result.docs.length} docs`) } if (result.docs.length === 0) { + // Close JSON array properly if JSON + if (!isCSV) { + this.push(encoder.encode(']')) + } this.push(null) return } - const batchRows = result.docs.map((doc) => flattenObject({ doc, fields, toCSVFunctions })) + if (isCSV) { + // --- CSV Streaming --- + const batchRows = result.docs.map((doc) => + filterDisabledCSV(flattenObject({ doc, fields, toCSVFunctions })), + ) - const paddedRows = batchRows.map((row) => { - const fullRow: Record = {} - for (const col of allColumns) { - fullRow[col] = row[col] ?? '' + const paddedRows = batchRows.map((row) => { + const fullRow: Record = {} + for (const col of allColumns) { + fullRow[col] = row[col] ?? '' + } + return fullRow + }) + + const csvString = stringify(paddedRows, { + header: isFirstBatch, + columns: allColumns, + }) + + this.push(encoder.encode(csvString)) + } else { + // --- JSON Streaming --- + const batchRows = result.docs.map((doc) => filterDisabledJSON(doc)) + + // Convert each filtered/flattened row into JSON string + const batchJSON = batchRows.map((row) => JSON.stringify(row)).join(',') + + if (isFirstBatch) { + this.push(encoder.encode('[' + batchJSON)) + } else { + this.push(encoder.encode(',' + batchJSON)) } - return fullRow - }) + } - const csvString = stringify(paddedRows, { - header: isFirstBatch, - columns: allColumns, - }) - - this.push(encoder.encode(csvString)) isFirstBatch = false streamPage += 1 if (!result.hasNextPage) { if (debug) { - req.payload.logger.info('Stream complete - no more pages') + req.payload.logger.debug('Stream complete - no more pages') + } + if (!isCSV) { + this.push(encoder.encode(']')) } this.push(null) // End the stream } @@ -195,7 +265,7 @@ export const createExport = async (args: CreateExportArgs) => { // Non-download path (buffered export) if (debug) { - req.payload.logger.info('Starting file generation') + req.payload.logger.debug('Starting file generation') } const outputData: string[] = [] @@ -212,13 +282,15 @@ export const createExport = async (args: CreateExportArgs) => { }) if (debug) { - req.payload.logger.info( + req.payload.logger.debug( `Processing batch ${findArgs.page} with ${result.docs.length} documents`, ) } if (isCSV) { - const batchRows = result.docs.map((doc) => flattenObject({ doc, fields, toCSVFunctions })) + const batchRows = result.docs.map((doc) => + filterDisabledCSV(flattenObject({ doc, fields, toCSVFunctions })), + ) // Track discovered column keys batchRows.forEach((row) => { @@ -232,8 +304,8 @@ export const createExport = async (args: CreateExportArgs) => { rows.push(...batchRows) } else { - const jsonInput = result.docs.map((doc) => JSON.stringify(doc)) - outputData.push(jsonInput.join(',\n')) + const batchRows = result.docs.map((doc) => filterDisabledJSON(doc)) + outputData.push(batchRows.map((doc) => JSON.stringify(doc)).join(',\n')) } hasNextPage = result.hasNextPage @@ -259,12 +331,12 @@ export const createExport = async (args: CreateExportArgs) => { const buffer = Buffer.from(format === 'json' ? `[${outputData.join(',')}]` : outputData.join('')) if (debug) { - req.payload.logger.info(`${format} file generation complete`) + req.payload.logger.debug(`${format} file generation complete`) } if (!id) { if (debug) { - req.payload.logger.info('Creating new export file') + req.payload.logger.debug('Creating new export file') } req.file = { name, @@ -274,7 +346,7 @@ export const createExport = async (args: CreateExportArgs) => { } } else { if (debug) { - req.payload.logger.info(`Updating existing export with id: ${id}`) + req.payload.logger.debug(`Updating existing export with id: ${id}`) } await req.payload.update({ id, @@ -290,6 +362,6 @@ export const createExport = async (args: CreateExportArgs) => { }) } if (debug) { - req.payload.logger.info('Export process completed successfully') + req.payload.logger.debug('Export process completed successfully') } } diff --git a/packages/plugin-import-export/src/export/flattenObject.ts b/packages/plugin-import-export/src/export/flattenObject.ts index 78902b245a..0801a2e5ef 100644 --- a/packages/plugin-import-export/src/export/flattenObject.ts +++ b/packages/plugin-import-export/src/export/flattenObject.ts @@ -24,20 +24,44 @@ export const flattenObject = ({ if (Array.isArray(value)) { value.forEach((item, index) => { if (typeof item === 'object' && item !== null) { - flatten(item, `${newKey}_${index}`) + const blockType = typeof item.blockType === 'string' ? item.blockType : undefined + + const itemPrefix = blockType ? `${newKey}_${index}_${blockType}` : `${newKey}_${index}` + + // Case: hasMany polymorphic relationships + if ( + 'relationTo' in item && + 'value' in item && + typeof item.value === 'object' && + item.value !== null + ) { + row[`${itemPrefix}_relationTo`] = item.relationTo + row[`${itemPrefix}_id`] = item.value.id + return + } + + flatten(item, itemPrefix) } else { if (toCSVFunctions?.[newKey]) { const columnName = `${newKey}_${index}` - const result = toCSVFunctions[newKey]({ - columnName, - data: row, - doc, - row, - siblingDoc, - value: item, - }) - if (typeof result !== 'undefined') { - row[columnName] = result + try { + const result = toCSVFunctions[newKey]({ + columnName, + data: row, + doc, + row, + siblingDoc, + value: item, + }) + if (typeof result !== 'undefined') { + row[columnName] = result + } + } catch (error) { + throw new Error( + `Error in toCSVFunction for array item "${columnName}": ${JSON.stringify(item)}\n${ + (error as Error).message + }`, + ) } } else { row[`${newKey}_${index}`] = item @@ -48,30 +72,42 @@ export const flattenObject = ({ if (!toCSVFunctions?.[newKey]) { flatten(value, newKey) } else { - const result = toCSVFunctions[newKey]({ - columnName: newKey, - data: row, - doc, - row, - siblingDoc, - value, - }) - if (typeof result !== 'undefined') { - row[newKey] = result + try { + const result = toCSVFunctions[newKey]({ + columnName: newKey, + data: row, + doc, + row, + siblingDoc, + value, + }) + if (typeof result !== 'undefined') { + row[newKey] = result + } + } catch (error) { + throw new Error( + `Error in toCSVFunction for nested object "${newKey}": ${JSON.stringify(value)}\n${(error as Error).message}`, + ) } } } else { if (toCSVFunctions?.[newKey]) { - const result = toCSVFunctions[newKey]({ - columnName: newKey, - data: row, - doc, - row, - siblingDoc, - value, - }) - if (typeof result !== 'undefined') { - row[newKey] = result + try { + const result = toCSVFunctions[newKey]({ + columnName: newKey, + data: row, + doc, + row, + siblingDoc, + value, + }) + if (typeof result !== 'undefined') { + row[newKey] = result + } + } catch (error) { + throw new Error( + `Error in toCSVFunction for field "${newKey}": ${JSON.stringify(value)}\n${(error as Error).message}`, + ) } } else { row[newKey] = value diff --git a/packages/plugin-import-export/src/export/getCreateExportCollectionTask.ts b/packages/plugin-import-export/src/export/getCreateExportCollectionTask.ts index 21fadf3450..893caf8cd1 100644 --- a/packages/plugin-import-export/src/export/getCreateExportCollectionTask.ts +++ b/packages/plugin-import-export/src/export/getCreateExportCollectionTask.ts @@ -1,5 +1,6 @@ import type { Config, TaskConfig, TypedUser } from 'payload' +import type { ImportExportPluginConfig } from '../types.js' import type { CreateExportArgs, Export } from './createExport.js' import { createExport } from './createExport.js' @@ -7,11 +8,12 @@ import { getFields } from './getFields.js' export const getCreateCollectionExportTask = ( config: Config, + pluginConfig?: ImportExportPluginConfig, ): TaskConfig<{ input: Export output: object }> => { - const inputSchema = getFields(config).concat( + const inputSchema = getFields(config, pluginConfig).concat( { name: 'user', type: 'text', diff --git a/packages/plugin-import-export/src/export/getCustomFieldFunctions.ts b/packages/plugin-import-export/src/export/getCustomFieldFunctions.ts index 826bee0ab8..931c4bcf04 100644 --- a/packages/plugin-import-export/src/export/getCustomFieldFunctions.ts +++ b/packages/plugin-import-export/src/export/getCustomFieldFunctions.ts @@ -1,21 +1,12 @@ -import { - type FlattenedField, - type SelectIncludeType, - traverseFields, - type TraverseFieldsCallback, -} from 'payload' +import { type FlattenedField, traverseFields, type TraverseFieldsCallback } from 'payload' import type { ToCSVFunction } from '../types.js' type Args = { fields: FlattenedField[] - select: SelectIncludeType | undefined } -export const getCustomFieldFunctions = ({ - fields, - select, -}: Args): Record => { +export const getCustomFieldFunctions = ({ fields }: Args): Record => { const result: Record = {} const buildCustomFunctions: TraverseFieldsCallback = ({ field, parentRef, ref }) => { @@ -54,7 +45,7 @@ export const getCustomFieldFunctions = ({ data[`${ref.prefix}${field.name}_relationTo`] = relationTo } } - return undefined + return undefined // prevents further flattening } } } else { @@ -98,10 +89,6 @@ export const getCustomFieldFunctions = ({ } } } - - // TODO: do this so we only return the functions needed based on the select used - ////@ts-expect-error ref is untyped - // ref.select = typeof select !== 'undefined' || select[field.name] ? select : {} } traverseFields({ callback: buildCustomFunctions, fields }) diff --git a/packages/plugin-import-export/src/export/getFields.ts b/packages/plugin-import-export/src/export/getFields.ts index c57d84b878..f095c5b798 100644 --- a/packages/plugin-import-export/src/export/getFields.ts +++ b/packages/plugin-import-export/src/export/getFields.ts @@ -1,8 +1,10 @@ import type { Config, Field, SelectField } from 'payload' +import type { ImportExportPluginConfig } from '../types.js' + import { getFilename } from './getFilename.js' -export const getFields = (config: Config): Field[] => { +export const getFields = (config: Config, pluginConfig?: ImportExportPluginConfig): Field[] => { let localeField: SelectField | undefined if (config.localization) { localeField = { @@ -45,9 +47,14 @@ export const getFields = (config: Config): Field[] => { name: 'format', type: 'select', admin: { + // Hide if a forced format is set via plugin config + condition: () => !pluginConfig?.format, width: '33%', }, - defaultValue: 'csv', + defaultValue: (() => { + // Default to plugin-defined format, otherwise 'csv' + return pluginConfig?.format ?? 'csv' + })(), // @ts-expect-error - this is not correctly typed in plugins right now label: ({ t }) => t('plugin-import-export:field-format-label'), options: [ @@ -132,12 +139,13 @@ export const getFields = (config: Config): Field[] => { ], }, { - // virtual field for the UI component to modify the hidden `where` field name: 'selectionToUse', type: 'radio', - defaultValue: 'all', - // @ts-expect-error - this is not correctly typed in plugins right now - label: ({ t }) => t('plugin-import-export:field-selectionToUse-label'), + admin: { + components: { + Field: '@payloadcms/plugin-import-export/rsc#SelectionToUseField', + }, + }, options: [ { // @ts-expect-error - this is not correctly typed in plugins right now @@ -155,7 +163,6 @@ export const getFields = (config: Config): Field[] => { value: 'all', }, ], - virtual: true, }, { name: 'fields', @@ -184,11 +191,16 @@ export const getFields = (config: Config): Field[] => { name: 'where', type: 'json', admin: { - components: { - Field: '@payloadcms/plugin-import-export/rsc#WhereField', - }, + hidden: true, }, defaultValue: {}, + hooks: { + beforeValidate: [ + ({ value }) => { + return value ?? {} + }, + ], + }, }, ], // @ts-expect-error - this is not correctly typed in plugins right now diff --git a/packages/plugin-import-export/src/exports/rsc.ts b/packages/plugin-import-export/src/exports/rsc.ts index 5072288925..9f2339b7bf 100644 --- a/packages/plugin-import-export/src/exports/rsc.ts +++ b/packages/plugin-import-export/src/exports/rsc.ts @@ -4,5 +4,5 @@ export { ExportSaveButton } from '../components/ExportSaveButton/index.js' export { FieldsToExport } from '../components/FieldsToExport/index.js' export { ImportExportProvider } from '../components/ImportExportProvider/index.js' export { Preview } from '../components/Preview/index.js' +export { SelectionToUseField } from '../components/SelectionToUseField/index.js' export { SortBy } from '../components/SortBy/index.js' -export { WhereField } from '../components/WhereField/index.js' diff --git a/packages/plugin-import-export/src/getExportCollection.ts b/packages/plugin-import-export/src/getExportCollection.ts index 070a86902a..c25b64824b 100644 --- a/packages/plugin-import-export/src/getExportCollection.ts +++ b/packages/plugin-import-export/src/getExportCollection.ts @@ -34,6 +34,10 @@ export const getExportCollection = ({ SaveButton: '@payloadcms/plugin-import-export/rsc#ExportSaveButton', }, }, + custom: { + disableDownload: pluginConfig.disableDownload ?? false, + disableSave: pluginConfig.disableSave ?? false, + }, group: false, useAsTitle: 'name', }, @@ -47,7 +51,7 @@ export const getExportCollection = ({ path: '/download', }, ], - fields: getFields(config), + fields: getFields(config, pluginConfig), hooks: { afterChange, beforeOperation, diff --git a/packages/plugin-import-export/src/index.ts b/packages/plugin-import-export/src/index.ts index 9bf001c281..e3b4f99f96 100644 --- a/packages/plugin-import-export/src/index.ts +++ b/packages/plugin-import-export/src/index.ts @@ -11,7 +11,11 @@ import { getCustomFieldFunctions } from './export/getCustomFieldFunctions.js' import { getSelect } from './export/getSelect.js' import { getExportCollection } from './getExportCollection.js' import { translations } from './translations/index.js' +import { collectDisabledFieldPaths } from './utilities/collectDisabledFieldPaths.js' import { getFlattenedFieldKeys } from './utilities/getFlattenedFieldKeys.js' +import { getValueAtPath } from './utilities/getvalueAtPath.js' +import { removeDisabledFields } from './utilities/removeDisabledFields.js' +import { setNestedValue } from './utilities/setNestedValue.js' export const importExportPlugin = (pluginConfig: ImportExportPluginConfig) => @@ -32,7 +36,7 @@ export const importExportPlugin = ) // inject the createExport job into the config - ;((config.jobs ??= {}).tasks ??= []).push(getCreateCollectionExportTask(config)) + ;((config.jobs ??= {}).tasks ??= []).push(getCreateCollectionExportTask(config, pluginConfig)) let collectionsToUpdate = config.collections @@ -58,6 +62,19 @@ export const importExportPlugin = }, path: '@payloadcms/plugin-import-export/rsc#ExportListMenuItem', }) + + // // Find fields explicitly marked as disabled for import/export + const disabledFieldAccessors = collectDisabledFieldPaths(collection.fields) + + // Store disabled field accessors in the admin config for use in the UI + collection.admin.custom = { + ...(collection.admin.custom || {}), + 'plugin-import-export': { + ...(collection.admin.custom?.['plugin-import-export'] || {}), + disabledFields: disabledFieldAccessors, + }, + } + collection.admin.components = components }) @@ -77,6 +94,7 @@ export const importExportPlugin = collectionSlug: string draft?: 'no' | 'yes' fields?: string[] + format?: 'csv' | 'json' limit?: number locale?: string sort?: any @@ -106,30 +124,58 @@ export const importExportPlugin = where, }) + const isCSV = req?.data?.format === 'csv' const docs = result.docs - const toCSVFunctions = getCustomFieldFunctions({ - fields: collection.config.fields as FlattenedField[], - select, - }) + let transformed: Record[] = [] - const possibleKeys = getFlattenedFieldKeys(collection.config.fields as FlattenedField[]) - - const transformed = docs.map((doc) => { - const row = flattenObject({ - doc, - fields, - toCSVFunctions, + if (isCSV) { + const toCSVFunctions = getCustomFieldFunctions({ + fields: collection.config.fields as FlattenedField[], }) - for (const key of possibleKeys) { - if (!(key in row)) { - row[key] = null - } - } + const possibleKeys = getFlattenedFieldKeys(collection.config.fields as FlattenedField[]) - return row - }) + transformed = docs.map((doc) => { + const row = flattenObject({ + doc, + fields, + toCSVFunctions, + }) + + for (const key of possibleKeys) { + if (!(key in row)) { + row[key] = null + } + } + + return row + }) + } else { + const disabledFields = + collection.config.admin.custom?.['plugin-import-export']?.disabledFields + + transformed = docs.map((doc) => { + let output: Record = { ...doc } + + // Remove disabled fields first + output = removeDisabledFields(output, disabledFields) + + // Then trim to selected fields only (if fields are provided) + if (Array.isArray(fields) && fields.length > 0) { + const trimmed: Record = {} + + for (const key of fields) { + const value = getValueAtPath(output, key) + setNestedValue(trimmed, key, value ?? null) + } + + output = trimmed + } + + return output + }) + } return Response.json({ docs: transformed, @@ -162,6 +208,17 @@ export const importExportPlugin = declare module 'payload' { export interface FieldCustom { 'plugin-import-export'?: { + /** + * When `true` the field is **completely excluded** from the import-export plugin: + * - It will not appear in the “Fields to export” selector. + * - It is hidden from the preview list when no specific fields are chosen. + * - Its data is omitted from the final CSV / JSON export. + * @default false + */ + disabled?: boolean + /** + * Custom function used to modify the outgoing csv data by manipulating the data, siblingData or by returning the desired value + */ toCSV?: ToCSVFunction } } diff --git a/packages/plugin-import-export/src/translations/languages/zhTw.ts b/packages/plugin-import-export/src/translations/languages/zhTw.ts index 08fd24e282..fdcefe21ce 100644 --- a/packages/plugin-import-export/src/translations/languages/zhTw.ts +++ b/packages/plugin-import-export/src/translations/languages/zhTw.ts @@ -2,21 +2,21 @@ import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.j export const zhTwTranslations: PluginDefaultTranslationsObject = { 'plugin-import-export': { - allLocales: '所有地區', + allLocales: '所有語言地區', exportDocumentLabel: '匯出 {{label}}', exportOptions: '匯出選項', - 'field-depth-label': '深度', + 'field-depth-label': '層級深度', 'field-drafts-label': '包含草稿', 'field-fields-label': '欄位', 'field-format-label': '匯出格式', - 'field-limit-label': '筆數限制', - 'field-locale-label': '語言/地區', + 'field-limit-label': '筆數上限', + 'field-locale-label': '語言地區', 'field-name-label': '檔案名稱', - 'field-selectionToUse-label': '選擇範圍', + 'field-selectionToUse-label': '使用的選取範圍', 'field-sort-label': '排序方式', 'selectionToUse-allDocuments': '使用所有文件', 'selectionToUse-currentFilters': '使用目前篩選條件', - 'selectionToUse-currentSelection': '使用目前選擇', + 'selectionToUse-currentSelection': '使用目前選取內容', totalDocumentsCount: '共 {{count}} 筆文件', }, } diff --git a/packages/plugin-import-export/src/types.ts b/packages/plugin-import-export/src/types.ts index f0d9a5cb77..9b48e2ba12 100644 --- a/packages/plugin-import-export/src/types.ts +++ b/packages/plugin-import-export/src/types.ts @@ -15,10 +15,30 @@ export type ImportExportPluginConfig = { * If true, enables debug logging */ debug?: boolean + /** + * If true, disables the download button in the export preview UI + * @default false + */ + disableDownload?: boolean /** * Enable to force the export to run synchronously */ disableJobsQueue?: boolean + /** + * If true, disables the save button in the export preview UI + * @default false + */ + disableSave?: boolean + /** + * Forces a specific export format (`csv` or `json`) and hides the format dropdown from the UI. + * + * When defined, this overrides the user's ability to choose a format manually. The export will + * always use the specified format, and the format selection field will be hidden. + * + * If not set, the user can choose between CSV and JSON in the export UI. + * @default undefined + */ + format?: 'csv' | 'json' /** * This function takes the default export collection configured in the plugin and allows you to override it by modifying and returning it * @param collection diff --git a/packages/plugin-import-export/src/utilities/buildDisabledFieldRegex.ts b/packages/plugin-import-export/src/utilities/buildDisabledFieldRegex.ts new file mode 100644 index 0000000000..41e44ad72e --- /dev/null +++ b/packages/plugin-import-export/src/utilities/buildDisabledFieldRegex.ts @@ -0,0 +1,13 @@ +/** + * Builds a RegExp that matches flattened field keys from a given dot-notated path. + */ +export const buildDisabledFieldRegex = (path: string): RegExp => { + const parts = path.split('.') + + const patternParts = parts.map((part) => { + return `${part}(?:_\\d+)?(?:_[^_]+)?` + }) + + const pattern = `^${patternParts.join('_')}(?:_.*)?$` + return new RegExp(pattern) +} diff --git a/packages/plugin-import-export/src/utilities/collectDisabledFieldPaths.ts b/packages/plugin-import-export/src/utilities/collectDisabledFieldPaths.ts new file mode 100644 index 0000000000..dafeae456c --- /dev/null +++ b/packages/plugin-import-export/src/utilities/collectDisabledFieldPaths.ts @@ -0,0 +1,82 @@ +import type { Field } from 'payload' + +import { traverseFields } from 'payload' +import { fieldAffectsData } from 'payload/shared' + +/** + * Recursively traverses a Payload field schema to collect all field paths + * that are explicitly disabled for the import/export plugin via: + * field.custom['plugin-import-export'].disabled + * + * Handles nested fields including named tabs, groups, arrays, blocks, etc. + * Tracks each field’s path by storing it in `ref.path` and manually propagating + * it through named tab layers via a temporary `__manualRef` marker. + * + * @param fields - The top-level array of Payload field definitions + * @returns An array of dot-notated field paths that are marked as disabled + */ +export const collectDisabledFieldPaths = (fields: Field[]): string[] => { + const disabledPaths: string[] = [] + + traverseFields({ + callback: ({ field, next, parentRef, ref }) => { + // Handle named tabs + if (field.type === 'tabs' && Array.isArray(field.tabs)) { + for (const tab of field.tabs) { + if ('name' in tab && typeof tab.name === 'string') { + // Build the path prefix for this tab + const parentPath = + parentRef && typeof (parentRef as { path?: unknown }).path === 'string' + ? (parentRef as { path: string }).path + : '' + const tabPath = parentPath ? `${parentPath}.${tab.name}` : tab.name + + // Prepare a ref for this named tab's children to inherit the path + const refObj = ref as Record + const tabRef = refObj[tab.name] ?? {} + tabRef.path = tabPath + tabRef.__manualRef = true // flag this as a manually constructed parentRef + refObj[tab.name] = tabRef + } + } + + // Skip further processing of the tab container itself + return + } + + // Skip unnamed fields (e.g. rows/collapsibles) + if (!('name' in field) || typeof field.name !== 'string') { + return + } + + // Determine the path to the current field + let parentPath: string | undefined + + if ( + parentRef && + typeof parentRef === 'object' && + 'path' in parentRef && + typeof (parentRef as { path?: unknown }).path === 'string' + ) { + parentPath = (parentRef as { path: string }).path + } else if ((ref as any)?.__manualRef && typeof (ref as any)?.path === 'string') { + // Fallback: if current ref is a manual tabRef, use its path + parentPath = (ref as any).path + } + + const fullPath = parentPath ? `${parentPath}.${field.name}` : field.name + + // Store current path for any nested children to use + ;(ref as any).path = fullPath + + // If field is a data-affecting field and disabled via plugin config, collect its path + if (fieldAffectsData(field) && field.custom?.['plugin-import-export']?.disabled) { + disabledPaths.push(fullPath) + return next?.() + } + }, + fields, + }) + + return disabledPaths +} diff --git a/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts b/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts index 491d3a2461..db25206b8b 100644 --- a/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts +++ b/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts @@ -16,56 +16,69 @@ export const getFlattenedFieldKeys = (fields: FieldWithPresentational[], prefix const keys: string[] = [] fields.forEach((field) => { - if (!('name' in field) || typeof field.name !== 'string') { - return - } + const fieldHasToCSVFunction = + 'custom' in field && + typeof field.custom === 'object' && + 'plugin-import-export' in field.custom && + field.custom['plugin-import-export']?.toCSV - const name = prefix ? `${prefix}_${field.name}` : field.name + const name = 'name' in field && typeof field.name === 'string' ? field.name : undefined + const fullKey = name && prefix ? `${prefix}_${name}` : (name ?? prefix) switch (field.type) { case 'array': { - const subKeys = getFlattenedFieldKeys(field.fields as FlattenedField[], `${name}_0`) + const subKeys = getFlattenedFieldKeys(field.fields as FlattenedField[], `${fullKey}_0`) keys.push(...subKeys) break } - case 'blocks': + case 'blocks': { field.blocks.forEach((block) => { - const blockKeys = getFlattenedFieldKeys(block.fields as FlattenedField[], `${name}_0`) - keys.push(...blockKeys) + const blockPrefix = `${fullKey}_0_${block.slug}` + keys.push(`${blockPrefix}_blockType`) + keys.push(`${blockPrefix}_id`) + keys.push(...getFlattenedFieldKeys(block.fields as FlattenedField[], blockPrefix)) }) break + } case 'collapsible': case 'group': case 'row': - keys.push(...getFlattenedFieldKeys(field.fields as FlattenedField[], name)) + keys.push(...getFlattenedFieldKeys(field.fields as FlattenedField[], fullKey)) break case 'relationship': if (field.hasMany) { - // e.g. hasManyPolymorphic_0_value_id - keys.push(`${name}_0_relationTo`, `${name}_0_value_id`) + if (Array.isArray(field.relationTo)) { + // hasMany polymorphic + keys.push(`${fullKey}_0_relationTo`, `${fullKey}_0_id`) + } else { + // hasMany monomorphic + keys.push(`${fullKey}_0`) + } } else { - // e.g. hasOnePolymorphic_id - keys.push(`${name}_id`, `${name}_relationTo`) + if (Array.isArray(field.relationTo)) { + // hasOne polymorphic + keys.push(`${fullKey}_relationTo`, `${fullKey}_id`) + } else { + // hasOne monomorphic + keys.push(fullKey) + } } break case 'tabs': - if (field.tabs) { - field.tabs.forEach((tab) => { - if (tab.name) { - const tabPrefix = prefix ? `${prefix}_${tab.name}` : tab.name - keys.push(...getFlattenedFieldKeys(tab.fields, tabPrefix)) - } else { - keys.push(...getFlattenedFieldKeys(tab.fields, prefix)) - } - }) - } + field.tabs?.forEach((tab) => { + const tabPrefix = tab.name ? `${fullKey}_${tab.name}` : fullKey + keys.push(...getFlattenedFieldKeys(tab.fields || [], tabPrefix)) + }) break default: + if (!name || fieldHasToCSVFunction) { + break + } if ('hasMany' in field && field.hasMany) { // Push placeholder for first index - keys.push(`${name}_0`) + keys.push(`${fullKey}_0`) } else { - keys.push(name) + keys.push(fullKey) } break } diff --git a/packages/plugin-import-export/src/utilities/getvalueAtPath.ts b/packages/plugin-import-export/src/utilities/getvalueAtPath.ts new file mode 100644 index 0000000000..4173b51730 --- /dev/null +++ b/packages/plugin-import-export/src/utilities/getvalueAtPath.ts @@ -0,0 +1,59 @@ +/** + * Safely retrieves a deeply nested value from an object using a dot-notation path. + * + * Supports: + * - Indexed array access (e.g., "array.0.field1") + * - Polymorphic blocks or keyed unions (e.g., "blocks.0.hero.title"), where the block key + * (e.g., "hero") maps to a nested object inside the block item. + * + * + * @param obj - The input object to traverse. + * @param path - A dot-separated string representing the path to retrieve. + * @returns The value at the specified path, or undefined if not found. + */ +export const getValueAtPath = (obj: unknown, path: string): unknown => { + if (!obj || typeof obj !== 'object') { + return undefined + } + + const parts = path.split('.') + let current: any = obj + + for (const part of parts) { + if (current == null) { + return undefined + } + + // If the path part is a number, treat it as an array index + if (!isNaN(Number(part))) { + current = current[Number(part)] + continue + } + + // Special case: if current is an array of blocks like [{ hero: { title: '...' } }] + // and the path is "blocks.0.hero.title", then `part` would be "hero" + if (Array.isArray(current)) { + const idx = Number(parts[parts.indexOf(part) - 1]) + const blockItem = current[idx] + + if (typeof blockItem === 'object') { + const keys = Object.keys(blockItem) + + // Find the key (e.g., "hero") that maps to an object + const matchingBlock = keys.find( + (key) => blockItem[key] && typeof blockItem[key] === 'object', + ) + + if (matchingBlock && part === matchingBlock) { + current = blockItem[matchingBlock] + continue + } + } + } + + // Fallback to plain object key access + current = current[part] + } + + return current +} diff --git a/packages/plugin-import-export/src/utilities/removeDisabledFields.ts b/packages/plugin-import-export/src/utilities/removeDisabledFields.ts new file mode 100644 index 0000000000..4f68799b42 --- /dev/null +++ b/packages/plugin-import-export/src/utilities/removeDisabledFields.ts @@ -0,0 +1,80 @@ +/** + * Recursively removes fields from a deeply nested object based on dot-notation paths. + * + * This utility supports removing: + * - Nested fields in plain objects (e.g., "group.value") + * - Fields inside arrays of objects (e.g., "group.array.field1") + * + * It safely traverses both object and array structures and avoids mutating the original input. + * + * @param obj - The original object to clean. + * @param disabled - An array of dot-separated paths indicating which fields to remove. + * @returns A deep clone of the original object with specified fields removed. + */ + +export const removeDisabledFields = ( + obj: Record, + disabled: string[] = [], +): Record => { + if (!disabled.length) { + return obj + } + + const clone = structuredClone(obj) + + // Process each disabled path independently + for (const path of disabled) { + const parts = path.split('.') + + /** + * Recursively walks the object tree according to the dot path, + * and deletes the field once the full path is reached. + * + * @param target - The current object or array being traversed + * @param i - The index of the current path part + */ + const removeRecursively = (target: any, i = 0): void => { + if (target == null) { + return + } + + const key = parts[i] + + // If at the final part of the path, perform the deletion + if (i === parts.length - 1) { + // If the current level is an array, delete the key from each item + if (Array.isArray(target)) { + for (const item of target) { + if (item && typeof item === 'object' && key !== undefined) { + delete item[key as keyof typeof item] + } + } + } else if (typeof target === 'object' && key !== undefined) { + delete target[key] + } + return + } + + if (key === undefined) { + return + } + + // Traverse to the next level in the path + const next = target[key] + + if (Array.isArray(next)) { + // If the next value is an array, recurse into each item + for (const item of next) { + removeRecursively(item, i + 1) + } + } else { + // Otherwise, continue down the object path + removeRecursively(next, i + 1) + } + } + + removeRecursively(clone) + } + + return clone +} diff --git a/packages/plugin-import-export/src/utilities/setNestedValue.ts b/packages/plugin-import-export/src/utilities/setNestedValue.ts new file mode 100644 index 0000000000..89e5487329 --- /dev/null +++ b/packages/plugin-import-export/src/utilities/setNestedValue.ts @@ -0,0 +1,65 @@ +/** + * Sets a value deeply into a nested object or array, based on a dot-notation path. + * + * This function: + * - Supports array indexing (e.g., "array.0.field1") + * - Creates intermediate arrays/objects as needed + * - Mutates the target object directly + * + * @example + * const obj = {} + * setNestedValue(obj, 'group.array.0.field1', 'hello') + * // Result: { group: { array: [ { field1: 'hello' } ] } } + * + * @param obj - The target object to mutate. + * @param path - A dot-separated string path indicating where to assign the value. + * @param value - The value to set at the specified path. + */ + +export const setNestedValue = ( + obj: Record, + path: string, + value: unknown, +): void => { + const parts = path.split('.') + let current: any = obj + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + const isLast = i === parts.length - 1 + const isIndex = !Number.isNaN(Number(part)) + + if (isIndex) { + const index = Number(part) + + // Ensure the current target is an array + if (!Array.isArray(current)) { + current = [] + } + + // Ensure the array slot is initialized + if (!current[index]) { + current[index] = {} + } + + if (isLast) { + current[index] = value + } else { + current = current[index] as Record + } + } else { + // Ensure the object key exists + if (isLast) { + if (typeof part === 'string') { + current[part] = value + } + } else { + if (typeof current[part as string] !== 'object' || current[part as string] === null) { + current[part as string] = {} + } + + current = current[part as string] as Record + } + } + } +} diff --git a/packages/plugin-multi-tenant/README.md b/packages/plugin-multi-tenant/README.md index aefc5ffebc..74cc95335c 100644 --- a/packages/plugin-multi-tenant/README.md +++ b/packages/plugin-multi-tenant/README.md @@ -36,11 +36,11 @@ type MultiTenantPluginConfig = { */ isGlobal?: boolean /** - * Set to `false` if you want to manually apply the baseListFilter + * Set to `false` if you want to manually apply the baseFilter * * @default true */ - useBaseListFilter?: boolean + useBaseFilter?: boolean /** * Set to `false` if you want to handle collection access manually without the multi-tenant constraints applied * diff --git a/packages/plugin-multi-tenant/package.json b/packages/plugin-multi-tenant/package.json index 494139f2ac..d6f16fb142 100644 --- a/packages/plugin-multi-tenant/package.json +++ b/packages/plugin-multi-tenant/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-multi-tenant", - "version": "3.46.0", + "version": "3.50.0", "description": "Multi Tenant plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-multi-tenant/src/components/GlobalViewRedirect/index.ts b/packages/plugin-multi-tenant/src/components/GlobalViewRedirect/index.ts index 9f99f41f2d..702b43a971 100644 --- a/packages/plugin-multi-tenant/src/components/GlobalViewRedirect/index.ts +++ b/packages/plugin-multi-tenant/src/components/GlobalViewRedirect/index.ts @@ -3,6 +3,8 @@ import type { CollectionSlug, ServerProps, ViewTypes } from 'payload' import { headers as getHeaders } from 'next/headers.js' import { redirect } from 'next/navigation.js' +import type { MultiTenantPluginConfig } from '../../types.js' + import { getGlobalViewRedirect } from '../../utilities/getGlobalViewRedirect.js' type Args = { @@ -10,9 +12,12 @@ type Args = { collectionSlug: CollectionSlug docID?: number | string globalSlugs: string[] + tenantArrayFieldName: string + tenantArrayTenantFieldName: string tenantFieldName: string tenantsCollectionSlug: string useAsTitle: string + userHasAccessToAllTenants: Required>['userHasAccessToAllTenants'] viewType: ViewTypes } & ServerProps @@ -27,9 +32,12 @@ export const GlobalViewRedirect = async (args: Args) => { headers, payload: args.payload, tenantFieldName: args.tenantFieldName, + tenantsArrayFieldName: args.tenantArrayFieldName, + tenantsArrayTenantFieldName: args.tenantArrayTenantFieldName, tenantsCollectionSlug: args.tenantsCollectionSlug, useAsTitle: args.useAsTitle, user: args.user, + userHasAccessToAllTenants: args.userHasAccessToAllTenants, view: args.viewType, }) diff --git a/packages/plugin-multi-tenant/src/endpoints/getTenantOptionsEndpoint.ts b/packages/plugin-multi-tenant/src/endpoints/getTenantOptionsEndpoint.ts new file mode 100644 index 0000000000..fe289f6130 --- /dev/null +++ b/packages/plugin-multi-tenant/src/endpoints/getTenantOptionsEndpoint.ts @@ -0,0 +1,45 @@ +import type { Endpoint } from 'payload' + +import { APIError } from 'payload' + +import type { MultiTenantPluginConfig } from '../types.js' + +import { getTenantOptions } from '../utilities/getTenantOptions.js' + +export const getTenantOptionsEndpoint = ({ + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + tenantsCollectionSlug, + useAsTitle, + userHasAccessToAllTenants, +}: { + tenantsArrayFieldName: string + tenantsArrayTenantFieldName: string + tenantsCollectionSlug: string + useAsTitle: string + userHasAccessToAllTenants: Required< + MultiTenantPluginConfig + >['userHasAccessToAllTenants'] +}): Endpoint => ({ + handler: async (req) => { + const { payload, user } = req + + if (!user) { + throw new APIError('Unauthorized', 401) + } + + const tenantOptions = await getTenantOptions({ + payload, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + tenantsCollectionSlug, + useAsTitle, + user, + userHasAccessToAllTenants, + }) + + return new Response(JSON.stringify({ tenantOptions })) + }, + method: 'get', + path: '/populate-tenant-options', +}) diff --git a/packages/plugin-multi-tenant/src/exports/utilities.ts b/packages/plugin-multi-tenant/src/exports/utilities.ts index c21ae23fa7..07f232a71f 100644 --- a/packages/plugin-multi-tenant/src/exports/utilities.ts +++ b/packages/plugin-multi-tenant/src/exports/utilities.ts @@ -1,4 +1,4 @@ -export { filterDocumentsBySelectedTenant as getTenantListFilter } from '../list-filters/filterDocumentsBySelectedTenant.js' +export { filterDocumentsByTenants as getTenantListFilter } from '../filters/filterDocumentsByTenants.js' export { getGlobalViewRedirect } from '../utilities/getGlobalViewRedirect.js' export { getTenantAccess } from '../utilities/getTenantAccess.js' export { getTenantFromCookie } from '../utilities/getTenantFromCookie.js' diff --git a/packages/plugin-multi-tenant/src/filters/filterDocumentsByTenants.ts b/packages/plugin-multi-tenant/src/filters/filterDocumentsByTenants.ts new file mode 100644 index 0000000000..bbf4c75e73 --- /dev/null +++ b/packages/plugin-multi-tenant/src/filters/filterDocumentsByTenants.ts @@ -0,0 +1,52 @@ +import type { PayloadRequest, Where } from 'payload' + +import { defaults } from '../defaults.js' +import { getCollectionIDType } from '../utilities/getCollectionIDType.js' +import { getTenantFromCookie } from '../utilities/getTenantFromCookie.js' +import { getUserTenantIDs } from '../utilities/getUserTenantIDs.js' + +type Args = { + filterFieldName: string + req: PayloadRequest + tenantsArrayFieldName?: string + tenantsArrayTenantFieldName?: string + tenantsCollectionSlug: string +} +export const filterDocumentsByTenants = ({ + filterFieldName, + req, + tenantsArrayFieldName = defaults.tenantsArrayFieldName, + tenantsArrayTenantFieldName = defaults.tenantsArrayTenantFieldName, + tenantsCollectionSlug, +}: Args): null | Where => { + const idType = getCollectionIDType({ + collectionSlug: tenantsCollectionSlug, + payload: req.payload, + }) + + // scope results to selected tenant + const selectedTenant = getTenantFromCookie(req.headers, idType) + if (selectedTenant) { + return { + [filterFieldName]: { + in: [selectedTenant], + }, + } + } + + // scope to user assigned tenants + const userAssignedTenants = getUserTenantIDs(req.user, { + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + }) + if (userAssignedTenants.length > 0) { + return { + [filterFieldName]: { + in: userAssignedTenants, + }, + } + } + + // no tenant selected and no user tenants, return null to allow access control to handle it + return null +} diff --git a/packages/plugin-multi-tenant/src/index.ts b/packages/plugin-multi-tenant/src/index.ts index 4b47f24594..88383982ba 100644 --- a/packages/plugin-multi-tenant/src/index.ts +++ b/packages/plugin-multi-tenant/src/index.ts @@ -7,16 +7,15 @@ import type { PluginDefaultTranslationsObject } from './translations/types.js' import type { MultiTenantPluginConfig } from './types.js' import { defaults } from './defaults.js' +import { getTenantOptionsEndpoint } from './endpoints/getTenantOptionsEndpoint.js' import { tenantField } from './fields/tenantField/index.js' import { tenantsArrayField } from './fields/tenantsArrayField/index.js' +import { filterDocumentsByTenants } from './filters/filterDocumentsByTenants.js' import { addTenantCleanup } from './hooks/afterTenantDelete.js' -import { filterDocumentsBySelectedTenant } from './list-filters/filterDocumentsBySelectedTenant.js' -import { filterTenantsBySelectedTenant } from './list-filters/filterTenantsBySelectedTenant.js' -import { filterUsersBySelectedTenant } from './list-filters/filterUsersBySelectedTenant.js' import { translations } from './translations/index.js' import { addCollectionAccess } from './utilities/addCollectionAccess.js' import { addFilterOptionsToFields } from './utilities/addFilterOptionsToFields.js' -import { combineListFilters } from './utilities/combineListFilters.js' +import { combineFilters } from './utilities/combineFilters.js' export const multiTenantPlugin = (pluginConfig: MultiTenantPluginConfig) => @@ -144,10 +143,13 @@ export const multiTenantPlugin = adminUsersCollection.admin = {} } - adminUsersCollection.admin.baseListFilter = combineListFilters({ - baseListFilter: adminUsersCollection.admin?.baseListFilter, + const baseFilter = + adminUsersCollection.admin?.baseFilter ?? adminUsersCollection.admin?.baseListFilter + adminUsersCollection.admin.baseFilter = combineFilters({ + baseFilter, customFilter: (args) => - filterUsersBySelectedTenant({ + filterDocumentsByTenants({ + filterFieldName: `${tenantsArrayFieldName}.${tenantsArrayTenantFieldName}`, req: args.req, tenantsArrayFieldName, tenantsArrayTenantFieldName, @@ -207,11 +209,15 @@ export const multiTenantPlugin = collection.admin = {} } - collection.admin.baseListFilter = combineListFilters({ - baseListFilter: collection.admin?.baseListFilter, + const baseFilter = collection.admin?.baseFilter ?? collection.admin?.baseListFilter + collection.admin.baseFilter = combineFilters({ + baseFilter, customFilter: (args) => - filterTenantsBySelectedTenant({ + filterDocumentsByTenants({ + filterFieldName: 'id', req: args.req, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, tenantsCollectionSlug, }), }) @@ -248,6 +254,17 @@ export const multiTenantPlugin = }, }, }) + + collection.endpoints = [ + ...(collection.endpoints || []), + getTenantOptionsEndpoint({ + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + tenantsCollectionSlug, + useAsTitle: tenantCollection.admin?.useAsTitle || 'id', + userHasAccessToAllTenants, + }), + ] } else if (pluginConfig.collections?.[collection.slug]) { const isGlobal = Boolean(pluginConfig.collections[collection.slug]?.isGlobal) @@ -282,7 +299,9 @@ export const multiTenantPlugin = }), ) - if (pluginConfig.collections[collection.slug]?.useBaseListFilter !== false) { + const { useBaseFilter, useBaseListFilter } = pluginConfig.collections[collection.slug] || {} + + if (useBaseFilter ?? useBaseListFilter ?? true) { /** * Add list filter to enabled collections * - filters results by selected tenant @@ -291,12 +310,15 @@ export const multiTenantPlugin = collection.admin = {} } - collection.admin.baseListFilter = combineListFilters({ - baseListFilter: collection.admin?.baseListFilter, + const baseFilter = collection.admin?.baseFilter ?? collection.admin?.baseListFilter + collection.admin.baseFilter = combineFilters({ + baseFilter, customFilter: (args) => - filterDocumentsBySelectedTenant({ + filterDocumentsByTenants({ + filterFieldName: tenantFieldName, req: args.req, - tenantFieldName, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, tenantsCollectionSlug, }), }) @@ -327,8 +349,11 @@ export const multiTenantPlugin = */ incomingConfig.admin.components.providers.push({ clientProps: { + tenantsArrayFieldName, + tenantsArrayTenantFieldName, tenantsCollectionSlug: tenantCollection.slug, useAsTitle: tenantCollection.admin?.useAsTitle || 'id', + userHasAccessToAllTenants, }, path: '@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider', }) @@ -343,8 +368,11 @@ export const multiTenantPlugin = basePath, globalSlugs: globalCollectionSlugs, tenantFieldName, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, tenantsCollectionSlug, useAsTitle: tenantCollection.admin?.useAsTitle || 'id', + userHasAccessToAllTenants, }, }) } diff --git a/packages/plugin-multi-tenant/src/list-filters/filterDocumentsBySelectedTenant.ts b/packages/plugin-multi-tenant/src/list-filters/filterDocumentsBySelectedTenant.ts deleted file mode 100644 index 4683273413..0000000000 --- a/packages/plugin-multi-tenant/src/list-filters/filterDocumentsBySelectedTenant.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { PayloadRequest, Where } from 'payload' - -import { getCollectionIDType } from '../utilities/getCollectionIDType.js' -import { getTenantFromCookie } from '../utilities/getTenantFromCookie.js' - -type Args = { - req: PayloadRequest - tenantFieldName: string - tenantsCollectionSlug: string -} -export const filterDocumentsBySelectedTenant = ({ - req, - tenantFieldName, - tenantsCollectionSlug, -}: Args): null | Where => { - const idType = getCollectionIDType({ - collectionSlug: tenantsCollectionSlug, - payload: req.payload, - }) - const selectedTenant = getTenantFromCookie(req.headers, idType) - - if (selectedTenant) { - return { - [tenantFieldName]: { - equals: selectedTenant, - }, - } - } - - return {} -} diff --git a/packages/plugin-multi-tenant/src/list-filters/filterTenantsBySelectedTenant.ts b/packages/plugin-multi-tenant/src/list-filters/filterTenantsBySelectedTenant.ts deleted file mode 100644 index cd515a2f33..0000000000 --- a/packages/plugin-multi-tenant/src/list-filters/filterTenantsBySelectedTenant.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { PayloadRequest, Where } from 'payload' - -import { getCollectionIDType } from '../utilities/getCollectionIDType.js' -import { getTenantFromCookie } from '../utilities/getTenantFromCookie.js' - -type Args = { - req: PayloadRequest - tenantsCollectionSlug: string -} -export const filterTenantsBySelectedTenant = ({ - req, - tenantsCollectionSlug, -}: Args): null | Where => { - const idType = getCollectionIDType({ - collectionSlug: tenantsCollectionSlug, - payload: req.payload, - }) - const selectedTenant = getTenantFromCookie(req.headers, idType) - - if (selectedTenant) { - return { - id: { - equals: selectedTenant, - }, - } - } - - return {} -} diff --git a/packages/plugin-multi-tenant/src/list-filters/filterUsersBySelectedTenant.ts b/packages/plugin-multi-tenant/src/list-filters/filterUsersBySelectedTenant.ts deleted file mode 100644 index 113e0a63a4..0000000000 --- a/packages/plugin-multi-tenant/src/list-filters/filterUsersBySelectedTenant.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { PayloadRequest, Where } from 'payload' - -import { getCollectionIDType } from '../utilities/getCollectionIDType.js' -import { getTenantFromCookie } from '../utilities/getTenantFromCookie.js' - -type Args = { - req: PayloadRequest - tenantsArrayFieldName: string - tenantsArrayTenantFieldName: string - tenantsCollectionSlug: string -} -/** - * Filter the list of users by the selected tenant - */ -export const filterUsersBySelectedTenant = ({ - req, - tenantsArrayFieldName, - tenantsArrayTenantFieldName, - tenantsCollectionSlug, -}: Args): null | Where => { - const idType = getCollectionIDType({ - collectionSlug: tenantsCollectionSlug, - payload: req.payload, - }) - const selectedTenant = getTenantFromCookie(req.headers, idType) - - if (selectedTenant) { - return { - [`${tenantsArrayFieldName}.${tenantsArrayTenantFieldName}`]: { - in: [selectedTenant], - }, - } - } - - return {} -} diff --git a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx index 89142469f8..f4f609398c 100644 --- a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx +++ b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx @@ -65,18 +65,14 @@ const Context = createContext({ export const TenantSelectionProviderClient = ({ children, + initialTenantOptions, initialValue, - tenantCookie, - tenantOptions: tenantOptionsFromProps, tenantsCollectionSlug, - useAsTitle, }: { children: React.ReactNode + initialTenantOptions: OptionObject[] initialValue?: number | string - tenantCookie?: string - tenantOptions: OptionObject[] tenantsCollectionSlug: string - useAsTitle: string }) => { const [selectedTenantID, setSelectedTenantID] = React.useState( initialValue, @@ -86,8 +82,10 @@ export const TenantSelectionProviderClient = ({ const { user } = useAuth() const { config } = useConfig() const userID = React.useMemo(() => user?.id, [user?.id]) + const prevUserID = React.useRef(userID) + const userChanged = userID !== prevUserID.current const [tenantOptions, setTenantOptions] = React.useState( - () => tenantOptionsFromProps, + () => initialTenantOptions, ) const selectedTenantLabel = React.useMemo( () => tenantOptions.find((option) => option.value === selectedTenantID)?.label, @@ -113,15 +111,15 @@ export const TenantSelectionProviderClient = ({ // users with multiple tenants can clear the tenant selection setSelectedTenantID(undefined) deleteCookie() - } else { + } else if (tenantOptions[0]) { // if there is only one tenant, force the selection of that tenant - setSelectedTenantID(tenantOptions[0]?.value) - setCookie(String(tenantOptions[0]?.value)) + setSelectedTenantID(tenantOptions[0].value) + setCookie(String(tenantOptions[0].value)) } } else if (!tenantOptions.find((option) => option.value === id)) { // if the tenant is not valid, set the first tenant as selected - if (tenantOptions?.[0]?.value) { - setTenant({ id: tenantOptions[0].value, refresh: true }) + if (tenantOptions[0]?.value) { + setTenant({ id: tenantOptions[0]?.value, refresh: true }) } else { setTenant({ id: undefined, refresh: true }) } @@ -140,7 +138,7 @@ export const TenantSelectionProviderClient = ({ const syncTenants = React.useCallback(async () => { try { const req = await fetch( - `${config.serverURL}${config.routes.api}/${tenantsCollectionSlug}?select[${useAsTitle}]=true&limit=0&depth=0`, + `${config.serverURL}${config.routes.api}/${tenantsCollectionSlug}/populate-tenant-options`, { credentials: 'include', method: 'GET', @@ -149,18 +147,18 @@ export const TenantSelectionProviderClient = ({ const result = await req.json() - if (result.docs) { - setTenantOptions( - result.docs.map((doc: Record) => ({ - label: doc[useAsTitle], - value: doc.id, - })), - ) + if (result.tenantOptions && userID) { + setTenantOptions(result.tenantOptions) + + if (result.tenantOptions.length === 1) { + setSelectedTenantID(result.tenantOptions[0].value) + setCookie(String(result.tenantOptions[0].value)) + } } } catch (e) { toast.error(`Error fetching tenants`) } - }, [config.serverURL, config.routes.api, tenantsCollectionSlug, useAsTitle]) + }, [config.serverURL, config.routes.api, tenantsCollectionSlug, setCookie, userID]) const updateTenants = React.useCallback( ({ id, label }) => { @@ -182,44 +180,21 @@ export const TenantSelectionProviderClient = ({ ) React.useEffect(() => { - if (userID && !tenantCookie) { - if (tenantOptionsFromProps.length === 1) { - // Users with no cookie set and only 1 tenant should set that tenant automatically - setTenant({ id: tenantOptionsFromProps[0]?.value, refresh: true }) - setTenantOptions(tenantOptionsFromProps) - } else if ( - (!tenantOptions || tenantOptions.length === 0) && - tenantOptionsFromProps.length > 0 - ) { - // If there are no tenant options, set them from the props - setTenantOptions(tenantOptionsFromProps) - } - } else if (userID && tenantCookie) { - if ((!tenantOptions || tenantOptions.length === 0) && tenantOptionsFromProps.length > 0) { - // If there are no tenant options, set them from the props - setTenantOptions(tenantOptionsFromProps) + if (userChanged) { + if (userID) { + // user logging in + void syncTenants() + } else { + // user logging out + setSelectedTenantID(undefined) + deleteCookie() + if (tenantOptions.length > 0) { + setTenantOptions([]) + } } + prevUserID.current = userID } - }, [ - initialValue, - selectedTenantID, - tenantCookie, - userID, - setTenant, - tenantOptionsFromProps, - tenantOptions, - ]) - - React.useEffect(() => { - if (!userID && tenantCookie) { - // User is not logged in, but has a tenant cookie, delete it - deleteCookie() - setSelectedTenantID(undefined) - } else if (userID) { - // User changed, refresh - router.refresh() - } - }, [userID, tenantCookie, deleteCookie, router]) + }, [userID, userChanged, syncTenants, deleteCookie, tenantOptions]) return ( = { children: React.ReactNode payload: Payload + tenantsArrayFieldName: string + tenantsArrayTenantFieldName: string tenantsCollectionSlug: string useAsTitle: string user: TypedUser + userHasAccessToAllTenants: Required< + MultiTenantPluginConfig + >['userHasAccessToAllTenants'] } export const TenantSelectionProvider = async ({ children, payload, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, tenantsCollectionSlug, useAsTitle, user, -}: Args) => { - let tenantOptions: OptionObject[] = [] - - try { - const { docs } = await findTenantOptions({ - limit: 0, - payload, - tenantsCollectionSlug, - useAsTitle, - user, - }) - tenantOptions = docs.map((doc) => ({ - label: String(doc[useAsTitle]), - value: doc.id, - })) - } catch (_) { - // user likely does not have access - } + userHasAccessToAllTenants, +}: Args) => { + const tenantOptions = await getTenantOptions({ + payload, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + tenantsCollectionSlug, + useAsTitle, + user, + userHasAccessToAllTenants, + }) const cookies = await getCookies() - let tenantCookie = cookies.get('payload-tenant')?.value + const tenantCookie = cookies.get('payload-tenant')?.value let initialValue = undefined /** @@ -56,17 +58,14 @@ export const TenantSelectionProvider = async ({ * If the there was no cookie or the cookie was an invalid tenantID set intialValue */ if (!initialValue) { - tenantCookie = undefined initialValue = tenantOptions.length > 1 ? undefined : tenantOptions[0]?.value } return ( {children} diff --git a/packages/plugin-multi-tenant/src/queries/findTenantOptions.ts b/packages/plugin-multi-tenant/src/queries/findTenantOptions.ts deleted file mode 100644 index 20f8f79bfe..0000000000 --- a/packages/plugin-multi-tenant/src/queries/findTenantOptions.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { PaginatedDocs, Payload, TypedUser } from 'payload' - -type Args = { - limit: number - payload: Payload - tenantsCollectionSlug: string - useAsTitle: string - user?: TypedUser -} -export const findTenantOptions = async ({ - limit, - payload, - tenantsCollectionSlug, - useAsTitle, - user, -}: Args): Promise => { - const isOrderable = payload.collections[tenantsCollectionSlug]?.config?.orderable || false - return payload.find({ - collection: tenantsCollectionSlug, - depth: 0, - limit, - overrideAccess: false, - select: { - [useAsTitle]: true, - ...(isOrderable ? { _order: true } : {}), - }, - sort: isOrderable ? '_order' : useAsTitle, - user, - }) -} diff --git a/packages/plugin-multi-tenant/src/translations/languages/zhTw.ts b/packages/plugin-multi-tenant/src/translations/languages/zhTw.ts index 9323556a8c..462691ddf4 100644 --- a/packages/plugin-multi-tenant/src/translations/languages/zhTw.ts +++ b/packages/plugin-multi-tenant/src/translations/languages/zhTw.ts @@ -3,9 +3,9 @@ import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.j export const zhTwTranslations: PluginDefaultTranslationsObject = { 'plugin-multi-tenant': { 'confirm-tenant-switch--body': - '您即將將所有權從 <0>{{fromTenant}} 轉移至 <0>{{toTenant}}', - 'confirm-tenant-switch--heading': '確認{{tenantLabel}}更改', - 'field-assignedTentant-label': '指定的租戶', + '您即將變更擁有者,從 <0>{{fromTenant}} 切換為 <0>{{toTenant}}', + 'confirm-tenant-switch--heading': '確認變更 {{tenantLabel}}', + 'field-assignedTentant-label': '指派的租用戶', }, } diff --git a/packages/plugin-multi-tenant/src/types.ts b/packages/plugin-multi-tenant/src/types.ts index 6ccc06642e..1f0e37236a 100644 --- a/packages/plugin-multi-tenant/src/types.ts +++ b/packages/plugin-multi-tenant/src/types.ts @@ -30,7 +30,19 @@ export type MultiTenantPluginConfig = { */ isGlobal?: boolean /** - * Set to `false` if you want to manually apply the baseListFilter + * Set to `false` if you want to manually apply the baseFilter + * + * @default true + */ + useBaseFilter?: boolean + /** + * @deprecated Use `useBaseFilter` instead. If both are defined, + * `useBaseFilter` will take precedence. This property remains only + * for backward compatibility and may be removed in a future version. + * + * Originally, `baseListFilter` was intended to filter only the List View + * in the admin panel. However, base filtering is often required in other areas + * such as internal link relationships in the Lexical editor. * * @default true */ diff --git a/packages/plugin-multi-tenant/src/utilities/combineListFilters.ts b/packages/plugin-multi-tenant/src/utilities/combineFilters.ts similarity index 59% rename from packages/plugin-multi-tenant/src/utilities/combineListFilters.ts rename to packages/plugin-multi-tenant/src/utilities/combineFilters.ts index c8024330fb..f6d15dffbf 100644 --- a/packages/plugin-multi-tenant/src/utilities/combineListFilters.ts +++ b/packages/plugin-multi-tenant/src/utilities/combineFilters.ts @@ -1,24 +1,24 @@ -import type { BaseListFilter, Where } from 'payload' +import type { BaseFilter, Where } from 'payload' type Args = { - baseListFilter?: BaseListFilter - customFilter: BaseListFilter + baseFilter?: BaseFilter + customFilter: BaseFilter } /** - * Combines a base list filter with a tenant list filter + * Combines a base filter with a tenant list filter * * Combines where constraints inside of an AND operator */ -export const combineListFilters = - ({ baseListFilter, customFilter }: Args): BaseListFilter => +export const combineFilters = + ({ baseFilter, customFilter }: Args): BaseFilter => async (args) => { const filterConstraints = [] - if (typeof baseListFilter === 'function') { - const baseListFilterResult = await baseListFilter(args) + if (typeof baseFilter === 'function') { + const baseFilterResult = await baseFilter(args) - if (baseListFilterResult) { - filterConstraints.push(baseListFilterResult) + if (baseFilterResult) { + filterConstraints.push(baseFilterResult) } } diff --git a/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts b/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts index 64b0acce93..ba5ebd15df 100644 --- a/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts +++ b/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts @@ -1,10 +1,13 @@ import type { Payload, TypedUser, ViewTypes } from 'payload' +import { unauthorized } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' -import { findTenantOptions } from '../queries/findTenantOptions.js' +import type { MultiTenantPluginConfig } from '../types.js' + import { getCollectionIDType } from './getCollectionIDType.js' import { getTenantFromCookie } from './getTenantFromCookie.js' +import { getTenantOptions } from './getTenantOptions.js' type Args = { basePath?: string @@ -13,9 +16,12 @@ type Args = { payload: Payload slug: string tenantFieldName: string + tenantsArrayFieldName: string + tenantsArrayTenantFieldName: string tenantsCollectionSlug: string useAsTitle: string user?: TypedUser + userHasAccessToAllTenants: Required>['userHasAccessToAllTenants'] view: ViewTypes } export async function getGlobalViewRedirect({ @@ -25,9 +31,12 @@ export async function getGlobalViewRedirect({ headers, payload, tenantFieldName, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, tenantsCollectionSlug, useAsTitle, user, + userHasAccessToAllTenants, view, }: Args): Promise { const idType = getCollectionIDType({ @@ -37,16 +46,22 @@ export async function getGlobalViewRedirect({ let tenant = getTenantFromCookie(headers, idType) let redirectRoute: `/${string}` | void = undefined + if (!user) { + return unauthorized() + } + if (!tenant) { - const tenantsQuery = await findTenantOptions({ - limit: 1, + const tenantOptions = await getTenantOptions({ payload, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, tenantsCollectionSlug, useAsTitle, user, + userHasAccessToAllTenants, }) - tenant = tenantsQuery.docs[0]?.id || null + tenant = tenantOptions[0]?.value || null } try { diff --git a/packages/plugin-multi-tenant/src/utilities/getTenantOptions.ts b/packages/plugin-multi-tenant/src/utilities/getTenantOptions.ts new file mode 100644 index 0000000000..f3a2d13e77 --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/getTenantOptions.ts @@ -0,0 +1,86 @@ +import type { OptionObject, Payload, TypedUser } from 'payload' + +import type { MultiTenantPluginConfig } from '../types.js' + +export const getTenantOptions = async ({ + payload, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + tenantsCollectionSlug, + useAsTitle, + user, + userHasAccessToAllTenants, +}: { + payload: Payload + tenantsArrayFieldName: string + tenantsArrayTenantFieldName: string + tenantsCollectionSlug: string + useAsTitle: string + user: TypedUser + userHasAccessToAllTenants: Required>['userHasAccessToAllTenants'] +}): Promise => { + let tenantOptions: OptionObject[] = [] + + if (!user) { + return tenantOptions + } + + if (userHasAccessToAllTenants(user)) { + // If the user has access to all tenants get them from the DB + const isOrderable = payload.collections[tenantsCollectionSlug]?.config?.orderable || false + const tenants = await payload.find({ + collection: tenantsCollectionSlug, + depth: 0, + limit: 0, + overrideAccess: false, + select: { + [useAsTitle]: true, + ...(isOrderable ? { _order: true } : {}), + }, + sort: isOrderable ? '_order' : useAsTitle, + user, + }) + + tenantOptions = tenants.docs.map((doc) => ({ + label: String(doc[useAsTitle as 'id']), // useAsTitle is dynamic but the type thinks we are only selecting `id` | `_order` + value: doc.id as string, + })) + } else { + const tenantsToPopulate: (number | string)[] = [] + + // i.e. users.tenants + ;((user[tenantsArrayFieldName] as { [key: string]: any }[]) || []).map((tenantRow) => { + const tenantField = tenantRow[tenantsArrayTenantFieldName] // tenants.tenant + if (typeof tenantField === 'string' || typeof tenantField === 'number') { + tenantsToPopulate.push(tenantField) + } else if (tenantField && typeof tenantField === 'object') { + tenantOptions.push({ + label: String(tenantField[useAsTitle]), + value: tenantField.id, + }) + } + }) + + if (tenantsToPopulate.length > 0) { + const populatedTenants = await payload.find({ + collection: tenantsCollectionSlug, + depth: 0, + limit: 0, + overrideAccess: false, + user, + where: { + id: { + in: tenantsToPopulate, + }, + }, + }) + + tenantOptions = populatedTenants.docs.map((doc) => ({ + label: String(doc[useAsTitle]), + value: doc.id as string, + })) + } + } + + return tenantOptions +} diff --git a/packages/plugin-multi-tenant/src/utilities/getUserTenantIDs.ts b/packages/plugin-multi-tenant/src/utilities/getUserTenantIDs.ts index 01ebd6a979..efd254e1a2 100644 --- a/packages/plugin-multi-tenant/src/utilities/getUserTenantIDs.ts +++ b/packages/plugin-multi-tenant/src/utilities/getUserTenantIDs.ts @@ -19,10 +19,9 @@ export const getUserTenantIDs = ( return [] } - const { - tenantsArrayFieldName = defaults.tenantsArrayFieldName, - tenantsArrayTenantFieldName = defaults.tenantsArrayTenantFieldName, - } = options || {} + const tenantsArrayFieldName = options?.tenantsArrayFieldName || defaults.tenantsArrayFieldName + const tenantsArrayTenantFieldName = + options?.tenantsArrayTenantFieldName || defaults.tenantsArrayTenantFieldName return ( (Array.isArray(user[tenantsArrayFieldName]) ? user[tenantsArrayFieldName] : [])?.reduce< diff --git a/packages/plugin-nested-docs/package.json b/packages/plugin-nested-docs/package.json index 0430468af7..4f6ff57df2 100644 --- a/packages/plugin-nested-docs/package.json +++ b/packages/plugin-nested-docs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-nested-docs", - "version": "3.46.0", + "version": "3.50.0", "description": "The official Nested Docs plugin for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/plugin-redirects/package.json b/packages/plugin-redirects/package.json index 8d9dc1fff1..0284afb521 100644 --- a/packages/plugin-redirects/package.json +++ b/packages/plugin-redirects/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-redirects", - "version": "3.46.0", + "version": "3.50.0", "description": "Redirects plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-search/package.json b/packages/plugin-search/package.json index e20f8dba58..0d4cc43dd8 100644 --- a/packages/plugin-search/package.json +++ b/packages/plugin-search/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-search", - "version": "3.46.0", + "version": "3.50.0", "description": "Search plugin for Payload", "keywords": [ "payload", @@ -65,8 +65,8 @@ }, "devDependencies": { "@payloadcms/eslint-config": "workspace:*", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "payload": "workspace:*" }, "peerDependencies": { diff --git a/packages/plugin-sentry/package.json b/packages/plugin-sentry/package.json index 74dd71dfae..bfea5ec719 100644 --- a/packages/plugin-sentry/package.json +++ b/packages/plugin-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-sentry", - "version": "3.46.0", + "version": "3.50.0", "description": "Sentry plugin for Payload", "keywords": [ "payload", @@ -59,8 +59,8 @@ }, "devDependencies": { "@payloadcms/eslint-config": "workspace:*", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "payload": "workspace:*" }, "peerDependencies": { diff --git a/packages/plugin-seo/package.json b/packages/plugin-seo/package.json index 5dd88c0753..dfc907a3fb 100644 --- a/packages/plugin-seo/package.json +++ b/packages/plugin-seo/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-seo", - "version": "3.46.0", + "version": "3.50.0", "description": "SEO plugin for Payload", "keywords": [ "payload", @@ -74,8 +74,8 @@ "devDependencies": { "@payloadcms/eslint-config": "workspace:*", "@payloadcms/next": "workspace:*", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "payload": "workspace:*" }, "peerDependencies": { diff --git a/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx b/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx index 50b347850f..5ba712609a 100644 --- a/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx +++ b/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx @@ -32,7 +32,13 @@ type MetaTitleProps = { export const MetaTitleComponent: React.FC = (props) => { const { - field: { label, maxLength: maxLengthFromProps, minLength: minLengthFromProps, required }, + field: { + label, + localized, + maxLength: maxLengthFromProps, + minLength: minLengthFromProps, + required, + }, hasGenerateTitleFn, readOnly, } = props @@ -128,7 +134,9 @@ export const MetaTitleComponent: React.FC = (props) => { }} >
- {Label ?? } + {Label ?? ( + + )} {hasGenerateTitleFn && (   —   diff --git a/packages/plugin-seo/src/translations/zhTw.ts b/packages/plugin-seo/src/translations/zhTw.ts index f864121586..7cd51e977a 100644 --- a/packages/plugin-seo/src/translations/zhTw.ts +++ b/packages/plugin-seo/src/translations/zhTw.ts @@ -4,24 +4,24 @@ export const zhTw: GenericTranslationsObject = { $schema: './translation-schema.json', 'plugin-seo': { almostThere: '快完成了', - autoGenerate: '自動生成', - bestPractices: '最佳實踐', - characterCount: '{{current}}/{{minLength}}-{{maxLength}} 字符, ', - charactersLeftOver: '{{characters}} 字符剩餘', - charactersToGo: '{{characters}} 字符待輸入', - charactersTooMany: '{{characters}} 字符太多', - checksPassing: '{{current}}/{{max}} 檢查通過', - good: '好', - imageAutoGenerationTip: '自動生成將獲取選定的主圖像。', + autoGenerate: '自動產生', + bestPractices: '最佳做法', + characterCount: '{{current}}/{{minLength}}-{{maxLength}} 字元,', + charactersLeftOver: '多出 {{characters}} 個字元', + charactersToGo: '還差 {{characters}} 個字元', + charactersTooMany: '超出 {{characters}} 個字元', + checksPassing: '{{current}} 項檢查通過,共 {{max}} 項', + good: '良好', + imageAutoGenerationTip: '系統會自動擷取選取的主圖。', lengthTipDescription: - '此文本應介於 {{minLength}} 和 {{maxLength}} 個字符之間。如需有關編寫高質量 meta 描述的幫助,請參見 ', + '長度應介於 {{minLength}} 到 {{maxLength}} 個字元。若需撰寫高品質後設資料描述的協助,請參閱', lengthTipTitle: - '此文本應介於 {{minLength}} 和 {{maxLength}} 個字符之間。如需有關編寫高質量 meta 標題的幫助,請參見 ', - missing: '缺失', + '長度應介於 {{minLength}} 到 {{maxLength}} 個字元。若需撰寫高品質後設資料標題的協助,請參閱', + missing: '尚未設定', noImage: '沒有圖片', preview: '預覽', - previewDescription: '實際搜尋結果可能會根據內容和搜尋相關性有所不同。', - tooLong: '太長', - tooShort: '太短', + previewDescription: '實際搜尋結果可能會因內容與關聯性而有所不同。', + tooLong: '過長', + tooShort: '過短', }, } diff --git a/packages/plugin-stripe/package.json b/packages/plugin-stripe/package.json index d15cf01347..8c213e80b7 100644 --- a/packages/plugin-stripe/package.json +++ b/packages/plugin-stripe/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-stripe", - "version": "3.46.0", + "version": "3.50.0", "description": "Stripe plugin for Payload", "keywords": [ "payload", @@ -72,8 +72,8 @@ "@payloadcms/eslint-config": "workspace:*", "@payloadcms/next": "workspace:*", "@types/lodash.get": "^4.4.7", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "@types/uuid": "10.0.0", "payload": "workspace:*" }, diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json index 88968fcb2e..963b3d1a08 100644 --- a/packages/richtext-lexical/package.json +++ b/packages/richtext-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-lexical", - "version": "3.46.0", + "version": "3.50.0", "description": "The officially supported Lexical richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { @@ -399,8 +399,8 @@ "@types/escape-html": "1.0.4", "@types/json-schema": "7.0.15", "@types/node": "22.15.30", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "babel-plugin-react-compiler": "19.1.0-rc.2", "babel-plugin-transform-remove-imports": "^1.8.0", "esbuild": "0.25.5", diff --git a/packages/richtext-lexical/src/features/blockquote/server/index.ts b/packages/richtext-lexical/src/features/blockquote/server/index.ts index 96ab9e7919..041da2318a 100644 --- a/packages/richtext-lexical/src/features/blockquote/server/index.ts +++ b/packages/richtext-lexical/src/features/blockquote/server/index.ts @@ -53,7 +53,11 @@ export const BlockquoteFeature = createServerFeature({ }) const style = [ node.format ? `text-align: ${node.format};` : '', - node.indent > 0 ? `padding-inline-start: ${Number(node.indent) * 2}rem;` : '', + // the unit should be px. Do not change it to rem, em, or something else. + // The quantity should be 40px. Do not change it either. + // See rationale in + // https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085 + node.indent > 0 ? `padding-inline-start: ${node.indent * 40}px;` : '', ] .filter(Boolean) .join(' ') diff --git a/packages/richtext-lexical/src/features/converters/lexicalToHtml/shared/findConverterForNode.ts b/packages/richtext-lexical/src/features/converters/lexicalToHtml/shared/findConverterForNode.ts index 83bde9163d..ebc1e793cd 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToHtml/shared/findConverterForNode.ts +++ b/packages/richtext-lexical/src/features/converters/lexicalToHtml/shared/findConverterForNode.ts @@ -83,7 +83,11 @@ export function findConverterForNode< if (!disableIndent && (!Array.isArray(disableIndent) || !disableIndent?.includes(node.type))) { if ('indent' in node && node.indent && node.type !== 'listitem') { - style['padding-inline-start'] = `${Number(node.indent) * 2}rem` + // the unit should be px. Do not change it to rem, em, or something else. + // The quantity should be 40px. Do not change it either. + // See rationale in + // https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085 + style['padding-inline-start'] = `${Number(node.indent) * 40}px` } } diff --git a/packages/richtext-lexical/src/features/converters/lexicalToHtml_deprecated/converter/converters/paragraph.ts b/packages/richtext-lexical/src/features/converters/lexicalToHtml_deprecated/converter/converters/paragraph.ts index cb737b94ca..5ce159e656 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToHtml_deprecated/converter/converters/paragraph.ts +++ b/packages/richtext-lexical/src/features/converters/lexicalToHtml_deprecated/converter/converters/paragraph.ts @@ -31,7 +31,11 @@ export const ParagraphHTMLConverter: HTMLConverter = { }) const style = [ node.format ? `text-align: ${node.format};` : '', - node.indent > 0 ? `padding-inline-start: ${Number(node.indent) * 2}rem;` : '', + // the unit should be px. Do not change it to rem, em, or something else. + // The quantity should be 40px. Do not change it either. + // See rationale in + // https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085 + node.indent > 0 ? `padding-inline-start: ${node.indent * 40}px;` : '', ] .filter(Boolean) .join(' ') diff --git a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/index.tsx b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/index.tsx index 15361de29c..772292795c 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/index.tsx +++ b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/index.tsx @@ -139,7 +139,11 @@ export function convertLexicalNodesToJSX({ (!Array.isArray(disableIndent) || !disableIndent?.includes(node.type)) ) { if ('indent' in node && node.indent && node.type !== 'listitem') { - style.paddingInlineStart = `${Number(node.indent) * 2}em` + // the unit should be px. Do not change it to rem, em, or something else. + // The quantity should be 40px. Do not change it either. + // See rationale in + // https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085 + style.paddingInlineStart = `${Number(node.indent) * 40}px` } } diff --git a/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/index.tsx b/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/index.tsx index 1553d9fb16..21cadb6191 100644 --- a/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/index.tsx +++ b/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/index.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from 'react' // eslint-disable-next-line payload/no-imports-from-exports-dir import { defaultJSXConverters, RichText } from '../../../../../exports/react/index.js' +import './style.scss' export function RichTextPlugin() { const [editor] = useLexicalComposerContext() @@ -16,5 +17,9 @@ export function RichTextPlugin() { }) }, [editor]) - return + return ( +
+ +
+ ) } diff --git a/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/style.scss b/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/style.scss new file mode 100644 index 0000000000..6f579ce353 --- /dev/null +++ b/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/style.scss @@ -0,0 +1,12 @@ +.debug-jsx-converter { + // this is to match the editor component, and be able to compare aligned styles + padding-left: 36px; + + // We revert to the browser defaults (user-agent), because we want to see + // the indentations look good without the need for CSS. + ul, + ol { + padding-left: revert; + margin: revert; + } +} diff --git a/packages/richtext-lexical/src/features/heading/server/index.ts b/packages/richtext-lexical/src/features/heading/server/index.ts index 1da8b1f99c..b7c80a5aec 100644 --- a/packages/richtext-lexical/src/features/heading/server/index.ts +++ b/packages/richtext-lexical/src/features/heading/server/index.ts @@ -71,7 +71,11 @@ export const HeadingFeature = createServerFeature< }) const style = [ node.format ? `text-align: ${node.format};` : '', - node.indent > 0 ? `padding-inline-start: ${Number(node.indent) * 2}rem;` : '', + // the unit should be px. Do not change it to rem, em, or something else. + // The quantity should be 40px. Do not change it either. + // See rationale in + // https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085 + node.indent > 0 ? `padding-inline-start: ${node.indent * 40}px;` : '', ] .filter(Boolean) .join(' ') diff --git a/packages/richtext-lexical/src/features/link/server/baseFields.ts b/packages/richtext-lexical/src/features/link/server/baseFields.ts index 93461025b3..8ce0ec596a 100644 --- a/packages/richtext-lexical/src/features/link/server/baseFields.ts +++ b/packages/richtext-lexical/src/features/link/server/baseFields.ts @@ -117,13 +117,23 @@ export const getBaseFields = ( type: 'relationship', filterOptions: !enabledCollections && !disabledCollections - ? ({ relationTo, user }) => { - const hidden = config.collections.find(({ slug }) => slug === relationTo)?.admin - .hidden + ? async ({ relationTo, req, user }) => { + const admin = config.collections.find(({ slug }) => slug === relationTo)?.admin + + const hidden = admin?.hidden if (typeof hidden === 'function' && hidden({ user } as { user: TypedUser })) { return false } - return true + + const baseFilter = admin?.baseFilter ?? admin?.baseListFilter + return ( + (await baseFilter?.({ + limit: 0, + page: 1, + req, + sort: 'id', + })) ?? true + ) } : null, label: ({ t }) => t('fields:chooseDocumentToLink'), diff --git a/packages/richtext-lexical/src/features/migrations/slateToLexical/converter/index.ts b/packages/richtext-lexical/src/features/migrations/slateToLexical/converter/index.ts index fd67d71e91..de3e59f625 100644 --- a/packages/richtext-lexical/src/features/migrations/slateToLexical/converter/index.ts +++ b/packages/richtext-lexical/src/features/migrations/slateToLexical/converter/index.ts @@ -53,12 +53,23 @@ export function convertSlateNodesToLexical({ const unknownConverter = converters.find((converter) => converter.nodeTypes.includes('unknown')) // @ts-expect-error - vestiges of the migration to strict mode. Probably not important enough in this file to fix return ( - slateNodes.map((slateNode, i) => { + // Flatten in case we unwrap an array of child nodes + slateNodes.flatMap((slateNode, i) => { if (!('type' in slateNode)) { if (canContainParagraphs) { // This is a paragraph node. They do not have a type property in Slate return convertParagraphNode(converters, slateNode) } else { + // Unwrap generic Slate nodes recursively since depth wasn't guaranteed by Slate, especially when copy + pasting rich text + // - If there are children and it can't be a paragraph in Lexical, assume that the generic node should be unwrapped until the text nodes, and only assume that its a text node when there are no more children + if (slateNode.children) { + return convertSlateNodesToLexical({ + canContainParagraphs, + converters, + parentNodeType, + slateNodes: slateNode.children || [], + }) + } // This is a simple text node. canContainParagraphs may be false if this is nested inside a paragraph already, since paragraphs cannot contain paragraphs return convertTextNode(slateNode) } @@ -113,7 +124,7 @@ export function convertTextNode(node: SlateNode): SerializedTextNode { format: convertNodeToFormat(node), mode: 'normal', style: '', - text: node.text, + text: node.text ?? "", version: 1, } } diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index d10a51c166..1256d68880 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -925,11 +925,16 @@ export { HeadingFeature, type HeadingFeatureProps } from './features/heading/ser export { HorizontalRuleFeature } from './features/horizontalRule/server/index.js' export { IndentFeature } from './features/indent/server/index.js' -export { AutoLinkNode } from './features/link/nodes/AutoLinkNode.js' -export { LinkNode } from './features/link/nodes/LinkNode.js' +export { + $createAutoLinkNode, + $isAutoLinkNode, + AutoLinkNode, +} from './features/link/nodes/AutoLinkNode.js' +export { $createLinkNode, $isLinkNode, LinkNode } from './features/link/nodes/LinkNode.js' export type { LinkFields } from './features/link/nodes/types.js' export { LinkFeature, type LinkFeatureServerProps } from './features/link/server/index.js' + export { ChecklistFeature } from './features/lists/checklist/server/index.js' export { OrderedListFeature } from './features/lists/orderedList/server/index.js' diff --git a/packages/richtext-slate/package.json b/packages/richtext-slate/package.json index a101437e7e..319784c509 100644 --- a/packages/richtext-slate/package.json +++ b/packages/richtext-slate/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-slate", - "version": "3.46.0", + "version": "3.50.0", "description": "The officially supported Slate richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { @@ -67,8 +67,8 @@ "@payloadcms/eslint-config": "workspace:*", "@types/is-hotkey": "^0.1.10", "@types/node": "22.15.30", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "payload": "workspace:*" }, "peerDependencies": { diff --git a/packages/storage-azure/package.json b/packages/storage-azure/package.json index 214d3ca24d..cc22394eec 100644 --- a/packages/storage-azure/package.json +++ b/packages/storage-azure/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-azure", - "version": "3.46.0", + "version": "3.50.0", "description": "Payload storage adapter for Azure Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-azure/src/staticHandler.ts b/packages/storage-azure/src/staticHandler.ts index 915c7de94d..625b4640dc 100644 --- a/packages/storage-azure/src/staticHandler.ts +++ b/packages/storage-azure/src/staticHandler.ts @@ -14,7 +14,7 @@ interface Args { } export const getHandler = ({ collection, getStorageClient }: Args): StaticHandler => { - return async (req, { params: { clientUploadContext, filename } }) => { + return async (req, { headers: incomingHeaders, params: { clientUploadContext, filename } }) => { try { const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req }) const blockBlobClient = getStorageClient().getBlockBlobClient( @@ -30,14 +30,34 @@ export const getHandler = ({ collection, getStorageClient }: Args): StaticHandle const response = blob._response + let initHeaders: Headers = { + ...(response.headers.rawHeaders() as unknown as Headers), + } + + // Typescript is difficult here with merging these types from Azure + if (incomingHeaders) { + initHeaders = { + ...initHeaders, + ...incomingHeaders, + } + } + + let headers = new Headers(initHeaders) + const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') const objectEtag = response.headers.get('etag') + if ( + collection.upload && + typeof collection.upload === 'object' && + typeof collection.upload.modifyResponseHeaders === 'function' + ) { + headers = collection.upload.modifyResponseHeaders({ headers }) || headers + } + if (etagFromHeaders && etagFromHeaders === objectEtag) { return new Response(null, { - headers: new Headers({ - ...response.headers.rawHeaders(), - }), + headers, status: 304, }) } @@ -63,7 +83,7 @@ export const getHandler = ({ collection, getStorageClient }: Args): StaticHandle }) return new Response(readableStream, { - headers: response.headers.rawHeaders(), + headers, status: response.status, }) } catch (err: unknown) { diff --git a/packages/storage-gcs/package.json b/packages/storage-gcs/package.json index 511328f1c6..bebb3a84b9 100644 --- a/packages/storage-gcs/package.json +++ b/packages/storage-gcs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-gcs", - "version": "3.46.0", + "version": "3.50.0", "description": "Payload storage adapter for Google Cloud Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-gcs/src/staticHandler.ts b/packages/storage-gcs/src/staticHandler.ts index 258fee971b..bceb1b730e 100644 --- a/packages/storage-gcs/src/staticHandler.ts +++ b/packages/storage-gcs/src/staticHandler.ts @@ -12,7 +12,7 @@ interface Args { } export const getHandler = ({ bucket, collection, getStorageClient }: Args): StaticHandler => { - return async (req, { params: { clientUploadContext, filename } }) => { + return async (req, { headers: incomingHeaders, params: { clientUploadContext, filename } }) => { try { const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req }) const file = getStorageClient().bucket(bucket).file(path.posix.join(prefix, filename)) @@ -22,13 +22,23 @@ export const getHandler = ({ bucket, collection, getStorageClient }: Args): Stat const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') const objectEtag = metadata.etag + let headers = new Headers(incomingHeaders) + + headers.append('Content-Length', String(metadata.size)) + headers.append('Content-Type', String(metadata.contentType)) + headers.append('ETag', String(metadata.etag)) + + if ( + collection.upload && + typeof collection.upload === 'object' && + typeof collection.upload.modifyResponseHeaders === 'function' + ) { + headers = collection.upload.modifyResponseHeaders({ headers }) || headers + } + if (etagFromHeaders && etagFromHeaders === objectEtag) { return new Response(null, { - headers: new Headers({ - 'Content-Length': String(metadata.size), - 'Content-Type': String(metadata.contentType), - ETag: String(metadata.etag), - }), + headers, status: 304, }) } @@ -50,11 +60,7 @@ export const getHandler = ({ bucket, collection, getStorageClient }: Args): Stat }) return new Response(readableStream, { - headers: new Headers({ - 'Content-Length': String(metadata.size), - 'Content-Type': String(metadata.contentType), - ETag: String(metadata.etag), - }), + headers, status: 200, }) } catch (err: unknown) { diff --git a/packages/storage-s3/package.json b/packages/storage-s3/package.json index bb08c1d4ae..1b5929dcd5 100644 --- a/packages/storage-s3/package.json +++ b/packages/storage-s3/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-s3", - "version": "3.46.0", + "version": "3.50.0", "description": "Payload storage adapter for Amazon S3", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-s3/src/staticHandler.ts b/packages/storage-s3/src/staticHandler.ts index 2f068fd654..08d528ecad 100644 --- a/packages/storage-s3/src/staticHandler.ts +++ b/packages/storage-s3/src/staticHandler.ts @@ -61,7 +61,7 @@ export const getHandler = ({ getStorageClient, signedDownloads, }: Args): StaticHandler => { - return async (req, { params: { clientUploadContext, filename } }) => { + return async (req, { headers: incomingHeaders, params: { clientUploadContext, filename } }) => { let object: AWS.GetObjectOutput | undefined = undefined try { const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req }) @@ -94,17 +94,31 @@ export const getHandler = ({ Key: key, }) + if (!object.Body) { + return new Response(null, { status: 404, statusText: 'Not Found' }) + } + + let headers = new Headers(incomingHeaders) + + headers.append('Content-Length', String(object.ContentLength)) + headers.append('Content-Type', String(object.ContentType)) + headers.append('Accept-Ranges', String(object.AcceptRanges)) + headers.append('ETag', String(object.ETag)) + const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') const objectEtag = object.ETag + if ( + collection.upload && + typeof collection.upload === 'object' && + typeof collection.upload.modifyResponseHeaders === 'function' + ) { + headers = collection.upload.modifyResponseHeaders({ headers }) || headers + } + if (etagFromHeaders && etagFromHeaders === objectEtag) { return new Response(null, { - headers: new Headers({ - 'Accept-Ranges': String(object.AcceptRanges), - 'Content-Length': String(object.ContentLength), - 'Content-Type': String(object.ContentType), - ETag: String(object.ETag), - }), + headers, status: 304, }) } @@ -125,12 +139,7 @@ export const getHandler = ({ const bodyBuffer = await streamToBuffer(object.Body) return new Response(bodyBuffer, { - headers: new Headers({ - 'Accept-Ranges': String(object.AcceptRanges), - 'Content-Length': String(object.ContentLength), - 'Content-Type': String(object.ContentType), - ETag: String(object.ETag), - }), + headers, status: 200, }) } catch (err) { diff --git a/packages/storage-uploadthing/package.json b/packages/storage-uploadthing/package.json index a34389d17d..83d2276881 100644 --- a/packages/storage-uploadthing/package.json +++ b/packages/storage-uploadthing/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-uploadthing", - "version": "3.46.0", + "version": "3.50.0", "description": "Payload storage adapter for uploadthing", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-uploadthing/src/staticHandler.ts b/packages/storage-uploadthing/src/staticHandler.ts index 3321184f14..a3475e7d1b 100644 --- a/packages/storage-uploadthing/src/staticHandler.ts +++ b/packages/storage-uploadthing/src/staticHandler.ts @@ -9,9 +9,13 @@ type Args = { } export const getHandler = ({ utApi }: Args): StaticHandler => { - return async (req, { doc, params: { clientUploadContext, collection, filename } }) => { + return async ( + req, + { doc, headers: incomingHeaders, params: { clientUploadContext, collection, filename } }, + ) => { try { let key: string + const collectionConfig = req.payload.collections[collection]?.config if ( clientUploadContext && @@ -21,7 +25,6 @@ export const getHandler = ({ utApi }: Args): StaticHandler => { ) { key = clientUploadContext.key } else { - const collectionConfig = req.payload.collections[collection]?.config let retrievedDoc = doc if (!retrievedDoc) { @@ -82,23 +85,32 @@ export const getHandler = ({ utApi }: Args): StaticHandler => { const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') const objectEtag = response.headers.get('etag') + let headers = new Headers(incomingHeaders) + + headers.append('Content-Length', String(blob.size)) + headers.append('Content-Type', blob.type) + + if (objectEtag) { + headers.append('ETag', objectEtag) + } + + if ( + collectionConfig?.upload && + typeof collectionConfig.upload === 'object' && + typeof collectionConfig.upload.modifyResponseHeaders === 'function' + ) { + headers = collectionConfig.upload.modifyResponseHeaders({ headers }) || headers + } + if (etagFromHeaders && etagFromHeaders === objectEtag) { return new Response(null, { - headers: new Headers({ - 'Content-Length': String(blob.size), - 'Content-Type': blob.type, - ETag: objectEtag, - }), + headers, status: 304, }) } return new Response(blob, { - headers: new Headers({ - 'Content-Length': String(blob.size), - 'Content-Type': blob.type, - ETag: objectEtag!, - }), + headers, status: 200, }) } catch (err) { diff --git a/packages/storage-vercel-blob/package.json b/packages/storage-vercel-blob/package.json index c5d04291ec..4f90450387 100644 --- a/packages/storage-vercel-blob/package.json +++ b/packages/storage-vercel-blob/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-vercel-blob", - "version": "3.46.0", + "version": "3.50.0", "description": "Payload storage adapter for Vercel Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-vercel-blob/src/staticHandler.ts b/packages/storage-vercel-blob/src/staticHandler.ts index 153bbb220d..0744434929 100644 --- a/packages/storage-vercel-blob/src/staticHandler.ts +++ b/packages/storage-vercel-blob/src/staticHandler.ts @@ -15,27 +15,36 @@ export const getStaticHandler = ( { baseUrl, cacheControlMaxAge = 0, token }: StaticHandlerArgs, collection: CollectionConfig, ): StaticHandler => { - return async (req, { params: { clientUploadContext, filename } }) => { + return async (req, { headers: incomingHeaders, params: { clientUploadContext, filename } }) => { try { const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req }) const fileKey = path.posix.join(prefix, encodeURIComponent(filename)) const fileUrl = `${baseUrl}/${fileKey}` const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') const blobMetadata = await head(fileUrl, { token }) - const uploadedAtString = blobMetadata.uploadedAt.toISOString() + const { contentDisposition, contentType, size, uploadedAt } = blobMetadata + const uploadedAtString = uploadedAt.toISOString() const ETag = `"${fileKey}-${uploadedAtString}"` - const { contentDisposition, contentType, size } = blobMetadata + let headers = new Headers(incomingHeaders) + + headers.append('Cache-Control', `public, max-age=${cacheControlMaxAge}`) + headers.append('Content-Disposition', contentDisposition) + headers.append('Content-Length', String(size)) + headers.append('Content-Type', contentType) + headers.append('ETag', ETag) + + if ( + collection.upload && + typeof collection.upload === 'object' && + typeof collection.upload.modifyResponseHeaders === 'function' + ) { + headers = collection.upload.modifyResponseHeaders({ headers }) || headers + } if (etagFromHeaders && etagFromHeaders === ETag) { return new Response(null, { - headers: new Headers({ - 'Cache-Control': `public, max-age=${cacheControlMaxAge}`, - 'Content-Disposition': contentDisposition, - 'Content-Length': String(size), - 'Content-Type': contentType, - ETag, - }), + headers, status: 304, }) } @@ -55,15 +64,10 @@ export const getStaticHandler = ( const bodyBuffer = await blob.arrayBuffer() + headers.append('Last-Modified', uploadedAtString) + return new Response(bodyBuffer, { - headers: new Headers({ - 'Cache-Control': `public, max-age=${cacheControlMaxAge}`, - 'Content-Disposition': contentDisposition, - 'Content-Length': String(size), - 'Content-Type': contentType, - ETag, - 'Last-Modified': blobMetadata.uploadedAt.toUTCString(), - }), + headers, status: 200, }) } catch (err: unknown) { diff --git a/packages/translations/package.json b/packages/translations/package.json index fae9ecc8bf..62a8230da6 100644 --- a/packages/translations/package.json +++ b/packages/translations/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/translations", - "version": "3.46.0", + "version": "3.50.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", @@ -60,8 +60,8 @@ "devDependencies": { "@payloadcms/eslint-config": "workspace:*", "@swc/core": "1.11.29", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "dotenv": "16.4.7", "prettier": "3.5.3", "typescript": "5.7.3" diff --git a/packages/translations/src/clientKeys.ts b/packages/translations/src/clientKeys.ts index ddfa284015..67c99862e4 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -65,15 +65,19 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'error:autosaving', 'error:correctInvalidFields', 'error:deletingTitle', + 'error:documentNotFound', 'error:emailOrPasswordIncorrect', 'error:usernameOrPasswordIncorrect', 'error:loadingDocument', + 'error:insufficientClipboardPermissions', + 'error:invalidClipboardData', 'error:invalidRequestArgs', 'error:invalidFileType', 'error:logoutFailed', 'error:noMatchedField', 'error:notAllowedToAccessPage', 'error:previewing', + 'error:unableToCopy', 'error:unableToDeleteCount', 'error:unableToReindexCollection', 'error:unableToUpdateCount', @@ -87,6 +91,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'error:tokenNotProvided', 'error:unPublishingDocument', 'error:problemUploadingFile', + 'error:restoringTitle', 'fields:addLabel', 'fields:addLink', @@ -130,6 +135,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'folder:browseByFolder', 'folder:deleteFolder', 'folder:folders', + 'folder:folderTypeDescription', 'folder:folderName', 'folder:itemsMovedToFolder', 'folder:itemsMovedToRoot', @@ -150,6 +156,14 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:all', 'general:aboutToDeleteCount', 'general:aboutToDelete', + 'general:aboutToPermanentlyDelete', + 'general:aboutToPermanentlyDeleteTrash', + 'general:aboutToRestore', + 'general:aboutToRestoreAsDraft', + 'general:aboutToRestoreAsDraftCount', + 'general:aboutToRestoreCount', + 'general:aboutToTrash', + 'general:aboutToTrashCount', 'general:addBelow', 'general:addFilter', 'general:adminTheme', @@ -179,9 +193,13 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:confirmReindexAll', 'general:confirmReindexDescription', 'general:confirmReindexDescriptionAll', + 'general:confirmRestoration', 'general:copied', + 'general:clear', 'general:clearAll', 'general:copy', + 'general:copyField', + 'general:copyRow', 'general:copyWarning', 'general:copying', 'general:create', @@ -196,6 +214,9 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:dark', 'general:dashboard', 'general:delete', + 'general:deleted', + 'general:deletedAt', + 'general:deletePermanently', 'general:deletedSuccessfully', 'general:deletedCountSuccessfully', 'general:deleting', @@ -203,6 +224,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:depth', 'general:deselectAllRows', 'general:document', + 'general:documentIsTrashed', 'general:documentLocked', 'general:documents', 'general:duplicate', @@ -216,6 +238,8 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:editedSince', 'general:email', 'general:emailAddress', + 'general:emptyTrash', + 'general:emptyTrashLabel', 'general:enterAValue', 'general:error', 'general:errors', @@ -225,6 +249,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:filterWhere', 'general:globals', 'general:goBack', + 'general:groupByLabel', 'general:isEditing', 'general:item', 'general:items', @@ -257,6 +282,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:noResults', 'general:notFound', 'general:nothingFound', + 'general:noTrashResults', 'general:noUpcomingEventsScheduled', 'general:noValue', 'general:of', @@ -267,7 +293,11 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:overwriteExistingData', 'general:pageNotFound', 'general:password', + 'general:pasteField', + 'general:pasteRow', 'general:payloadSettings', + 'general:permanentlyDelete', + 'general:permanentlyDeletedCountSuccessfully', 'general:perPage', 'general:previous', 'general:reindex', @@ -278,6 +308,10 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:resetPreferences', 'general:resetPreferencesDescription', 'general:resettingPreferences', + 'general:restore', + 'general:restoreAsPublished', + 'general:restoredCountSuccessfully', + 'general:restoring', 'general:row', 'general:rows', 'general:save', @@ -307,6 +341,10 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:time', 'general:timezone', 'general:titleDeleted', + 'general:titleTrashed', + 'general:titleRestored', + 'general:trash', + 'general:trashedCountSuccessfully', 'general:import', 'general:export', 'general:allLocales', @@ -326,6 +364,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:updatedSuccessfully', 'general:updating', 'general:value', + 'general:viewing', 'general:viewReadOnly', 'general:uploading', 'general:uploadingBulk', @@ -433,6 +472,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'version:noRowsFound', 'version:noRowsSelected', 'version:preview', + 'version:previouslyDraft', 'version:previouslyPublished', 'version:previousVersion', 'version:problemRestoringVersion', diff --git a/packages/translations/src/exports/all.ts b/packages/translations/src/exports/all.ts index 00a6cf044e..cebcf4c269 100644 --- a/packages/translations/src/exports/all.ts +++ b/packages/translations/src/exports/all.ts @@ -18,6 +18,7 @@ import { he } from '../languages/he.js' import { hr } from '../languages/hr.js' import { hu } from '../languages/hu.js' import { hy } from '../languages/hy.js' +import { id } from '../languages/id.js' import { it } from '../languages/it.js' import { ja } from '../languages/ja.js' import { ko } from '../languages/ko.js' @@ -61,6 +62,8 @@ export const translations = { hr, hu, hy, + + id, it, ja, ko, diff --git a/packages/translations/src/importDateFNSLocale.ts b/packages/translations/src/importDateFNSLocale.ts index 46af2225c5..747f9e380b 100644 --- a/packages/translations/src/importDateFNSLocale.ts +++ b/packages/translations/src/importDateFNSLocale.ts @@ -71,6 +71,10 @@ export const importDateFNSLocale = async (locale: string): Promise => { case 'hu': result = (await import('date-fns/locale/hu')).hu + break + case 'id': + result = (await import('date-fns/locale/id')).id + break case 'it': result = (await import('date-fns/locale/it')).it diff --git a/packages/translations/src/languages/ar.ts b/packages/translations/src/languages/ar.ts index 790fbbef2d..e58e1589df 100644 --- a/packages/translations/src/languages/ar.ts +++ b/packages/translations/src/languages/ar.ts @@ -86,10 +86,14 @@ export const arTranslations: DefaultTranslationsObject = { deletingFile: 'حدث خطأ أثناء حذف الملف.', deletingTitle: 'حدث خطأ أثناء حذف {{title}}. يرجى التحقق من الاتصال الخاص بك والمحاولة مرة أخرى.', + documentNotFound: + 'لم يتم العثور على المستند بالمعرف {{id}}. قد يكون قد تم حذفه أو لم يكن موجودًا أصلاً ، أو قد لا يكون لديك الوصول إليه.', emailOrPasswordIncorrect: 'البريد الإلكتروني أو كلمة المرور المقدمة غير صحيحة.', followingFieldsInvalid_one: 'الحقل التالي غير صالح:', followingFieldsInvalid_other: 'الحقول التالية غير صالحة:', incorrectCollection: 'مجموعة غير صحيحة', + insufficientClipboardPermissions: 'تم رفض الوصول إلى الحافظة. يرجى التحقق من أذونات الحافظة.', + invalidClipboardData: 'بيانات الحافظة غير صالحة.', invalidFileType: 'نوع ملف غير صالح', invalidFileTypeValue: 'نوع ملف غير صالح: {{value}}', invalidRequestArgs: 'تم تمرير وسيطات غير صالحة في الطلب: {{args}}', @@ -109,8 +113,10 @@ export const arTranslations: DefaultTranslationsObject = { noUser: 'لا يوجد مستخدم', previewing: 'حدث خطأ في اثناء معاينة هذا المستند.', problemUploadingFile: 'حدث خطأ اثناء رفع الملفّ.', + restoringTitle: 'حدث خطأ أثناء استعادة {{title}}. يرجى التحقق من اتصالك وحاول مرة أخرى.', tokenInvalidOrExpired: 'الرّمز إمّا غير صالح أو منتهي الصّلاحيّة.', tokenNotProvided: 'لم يتم تقديم الرمز.', + unableToCopy: 'تعذر النسخ.', unableToDeleteCount: 'يتعذّر حذف {{count}} من {{total}} {{label}}.', unableToReindexCollection: 'خطأ في إعادة فهرسة المجموعة {{collection}}. تم إيقاف العملية.', unableToUpdateCount: 'يتعذّر تحديث {{count}} من {{total}} {{label}}.', @@ -178,6 +184,7 @@ export const arTranslations: DefaultTranslationsObject = { deleteFolder: 'حذف المجلد', folderName: 'اسم المجلد', folders: 'مجلدات', + folderTypeDescription: 'حدد نوع المستندات التي يجب السماح بها في هذا المجلد من المجموعات.', itemHasBeenMoved: 'تم نقل {{title}} إلى {{folderName}}', itemHasBeenMovedToRoot: 'تم نقل {{title}} إلى المجلد الجذر', itemsMovedToFolder: '{{title}} تم نقله إلى {{folderName}}', @@ -203,6 +210,15 @@ export const arTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'أنت على وشك حذف {{count}} {{label}}', aboutToDeleteCount_one: 'أنت على وشك حذف {{count}} {{label}}', aboutToDeleteCount_other: 'أنت على وشك حذف {{count}} {{label}}', + aboutToPermanentlyDelete: 'أنت على وشك حذف {{label}} <1>{{title}} نهائيا. هل أنت متأكد؟', + aboutToPermanentlyDeleteTrash: + 'أنت على وشك حذف <0>{{count}} <1>{{label}} نهائياً من سلة المهملات. هل أنت متأكد؟', + aboutToRestore: 'أنت على وشك استعادة {{label}} <1>{{title}}. هل أنت متأكد؟', + aboutToRestoreAsDraft: 'أنت على وشك استعادة {{label}} <1>{{title}} كمسودة. هل أنت متأكد؟', + aboutToRestoreAsDraftCount: 'أنت على وشك استعادة {{count}} {{label}} كمسودة', + aboutToRestoreCount: 'أنت على وشك استعادة {{count}} {{label}}', + aboutToTrash: 'أنت على وشك نقل {{label}} <1>{{title}} إلى القمامة. هل أنت متأكد؟', + aboutToTrashCount: 'أنت على وشك نقل {{count}} {{label}} إلى المهملات', addBelow: 'أضف في الاسفل', addFilter: 'أضف فلتر', adminTheme: 'شكل واجهة المستخدم', @@ -218,6 +234,7 @@ export const arTranslations: DefaultTranslationsObject = { backToDashboard: 'العودة للوحة التّحكّم', cancel: 'إلغاء', changesNotSaved: 'لم يتمّ حفظ التّغييرات. إن غادرت الآن ، ستفقد تغييراتك.', + clear: 'واضح', clearAll: 'امسح الكل', close: 'إغلاق', collapse: 'طيّ', @@ -235,9 +252,12 @@ export const arTranslations: DefaultTranslationsObject = { 'سيؤدي هذا إلى إزالة الفهارس الحالية وإعادة فهرسة المستندات في مجموعات {{collections}}.', confirmReindexDescriptionAll: 'سيؤدي هذا إلى إزالة الفهارس الحالية وإعادة فهرسة المستندات في جميع المجموعات.', + confirmRestoration: 'تأكيد الاستعادة', copied: 'تمّ النّسخ', copy: 'نسخ', + copyField: 'نسخ الحقل', copying: 'نسخ', + copyRow: 'نسخ الصف', copyWarning: 'أنت على وشك الكتابة فوق {{to}} بـ {{from}} لـ {{label}} {{title}}. هل أنت متأكد؟', create: 'إنشاء', created: 'تمّ الإنشاء', @@ -252,13 +272,17 @@ export const arTranslations: DefaultTranslationsObject = { dark: 'غامق', dashboard: 'لوحة التّحكّم', delete: 'حذف', + deleted: 'تم الحذف', + deletedAt: 'تم الحذف في', deletedCountSuccessfully: 'تمّ حذف {{count}} {{label}} بنجاح.', deletedSuccessfully: 'تمّ الحذف بنجاح.', + deletePermanently: 'تجاوز السلة واحذف بشكل دائم', deleting: 'يتمّ الحذف...', depth: 'عمق', descending: 'تنازلي', deselectAllRows: 'إلغاء تحديد جميع الصفوف', document: 'وثيقة', + documentIsTrashed: 'تم تحويل {{label}} هذا إلى المهملات وهو للقراءة فقط.', documentLocked: 'تم قفل المستند', documents: 'وثائق', duplicate: 'استنساخ', @@ -274,6 +298,8 @@ export const arTranslations: DefaultTranslationsObject = { editLabel: 'تعديل {{label}}', email: 'البريد الإلكتروني', emailAddress: 'عنوان البريد الإلكتروني', + emptyTrash: 'أفرغ القمامة', + emptyTrashLabel: 'أفرغ سلة المحذوفات {{label}}', enterAValue: 'أدخل قيمة', error: 'خطأ', errors: 'أخطاء', @@ -286,6 +312,7 @@ export const arTranslations: DefaultTranslationsObject = { filterWhere: 'تصفية {{label}} حيث', globals: 'عامة', goBack: 'العودة', + groupByLabel: 'التجميع حسب {{label}}', import: 'استيراد', isEditing: 'يحرر', item: 'عنصر', @@ -320,6 +347,7 @@ export const arTranslations: DefaultTranslationsObject = { 'لا يوجد {{label}}. إما أن لا {{label}} موجودة حتى الآن أو لا تتطابق مع عوامل التصفية التي حددتها أعلاه.', notFound: 'غير موجود', nothingFound: 'لم يتم العثور على شيء', + noTrashResults: 'لا {{label}} في المهملات.', noUpcomingEventsScheduled: 'لا يوجد أحداث مقبلة مجدولة.', noValue: 'لا يوجد قيمة', of: 'من', @@ -330,7 +358,11 @@ export const arTranslations: DefaultTranslationsObject = { overwriteExistingData: 'استبدل بيانات الحقل الموجودة', pageNotFound: 'الصّفحة غير موجودة', password: 'كلمة المرور', + pasteField: 'لصق الحقل', + pasteRow: 'لصق الصف', payloadSettings: 'الإعدادات', + permanentlyDelete: 'حذف بشكل دائم', + permanentlyDeletedCountSuccessfully: 'تم حذف {{count}} {{label}} بشكل دائم بنجاح.', perPage: 'لكلّ صفحة: {{limit}}', previous: 'سابق', reindex: 'إعادة الفهرسة', @@ -342,6 +374,11 @@ export const arTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'سيؤدي ذلك إلى إعادة تعيين جميع تفضيلاتك إلى الإعدادات الافتراضية.', resettingPreferences: 'إعادة تعيين التفضيلات.', + restore: 'استعادة', + restoreAsPublished: 'استعادة كإصدار منشور', + restoredCountSuccessfully: 'تمت استعادة {{count}} {{label}} بنجاح.', + restoring: + 'احترم معنى النص الأصلي في سياق Payload. هنا قائمة بالمصطلحات الشائعة في Payload التي تحمل معانٍ محددة جدًا:\n - Collection: المجموعة هي مجموعة من الوثائق التي تتشارك في الهيكل والغرض المشترك. تُستخدم المجموعات لتنظيم وإدارة المحتوى في Payload.', row: 'سطر', rows: 'أسطُر', save: 'حفظ', @@ -372,6 +409,10 @@ export const arTranslations: DefaultTranslationsObject = { time: 'الوقت', timezone: 'المنطقة الزمنية', titleDeleted: 'تم حذف {{label}} "{{title}}" بنجاح.', + titleRestored: 'تمت استعادة "{{title}}" "{{label}}" بنجاح.', + titleTrashed: '"{{label}}" "{{title}}" تم نقلها إلى سلة المهملات.', + trash: 'سلة المهملات', + trashedCountSuccessfully: '{{count}} {{label}} تم نقلها إلى سلة المهملات.', true: 'صحيح', unauthorized: 'غير مصرح به', unsavedChanges: 'لديك تغييرات غير محفوظة. قم بالحفظ أو التجاهل قبل المتابعة.', @@ -390,6 +431,7 @@ export const arTranslations: DefaultTranslationsObject = { username: 'اسم المستخدم', users: 'المستخدمين', value: 'القيمة', + viewing: 'عرض', viewReadOnly: 'عرض للقراءة فقط', welcome: 'مرحبًا', yes: 'نعم', @@ -510,6 +552,7 @@ export const arTranslations: DefaultTranslationsObject = { noRowsFound: 'لم يتمّ العثور على {{label}}', noRowsSelected: 'لم يتم اختيار {{label}}', preview: 'معاينة', + previouslyDraft: 'سابقا مسودة', previouslyPublished: 'نشر سابقا', previousVersion: 'النسخة السابقة', problemRestoringVersion: 'حدث خطأ في استعادة هذه النّسخة', diff --git a/packages/translations/src/languages/az.ts b/packages/translations/src/languages/az.ts index 95807d643c..08b1f07359 100644 --- a/packages/translations/src/languages/az.ts +++ b/packages/translations/src/languages/az.ts @@ -86,10 +86,15 @@ export const azTranslations: DefaultTranslationsObject = { deletingFile: 'Faylın silinməsində xəta baş verdi.', deletingTitle: '{{title}} silinərkən xəta baş verdi. Zəhmət olmasa, bağlantınızı yoxlayın və yenidən cəhd edin.', + documentNotFound: + '{{id}} ID-li sənəd tapılmadı. Bu, onun silinmiş və ya heç vaxt mövcud olmamış ola bilər və ya sizin ona giriş hüququnuz olmayabilir.', emailOrPasswordIncorrect: 'Təqdim olunan e-poçt və ya şifrə yanlışdır.', followingFieldsInvalid_one: 'Aşağıdakı sahə yanlışdır:', followingFieldsInvalid_other: 'Aşağıdaki sahələr yanlışdır:', incorrectCollection: 'Yanlış Kolleksiya', + insufficientClipboardPermissions: + 'Mübadilə buferinə giriş rədd edildi. Zəhmət olmasa, icazələri yoxlayın.', + invalidClipboardData: 'Yanlış mübadilə buferi məlumatı.', invalidFileType: 'Yanlış fayl növü', invalidFileTypeValue: 'Yanlış fayl növü: {{value}}', invalidRequestArgs: 'Sorguda etibarsız arqumentlər təqdim edildi: {{args}}', @@ -109,8 +114,11 @@ export const azTranslations: DefaultTranslationsObject = { noUser: 'İstifadəçi Yoxdur', previewing: 'Bu sənədin ön baxışı zamanı problem yarandı.', problemUploadingFile: 'Faylın yüklənməsi zamanı problem yarandı.', + restoringTitle: + '{{title}} bərpa olunarkən xəta baş verdi. Zəhmət olmasa, internet bağlantınızı yoxlayın və yenidən cəhd edin.', tokenInvalidOrExpired: 'Token ya yanlışdır və ya müddəti bitib.', tokenNotProvided: 'Token təqdim edilməyib.', + unableToCopy: 'Kopyalama mümkün deyil.', unableToDeleteCount: '{{count}} dən {{total}} {{label}} silinə bilmir.', unableToReindexCollection: '{{collection}} kolleksiyasının yenidən indekslənməsi zamanı səhv baş verdi. Əməliyyat dayandırıldı.', @@ -180,6 +188,7 @@ export const azTranslations: DefaultTranslationsObject = { deleteFolder: 'Qovluğu Sil', folderName: 'Qovluq Adı', folders: 'Qovluqlar', + folderTypeDescription: 'Bu qovluqda hangi tip kolleksiya sənədlərinə icazə verilməlidir seçin.', itemHasBeenMoved: '{{title}} {{folderName}} qovluğuna köçürüldü.', itemHasBeenMovedToRoot: '{{title}} kök qovluğa köçürüldü.', itemsMovedToFolder: '{{title}} {{folderName}} qovluğuna köçürüldü', @@ -206,6 +215,19 @@ export const azTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Siz {{count}} {{label}} silməyə hazırsınız.', aboutToDeleteCount_one: 'Siz {{count}} {{label}} silməyə hazırsınız.', aboutToDeleteCount_other: 'Siz {{count}} {{label}} silməyə hazırsınız.', + aboutToPermanentlyDelete: + 'Siz əbədi olaraq {{label}} <1>{{title}} silmək üzrəsiniz. Eminsiniz?', + aboutToPermanentlyDeleteTrash: + 'Siz müllifdən daimi olaraq <0>{{count}} <1>{{label}} silinəcəkdir. Eminsiniz?', + aboutToRestore: '{{label}} <1>{{title}} bərpa edilmək üzrədir. Eminsiniz?', + aboutToRestoreAsDraft: + 'Siz {{label}} <1>{{title}} draft kimi bərpa etmək əzəldəsiniz. Eminsinizmi?', + aboutToRestoreAsDraftCount: + 'Siz {{count}} {{label}}-ni qaralamak üçün bərpa etməyə hazırlaşırsınız', + aboutToRestoreCount: 'Siz {{count}} {{label}} bərpa etməyə hazırlaşırsınız.', + aboutToTrash: + 'Siz {{label}} <1>{{title}} elementini zibilliyə köçürmək barədəsiniz. Eminsiniz?', + aboutToTrashCount: 'Siz {{count}} {{label}}-i zibilə köçürmək barədəsiz.', addBelow: 'Aşağıya əlavə et', addFilter: 'Filter əlavə et', adminTheme: 'Admin Mövzusu', @@ -222,6 +244,8 @@ export const azTranslations: DefaultTranslationsObject = { cancel: 'Ləğv et', changesNotSaved: 'Dəyişiklikləriniz saxlanılmayıb. İndi çıxsanız, dəyişikliklərinizi itirəcəksiniz.', + clear: + 'Payload kontekstində orijinal mətnin mənasını qoruya. İşte Payload terminləri siyahısıdır ki, onlar üzərində çox xüsusi mənalar gəlir:\n - Kolleksiya: Kolleksiya sənədlərin hamıya ortaq struktur və məqsəd sərbəst olan bir qrupdur. Kolleksiyalar Payload-da məzmunu təşkil etmək və idarə etmək üçün istifadə edilir.\n - Sahə: Sahə', clearAll: 'Hamısını təmizlə', close: 'Bağla', collapse: 'Bağla', @@ -239,9 +263,12 @@ export const azTranslations: DefaultTranslationsObject = { 'Bu, mövcud indeksləri siləcək və {{collections}} kolleksiyalarında sənədləri yenidən indeksləyəcək.', confirmReindexDescriptionAll: 'Bu, mövcud indeksləri siləcək və bütün kolleksiyalardakı sənədləri yenidən indeksləyəcək.', + confirmRestoration: 'Bərpa etməni təsdiqləyin', copied: 'Kopyalandı', copy: 'Kopyala', + copyField: 'Sahəni kopyala', copying: 'Kopyalama', + copyRow: 'Sətiri kopyala', copyWarning: 'Siz {{label}} {{title}} üçün {{from}} ilə {{to}} -nu üzərindən yazmaq ətrafındasınız. Eminsiniz?', create: 'Yarat', @@ -257,13 +284,17 @@ export const azTranslations: DefaultTranslationsObject = { dark: 'Tünd', dashboard: 'Panel', delete: 'Sil', + deleted: 'Silinmiş', + deletedAt: 'Silinib Tarixi', deletedCountSuccessfully: '{{count}} {{label}} uğurla silindi.', deletedSuccessfully: 'Uğurla silindi.', + deletePermanently: 'Çöplüyü atlayın və daimi olaraq silin', deleting: 'Silinir...', depth: 'Dərinlik', descending: 'Azalan', deselectAllRows: 'Bütün sıraları seçimi ləğv edin', document: 'Sənəd', + documentIsTrashed: 'Bu {{label}} zibil qutusuna atılıb və yalnız oxuna bilər.', documentLocked: 'Sənəd kilidləndi', documents: 'Sənədlər', duplicate: 'Dublikat', @@ -279,6 +310,8 @@ export const azTranslations: DefaultTranslationsObject = { editLabel: '{{label}} redaktə et', email: 'Elektron poçt', emailAddress: 'Elektron poçt ünvanı', + emptyTrash: 'Zibil qutusunu boşaltın', + emptyTrashLabel: '{{label}} zibilini boşaltın', enterAValue: 'Bir dəyər daxil edin', error: 'Xəta', errors: 'Xətalar', @@ -291,6 +324,7 @@ export const azTranslations: DefaultTranslationsObject = { filterWhere: '{{label}} filtrlə', globals: 'Qloballar', goBack: 'Geri qayıt', + groupByLabel: '{{label}} ilə qruplaşdırın', import: 'İdxal', isEditing: 'redaktə edir', item: 'əşya', @@ -326,6 +360,7 @@ export const azTranslations: DefaultTranslationsObject = { 'Heç bir {{label}} tapılmadı. Ya hələ {{label}} yoxdur, ya da yuxarıda göstərdiyiniz filtrlərə uyğun gəlmir.', notFound: 'Tapılmadı', nothingFound: 'Heç nə tapılmadı', + noTrashResults: 'Çöplükdə heç bir {{label}} yoxdur.', noUpcomingEventsScheduled: 'Heç bir gələcək tədbir cədvələ alınmayıb.', noValue: 'Dəyər yoxdur', of: 'dən', @@ -336,7 +371,11 @@ export const azTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Mövcud sahə məlumatlarını yenidən yazın', pageNotFound: 'Səhifə tapılmadı', password: 'Şifrə', + pasteField: 'Sahəni yapışdır', + pasteRow: 'Sətiri yapışdır', payloadSettings: 'Payload Parametrləri', + permanentlyDelete: 'Daimi Olaraq Sil', + permanentlyDeletedCountSuccessfully: '{{count}} {{label}} uğurla daimi olaraq silindi.', perPage: 'Hər səhifədə: {{limit}}', previous: 'Əvvəlki', reindex: 'Yenidən indekslə', @@ -347,6 +386,11 @@ export const azTranslations: DefaultTranslationsObject = { resetPreferences: 'Təhlükəsizlik parametrlərini sıfırlamaq', resetPreferencesDescription: 'Bu, bütün parametrlərinizi standart vəziyyətlərinə sıfırlayacaq.', resettingPreferences: 'Təhlükəsizlik parametrləri sıfırlanır.', + restore: 'Bərpa et', + restoreAsPublished: 'Nəşr edilmiş versiya kimi bərpa et', + restoredCountSuccessfully: '{{count}} {{label}} uğurla bərpa edildi.', + restoring: + 'Orijinal mətnin mənasını Payload kontekstində qoruyun. Ən əhəmiyyətli Payload ifadələrinin siyahısı aşağıdakı kimi dir:\n\n - Collection: "Collection" bir sıra sənədlərin əməkdaş olduğu, ortaq struktur və məqsədi olan bir qrupdur. "Collections", Payload-də məzmunu təşkil etmək və idarə etmək üçün istifadə edilir.\n - Field: "Field", kolle', row: 'Sətir', rows: 'Sətirlər', save: 'Saxla', @@ -377,6 +421,10 @@ export const azTranslations: DefaultTranslationsObject = { time: 'Vaxt', timezone: 'Saat qurşağı', titleDeleted: '{{label}} "{{title}}" uğurla silindi.', + titleRestored: '"{{title}}" "{{label}}" uğurla bərpa edildi.', + titleTrashed: '{{label}} "{{title}}" zibilə köçürüldü.', + trash: 'Zibil', + trashedCountSuccessfully: '{{count}} {{label}} zibilə köçürüldü.', true: 'Doğru', unauthorized: 'İcazəsiz', unsavedChanges: @@ -397,6 +445,7 @@ export const azTranslations: DefaultTranslationsObject = { username: 'İstifadəçi adı', users: 'İstifadəçilər', value: 'Dəyər', + viewing: 'Baxış', viewReadOnly: 'Yalnız oxu rejimində bax', welcome: 'Xoş gəldiniz', yes: 'Bəli', @@ -521,6 +570,7 @@ export const azTranslations: DefaultTranslationsObject = { noRowsFound: 'Heç bir {{label}} tapılmadı', noRowsSelected: 'Heç bir {{label}} seçilməyib', preview: 'Öncədən baxış', + previouslyDraft: 'Daha öncə bir Qaralama', previouslyPublished: 'Daha əvvəl nəşr olunmuş', previousVersion: 'Əvvəlki Versiya', problemRestoringVersion: 'Bu versiyanın bərpasında problem yaşandı', diff --git a/packages/translations/src/languages/bg.ts b/packages/translations/src/languages/bg.ts index b1327aa19d..a92388bb4c 100644 --- a/packages/translations/src/languages/bg.ts +++ b/packages/translations/src/languages/bg.ts @@ -86,10 +86,15 @@ export const bgTranslations: DefaultTranslationsObject = { deletingFile: 'Имаше грешка при изтриването на файла.', deletingTitle: 'Имаше проблем при изтриването на {{title}}. Моля провери връзката си и опитай отново.', + documentNotFound: + 'Документът с ID {{id}} не можа да бъде намерен. Възможно е да е бил изтрит или никога да не е съществувал или може би нямате достъп до него.', emailOrPasswordIncorrect: 'Имейлът или паролата не са правилни.', followingFieldsInvalid_one: 'Следното поле е некоректно:', followingFieldsInvalid_other: 'Следните полета са некоректни:', incorrectCollection: 'Грешна колекция', + insufficientClipboardPermissions: + 'Достъпът до клипборда е отказан. Моля, проверете вашите разрешения за клипборда.', + invalidClipboardData: 'Невалидни данни в клипборда.', invalidFileType: 'Невалиден тип на файл', invalidFileTypeValue: 'Невалиден тип на файл: {{value}}', invalidRequestArgs: 'Невалидни аргументи в заявката: {{args}}', @@ -109,8 +114,11 @@ export const bgTranslations: DefaultTranslationsObject = { noUser: 'Липсващ потребител', previewing: 'Имаше проблем при предварителното разглеждане на документа.', problemUploadingFile: 'Имаше проблем при качването на файла.', + restoringTitle: + 'Възникна грешка при възстановяването на {{title}}. Моля, проверете връзката си и опитайте отново.', tokenInvalidOrExpired: 'Ключът е невалиден или изтекъл.', tokenNotProvided: 'Токенът не е предоставен.', + unableToCopy: 'Неуспешно копиране.', unableToDeleteCount: 'Не беше възможно да се изтрият {{count}} от {{total}} {{label}}.', unableToReindexCollection: 'Грешка при преиндексиране на колекцията {{collection}}. Операцията е прекратена.', @@ -180,6 +188,8 @@ export const bgTranslations: DefaultTranslationsObject = { deleteFolder: 'Изтрий папка', folderName: 'Име на папка', folders: 'Папки', + folderTypeDescription: + 'Изберете кой тип документи от колекциите трябва да се допускат в тази папка.', itemHasBeenMoved: '{{title}} е преместен в {{folderName}}', itemHasBeenMovedToRoot: '{{title}} беше преместено в основната папка', itemsMovedToFolder: '{{title}} беше преместен в {{folderName}}', @@ -206,6 +216,17 @@ export const bgTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'На път си да изтриеш {{count}} {{label}}', aboutToDeleteCount_one: 'На път си да изтриеш {{count}} {{label}}', aboutToDeleteCount_other: 'На път си да изтриеш {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Предстои да изтриете завинаги {{label}} <1>{{title}}. Сигурни ли сте?', + aboutToPermanentlyDeleteTrash: + 'Вие се насочвате към перманентно изтриване на <0>{{count}} <1>{{label}} от кошчето. Сигурни ли сте?', + aboutToRestore: 'Предстои да възстановите {{label}} <1>{{title}}. Сигурни ли сте?', + aboutToRestoreAsDraft: + 'Предстои да възстановите {{label}} <1>{{title}} като чернова. Сигурни ли сте?', + aboutToRestoreAsDraftCount: 'Предстои да възстановите {{count}} {{label}} като чернова', + aboutToRestoreCount: 'Предстои да възстановите {{count}} {{label}}', + aboutToTrash: 'Предстои да преместите {{label}} <1>{{title}} в кошчето. Сигурни ли сте?', + aboutToTrashCount: 'Предстои да преместите {{count}} {{label}} в кошчето', addBelow: 'Добави отдолу', addFilter: 'Добави филтър', adminTheme: 'Цветова тема', @@ -221,6 +242,7 @@ export const bgTranslations: DefaultTranslationsObject = { backToDashboard: 'Обратно към таблото', cancel: 'Отмени', changesNotSaved: 'Промените ти не са запазени. Ако напуснеш сега, ще ги загубиш.', + clear: 'Ясно', clearAll: 'Изчисти всичко', close: 'Затвори', collapse: 'Свий', @@ -238,9 +260,12 @@ export const bgTranslations: DefaultTranslationsObject = { 'Това ще премахне съществуващите индекси и ще преиндексира документите в колекциите {{collections}}.', confirmReindexDescriptionAll: 'Това ще премахне съществуващите индекси и ще преиндексира документите във всички колекции.', + confirmRestoration: 'Потвърдете възстановяването', copied: 'Копирано', copy: 'Копирай', + copyField: 'Копирай поле', copying: 'Копиране', + copyRow: 'Копирай ред', copyWarning: 'Предстои да презапишете {{to}} с {{from}} за {{label}} {{title}}. Сигурни ли сте?', create: 'Създай', @@ -256,13 +281,17 @@ export const bgTranslations: DefaultTranslationsObject = { dark: 'Тъмна', dashboard: 'Табло', delete: 'Изтрий', + deleted: 'Изтрито', + deletedAt: 'Изтрито на', deletedCountSuccessfully: 'Изтрити {{count}} {{label}} успешно.', deletedSuccessfully: 'Изтрито успешно.', + deletePermanently: 'Пропуснете кошчето и изтрийте перманентно', deleting: 'Изтриване...', depth: 'Дълбочина', descending: 'Низходящо', deselectAllRows: 'Демаркирай всички редове', document: 'Документ', + documentIsTrashed: 'Този {{label}} е изтрит и е само за четене.', documentLocked: 'Документът е заключен', documents: 'Документи', duplicate: 'Дупликирай', @@ -278,6 +307,8 @@ export const bgTranslations: DefaultTranslationsObject = { editLabel: 'Редактирай {{label}}', email: 'Имейл', emailAddress: 'Имейл адрес', + emptyTrash: 'Изпразни кошчето', + emptyTrashLabel: 'Изпразнете кошчето за {{label}}', enterAValue: 'Въведи стойност', error: 'Грешка', errors: 'Грешки', @@ -290,6 +321,7 @@ export const bgTranslations: DefaultTranslationsObject = { filterWhere: 'Филтрирай {{label}} където', globals: 'Глобални', goBack: 'Върни се', + groupByLabel: 'Групирай по {{label}}', import: 'Внос', isEditing: 'редактира', item: 'артикул', @@ -325,6 +357,7 @@ export const bgTranslations: DefaultTranslationsObject = { '{{label}} не е открит. {{label}} не съществува или никой не отговаря на зададените филтри.', notFound: 'Няма открит', nothingFound: 'Нищо не беше открито', + noTrashResults: 'Няма {{label}} в кошчето.', noUpcomingEventsScheduled: 'Няма предстоящи събития.', noValue: 'Няма стойност', of: 'от', @@ -335,7 +368,11 @@ export const bgTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Презапишете съществуващите данни в полето', pageNotFound: 'Страницата не беше открита', password: 'Парола', + pasteField: 'Постави поле', + pasteRow: 'Постави ред', payloadSettings: 'Настройки на Payload', + permanentlyDelete: 'Трайно изтриване', + permanentlyDeletedCountSuccessfully: 'Успешно изтрити завинаги {{count}} {{label}}.', perPage: 'На страница: {{limit}}', previous: 'Предишен', reindex: 'Преиндексиране', @@ -347,6 +384,10 @@ export const bgTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Това ще нулира всички ваши предпочитания до техните настройки по подразбиране.', resettingPreferences: 'Нулиране на предпочитанията.', + restore: 'Възстановяване', + restoreAsPublished: 'Възстановете като публикувана версия', + restoredCountSuccessfully: 'Успешно възстановени {{count}} {{label}}.', + restoring: 'Възстановяване...', row: 'ред', rows: 'Редове', save: 'Запази', @@ -377,6 +418,10 @@ export const bgTranslations: DefaultTranslationsObject = { time: 'Време', timezone: 'Часова зона', titleDeleted: '{{label}} "{{title}}" успешно изтрит.', + titleRestored: '{{label}} "{{title}}" беше успешно възстановено.', + titleTrashed: '{{label}} "{{title}}" е преместено в кошчето.', + trash: 'Боклук', + trashedCountSuccessfully: '{{count}} {{label}} преместени в кошчето.', true: 'Вярно', unauthorized: 'Неоторизиран', unsavedChanges: 'Имате незапазени промени. Запазете или отхвърлете преди да продължите.', @@ -395,6 +440,7 @@ export const bgTranslations: DefaultTranslationsObject = { username: 'Потребителско име', users: 'Потребители', value: 'Стойност', + viewing: 'Преглеждане', viewReadOnly: 'Преглед само за четене', welcome: 'Добре дошъл', yes: 'Да', @@ -520,6 +566,7 @@ export const bgTranslations: DefaultTranslationsObject = { noRowsFound: 'Не е открит {{label}}', noRowsSelected: 'Не е избран {{label}}', preview: 'Предварителен преглед', + previouslyDraft: 'Предишно беше Чернова', previouslyPublished: 'Предишно публикувано', previousVersion: 'Предишна версия', problemRestoringVersion: 'Имаше проблем при възстановяването на тази версия', diff --git a/packages/translations/src/languages/bnBd.ts b/packages/translations/src/languages/bnBd.ts index 2afab6ad17..01276ef13c 100644 --- a/packages/translations/src/languages/bnBd.ts +++ b/packages/translations/src/languages/bnBd.ts @@ -86,10 +86,15 @@ export const bnBdTranslations: DefaultTranslationsObject = { deletingFile: 'ফাইল মুছতে একটি ত্রুটি হয়েছে।', deletingTitle: '{{title}} মুছতে একটি ত্রুটি হয়েছে। আপনার সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।', + documentNotFound: + 'আইডি {{id}} এর সাথে সম্পর্কিত ডকুমেন্টটি পাওয়া যাচ্ছে না। এটি মোছা হয়েছে বা কখনই না থাকতে পারে, অথবা আপনার এর প্রবেশাধিকার না থ', emailOrPasswordIncorrect: 'প্রদত্ত ইমেইল বা পাসওয়ার্ড ভুল।', followingFieldsInvalid_one: 'নিম্নলিখিত ক্ষেত্রটি অবৈধ:', followingFieldsInvalid_other: 'নিম্নলিখিত ক্ষেত্রগুলি অবৈধ:', incorrectCollection: 'ভুল সংগ্রহ', + insufficientClipboardPermissions: + 'ক্লিপবোর্ড অ্যাক্সেস প্রত্যাখ্যান করা হয়েছে। দয়া করে আপনার ক্লিপবোর্ড অনুমতিগুলি পরীক্ষা করুন।', + invalidClipboardData: 'অবৈধ ক্লিপবোর্ড ডেটা।', invalidFileType: 'অবৈধ ফাইল প্রকার', invalidFileTypeValue: 'অবৈধ ফাইল প্রকার: {{value}}', invalidRequestArgs: 'অনুরোধে অবৈধ আর্গুমেন্ট পাস করা হয়েছে: {{args}}', @@ -109,8 +114,11 @@ export const bnBdTranslations: DefaultTranslationsObject = { noUser: 'কোনো ব্যবহারকারী নেই', previewing: 'এই ডকুমেন্টটি প্রিভিউ করতে একটি সমস্যা হয়েছে।', problemUploadingFile: 'ফাইল আপলোড করতে একটি সমস্যা হয়েছে।', + restoringTitle: + '{{title}} পুনরুদ্ধার করার সময় একটি ত্রুটি ঘটেছে। দয়া করে আপনার সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।', tokenInvalidOrExpired: 'টোকেন অবৈধ বা মেয়াদ শেষ হয়ে গেছে।', tokenNotProvided: 'টোকেন প্রদান করা হয়নি।', + unableToCopy: 'কপি করা সম্ভব নয়।', unableToDeleteCount: '{{total}} {{label}} এর মধ্যে {{count}} টি মুছতে অক্ষম।', unableToReindexCollection: '{{collection}} সংগ্রহ পুনরায় সূচিবদ্ধ করতে ত্রুটি হয়েছে। অপারেশন বাতিল করা হয়েছে।', @@ -181,6 +189,8 @@ export const bnBdTranslations: DefaultTranslationsObject = { deleteFolder: 'ফোল্ডার মুছুন', folderName: 'ফোল্ডারের নাম', folders: 'ফোল্ডারগুলি', + folderTypeDescription: + 'এই ফোল্ডারে কোন ধরনের সংগ্রহ নথিপত্র অনুমোদিত হওয়া উচিত তা নির্বাচন করুন।', itemHasBeenMoved: '{{title}} কে {{folderName}} এ সরানো হয়েছে', itemHasBeenMovedToRoot: '{{title}} কে মূল ফোল্ডারে সরানো হয়েছে', itemsMovedToFolder: '{{title}} কে {{folderName}} এ সরানো হয়েছে', @@ -207,6 +217,19 @@ export const bnBdTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'আপনি {{count}} {{label}} মুছতে চলেছেন', aboutToDeleteCount_one: 'আপনি {{count}} {{label}} মুছতে চলেছেন', aboutToDeleteCount_other: 'আপনি {{count}} {{label}} মুছতে চলেছেন', + aboutToPermanentlyDelete: + 'আপনি স্থায়ীভাবে {{label}} <1>{{title}} মুছে ফেলতে যাচ্ছেন। আপনি কি নিশ্চিত?', + aboutToPermanentlyDeleteTrash: + 'আপনি চূর্ণনিবিন্ন <0>{{count}} <1>{{label}} টি সর্বদা মুছে ফেলতে যাচ্ছেন। আপনি কি নিশ্চিত?', + aboutToRestore: 'আপনি কি নিশ্চিত যে আপনি {{label}} <1>{{title}} পুনরুদ্ধার করতে চান?', + aboutToRestoreAsDraft: + 'আপনি কি নিশ্চিত যে, আপনি {{label}} <1>{{title}} একটি খসড়া হিসাবে পুনরুদ্ধার করতে চলেছেন?', + aboutToRestoreAsDraftCount: + 'আপনি সম্প্রদায়ে {{count}} {{label}} খসড়া হিসাবে পুনরুদ্ধার করতে যাচ্ছেন', + aboutToRestoreCount: 'আপনি এখন প্রস্তুত {{count}} {{label}} পুনরুদ্ধার করতে', + aboutToTrash: + 'আপনি প্রথমরা {{label}} <1>{{title}} কে আবর্জনায় স্থানান্তর করতে যাচ্ছেন। আপনি কি নিশ্চিত?', + aboutToTrashCount: 'আপনি সম্পর্কে {{count}} {{label}} মুছে ফেলার জন্য সরিয়ে ফেলাতে যাচ্ছেন', addBelow: 'নিচে যোগ করুন', addFilter: 'ফিল্টার যোগ করুন', adminTheme: 'অ্যাডমিন থিম', @@ -223,6 +246,8 @@ export const bnBdTranslations: DefaultTranslationsObject = { cancel: 'বাতিল করুন', changesNotSaved: 'আপনার পরিবর্তনগুলি সংরক্ষণ করা হয়নি। আপনি যদি এখন চলে যান, তাহলে আপনার পরিবর্তনগুলি হারিয়ে যাবে।', + clear: + 'মূল পাঠের অর্থ সম্মান করুন পেলোড প্রসঙ্গে। এখানে পেলোড নির্দিষ্ট বিশেষ অর্থ বহন করে এরকম একটি সাধারণ টার্মের তালিকা:\n - সংগ্রহ', clearAll: 'সমস্ত সাফ করুন', close: 'বন্ধ করুন', collapse: 'সংকুচিত করুন', @@ -240,9 +265,12 @@ export const bnBdTranslations: DefaultTranslationsObject = { 'এটি বিদ্যমান সূচিগুলি সরিয়ে দেবে এবং {{collections}} সংগ্রহগুলির ডকুমেন্টগুলি পুনরায় সূচিবদ্ধ করবে।', confirmReindexDescriptionAll: 'এটি বিদ্যমান সূচিগুলি সরিয়ে দেবে এবং সমস্ত সংগ্রহগুলির ডকুমেন্টগুলি পুনরায় সূচিবদ্ধ করবে।', + confirmRestoration: 'পুনরুদ্ধার নিশ্চিত করুন', copied: 'কপি করা হয়েছে', copy: 'কপি করুন', + copyField: 'ফিল্ড কপি করুন', copying: 'কপি করা হচ্ছে', + copyRow: 'সারি কপি করুন', copyWarning: 'আপনি {{label}} {{title}} এর জন্য {{to}} কে {{from}} দ্বারা ওভাররাইট করতে চলেছেন। আপনি কি নিশ্চিত?', create: 'তৈরি করুন', @@ -258,13 +286,17 @@ export const bnBdTranslations: DefaultTranslationsObject = { dark: 'ডার্ক', dashboard: 'ড্যাশবোর্ড', delete: 'মুছুন', + deleted: 'মুছে ফেলা হয়েছে', + deletedAt: 'মুছে ফেলার সময়', deletedCountSuccessfully: '{{count}} {{label}} সফলভাবে মুছে ফেলা হয়েছে।', deletedSuccessfully: 'সফলভাবে মুছে ফেলা হয়েছে।', + deletePermanently: 'ট্র্যাশ এড়িয়ে স্থায়ীভাবে মুছুন', deleting: 'মুছে ফেলা হচ্ছে...', depth: 'গভীরতা', descending: 'অবরোহী', deselectAllRows: 'সমস্ত সারি নির্বাচন বাতিল করুন', document: 'ডকুমেন্ট', + documentIsTrashed: 'এই {{label}} ট্র্যাশ করা হয়েছে এবং এটি শুধুমাত্র পাঠনীয়।', documentLocked: 'ডকুমেন্ট লক করা হয়েছে', documents: 'ডকুমেন্টগুলি', duplicate: 'ডুপ্লিকেট করুন', @@ -280,6 +312,8 @@ export const bnBdTranslations: DefaultTranslationsObject = { editLabel: '{{label}} সম্পাদনা করুন', email: 'ইমেইল', emailAddress: 'ইমেইল ঠিকানা', + emptyTrash: 'ট্র্যাশ খালি করুন', + emptyTrashLabel: '{{label}} ট্র্যাশ খালি করুন', enterAValue: 'একটি মান লিখুন', error: 'ত্রুটি', errors: 'ত্রুটিগুলি', @@ -292,6 +326,7 @@ export const bnBdTranslations: DefaultTranslationsObject = { filterWhere: '{{label}} যেখানে ফিল্টার করুন', globals: 'গ্লোবালগুলি', goBack: 'পিছনে যান', + groupByLabel: '{{label}} অনুযায়ী গ্রুপ করুন', import: 'ইম্পোর্ট করুন', isEditing: 'সম্পাদনা করছেন', item: 'আইটেম', @@ -327,6 +362,7 @@ export const bnBdTranslations: DefaultTranslationsObject = { 'কোনো {{label}} পাওয়া যায়নি। হয় এখনও কোনো {{label}} তৈরি করা হয়নি বা উপরে নির্দিষ্ট করা ফিল্টারগুলির সাথে কোনোটি মেলে না।', notFound: 'পাওয়া যায়নি', nothingFound: 'কিছুই পাওয়া যায়নি', + noTrashResults: 'ট্র্যাশে কোন {{label}} নেই।', noUpcomingEventsScheduled: 'কোনো আসন্ন ইভেন্ট নির্ধারিত নেই।', noValue: 'কোনো মান নেই', of: 'এর', @@ -337,7 +373,12 @@ export const bnBdTranslations: DefaultTranslationsObject = { overwriteExistingData: 'বিদ্যমান ফিল্ড ডেটা ওভাররাইট করুন', pageNotFound: 'পৃষ্ঠা পাওয়া যায়নি', password: 'পাসওয়ার্ড', + pasteField: 'ফিল্ড পেস্ট করুন', + pasteRow: 'সারি পেস্ট করুন', payloadSettings: 'পেলোড সেটিংস', + permanentlyDelete: 'চিরতরে মুছে ফেলুন', + permanentlyDeletedCountSuccessfully: + 'স্থায়ীভাবে {{count}} {{label}} সফলভাবে মুছে ফেলা হয়েছে।', perPage: 'প্রতি পৃষ্ঠায়: {{limit}}', previous: 'পূর্ববর্তী', reindex: 'পুনরায় সূচিবদ্ধ করুন', @@ -348,6 +389,11 @@ export const bnBdTranslations: DefaultTranslationsObject = { resetPreferences: 'পছন্দগুলি রিসেট করুন', resetPreferencesDescription: 'এটি আপনার সমস্ত পছন্দগুলি তাদের ডিফল্ট সেটিংসে রিসেট করবে।', resettingPreferences: 'পছন্দগুলি রিসেট করা হচ্ছে।', + restore: 'পুনরুদ্ধার করুন', + restoreAsPublished: 'প্রকাশিত সংস্করণ হিসাবে পুনরুদ্ধার করুন', + restoredCountSuccessfully: '{{count}} {{label}} সফলভাবে পুনরুদ্ধার করা হয়েছে।', + restoring: + 'Payload এর প্রসঙ্গে মূল পাঠের অর্থ সম্মান করুন। এখানে Payload পদ গুলির একটি তালিকা রয়েছে যা খুব নির্দিষ্ট অর্থ বহন করে:\n - সংগ্রহ: একটি সং', row: 'সারি', rows: 'সারিগুলি', save: 'সংরক্ষণ করুন', @@ -378,6 +424,10 @@ export const bnBdTranslations: DefaultTranslationsObject = { time: 'সময়', timezone: 'টাইমজোন', titleDeleted: '{{label}} "{{title}}" সফলভাবে মুছে ফেলা হয়েছে।', + titleRestored: '"{{label}}" "{{title}}" সফলভাবে পুনরুদ্ধার করা হয়েছে।', + titleTrashed: '{{label}} "{{title}}" আবর্জনাস্থলে সরিয়ে নেওয়া হয়েছে।', + trash: 'আবর্জনা', + trashedCountSuccessfully: '{{count}} {{label}} ট্র্যাশে মুভ করা হয়েছে।', true: 'সত্য', unauthorized: 'অননুমোদিত', unsavedChanges: @@ -398,6 +448,7 @@ export const bnBdTranslations: DefaultTranslationsObject = { username: 'ব্যবহারকারীর নাম', users: 'ব্যবহারকারীরা', value: 'মান', + viewing: 'দেখা', viewReadOnly: 'শুধুমাত্র পড়ার জন্য দেখুন', welcome: 'স্বাগতম', yes: 'হ্যাঁ', @@ -521,6 +572,7 @@ export const bnBdTranslations: DefaultTranslationsObject = { noRowsFound: 'কোনো {{label}} পাওয়া যায়নি', noRowsSelected: 'কোনো {{label}} নির্বাচিত হয়নি', preview: 'প্রাকদর্শন', + previouslyDraft: 'পূর্বে একটি খসড়া', previouslyPublished: 'পূর্বে প্রকাশিত', previousVersion: 'পূর্ববর্তী সংস্করণ', problemRestoringVersion: 'এই সংস্করণ পুনরুদ্ধারে সমস্যা হয়েছে', diff --git a/packages/translations/src/languages/bnIn.ts b/packages/translations/src/languages/bnIn.ts index 6ee79296d7..3d0efea0ab 100644 --- a/packages/translations/src/languages/bnIn.ts +++ b/packages/translations/src/languages/bnIn.ts @@ -86,10 +86,15 @@ export const bnInTranslations: DefaultTranslationsObject = { deletingFile: 'ফাইল মুছতে একটি ত্রুটি হয়েছে।', deletingTitle: '{{title}} মুছতে একটি ত্রুটি হয়েছে। আপনার সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।', + documentNotFound: + 'ID সহ {{id}} ডকুমেন্টটি পাওয়া যায়নি। এটি মুছে ফেলা হতে পারে বা কখনই ছিল না, অথবা আপনার এটির অ্যাক্সেস নেই।', emailOrPasswordIncorrect: 'প্রদত্ত ইমেইল বা পাসওয়ার্ড ভুল।', followingFieldsInvalid_one: 'নিম্নলিখিত ক্ষেত্রটি অবৈধ:', followingFieldsInvalid_other: 'নিম্নলিখিত ক্ষেত্রগুলি অবৈধ:', incorrectCollection: 'ভুল সংগ্রহ', + insufficientClipboardPermissions: + 'ক্লিপবোর্ড অ্যাক্সেস অস্বীকৃত হয়েছে। অনুগ্রহ করে আপনার ক্লিপবোর্ড অনুমতিগুলি পরীক্ষা করুন।', + invalidClipboardData: 'অবৈধ ক্লিপবোর্ড ডেটা।', invalidFileType: 'অবৈধ ফাইল প্রকার', invalidFileTypeValue: 'অবৈধ ফাইল প্রকার: {{value}}', invalidRequestArgs: 'অনুরোধে অবৈধ আর্গুমেন্ট পাস করা হয়েছে: {{args}}', @@ -109,8 +114,11 @@ export const bnInTranslations: DefaultTranslationsObject = { noUser: 'কোনো ব্যবহারকারী নেই', previewing: 'এই ডকুমেন্টটি প্রিভিউ করতে একটি সমস্যা হয়েছে।', problemUploadingFile: 'ফাইল আপলোড করতে একটি সমস্যা হয়েছে।', + restoringTitle: + '{{title}} পুনরুদ্ধার করতে গিয়ে একটি ত্রুটি ঘটেছে। দয়া করে আপনার সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।', tokenInvalidOrExpired: 'টোকেন অবৈধ বা মেয়াদ শেষ হয়ে গেছে।', tokenNotProvided: 'টোকেন প্রদান করা হয়নি।', + unableToCopy: 'কপি করতে অক্ষম।', unableToDeleteCount: '{{total}} {{label}} এর মধ্যে {{count}} টি মুছতে অক্ষম।', unableToReindexCollection: '{{collection}} সংগ্রহ পুনরায় সূচিবদ্ধ করতে ত্রুটি হয়েছে। অপারেশন বাতিল করা হয়েছে।', @@ -181,6 +189,8 @@ export const bnInTranslations: DefaultTranslationsObject = { deleteFolder: 'ফোল্ডার মুছুন', folderName: 'ফোল্ডারের নাম', folders: 'ফোল্ডারগুলি', + folderTypeDescription: + 'এই ফোল্ডারে কোন ধরণের কালেকশন ডকুমেন্টস অনুমতি দেওয়া উচিত তা নির্বাচন করুন।', itemHasBeenMoved: '{{title}} কে {{folderName}} এ সরানো হয়েছে', itemHasBeenMovedToRoot: '{{title}} কে মূল ফোল্ডারে সরানো হয়েছে', itemsMovedToFolder: '{{title}} কে {{folderName}} এ সরানো হয়েছে', @@ -207,6 +217,19 @@ export const bnInTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'আপনি {{count}} {{label}} মুছতে চলেছেন', aboutToDeleteCount_one: 'আপনি {{count}} {{label}} মুছতে চলেছেন', aboutToDeleteCount_other: 'আপনি {{count}} {{label}} মুছতে চলেছেন', + aboutToPermanentlyDelete: + 'আপনি চিরস্থায়ীভাবে {{label}} <1>{{title}} মুছে ফেলার ব্যাপারে চিন্তাভাবনা করছেন। আপনি কি নিশ্চিত?', + aboutToPermanentlyDeleteTrash: + 'আপনি স্থায়ীভাবে ট্র্যাশ থেকে <0>{{count}} <1>{{label}} মুছে ফেলতে যাচ্ছেন। আপনি কি নিশ্চিত?', + aboutToRestore: 'আপনি চলেছেন {{label}} <1>{{title}} পুনরুদ্ধার করতে। আপনি কি নিশ্চিত?', + aboutToRestoreAsDraft: + 'আপনি যে {{label}} <1>{{title}} একটি খসড়া হিসাবে পুনঃস্থাপন করতে যাচ্ছেন । আপনি কি নিশ্চিত?', + aboutToRestoreAsDraftCount: + 'আপনি প্রস্তাবিত {{count}} {{label}} ড্রাফ্ট হিসেবে পুনরুদ্ধার করতে যাচ্ছেন', + aboutToRestoreCount: 'আপনি একটি পুনরুদ্ধার করতে যাচ্ছেন {{count}} {{label}}', + aboutToTrash: + 'আপনি সত্যিই স্থানান্তর করতে চাইছেন {{label}} <1>{{title}} কে আবর্জনায়? আপনি কি নিশ্চিত?', + aboutToTrashCount: 'আপনি চলে যাচ্ছেন {{count}} {{label}} ট্র্যাশে সরাতে', addBelow: 'নিচে যোগ করুন', addFilter: 'ফিল্টার যোগ করুন', adminTheme: 'অ্যাডমিন থিম', @@ -223,6 +246,7 @@ export const bnInTranslations: DefaultTranslationsObject = { cancel: 'বাতিল করুন', changesNotSaved: 'আপনার পরিবর্তনগুলি সংরক্ষণ করা হয়নি। আপনি যদি এখন চলে যান, তাহলে আপনার পরিবর্তনগুলি হারিয়ে যাবে।', + clear: 'স্পষ্ট', clearAll: 'সমস্ত সাফ করুন', close: 'বন্ধ করুন', collapse: 'সংকুচিত করুন', @@ -240,9 +264,12 @@ export const bnInTranslations: DefaultTranslationsObject = { 'এটি বিদ্যমান সূচিগুলি সরিয়ে দেবে এবং {{collections}} সংগ্রহগুলির ডকুমেন্টগুলি পুনরায় সূচিবদ্ধ করবে।', confirmReindexDescriptionAll: 'এটি বিদ্যমান সূচিগুলি সরিয়ে দেবে এবং সমস্ত সংগ্রহগুলির ডকুমেন্টগুলি পুনরায় সূচিবদ্ধ করবে।', + confirmRestoration: 'পুনর্বাসন নিশ্চিত করুন', copied: 'কপি করা হয়েছে', copy: 'কপি করুন', + copyField: 'ফিল্ড কপি করুন', copying: 'কপি করা হচ্ছে', + copyRow: 'সারি কপি করুন', copyWarning: 'আপনি {{label}} {{title}} এর জন্য {{to}} কে {{from}} দ্বারা ওভাররাইট করতে চলেছেন। আপনি কি নিশ্চিত?', create: 'তৈরি করুন', @@ -258,13 +285,17 @@ export const bnInTranslations: DefaultTranslationsObject = { dark: 'ডার্ক', dashboard: 'ড্যাশবোর্ড', delete: 'মুছুন', + deleted: 'মুছে ফেলা হয়েছে', + deletedAt: 'মুছে ফেলার সময়', deletedCountSuccessfully: '{{count}} {{label}} সফলভাবে মুছে ফেলা হয়েছে।', deletedSuccessfully: 'সফলভাবে মুছে ফেলা হয়েছে।', + deletePermanently: 'ট্র্যাশ এড়িয়ে চিরতরে মুছে ফেলুন', deleting: 'মুছে ফেলা হচ্ছে...', depth: 'গভীরতা', descending: 'অবরোহী', deselectAllRows: 'সমস্ত সারি নির্বাচন বাতিল করুন', document: 'ডকুমেন্ট', + documentIsTrashed: 'এই {{label}} টি মুছে ফেলা হয়েছে এবং এটি কেবল পড়ার জন্য।', documentLocked: 'ডকুমেন্ট লক করা হয়েছে', documents: 'ডকুমেন্টগুলি', duplicate: 'ডুপ্লিকেট করুন', @@ -280,6 +311,8 @@ export const bnInTranslations: DefaultTranslationsObject = { editLabel: '{{label}} সম্পাদনা করুন', email: 'ইমেইল', emailAddress: 'ইমেইল ঠিকানা', + emptyTrash: 'ট্র্যাশ খালি করুন', + emptyTrashLabel: '{{label}} ফাঁকা করুন', enterAValue: 'একটি মান লিখুন', error: 'ত্রুটি', errors: 'ত্রুটিগুলি', @@ -292,6 +325,7 @@ export const bnInTranslations: DefaultTranslationsObject = { filterWhere: '{{label}} যেখানে ফিল্টার করুন', globals: 'গ্লোবালগুলি', goBack: 'পিছনে যান', + groupByLabel: '{{label}} দ্বারা গ্রুপ করুন', import: 'ইম্পোর্ট করুন', isEditing: 'সম্পাদনা করছেন', item: 'আইটেম', @@ -327,6 +361,7 @@ export const bnInTranslations: DefaultTranslationsObject = { 'কোনো {{label}} পাওয়া যায়নি। হয় এখনও কোনো {{label}} তৈরি করা হয়নি বা উপরে নির্দিষ্ট করা ফিল্টারগুলির সাথে কোনোটি মেলে না।', notFound: 'পাওয়া যায়নি', nothingFound: 'কিছুই পাওয়া যায়নি', + noTrashResults: 'ট্র্যাশে কোনো {{label}} নেই।', noUpcomingEventsScheduled: 'কোনো আসন্ন ইভেন্ট নির্ধারিত নেই।', noValue: 'কোনো মান নেই', of: 'এর', @@ -337,7 +372,12 @@ export const bnInTranslations: DefaultTranslationsObject = { overwriteExistingData: 'বিদ্যমান ফিল্ড ডেটা ওভাররাইট করুন', pageNotFound: 'পৃষ্ঠা পাওয়া যায়নি', password: 'পাসওয়ার্ড', + pasteField: 'ফিল্ড পেস্ট করুন', + pasteRow: 'সারি পেস্ট করুন', payloadSettings: 'পেলোড সেটিংস', + permanentlyDelete: 'স্থায়ীভাবে মুছে ফেলুন', + permanentlyDeletedCountSuccessfully: + 'স্থায়ীভাবে {{count}} টি {{label}} সফলভাবে মুছে ফেলা হয়েছে।', perPage: 'প্রতি পৃষ্ঠায়: {{limit}}', previous: 'পূর্ববর্তী', reindex: 'পুনরায় সূচিবদ্ধ করুন', @@ -348,6 +388,11 @@ export const bnInTranslations: DefaultTranslationsObject = { resetPreferences: 'পছন্দগুলি রিসেট করুন', resetPreferencesDescription: 'এটি আপনার সমস্ত পছন্দগুলি তাদের ডিফল্ট সেটিংসে রিসেট করবে।', resettingPreferences: 'পছন্দগুলি রিসেট করা হচ্ছে।', + restore: 'পুনরুদ্ধার করুন', + restoreAsPublished: 'প্রকাশিত সংস্করণ হিসাবে পুনরুদ্ধার করুন', + restoredCountSuccessfully: '{{count}} {{label}} সফলভাবে পুনরুদ্ধার করা হয়েছে।', + restoring: + 'প্রস্থাপনার অর্থকে সম্মান করুন। এখানে Payload এর সাথে সম্পর্কিত কিছু সাধারণ পদগুলির তালিকা রয়েছে যা খুব নির্দিষ্ট অর্থ বহন করে:\n - কালেক', row: 'সারি', rows: 'সারিগুলি', save: 'সংরক্ষণ করুন', @@ -378,6 +423,10 @@ export const bnInTranslations: DefaultTranslationsObject = { time: 'সময়', timezone: 'টাইমজোন', titleDeleted: '{{label}} "{{title}}" সফলভাবে মুছে ফেলা হয়েছে।', + titleRestored: '"{{label}}" "{{title}}" সফলভাবে পুনরুদ্ধার করা হয়েছে।', + titleTrashed: '"{{label}}" "{{title}}" ট্র্যাশে সরিয়ে দেওয়া হয়েছে।', + trash: 'আবর্জনা', + trashedCountSuccessfully: '{{count}} {{label}} ট্র্যাশে সরানো হয়েছে।', true: 'সত্য', unauthorized: 'অননুমোদিত', unsavedChanges: @@ -398,6 +447,7 @@ export const bnInTranslations: DefaultTranslationsObject = { username: 'ব্যবহারকারীর নাম', users: 'ব্যবহারকারীরা', value: 'মান', + viewing: 'দর্শন', viewReadOnly: 'শুধুমাত্র পড়ার জন্য দেখুন', welcome: 'স্বাগতম', yes: 'হ্যাঁ', @@ -521,6 +571,7 @@ export const bnInTranslations: DefaultTranslationsObject = { noRowsFound: 'কোনো {{label}} পাওয়া যায়নি', noRowsSelected: 'কোনো {{label}} নির্বাচিত হয়নি', preview: 'প্রাকদর্শন', + previouslyDraft: 'পূর্বে একটি খসড়া', previouslyPublished: 'পূর্বে প্রকাশিত', previousVersion: 'পূর্ববর্তী সংস্করণ', problemRestoringVersion: 'এই সংস্করণ পুনরুদ্ধারে সমস্যা হয়েছে', diff --git a/packages/translations/src/languages/ca.ts b/packages/translations/src/languages/ca.ts index f724b604dd..e340d98eec 100644 --- a/packages/translations/src/languages/ca.ts +++ b/packages/translations/src/languages/ca.ts @@ -86,11 +86,16 @@ export const caTranslations: DefaultTranslationsObject = { deletingFile: "Hi ha hagut un error en eliminar l'arxiu.", deletingTitle: "Hi ha hagut un error mentre s'eliminava {{title}}. Si us plau, comprova la teva connexió i torna-ho a intentar.", + documentNotFound: + "El document amb ID {{id}} no s'ha pogut trobar. Pot haver estat esborrat o mai haver existit, o potser no tens accés a aquest.", emailOrPasswordIncorrect: 'El correu electrònic o la contrasenya proporcionats no són correctes.', followingFieldsInvalid_one: 'El següent camp no és vàlid:', followingFieldsInvalid_other: 'Els següents camps no són vàlids:', incorrectCollection: 'Col·lecció incorrecta', + insufficientClipboardPermissions: + 'Accés al porta-retalls denegat. Comproveu els permisos del porta-retalls.', + invalidClipboardData: 'Dades del porta-retalls no vàlides.', invalidFileType: "Tipus d'arxiu no vàlid", invalidFileTypeValue: "Tipus d'arxiu no vàlid: {{value}}", invalidRequestArgs: 'Arguments no vàlids en la sol·licitud: {{args}}', @@ -110,8 +115,11 @@ export const caTranslations: DefaultTranslationsObject = { noUser: 'Cap usuari', previewing: 'Hi ha hagut un problema en previsualitzar aquest document.', problemUploadingFile: "Hi ha hagut un problema mentre es carregava l'arxiu.", + restoringTitle: + 'Hi ha hagut un error en restaurar {{title}}. Si us plau, comproveu la vostra connexió i torneu-ho a provar.', tokenInvalidOrExpired: 'El token és invàlid o ha caducat.', tokenNotProvided: "No s'ha proporcionat cap token.", + unableToCopy: 'No es pot copiar.', unableToDeleteCount: "No s'han pogut eliminar {{count}} de {{total}} {{label}}.", unableToReindexCollection: 'Error al reindexar la col·lecció {{collection}}. Operació cancel·lada.', @@ -181,6 +189,8 @@ export const caTranslations: DefaultTranslationsObject = { deleteFolder: 'Esborra la carpeta', folderName: 'Nom de la Carpeta', folders: 'Carpetes', + folderTypeDescription: + 'Seleccioneu quin tipus de documents de la col·lecció haurien de ser permesos en aquesta carpeta.', itemHasBeenMoved: "{{title}} s'ha traslladat a {{folderName}}", itemHasBeenMovedToRoot: "{{title}} s'ha mogut a la carpeta arrel", itemsMovedToFolder: "{{title}} s'ha traslladat a {{folderName}}", @@ -207,6 +217,18 @@ export const caTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Estas apunt de eliminar {{count}} {{label}}', aboutToDeleteCount_one: 'Estas apunt de eliminar {{count}} {{label}}', aboutToDeleteCount_other: 'Estas apunt de eliminar {{count}} {{label}}', + aboutToPermanentlyDelete: + "Estàs a punt d'esborrar permanentment l'{{etiqueta}} <1>{{títol}}. N'estàs segur?", + aboutToPermanentlyDeleteTrash: + "Estàs a punt de suprimir permanentment <0>{{count}} <1>{{label}} de la paperera. N'estàs segur?", + aboutToRestore: "Estàs a punt de restaurar l'{{label}} <1>{{title}}. N'estàs segur?", + aboutToRestoreAsDraft: + "Estàs a punt de restaurar l'etiqueta {{label}} <1>{{title}} com a esborrany. N'estàs segur?", + aboutToRestoreAsDraftCount: 'Està a punt de restaurar {{count}} {{label}} com a esborrany', + aboutToRestoreCount: 'Està a punt de restaurar {{count}} {{label}}', + aboutToTrash: + "Estàs a punt de moure l'{{label}} <1>{{title}} a la paperera. N'estàs segur?", + aboutToTrashCount: 'Estàs a punt de moure {{count}} {{label}} a la paperera', addBelow: 'Afegeix a sota', addFilter: 'Afegeix filtre', adminTheme: "Tema d'administració", @@ -222,6 +244,7 @@ export const caTranslations: DefaultTranslationsObject = { backToDashboard: 'Torna al tauler', cancel: 'Cancel·la', changesNotSaved: 'El teu document té canvis no desats. Si continues, els canvis es perdran.', + clear: 'Clar', clearAll: 'Esborra-ho tot', close: 'Tanca', collapse: 'Replegar', @@ -239,9 +262,12 @@ export const caTranslations: DefaultTranslationsObject = { 'Aixo eliminarà els índexs existents i reindexarà els documents de les col·leccions {{collections}}.', confirmReindexDescriptionAll: 'Aixo eliminarà els índexs existents i reindexarà els documents de totes les col·leccions.', + confirmRestoration: 'Confirmeu la restauració', copied: 'Copiat', copy: 'Copiar', + copyField: 'Copiar camp', copying: 'Copiant', + copyRow: 'Copiar fila', copyWarning: 'Estas a punt de sobreescriure {{to}} amb {{from}} per {{label}} {{title}}. Estas segur?', create: 'Crear', @@ -257,13 +283,17 @@ export const caTranslations: DefaultTranslationsObject = { dark: 'Fosc', dashboard: 'Tauler', delete: 'Eliminar', + deleted: 'Eliminat', + deletedAt: 'Eliminat en', deletedCountSuccessfully: 'Eliminat {{count}} {{label}} correctament.', deletedSuccessfully: 'Eliminat correntament.', + deletePermanently: 'Omet la paperera i elimina permanentment', deleting: 'Eliminant...', depth: 'Profunditat', descending: 'Descendent', deselectAllRows: 'Deselecciona totes les files', document: 'Document', + documentIsTrashed: "Aquesta {{label}} s'ha eliminat i és de només lectura.", documentLocked: 'Document bloquejat', documents: 'Documents', duplicate: 'Duplicar', @@ -279,6 +309,8 @@ export const caTranslations: DefaultTranslationsObject = { editLabel: 'Edita {{label}}', email: 'correu electrònic', emailAddress: 'Addressa de correu electrònic', + emptyTrash: 'Buida la paperera', + emptyTrashLabel: 'Buideu la paperera {{label}}', enterAValue: 'Introdueix un valor', error: 'Error', errors: 'Errors', @@ -291,6 +323,7 @@ export const caTranslations: DefaultTranslationsObject = { filterWhere: 'Filtra {{label}} on', globals: 'Globals', goBack: 'Torna enrere', + groupByLabel: 'Agrupa per {{label}}', import: 'Importar', isEditing: 'esta editant', item: 'element', @@ -326,6 +359,7 @@ export const caTranslations: DefaultTranslationsObject = { "No s'ha trobat cap {{label}}. O no n'hi ha cap encara o cap coincideix amb els filtres que has especificat anteriorment.", notFound: 'No trobat', nothingFound: 'Res trobat', + noTrashResults: 'No hi ha cap {{label}} a la paperera.', noUpcomingEventsScheduled: 'No hi ha esdeveniments programats.', noValue: 'No hi ha cap valor', of: 'de', @@ -336,7 +370,12 @@ export const caTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Sobreescriu les dades existents', pageNotFound: 'Pàgina no trobada', password: 'Contrasenya', + pasteField: 'Enganxar camp', + pasteRow: 'Enganxar fila', payloadSettings: 'configuracio Payload', + permanentlyDelete: 'Esborrar permanentment', + permanentlyDeletedCountSuccessfully: + "S'ha eliminat permanentment {{count}} {{label}} amb èxit.", perPage: 'Per pagian: {{limit}}', previous: 'Previ', reindex: 'Reindexa', @@ -348,6 +387,10 @@ export const caTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Això restablirà totes les teves preferències a les configuracions per defecte.', resettingPreferences: 'Restablint les preferències.', + restore: 'Restaura', + restoreAsPublished: 'Restaura com a versió publicada', + restoredCountSuccessfully: "S'ha restaurat {{count}} {{label}} correctament.", + restoring: 'Restauració...', row: 'Fila', rows: 'Files', save: 'Desa', @@ -378,6 +421,10 @@ export const caTranslations: DefaultTranslationsObject = { time: 'Temps', timezone: 'Fus horari', titleDeleted: '{{label}} "{{title}}" eliminat correctament.', + titleRestored: '{{label}} "{{title}}" s\'ha restaurat correctament.', + titleTrashed: '{{label}} "{{title}}" s\'ha traslladat a la paperera.', + trash: 'Brossa', + trashedCountSuccessfully: "{{count}} {{label}} s'ha mogut a la paperera.", true: 'Veritat', unauthorized: 'No autoritzat', unsavedChanges: 'Tens canvis no desats. Vols continuar sense desar?', @@ -396,6 +443,7 @@ export const caTranslations: DefaultTranslationsObject = { username: "Nom d'usuari", users: 'Usuaris', value: 'Valor', + viewing: 'Visualització', viewReadOnly: 'Veure només de lectura', welcome: 'Benvingut', yes: 'Sí', @@ -523,6 +571,7 @@ export const caTranslations: DefaultTranslationsObject = { noRowsFound: "No s'han trobat {{label}}", noRowsSelected: "No s'han seleccionat {{label}}", preview: 'Vista prèvia', + previouslyDraft: 'Anteriorment un Esborrany', previouslyPublished: 'Publicat anteriorment', previousVersion: 'Versió anterior', problemRestoringVersion: 'Hi ha hagut un problema en restaurar aquesta versió', diff --git a/packages/translations/src/languages/cs.ts b/packages/translations/src/languages/cs.ts index 5cdd9a101f..d475579f31 100644 --- a/packages/translations/src/languages/cs.ts +++ b/packages/translations/src/languages/cs.ts @@ -86,10 +86,15 @@ export const csTranslations: DefaultTranslationsObject = { deletingFile: 'Při mazání souboru došlo k chybě.', deletingTitle: 'Při mazání {{title}} došlo k chybě. Zkontrolujte své připojení a zkuste to znovu.', + documentNotFound: + 'Dokument s ID {{id}} nebyl nalezen. Mohlo být smazáno nebo nikdy neexistovalo, nebo k němu nemáte přístup.', emailOrPasswordIncorrect: 'Zadaný email nebo heslo není správné.', followingFieldsInvalid_one: 'Následující pole je neplatné:', followingFieldsInvalid_other: 'Následující pole jsou neplatná:', incorrectCollection: 'Nesprávná kolekce', + insufficientClipboardPermissions: + 'Přístup ke schránce byl odepřen. Zkontrolujte oprávnění ke schránce.', + invalidClipboardData: 'Neplatná data ve schránce.', invalidFileType: 'Neplatný typ souboru', invalidFileTypeValue: 'Neplatný typ souboru: {{value}}', invalidRequestArgs: 'Neplatné argumenty v požadavku: {{args}}', @@ -109,8 +114,11 @@ export const csTranslations: DefaultTranslationsObject = { noUser: 'Žádný uživatel', previewing: 'Při náhledu tohoto dokumentu došlo k chybě.', problemUploadingFile: 'Při nahrávání souboru došlo k chybě.', + restoringTitle: + 'Došlo k chybě při obnovování {{title}}. Zkontrolujte prosím své připojení a zkuste to znovu.', tokenInvalidOrExpired: 'Token je neplatný nebo vypršel.', tokenNotProvided: 'Token není poskytnut.', + unableToCopy: 'Nelze zkopírovat.', unableToDeleteCount: 'Nelze smazat {{count}} z {{total}} {{label}}', unableToReindexCollection: 'Chyba při přeindexování kolekce {{collection}}. Operace byla přerušena.', @@ -180,6 +188,8 @@ export const csTranslations: DefaultTranslationsObject = { deleteFolder: 'Smazat složku', folderName: 'Název složky', folders: 'Složky', + folderTypeDescription: + 'Vyberte, který typ dokumentů ze sbírky by měl být dovolen v této složce.', itemHasBeenMoved: '{{title}} bylo přesunuto do {{folderName}}', itemHasBeenMovedToRoot: '{{title}} byl přesunut do kořenové složky', itemsMovedToFolder: '{{title}} přesunuto do {{folderName}}', @@ -206,6 +216,17 @@ export const csTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Chystáte se smazat {{count}} {{label}}', aboutToDeleteCount_one: 'Chystáte se smazat {{count}} {{label}}', aboutToDeleteCount_other: 'Chystáte se smazat {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Chystáte se trvale odstranit {{label}} <1>{{title}}. Jste si jistý?', + aboutToPermanentlyDeleteTrash: + 'Chystáte se trvale smazat <0>{{count}} <1>{{label}} z koše. Jste si jistý?', + aboutToRestore: 'Chystáte se obnovit {{label}} <1>{{title}}. Jste si jistý?', + aboutToRestoreAsDraft: + 'Chystáte se obnovit {{label}} <1>{{title}} jako koncept. Jste si jistý?', + aboutToRestoreAsDraftCount: 'Chystáte se obnovit {{count}} {{label}} jako koncept', + aboutToRestoreCount: 'Chystáte se obnovit {{count}} {{label}}', + aboutToTrash: 'Chystáte se přesunout {{label}} <1>{{title}} do koše. Jste si jisti?', + aboutToTrashCount: 'Chystáte se přesunout {{count}} {{label}} do koše', addBelow: 'Přidat pod', addFilter: 'Přidat filtr', adminTheme: 'Motiv administračního rozhraní', @@ -221,6 +242,7 @@ export const csTranslations: DefaultTranslationsObject = { backToDashboard: 'Zpět na nástěnku', cancel: 'Zrušit', changesNotSaved: 'Vaše změny nebyly uloženy. Pokud teď odejdete, ztratíte své změny.', + clear: 'Jasný', clearAll: 'Vymazat vše', close: 'Zavřít', collapse: 'Sbalit', @@ -238,9 +260,12 @@ export const csTranslations: DefaultTranslationsObject = { 'Tímto budou odstraněny stávající indexy a dokumenty v kolekcích {{collections}} budou znovu zaindexovány.', confirmReindexDescriptionAll: 'Tímto budou odstraněny stávající indexy a dokumenty ve všech kolekcích budou znovu zaindexovány.', + confirmRestoration: 'Potvrdit obnovení', copied: 'Zkopírováno', copy: 'Kopírovat', + copyField: 'Kopírovat pole', copying: 'Kopírování', + copyRow: 'Kopírovat řádek', copyWarning: 'Chystáte se přepsat {{to}} s {{from}} pro {{label}} {{title}}. Jste si jistý?', create: 'Vytvořit', created: 'Vytvořeno', @@ -255,13 +280,17 @@ export const csTranslations: DefaultTranslationsObject = { dark: 'Tmavý', dashboard: 'Nástěnka', delete: 'Odstranit', + deleted: 'Smazáno', + deletedAt: 'Smazáno dne', deletedCountSuccessfully: 'Úspěšně smazáno {{count}} {{label}}.', deletedSuccessfully: 'Úspěšně odstraněno.', + deletePermanently: 'Preskočit koš a smazat trvale', deleting: 'Odstraňování...', depth: 'Hloubka', descending: 'Sestupně', deselectAllRows: 'Zrušte výběr všech řádků', document: 'Dokument', + documentIsTrashed: 'Tento {{label}} je v koši a je pouze pro čtení.', documentLocked: 'Dokument je uzamčen', documents: 'Dokumenty', duplicate: 'Duplikovat', @@ -277,6 +306,8 @@ export const csTranslations: DefaultTranslationsObject = { editLabel: 'Upravit {{label}}', email: 'E-mail', emailAddress: 'E-mailová adresa', + emptyTrash: 'Vyprázdnit koš', + emptyTrashLabel: 'Vyprázdnit {{label}} koš', enterAValue: 'Zadejte hodnotu', error: 'Chyba', errors: 'Chyby', @@ -289,6 +320,7 @@ export const csTranslations: DefaultTranslationsObject = { filterWhere: 'Filtrovat {{label}} kde', globals: 'Globální', goBack: 'Vrátit se', + groupByLabel: 'Seskupit podle {{label}}', import: 'Import', isEditing: 'upravuje', item: 'položka', @@ -324,6 +356,7 @@ export const csTranslations: DefaultTranslationsObject = { 'Nebyly nalezeny žádné {{label}}. Buď ještě neexistují žádné {{label}}, nebo žádné nesplňují filtry, které jste zadali výše.', notFound: 'Nenalezeno', nothingFound: 'Nic nenalezeno', + noTrashResults: 'Žádný {{label}} v koši.', noUpcomingEventsScheduled: 'Žádné nadcházející události nejsou naplánovány.', noValue: 'Žádná hodnota', of: 'z', @@ -334,7 +367,11 @@ export const csTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Přepsat existující data pole', pageNotFound: 'Stránka nenalezena', password: 'Heslo', + pasteField: 'Vložit pole', + pasteRow: 'Vložit řádek', payloadSettings: 'Payload nastavení', + permanentlyDelete: 'Trvale smazat', + permanentlyDeletedCountSuccessfully: 'Trvale odstraněno {{count}} {{label}} úspěšně.', perPage: 'Na stránku: {{limit}}', previous: 'Předchozí', reindex: 'Přeindexovat', @@ -345,6 +382,11 @@ export const csTranslations: DefaultTranslationsObject = { resetPreferences: 'Obnovit nastavení', resetPreferencesDescription: 'Toto obnoví všechna vaše nastavení na výchozí hodnoty.', resettingPreferences: 'Obnovování nastavení.', + restore: 'Obnovit', + restoreAsPublished: 'Obnovit jako publikovanou verzi', + restoredCountSuccessfully: 'Úspěšně obnoveno {{count}} {{label}}.', + restoring: + 'Respektujte význam původního textu v kontextu Payload. Zde je seznam běžných termínů Payload, které nesou velmi specifické významy:\n - Collection: Sbírka je skupina dokumentů, které sdílejí společnou strukturu a účel. Sbírky se používají k organizaci a správě obsahu v Payload.\n - Field: Field je specifický prvek dat v dokumentu ve sbírce. Field definuje strukturu a typ dat, které mohou', row: 'Řádek', rows: 'Řádky', save: 'Uložit', @@ -375,6 +417,10 @@ export const csTranslations: DefaultTranslationsObject = { time: 'Čas', timezone: 'Časové pásmo', titleDeleted: '{{label}} "{{title}}" úspěšně smazáno.', + titleRestored: '{{label}} "{{title}}" úspěšně obnoveno.', + titleTrashed: '{{label}} "{{title}}" přesunuto do koše.', + trash: 'Koš', + trashedCountSuccessfully: '{{count}} {{label}} přesunuto do koše.', true: 'Pravda', unauthorized: 'Neoprávněný', unsavedChanges: 'Máte neuložené změny. Uložte nebo zahoďte před pokračováním.', @@ -393,6 +439,7 @@ export const csTranslations: DefaultTranslationsObject = { username: 'Uživatelské jméno', users: 'Uživatelé', value: 'Hodnota', + viewing: 'Prohlížení', viewReadOnly: 'Zobrazit pouze pro čtení', welcome: 'Vítejte', yes: 'Ano', @@ -517,6 +564,7 @@ export const csTranslations: DefaultTranslationsObject = { noRowsFound: 'Nenalezen {{label}}', noRowsSelected: 'Nebyl vybrán žádný {{label}}', preview: 'Náhled', + previouslyDraft: 'Dříve Koncept', previouslyPublished: 'Dříve publikováno', previousVersion: 'Předchozí verze', problemRestoringVersion: 'Při obnovování této verze došlo k problému', diff --git a/packages/translations/src/languages/da.ts b/packages/translations/src/languages/da.ts index a7d4d81af0..199e044f5a 100644 --- a/packages/translations/src/languages/da.ts +++ b/packages/translations/src/languages/da.ts @@ -85,10 +85,15 @@ export const daTranslations: DefaultTranslationsObject = { deletingFile: 'Der opstod en fejl under sletning af filen.', deletingTitle: 'Der opstod en fejl under sletningen {{title}}. Tjek din forbindelse eller prøv igen.', + documentNotFound: + 'Dokumentet med ID {{id}} kunne ikke findes. Det kan være slettet eller har aldrig eksisteret, eller du har muligvis ikke adgang til det.', emailOrPasswordIncorrect: 'Email eller adgangskode er forkert.', followingFieldsInvalid_one: 'Feltet er ugyldigt:', followingFieldsInvalid_other: 'Felterne er ugyldige:', incorrectCollection: 'Forkert samling', + insufficientClipboardPermissions: + 'Adgang til udklipsholder nægtet. Kontroller dine udklipsholderrettigheder.', + invalidClipboardData: 'Ugyldige data i udklipsholderen.', invalidFileType: 'Ugyldig filtype', invalidFileTypeValue: 'Ugyldig filtype: {{value}}', invalidRequestArgs: 'Ugyldige argumenter i anmodningen: {{args}}', @@ -108,8 +113,11 @@ export const daTranslations: DefaultTranslationsObject = { noUser: 'Ingen bruger', previewing: 'Der opstod et problem med at vise dokumentet.', problemUploadingFile: 'Der opstod et problem under uploadingen af filen.', + restoringTitle: + 'Der opstod en fejl under genoprettelsen af {{title}}. Kontroller venligst din forbindelse og prøv igen.', tokenInvalidOrExpired: 'Token er enten ugyldig eller udløbet.', tokenNotProvided: 'Token ikke angivet.', + unableToCopy: 'Kan ikke kopiere.', unableToDeleteCount: 'Kunne ikke slette {{count}} mangler {{total}} {{label}}.', unableToReindexCollection: 'Fejl ved genindeksering af samling {{collection}}. Operationen blev afbrudt.', @@ -179,6 +187,8 @@ export const daTranslations: DefaultTranslationsObject = { deleteFolder: 'Slet mappe', folderName: 'Mappenavn', folders: 'Mapper', + folderTypeDescription: + 'Vælg hvilken type samling af dokumenter der bør være tilladt i denne mappe.', itemHasBeenMoved: '{{title}} er blevet flyttet til {{folderName}}', itemHasBeenMovedToRoot: '{{title}} er blevet flyttet til rodmappen', itemsMovedToFolder: '{{title}} flyttet til {{folderName}}', @@ -204,6 +214,18 @@ export const daTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Du er ved at slette {{count}} {{label}}', aboutToDeleteCount_one: 'Du er ved at slette {{count}} {{label}}', aboutToDeleteCount_other: 'Du er ved at slette {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Du er ved at slette {{label}} <1>{{title}} permanent. Er du sikker?', + aboutToPermanentlyDeleteTrash: + 'Du er ved at slette <0>{{count}} <1>{{label}} permanent fra papirkurven. Er du sikker?', + aboutToRestore: 'Du er ved at gendanne {{label}} <1>{{title}}. Er du sikker?', + aboutToRestoreAsDraft: + 'Du er ved at gendanne {{label}} <1>{{title}} som et udkast. Er du sikker?', + aboutToRestoreAsDraftCount: 'Du er ved at gendanne {{count}} {{label}} som udkast', + aboutToRestoreCount: 'Du er ved at gendanne {{count}} {{label}}', + aboutToTrash: + 'Du er ved at flytte {{label}} <1>{{title}} til skraldespanden. Er du sikker?', + aboutToTrashCount: 'Du er ved at flytte {{count}} {{label}} til skraldespanden', addBelow: 'Tilføj under', addFilter: 'Tilføj filter', adminTheme: 'Admin tema', @@ -220,6 +242,7 @@ export const daTranslations: DefaultTranslationsObject = { cancel: 'Anuller', changesNotSaved: 'Dine ændringer er ikke blevet gemt. Hvis du forlader siden, vil din ændringer gå tabt.', + clear: 'Klar', clearAll: 'Ryd alt', close: 'Luk', collapse: 'Skjul', @@ -237,9 +260,12 @@ export const daTranslations: DefaultTranslationsObject = { 'Dette vil fjerne eksisterende indekser og genindeksere dokumenter i {{collections}}-samlingerne.', confirmReindexDescriptionAll: 'Dette vil fjerne eksisterende indekser og genindeksere dokumenter i alle samlinger.', + confirmRestoration: 'Bekræft gendannelse', copied: 'Kopieret', copy: 'Kopier', + copyField: 'Kopiér felt', copying: 'Kopiering', + copyRow: 'Kopiér række', copyWarning: 'Du er lige ved at overskrive {{to}} med {{from}} for {{label}} {{title}}. Er du sikker?', create: 'Opret', @@ -254,13 +280,17 @@ export const daTranslations: DefaultTranslationsObject = { dark: 'Mørk', dashboard: 'Dashboard', delete: 'Slet', + deleted: 'Slettet', + deletedAt: 'Slettet Ved', deletedCountSuccessfully: 'Slettet {{count}} {{label}}.', deletedSuccessfully: 'Slettet.', + deletePermanently: 'Spring affald over og slet permanent', deleting: 'Sletter...', depth: 'Dybde', descending: 'Faldende', deselectAllRows: 'Fjern markering af alle rækker', document: 'Dokument', + documentIsTrashed: 'Denne {{label}} er smidt væk og er kun til læsning.', documentLocked: 'Dette dokument er låst', documents: 'Dokumenter', duplicate: 'Duplikér', @@ -276,6 +306,8 @@ export const daTranslations: DefaultTranslationsObject = { editLabel: 'Redigere {{label}}', email: 'Email', emailAddress: 'e-mailadresse', + emptyTrash: 'Tøm skraldespanden', + emptyTrashLabel: 'Tøm {{label}} skraldespanden', enterAValue: 'Indtast en værdi', error: 'Fejl', errors: 'Fejl', @@ -288,6 +320,7 @@ export const daTranslations: DefaultTranslationsObject = { filterWhere: 'Filter {{label}} hvor', globals: 'Globale', goBack: 'Gå tilbage', + groupByLabel: 'Gruppér efter {{label}}', import: 'Import', isEditing: 'redigerer', item: 'vare', @@ -323,6 +356,7 @@ export const daTranslations: DefaultTranslationsObject = { 'No {{label}} fundet. Enten findes der endnu ingen {{label}}, eller også matcher ingen af de filtre angivet ovenfor.', notFound: 'Ikke fundet', nothingFound: 'Intet fundet', + noTrashResults: 'Ingen {{label}} i papirkurven.', noUpcomingEventsScheduled: 'Ingen kommende begivenheder planlagt.', noValue: 'Ingen værdi', of: 'Af', @@ -333,7 +367,11 @@ export const daTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Overskriv eksisterende feltdata', pageNotFound: 'Siden blev ikke fundet', password: 'Adgangskode', + pasteField: 'Indsæt felt', + pasteRow: 'Indsæt række', payloadSettings: 'Payload-indstillinger', + permanentlyDelete: 'Permanent Sletning', + permanentlyDeletedCountSuccessfully: 'Permanent slettet {{count}} {{label}} succesfuldt.', perPage: 'Per side: {{limit}}', previous: 'Tidligere', reindex: 'Genindekser', @@ -345,6 +383,11 @@ export const daTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Dette vil nulstille alle dine præferencer til standardindstillingerne.', resettingPreferences: 'Nulstiller præferencer.', + restore: 'Gendan', + restoreAsPublished: 'Gendan som udgivet version', + restoredCountSuccessfully: 'Gendannede {{count}} {{label}} succesfuldt.', + restoring: + 'Respekter betydningen af den originale tekst inden for konteksten Payload. Her er en liste over almindelige Payload-udtryk, der bærer meget specifikke betydninger:\n - Samling: En samling er en gruppe af dokumenter, der deler en fælles struktur og formål. Samlinger anvendes til at organisere og administrere indhold i Payload.\n - Felt: Et felt er et specifikt stykke data i et dokument i en samling. Felter definerer struktur og type af data, der kan gemmes i et dokument.\n - Dokument: Et dokument er en individuel post inden for', row: 'Række', rows: 'Rækker', save: 'Gem', @@ -375,6 +418,10 @@ export const daTranslations: DefaultTranslationsObject = { time: 'Tid', timezone: 'Tidszone', titleDeleted: '{{label}} "{{title}}" slettet.', + titleRestored: '{{label}} "{{title}}" succesfuldt genoprettet.', + titleTrashed: '{{label}} "{{title}}" flyttet til papirkurven.', + trash: 'Affald', + trashedCountSuccessfully: '{{count}} {{label}} flyttet til papirkurven.', true: 'Sandt', unauthorized: 'Uautoriseret', unsavedChanges: 'Du har ikke gemte ændringer. Gem eller kassér før fortsættelse.', @@ -393,6 +440,7 @@ export const daTranslations: DefaultTranslationsObject = { username: 'Brugernavn', users: 'Brugere', value: 'Værdi', + viewing: 'Visning', viewReadOnly: 'Vis kun-læsning', welcome: 'Velkommen', yes: 'Ja', @@ -518,6 +566,7 @@ export const daTranslations: DefaultTranslationsObject = { noRowsFound: 'Ingen {{label}} fundet', noRowsSelected: 'Ingen {{label}} valgt', preview: 'Forhåndsvisning', + previouslyDraft: 'Tidligere et udkast', previouslyPublished: 'Tidligere offentliggjort', previousVersion: 'Tidligere version', problemRestoringVersion: 'Der opstod et problem med at gendanne denne version', diff --git a/packages/translations/src/languages/de.ts b/packages/translations/src/languages/de.ts index 5ca319cc80..910d99f4b9 100644 --- a/packages/translations/src/languages/de.ts +++ b/packages/translations/src/languages/de.ts @@ -88,10 +88,15 @@ export const deTranslations: DefaultTranslationsObject = { deletingFile: 'Beim Löschen der Datei ist ein Fehler aufgetreten.', deletingTitle: 'Es gab ein Problem während der Löschung von {{title}}. Bitte überprüfe deine Verbindung und versuche es erneut.', + documentNotFound: + 'Das Dokument mit der ID {{id}} konnte nicht gefunden werden. Es könnte gelöscht oder niemals existiert haben, oder Sie haben möglicherweise keinen Zugang dazu.', emailOrPasswordIncorrect: 'Die E-Mail-Adresse oder das Passwort sind nicht korrekt.', followingFieldsInvalid_one: 'Das folgende Feld ist nicht korrekt:', followingFieldsInvalid_other: 'Die folgenden Felder sind nicht korrekt:', incorrectCollection: 'Falsche Sammlung', + insufficientClipboardPermissions: + 'Zugriff auf die Zwischenablage verweigert. Bitte überprüfen Sie die Berechtigungen.', + invalidClipboardData: 'Ungültige Zwischenablagedaten.', invalidFileType: 'Ungültiger Datei-Typ', invalidFileTypeValue: 'Ungültiger Datei-Typ: {{value}}', invalidRequestArgs: 'Ungültige Argumente in der Anfrage: {{args}}', @@ -111,9 +116,12 @@ export const deTranslations: DefaultTranslationsObject = { noUser: 'Kein Benutzer', previewing: 'Bei der Vorschau dieses Dokuments ist ein Fehler aufgetreten.', problemUploadingFile: 'Beim Hochladen der Datei ist ein Fehler aufgetreten.', + restoringTitle: + 'Es gab einen Fehler beim Wiederherstellen von {{title}}. Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.', tokenInvalidOrExpired: 'Token ist entweder ungültig oder abgelaufen.', - tokenNotProvided: 'Kein Token vorhanden.', - unableToDeleteCount: '{{count}} von {{total}} {{label}} konnten nicht gelöscht werden.', + tokenNotProvided: 'Token nicht bereitgestellt.', + unableToCopy: 'Kopieren nicht möglich.', + unableToDeleteCount: '{{count}} von {{total}} {{label}} konnte nicht gelöscht werden.', unableToReindexCollection: 'Fehler beim Neuindizieren der Sammlung {{collection}}. Vorgang abgebrochen.', unableToUpdateCount: '{{count}} von {{total}} {{label}} konnten nicht aktualisiert werden.', @@ -185,6 +193,8 @@ export const deTranslations: DefaultTranslationsObject = { deleteFolder: 'Ordner löschen', folderName: 'Ordnername', folders: 'Ordner', + folderTypeDescription: + 'Wählen Sie aus, welche Art von Sammlungsdokumenten in diesem Ordner zugelassen sein sollte.', itemHasBeenMoved: '{{title}} wurde in {{folderName}} verschoben.', itemHasBeenMovedToRoot: '{{title}} wurde in den Hauptordner verschoben', itemsMovedToFolder: '{{title}} wurde in {{folderName}} verschoben.', @@ -211,6 +221,20 @@ export const deTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Du bist dabei, {{count}} {{label}} zu löschen', aboutToDeleteCount_one: 'Du bist dabei, {{count}} {{label}} zu löschen', aboutToDeleteCount_other: 'Du bist dabei, {{count}} {{label}} zu löschen', + aboutToPermanentlyDelete: + 'Sie sind im Begriff, das {{label}} <1>{{title}} dauerhaft zu löschen. Sind Sie sicher?', + aboutToPermanentlyDeleteTrash: + 'Sie sind dabei, <0>{{count}} <1>{{label}} endgültig aus dem Papierkorb zu löschen. Sind Sie sicher?', + aboutToRestore: + 'Sie sind dabei, das {{label}} <1>{{title}} wiederherzustellen. Sind Sie sicher?', + aboutToRestoreAsDraft: + 'Sie sind dabei, das {{label}} <1>{{title}} als Entwurf wiederherzustellen. Sind Sie sicher?', + aboutToRestoreAsDraftCount: + 'Sie sind dabei, {{count}} {{label}} als Entwurf wiederherzustellen.', + aboutToRestoreCount: 'Sie sind dabei {{count}} {{label}} wiederherzustellen', + aboutToTrash: + 'Sie sind dabei, das {{label}} <1>{{title}} in den Papierkorb zu verschieben. Sind Sie sicher?', + aboutToTrashCount: 'Sie sind dabei, {{count}} {{label}} in den Papierkorb zu verschieben.', addBelow: 'Unterhalb hinzufügen', addFilter: 'Filter hinzufügen', adminTheme: 'Admin-Erscheinungsbild', @@ -227,6 +251,8 @@ export const deTranslations: DefaultTranslationsObject = { cancel: 'Abbrechen', changesNotSaved: 'Deine Änderungen wurden nicht gespeichert. Wenn du diese Seite verlässt, gehen deine Änderungen verloren.', + clear: + 'Respektieren Sie die Bedeutung des ursprünglichen Textes im Kontext von Payload. Hier ist eine Liste von gängigen Payload-Begriffen, die sehr spezifische Bedeutungen tragen:\n - Sammlung: Eine Sammlung ist eine Gruppe von Dokumenten, die eine gemeinsame Struktur und Funktion teilen. Sammlungen werden verwendet, um Inhalte in Payload zu organisieren und zu verwalten.\n - Feld: Ein Feld ist ein spezifisches Datenstück innerhalb eines Dokuments in einer Sammlung. Felder definieren die Struktur und den Datentyp, der in einem Dokument gespeichert werden kann.\n -', clearAll: 'Alles löschen', close: 'Schließen', collapse: 'Einklappen', @@ -244,9 +270,12 @@ export const deTranslations: DefaultTranslationsObject = { 'Dies entfernt bestehende Indizes und indiziert die Dokumente in den {{collections}}-Sammlungen neu.', confirmReindexDescriptionAll: 'Dies entfernt bestehende Indizes und indiziert die Dokumente in allen Sammlungen neu.', + confirmRestoration: 'Bestätigen Sie die Wiederherstellung', copied: 'Kopiert', copy: 'Kopieren', + copyField: 'Feld kopieren', copying: 'Kopieren', + copyRow: 'Zeile kopieren', copyWarning: 'Du bist dabei, {{to}} mit {{from}} für {{label}} {{title}} zu überschreiben. Bist du dir sicher?', create: 'Erstellen', @@ -262,13 +291,17 @@ export const deTranslations: DefaultTranslationsObject = { dark: 'Dunkel', dashboard: 'Übersicht', delete: 'Löschen', + deleted: 'Gelöscht', + deletedAt: 'Gelöscht am', deletedCountSuccessfully: '{{count}} {{label}} erfolgreich gelöscht.', deletedSuccessfully: 'Erfolgreich gelöscht.', + deletePermanently: 'Überspringen Sie den Papierkorb und löschen Sie dauerhaft.', deleting: 'Löschen...', depth: 'Tiefe', descending: 'Absteigend', deselectAllRows: 'Alle Zeilen abwählen', document: 'Dokument', + documentIsTrashed: 'Dieses {{label}} wurde gelöscht und ist nur lesbar.', documentLocked: 'Dokument gesperrt', documents: 'Dokumente', duplicate: 'Duplizieren', @@ -284,6 +317,8 @@ export const deTranslations: DefaultTranslationsObject = { editLabel: '{{label}} bearbeiten', email: 'E-Mail', emailAddress: 'E-Mail-Adresse', + emptyTrash: 'Papierkorb leeren', + emptyTrashLabel: 'Leeren Sie den {{label}} Papierkorb', enterAValue: 'Gib einen Wert ein', error: 'Fehler', errors: 'Fehler', @@ -296,6 +331,7 @@ export const deTranslations: DefaultTranslationsObject = { filterWhere: 'Filter {{label}}, wo', globals: 'Globale Dokumente', goBack: 'Zurück', + groupByLabel: 'Gruppieren nach {{label}}', import: 'Importieren', isEditing: 'bearbeitet gerade', item: 'Artikel', @@ -331,6 +367,7 @@ export const deTranslations: DefaultTranslationsObject = { 'Keine {{label}} gefunden. Entweder es existieren keine {{label}} oder es gibt keine Übereinstimmung zu den von dir verwendeten Filtern.', notFound: 'Nicht gefunden', nothingFound: 'Keine Ergebnisse', + noTrashResults: 'Kein {{label}} im Papierkorb.', noUpcomingEventsScheduled: 'Keine bevorstehenden Veranstaltungen geplant.', noValue: 'Kein Wert', of: 'von', @@ -341,7 +378,11 @@ export const deTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Vorhandene Eingaben überschreiben', pageNotFound: 'Seite nicht gefunden', password: 'Passwort', + pasteField: 'Feld einfügen', + pasteRow: 'Zeile einfügen', payloadSettings: 'Payload-Einstellungen', + permanentlyDelete: 'Dauerhaft löschen', + permanentlyDeletedCountSuccessfully: '{{count}} {{label}} erfolgreich dauerhaft gelöscht.', perPage: 'Pro Seite: {{limit}}', previous: 'Vorherige', reindex: 'Neuindizieren', @@ -352,6 +393,11 @@ export const deTranslations: DefaultTranslationsObject = { resetPreferences: 'Präferenzen zurücksetzen', resetPreferencesDescription: 'Alle Präferenzen werden auf die Standardwerte zurückgesetzt.', resettingPreferences: 'Präferenzen werden zurückgesetzt.', + restore: 'Wiederherstellen', + restoreAsPublished: 'Wiederherstellen als veröffentlichte Version', + restoredCountSuccessfully: '{{count}} {{label}} erfolgreich wiederhergestellt.', + restoring: + 'Respektieren Sie die Bedeutung des Originaltextes im Kontext von Payload. Hier ist eine Liste häufiger Payload-Begriffe, die sehr spezifische Bedeutungen haben:\n - Sammlung: Eine Sammlung ist eine Gruppe von Dokumenten, die eine gemeinsame Struktur und einen gemeinsamen Zweck teilen. Sammlungen werden verwendet, um Inhalte in Payload zu organisieren und zu verwalten.\n - Feld: Ein Feld ist ein spezifisches Datenstück innerhalb eines Dokuments in einer Sammlung. Felder definieren die Struktur und den Datentyp, der in einem Dokument gespeichert werden kann.\n - Dokument:', row: 'Zeile', rows: 'Zeilen', save: 'Speichern', @@ -383,6 +429,10 @@ export const deTranslations: DefaultTranslationsObject = { time: 'Zeit', timezone: 'Zeitzone', titleDeleted: '{{label}} {{title}} wurde erfolgreich gelöscht.', + titleRestored: '{{label}} "{{title}}" erfolgreich wiederhergestellt.', + titleTrashed: '{{label}} "{{title}}" wurde in den Papierkorb verschoben.', + trash: 'Müll', + trashedCountSuccessfully: '{{count}} {{label}} wurde in den Papierkorb verschoben.', true: 'Wahr', unauthorized: 'Nicht autorisiert', unsavedChanges: @@ -403,6 +453,7 @@ export const deTranslations: DefaultTranslationsObject = { username: 'Benutzername', users: 'Benutzer', value: 'Wert', + viewing: 'Ansehen', viewReadOnly: 'Nur-Lese-Ansicht', welcome: 'Willkommen', yes: 'Ja', @@ -527,6 +578,7 @@ export const deTranslations: DefaultTranslationsObject = { noRowsFound: 'Kein {{label}} gefunden', noRowsSelected: 'Kein {{label}} ausgewählt', preview: 'Vorschau', + previouslyDraft: 'Früher ein Entwurf', previouslyPublished: 'Zuvor veröffentlicht', previousVersion: 'Frühere Version', problemRestoringVersion: 'Bei der Wiederherstellung der Version ist ein Fehler aufgetreten', diff --git a/packages/translations/src/languages/en.ts b/packages/translations/src/languages/en.ts index a00695959c..2cacb46e5c 100644 --- a/packages/translations/src/languages/en.ts +++ b/packages/translations/src/languages/en.ts @@ -1,3 +1,5 @@ +import { title } from 'process' + import type { Language } from '../types.js' export const enTranslations = { @@ -87,10 +89,15 @@ export const enTranslations = { deletingFile: 'There was an error deleting file.', deletingTitle: 'There was an error while deleting {{title}}. Please check your connection and try again.', + documentNotFound: + 'The document with ID {{id}} could not be found. It may have been deleted or never existed, or you may not have access to it.', emailOrPasswordIncorrect: 'The email or password provided is incorrect.', followingFieldsInvalid_one: 'The following field is invalid:', followingFieldsInvalid_other: 'The following fields are invalid:', incorrectCollection: 'Incorrect Collection', + insufficientClipboardPermissions: + 'Clipboard access denied. Please check your clipboard permissions.', + invalidClipboardData: 'Invalid clipboard data.', invalidFileType: 'Invalid file type', invalidFileTypeValue: 'Invalid file type: {{value}}', invalidRequestArgs: 'Invalid arguments passed in request: {{args}}', @@ -110,8 +117,11 @@ export const enTranslations = { noUser: 'No User', previewing: 'There was a problem previewing this document.', problemUploadingFile: 'There was a problem while uploading the file.', + restoringTitle: + 'There was an error while restoring {{title}}. Please check your connection and try again.', tokenInvalidOrExpired: 'Token is either invalid or has expired.', tokenNotProvided: 'Token not provided.', + unableToCopy: 'Unable to copy.', unableToDeleteCount: 'Unable to delete {{count}} out of {{total}} {{label}}.', unableToReindexCollection: 'Error reindexing collection {{collection}}. Operation aborted.', unableToUpdateCount: 'Unable to update {{count}} out of {{total}} {{label}}.', @@ -180,6 +190,8 @@ export const enTranslations = { deleteFolder: 'Delete Folder', folderName: 'Folder Name', folders: 'Folders', + folderTypeDescription: + 'Select which type of collection documents should be allowed in this folder.', itemHasBeenMoved: '{{title}} has been moved to {{folderName}}', itemHasBeenMovedToRoot: '{{title}} has been moved to the root folder', itemsMovedToFolder: '{{title}} moved to {{folderName}}', @@ -206,6 +218,18 @@ export const enTranslations = { aboutToDeleteCount_many: 'You are about to delete {{count}} {{label}}', aboutToDeleteCount_one: 'You are about to delete {{count}} {{label}}', aboutToDeleteCount_other: 'You are about to delete {{count}} {{label}}', + aboutToPermanentlyDelete: + 'You are about to permanently delete the {{label}} <1>{{title}}. Are you sure?', + aboutToPermanentlyDeleteTrash: + 'You are about to permanently delete <0>{{count}} <1>{{label}} from the trash. Are you sure?', + aboutToRestore: 'You are about to restore the {{label}} <1>{{title}}. Are you sure?', + aboutToRestoreAsDraft: + 'You are about to restore the {{label}} <1>{{title}} as a draft. Are you sure?', + aboutToRestoreAsDraftCount: 'You are about to restore {{count}} {{label}} as draft', + aboutToRestoreCount: 'You are about to restore {{count}} {{label}}', + aboutToTrash: + 'You are about to move the {{label}} <1>{{title}} to the trash. Are you sure?', + aboutToTrashCount: 'You are about to move {{count}} {{label}} to the trash', addBelow: 'Add Below', addFilter: 'Add Filter', adminTheme: 'Admin Theme', @@ -222,6 +246,7 @@ export const enTranslations = { cancel: 'Cancel', changesNotSaved: 'Your changes have not been saved. If you leave now, you will lose your changes.', + clear: 'Clear', clearAll: 'Clear All', close: 'Close', collapse: 'Collapse', @@ -239,9 +264,12 @@ export const enTranslations = { 'This will remove existing indexes and reindex documents in the {{collections}} collections.', confirmReindexDescriptionAll: 'This will remove existing indexes and reindex documents in all collections.', + confirmRestoration: 'Confirm restoration', copied: 'Copied', copy: 'Copy', + copyField: 'Copy Field', copying: 'Copying', + copyRow: 'Copy Row', copyWarning: 'You are about to overwrite {{to}} with {{from}} for {{label}} {{title}}. Are you sure?', create: 'Create', @@ -257,13 +285,17 @@ export const enTranslations = { dark: 'Dark', dashboard: 'Dashboard', delete: 'Delete', + deleted: 'Deleted', + deletedAt: 'Deleted At', deletedCountSuccessfully: 'Deleted {{count}} {{label}} successfully.', deletedSuccessfully: 'Deleted successfully.', + deletePermanently: 'Skip trash and delete permanently', deleting: 'Deleting...', depth: 'Depth', descending: 'Descending', deselectAllRows: 'Deselect all rows', document: 'Document', + documentIsTrashed: 'This {{label}} is trashed and is read-only.', documentLocked: 'Document locked', documents: 'Documents', duplicate: 'Duplicate', @@ -279,6 +311,8 @@ export const enTranslations = { editLabel: 'Edit {{label}}', email: 'Email', emailAddress: 'Email Address', + emptyTrash: 'Empty trash', + emptyTrashLabel: 'Empty {{label}} trash', enterAValue: 'Enter a value', error: 'Error', errors: 'Errors', @@ -291,6 +325,7 @@ export const enTranslations = { filterWhere: 'Filter {{label}} where', globals: 'Globals', goBack: 'Go back', + groupByLabel: 'Group by {{label}}', import: 'Import', isEditing: 'is editing', item: 'item', @@ -326,6 +361,7 @@ export const enTranslations = { "No {{label}} found. Either no {{label}} exist yet or none match the filters you've specified above.", notFound: 'Not Found', nothingFound: 'Nothing found', + noTrashResults: 'No {{label}} in trash.', noUpcomingEventsScheduled: 'No upcoming events scheduled.', noValue: 'No value', of: 'of', @@ -336,7 +372,11 @@ export const enTranslations = { overwriteExistingData: 'Overwrite existing field data', pageNotFound: 'Page not found', password: 'Password', + pasteField: 'Paste Field', + pasteRow: 'Paste Row', payloadSettings: 'Payload Settings', + permanentlyDelete: 'Permanently Delete', + permanentlyDeletedCountSuccessfully: 'Permanently deleted {{count}} {{label}} successfully.', perPage: 'Per Page: {{limit}}', previous: 'Previous', reindex: 'Reindex', @@ -348,6 +388,10 @@ export const enTranslations = { resetPreferencesDescription: 'This will reset all of your preferences to their default settings.', resettingPreferences: 'Resetting Preferences.', + restore: 'Restore', + restoreAsPublished: 'Restore as published version', + restoredCountSuccessfully: 'Restored {{count}} {{label}} successfully.', + restoring: 'Restoring...', row: 'Row', rows: 'Rows', save: 'Save', @@ -378,6 +422,10 @@ export const enTranslations = { time: 'Time', timezone: 'Timezone', titleDeleted: '{{label}} "{{title}}" successfully deleted.', + titleRestored: '{{label}} "{{title}}" successfully restored.', + titleTrashed: '{{label}} "{{title}}" moved to trash.', + trash: 'Trash', + trashedCountSuccessfully: '{{count}} {{label}} moved to trash.', true: 'True', unauthorized: 'Unauthorized', unsavedChanges: 'You have unsaved changes. Save or discard before continuing.', @@ -396,6 +444,7 @@ export const enTranslations = { username: 'Username', users: 'Users', value: 'Value', + viewing: 'Viewing', viewReadOnly: 'View read-only', welcome: 'Welcome', yes: 'Yes', @@ -521,6 +570,7 @@ export const enTranslations = { noRowsFound: 'No {{label}} found', noRowsSelected: 'No {{label}} selected', preview: 'Preview', + previouslyDraft: 'Previously a Draft', previouslyPublished: 'Previously Published', previousVersion: 'Previous Version', problemRestoringVersion: 'There was a problem restoring this version', diff --git a/packages/translations/src/languages/es.ts b/packages/translations/src/languages/es.ts index 41186f09f3..8ca8c5b504 100644 --- a/packages/translations/src/languages/es.ts +++ b/packages/translations/src/languages/es.ts @@ -86,10 +86,15 @@ export const esTranslations: DefaultTranslationsObject = { deletingFile: 'Ocurrió un error al eliminar el archivo.', deletingTitle: 'Ocurrió un error al eliminar {{title}}. Por favor, revisa tu conexión y vuelve a intentarlo.', + documentNotFound: + 'No se pudo encontrar el documento con ID {{id}}. Puede haber sido eliminado o nunca existió, o puede que no tenga acceso a él.', emailOrPasswordIncorrect: 'El correo o la contraseña son incorrectos.', followingFieldsInvalid_one: 'El siguiente campo es inválido:', followingFieldsInvalid_other: 'Los siguientes campos son inválidos:', incorrectCollection: 'Colección Incorrecta', + insufficientClipboardPermissions: + 'Acceso al portapapeles denegado. Verifique los permisos del portapapeles.', + invalidClipboardData: 'Datos del portapapeles no válidos.', invalidFileType: 'Tipo de archivo inválido', invalidFileTypeValue: 'Tipo de archivo inválido: {{value}}', invalidRequestArgs: 'Argumentos inválidos en la solicitud: {{args}}', @@ -109,8 +114,11 @@ export const esTranslations: DefaultTranslationsObject = { noUser: 'Sin usuario', previewing: 'Ocurrió un problema al previsualizar este documento.', problemUploadingFile: 'Ocurrió un problema al subir el archivo.', + restoringTitle: + 'Hubo un error al restaurar {{title}}. Por favor, verifique su conexión e intente nuevamente.', tokenInvalidOrExpired: 'El token es inválido o ya expiró.', tokenNotProvided: 'Token no proporcionado.', + unableToCopy: 'No se puede copiar.', unableToDeleteCount: 'No se pudo eliminar {{count}} de {{total}} {{label}}.', unableToReindexCollection: 'Error al reindexar la colección {{collection}}. Operación abortada.', @@ -184,6 +192,8 @@ export const esTranslations: DefaultTranslationsObject = { deleteFolder: 'Eliminar Carpeta', folderName: 'Nombre de la Carpeta', folders: 'Carpetas', + folderTypeDescription: + 'Seleccione qué tipo de documentos de la colección se deben permitir en esta carpeta.', itemHasBeenMoved: '{{title}} se ha movido a {{folderName}}', itemHasBeenMovedToRoot: '{{title}} se ha movido a la carpeta raíz', itemsMovedToFolder: '{{title}} movido a {{folderName}}', @@ -210,6 +220,18 @@ export const esTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Estás a punto de eliminar {{count}} {{label}}', aboutToDeleteCount_one: 'Estás a punto de eliminar {{count}} {{label}}', aboutToDeleteCount_other: 'Estás a punto de eliminar {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Está a punto de eliminar permanentemente la {{label}} <1>{{title}}. ¿Está seguro?', + aboutToPermanentlyDeleteTrash: + 'Está a punto de eliminar permanentemente <0>{{count}} <1>{{label}} de la basura. ¿Está seguro?', + aboutToRestore: 'Está a punto de restaurar la {{label}} <1>{{title}}. ¿Está seguro?', + aboutToRestoreAsDraft: + 'Está a punto de restaurar la {{label}} <1>{{title}} como borrador. ¿Está seguro?', + aboutToRestoreAsDraftCount: 'Estás a punto de restaurar {{count}} {{label}} como borrador', + aboutToRestoreCount: 'Estás a punto de restaurar {{count}} {{label}}', + aboutToTrash: + 'Estás a punto de mover la {{label}} <1>{{title}} a la papelera. ¿Estás seguro?', + aboutToTrashCount: 'Estás a punto de mover {{count}} {{label}} a la papelera', addBelow: 'Añadir abajo', addFilter: 'Añadir filtro', adminTheme: 'Tema del admin', @@ -226,6 +248,7 @@ export const esTranslations: DefaultTranslationsObject = { cancel: 'Cancelar', changesNotSaved: 'Tus cambios no han sido guardados. Si te sales ahora, se perderán tus cambios.', + clear: 'Claro', clearAll: 'Limpiar todo', close: 'Cerrar', collapse: 'Contraer', @@ -243,9 +266,12 @@ export const esTranslations: DefaultTranslationsObject = { 'Esto eliminará los índices existentes y volverá a indexar los documentos en las colecciones {{collections}}.', confirmReindexDescriptionAll: 'Esto eliminará los índices existentes y volverá a indexar los documentos en todas las colecciones.', + confirmRestoration: 'Confirme la restauración', copied: 'Copiado', copy: 'Copiar', + copyField: 'Copiar campo', copying: 'Copiando', + copyRow: 'Copiar fila', copyWarning: 'Estás a punto de sobrescribir {{to}} con {{from}} para {{label}} {{title}}. ¿Estás seguro?', create: 'Crear', @@ -261,13 +287,17 @@ export const esTranslations: DefaultTranslationsObject = { dark: 'Oscuro', dashboard: 'Panel de Control', delete: 'Eliminar', + deleted: 'Eliminado', + deletedAt: 'Eliminado En', deletedCountSuccessfully: 'Se eliminaron {{count}} {{label}} correctamente.', deletedSuccessfully: 'Eliminado correctamente.', + deletePermanently: 'Omitir la papelera y eliminar permanentemente', deleting: 'Eliminando...', depth: 'Profundidad', descending: 'Descendente', deselectAllRows: 'Deseleccionar todas las filas', document: 'Documento', + documentIsTrashed: 'Esta {{label}} está en la papelera y es de solo lectura.', documentLocked: 'Documento bloqueado', documents: 'Documentos', duplicate: 'Duplicar', @@ -283,6 +313,8 @@ export const esTranslations: DefaultTranslationsObject = { editLabel: 'Editar {{label}}', email: 'Correo electrónico', emailAddress: 'Dirección de Correo Electrónico', + emptyTrash: 'Vaciar la papelera', + emptyTrashLabel: 'Vaciar la basura {{label}}', enterAValue: 'Introduce un valor', error: 'Error', errors: 'Errores', @@ -295,6 +327,7 @@ export const esTranslations: DefaultTranslationsObject = { filterWhere: 'Filtrar {{label}} donde', globals: 'Globales', goBack: 'Volver', + groupByLabel: 'Agrupar por {{label}}', import: 'Importar', isEditing: 'está editando', item: 'artículo', @@ -330,6 +363,7 @@ export const esTranslations: DefaultTranslationsObject = { 'No se encontró ningún {{label}}. Puede que aún no existan o que no coincidan con los filtros aplicados.', notFound: 'No encontrado', nothingFound: 'No se encontró nada', + noTrashResults: 'No hay {{label}} en la papelera.', noUpcomingEventsScheduled: 'No hay eventos próximos programados.', noValue: 'Sin valor', of: 'de', @@ -340,7 +374,12 @@ export const esTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Sobrescribir los datos existentes del campo', pageNotFound: 'Página no encontrada', password: 'Contraseña', + pasteField: 'Pegar campo', + pasteRow: 'Pegar fila', payloadSettings: 'Configuración de Payload', + permanentlyDelete: 'Eliminar Permanentemente', + permanentlyDeletedCountSuccessfully: + 'Se ha eliminado permanentemente {{count}} {{label}} con éxito.', perPage: 'Por página: {{limit}}', previous: 'Anterior', reindex: 'Reindexar', @@ -352,6 +391,10 @@ export const esTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Esto restablecerá todas tus preferencias a los valores predeterminados.', resettingPreferences: 'Restableciendo preferencias...', + restore: 'Restaurar', + restoreAsPublished: 'Restaurar como versión publicada', + restoredCountSuccessfully: 'Restaurado {{count}} {{label}} con éxito.', + restoring: 'Restaurando...', row: 'Fila', rows: 'Filas', save: 'Guardar', @@ -382,6 +425,10 @@ export const esTranslations: DefaultTranslationsObject = { time: 'Hora', timezone: 'Zona horaria', titleDeleted: '{{label}} "{{title}}" eliminado con éxito.', + titleRestored: '{{label}} "{{title}}" restaurado con éxito.', + titleTrashed: '{{label}} "{{title}}" movido a la papelera.', + trash: 'Basura', + trashedCountSuccessfully: '{{count}} {{label}} movido a la papelera.', true: 'Verdadero', unauthorized: 'No autorizado', unsavedChanges: 'Tienes cambios sin guardar. Guarda o descarta antes de continuar.', @@ -400,6 +447,7 @@ export const esTranslations: DefaultTranslationsObject = { username: 'Nombre de usuario', users: 'Usuarios', value: 'Valor', + viewing: 'Visualización', viewReadOnly: 'Ver solo lectura', welcome: 'Bienvenido', yes: 'Sí', @@ -525,6 +573,7 @@ export const esTranslations: DefaultTranslationsObject = { noRowsFound: 'No se encontraron {{label}}.', noRowsSelected: 'No se ha seleccionado ningún {{label}}.', preview: 'Vista previa', + previouslyDraft: 'Previamente un Borrador', previouslyPublished: 'Publicado anteriormente', previousVersion: 'Versión Anterior', problemRestoringVersion: 'Hubo un problema al restaurar esta versión', diff --git a/packages/translations/src/languages/et.ts b/packages/translations/src/languages/et.ts index 25004901ca..0a7aaa3991 100644 --- a/packages/translations/src/languages/et.ts +++ b/packages/translations/src/languages/et.ts @@ -85,10 +85,15 @@ export const etTranslations: DefaultTranslationsObject = { deletingFile: 'Faili kustutamisel tekkis viga.', deletingTitle: '{{title}} kustutamisel tekkis viga. Palun kontrollige ühendust ja proovige uuesti.', + documentNotFound: + 'Dokumenti ID-ga {{id}} ei leitud. Võimalik, et see on kustutatud või pole seda kunagi olnud, või ei pruugi teil sellele juurdepääsu olla.', emailOrPasswordIncorrect: 'Sisestatud e-post või parool on vale.', followingFieldsInvalid_one: 'Järgmine väli on vigane:', followingFieldsInvalid_other: 'Järgmised väljad on vigased:', incorrectCollection: 'Vale kollektsioon', + insufficientClipboardPermissions: + 'Lõikelaua juurdepääs keelatud. Palun kontrollige oma lõikelaua õigusi.', + invalidClipboardData: 'Kehtetu lõikelaua andmed.', invalidFileType: 'Vale failitüüp', invalidFileTypeValue: 'Vale failitüüp: {{value}}', invalidRequestArgs: 'Päringule edastati vigased argumendid: {{args}}', @@ -108,8 +113,11 @@ export const etTranslations: DefaultTranslationsObject = { noUser: 'Kasutajat pole', previewing: 'Selle dokumendi eelvaatamisel tekkis probleem.', problemUploadingFile: 'Faili üleslaadimisel tekkis probleem.', + restoringTitle: + 'Ilmnes viga, kui {{title}} taastati. Kontrollige oma ühendust ja proovige uuesti.', tokenInvalidOrExpired: 'Võti on kas vigane või aegunud.', tokenNotProvided: 'Võtit ei esitatud.', + unableToCopy: 'Kopeerimine ebaõnnestus.', unableToDeleteCount: 'Ei õnnestunud kustutada {{count}} {{total}}-st {{label}}.', unableToReindexCollection: 'Viga kollektsiooni {{collection}} taasindekseerimisel. Toiming katkestatud.', @@ -179,6 +187,7 @@ export const etTranslations: DefaultTranslationsObject = { deleteFolder: 'Kustuta kaust', folderName: 'Kausta nimi', folders: 'Kaustad', + folderTypeDescription: 'Valige, millist tüüpi kogumiku dokumente peaks selles kaustas lubama.', itemHasBeenMoved: '{{title}} on teisaldatud kausta {{folderName}}', itemHasBeenMovedToRoot: '{{title}} on teisaldatud juurkausta', itemsMovedToFolder: '{{title}} viidi üle kausta {{folderName}}', @@ -205,6 +214,18 @@ export const etTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Olete kustutamas {{count}} {{label}}', aboutToDeleteCount_one: 'Olete kustutamas {{count}} {{label}}', aboutToDeleteCount_other: 'Olete kustutamas {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Te olete just hakkamas püsivalt kustutama {{label}} <1>{{title}}. Kas olete kindel?', + aboutToPermanentlyDeleteTrash: + 'Te oled püsivalt kustutamas <0>{{count}} <1>{{label}} prügikastist. Kas oled kindel?', + aboutToRestore: 'Te oled taastamas järgnevat {{label}} <1>{{title}}. Kas oled kindel?', + aboutToRestoreAsDraft: + 'Te oled taastamas {{label}} <1>{{title}} mustandina. Kas oled kindel?', + aboutToRestoreAsDraftCount: 'Te oled kohe taastamas {{count}} {{label}} mustandina', + aboutToRestoreCount: 'Te oled taastamas {{count}} {{label}}', + aboutToTrash: + 'Te olete just prügikasti liigutamas {{label}} <1>{{title}}. Kas olete kindel?', + aboutToTrashCount: 'Te oled valmis liigutama {{count}} {{label}} prügikasti.', addBelow: 'Lisa alla', addFilter: 'Lisa filter', adminTheme: 'Administreerimisliidese teema', @@ -220,6 +241,7 @@ export const etTranslations: DefaultTranslationsObject = { backToDashboard: 'Tagasi töölaua juurde', cancel: 'Tühista', changesNotSaved: 'Teie muudatusi pole salvestatud. Kui lahkute praegu, kaotate oma muudatused.', + clear: 'Selge', clearAll: 'Tühjenda kõik', close: 'Sulge', collapse: 'Ahenda', @@ -237,9 +259,12 @@ export const etTranslations: DefaultTranslationsObject = { 'See eemaldab olemasolevad indeksid ja indekseerib uuesti dokumendid kollektsioonides {{collections}}.', confirmReindexDescriptionAll: 'See eemaldab olemasolevad indeksid ja indekseerib uuesti dokumendid kõigis kollektsioonides.', + confirmRestoration: 'Kinnita taastamine', copied: 'Kopeeritud', copy: 'Kopeeri', + copyField: 'Kopeeri väli', copying: 'Kopeerimine', + copyRow: 'Kopeeri rida', copyWarning: 'Olete üle kirjutamas {{to}} {{from}}-ga {{label}} {{title}} jaoks. Olete kindel?', create: 'Loo', created: 'Loodud', @@ -254,13 +279,17 @@ export const etTranslations: DefaultTranslationsObject = { dark: 'Tume', dashboard: 'Töölaud', delete: 'Kustuta', + deleted: 'Kustutatud', + deletedAt: 'Kustutatud', deletedCountSuccessfully: 'Kustutatud {{count}} {{label}} edukalt.', - deletedSuccessfully: 'Edukalt kustutatud.', + deletedSuccessfully: 'Kustutatud edukalt.', + deletePermanently: 'Jäta prügikasti vahele ja kustuta lõplikult', deleting: 'Kustutamine...', depth: 'Sügavus', descending: 'Kahanev', deselectAllRows: 'Tühista kõigi ridade valik', document: 'Dokument', + documentIsTrashed: 'See {{label}} on prügikastis ja on ainult loetav.', documentLocked: 'Dokument lukustatud', documents: 'Dokumendid', duplicate: 'Dubleeri', @@ -276,6 +305,8 @@ export const etTranslations: DefaultTranslationsObject = { editLabel: 'Muuda {{label}}', email: 'E-post', emailAddress: 'E-posti aadress', + emptyTrash: 'Tühjenda prügikast', + emptyTrashLabel: 'Tühjenda {{label}} prügikast', enterAValue: 'Sisesta väärtus', error: 'Viga', errors: 'Vead', @@ -288,6 +319,7 @@ export const etTranslations: DefaultTranslationsObject = { filterWhere: 'Filtreeri {{label}} kus', globals: 'Globaalsed', goBack: 'Mine tagasi', + groupByLabel: 'Rühmita {{label}} järgi', import: 'Importimine', isEditing: 'muudab', item: 'üksus', @@ -322,6 +354,7 @@ export const etTranslations: DefaultTranslationsObject = { '{{label}} ei leitud. Kas ühtegi {{label}} pole veel olemas või ükski ei vasta ülal määratud filtritele.', notFound: 'Ei leitud', nothingFound: 'Midagi ei leitud', + noTrashResults: 'Pole {{label}} prügikastis.', noUpcomingEventsScheduled: 'Eelseisvaid sündmusi ei ole plaanitud.', noValue: 'Väärtus puudub', of: '/', @@ -332,7 +365,11 @@ export const etTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Kirjuta olemasolevad välja andmed üle', pageNotFound: 'Lehte ei leitud', password: 'Parool', + pasteField: 'Kleebi väli', + pasteRow: 'Kleebi rida', payloadSettings: 'Payload seaded', + permanentlyDelete: 'Püsivalt Kustuta', + permanentlyDeletedCountSuccessfully: '{{count}} {{label}} edukalt ja lõplikult kustutatud.', perPage: 'Lehel: {{limit}}', previous: 'Eelmine', reindex: 'Indekseeri uuesti', @@ -343,6 +380,10 @@ export const etTranslations: DefaultTranslationsObject = { resetPreferences: 'Lähtesta eelistused', resetPreferencesDescription: 'See lähtestab kõik teie eelistused vaikeväärtustele.', resettingPreferences: 'Lähtestan eelistusi.', + restore: 'Taasta', + restoreAsPublished: 'Taasta avaldatud versioonina', + restoredCountSuccessfully: 'Taastatud {{count}} {{label}} edukalt.', + restoring: 'Austades...', row: 'Rida', rows: 'Read', save: 'Salvesta', @@ -373,6 +414,10 @@ export const etTranslations: DefaultTranslationsObject = { time: 'Aeg', timezone: 'Ajavöönd', titleDeleted: '{{label}} "{{title}}" edukalt kustutatud.', + titleRestored: '{{label}} "{{title}}" edukalt taastatud.', + titleTrashed: '{{label}} "{{title}}" viidi prügikasti.', + trash: 'Prügi', + trashedCountSuccessfully: '{{count}} {{label}} kanti prügikasti.', true: 'Tõene', unauthorized: 'Volitamata', unsavedChanges: 'Teil on salvestamata muudatusi. Salvestage või tühistage enne jätkamist.', @@ -391,6 +436,7 @@ export const etTranslations: DefaultTranslationsObject = { username: 'Kasutajanimi', users: 'Kasutajad', value: 'Väärtus', + viewing: 'Vaade', viewReadOnly: 'Vaata ainult lugemiseks', welcome: 'Tere tulemast', yes: 'Jah', @@ -513,6 +559,7 @@ export const etTranslations: DefaultTranslationsObject = { noRowsFound: '{{label}} ei leitud', noRowsSelected: '{{label}} pole valitud', preview: 'Eelvaade', + previouslyDraft: 'Eelnevalt mustand', previouslyPublished: 'Varem avaldatud', previousVersion: 'Eelmine versioon', problemRestoringVersion: 'Selle versiooni taastamisel tekkis probleem', diff --git a/packages/translations/src/languages/fa.ts b/packages/translations/src/languages/fa.ts index 0b7aaf46c5..12237ecff8 100644 --- a/packages/translations/src/languages/fa.ts +++ b/packages/translations/src/languages/fa.ts @@ -85,10 +85,15 @@ export const faTranslations: DefaultTranslationsObject = { correctInvalidFields: 'لطفا کادرهای نامعتبر را تصحیح کنید.', deletingFile: 'هنگام حذف فایل خطایی روی داد.', deletingTitle: 'هنگام حذف {{title}} خطایی رخ داد. لطفاً وضعیت اتصال اینترنت خود را بررسی کنید.', + documentNotFound: + 'سند با شناسه {{id}} پیدا نشد. ممکن است حذف شده باشد یا هرگز وجود نداشته باشد، یا شاید شما به آن دسترسی نداشته باشید.', emailOrPasswordIncorrect: 'رایانامه یا گذرواژه ارائه شده نادرست است.', followingFieldsInvalid_one: 'کادر زیر نامعتبر است:', followingFieldsInvalid_other: 'کادرهای زیر نامعتبر هستند:', incorrectCollection: 'مجموعه نادرست', + insufficientClipboardPermissions: + 'دسترسی به کلیپ‌بورد رد شد. لطفاً دسترسی‌های کلیپ‌بورد خود را بررسی کنید.', + invalidClipboardData: 'داده‌های نامعتبر در کلیپ‌بورد.', invalidFileType: 'نوع رسانه نامعتبر است', invalidFileTypeValue: 'نوع رسانه نامعتبر: {{value}}', invalidRequestArgs: 'آرگومان‌های نامعتبر در درخواست ارسال شدند: {{args}}', @@ -108,8 +113,11 @@ export const faTranslations: DefaultTranslationsObject = { noUser: 'بدون کاربر', previewing: 'مشکلی در پیش‌نمایش این رسانه رخ داد.', problemUploadingFile: 'هنگام بارگذاری سند خطایی رخ داد.', + restoringTitle: + 'هنگام بازیابی {{title}} خطایی رخ داد. لطفا اتصال خود را بررسی کرده و دوباره تلاش کنید.', tokenInvalidOrExpired: 'ژتون شما نامعتبر یا منقضی شده است.', tokenNotProvided: 'توکن ارائه نشده است.', + unableToCopy: 'کپی امکان‌پذیر نیست.', unableToDeleteCount: 'نمی‌توان {{count}} از {{total}} {{label}} را حذف کرد.', unableToReindexCollection: 'خطا در بازنمایه‌سازی مجموعه {{collection}}. عملیات متوقف شد.', unableToUpdateCount: 'امکان به روز رسانی {{count}} خارج از {{total}} {{label}} وجود ندارد.', @@ -178,6 +186,7 @@ export const faTranslations: DefaultTranslationsObject = { deleteFolder: 'حذف پوشه', folderName: 'نام پوشه', folders: 'پوشه‌ها', + folderTypeDescription: 'انتخاب کنید که کدام نوع اسناد مجموعه باید در این پوشه مجاز باشند.', itemHasBeenMoved: '{{title}} به {{folderName}} منتقل شده است.', itemHasBeenMovedToRoot: '{{title}} به پوشه اصلی انتقال یافته است.', itemsMovedToFolder: '{{title}} به {{folderName}} منتقل شد.', @@ -204,6 +213,16 @@ export const faTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'شما در حال پاک کردن {{count}} تعداد {{label}} هستید', aboutToDeleteCount_one: 'شما در حال پاک کردن {{count}} تعداد {{label}} هستید', aboutToDeleteCount_other: 'شما در شرف حذف هستید {{count}} {{label}}', + aboutToPermanentlyDelete: 'شما در حال حذف دائمی {{label}} <1>{{title}} هستید. آیا مطمئنید؟', + aboutToPermanentlyDeleteTrash: + 'شما در حال حذف همیشگی <0>{{count}} <1>{{label}} از سطل زباله هستید. آیا مطمئن هستید؟', + aboutToRestore: 'شما در حال بازیابی {{label}} <1>{{title}} هستید. آیا مطمئن هستید؟', + aboutToRestoreAsDraft: + 'شما در حال بازگرداندن {{label}} <1>{{title}} به عنوان پیش‌نویس هستید. آیا مطمئن هستید؟', + aboutToRestoreAsDraftCount: 'شما در حال بازگرداندن {{count}} {{label}} به صورت پیش‌نویس هستید.', + aboutToRestoreCount: 'شما در حال بازیابی {{count}} {{label}} هستید', + aboutToTrash: 'شما در حال حاضر در صدد حذف {{label}} <1>{{title}} هستید. آيا مطمئن هستید؟', + aboutToTrashCount: 'شما در حال حاضر در مورد انتقال {{count}} {{label}} به سطل زباله هستید', addBelow: 'افزودن به زیر', addFilter: 'افزودن علامت', adminTheme: 'پوسته پیشخوان', @@ -220,6 +239,7 @@ export const faTranslations: DefaultTranslationsObject = { cancel: 'لغو', changesNotSaved: 'تغییرات شما ذخیره نشده، اگر این برگه را ترک کنید. تمام تغییرات از دست خواهد رفت.', + clear: 'روشن', clearAll: 'همه را پاک کنید', close: 'بستن', collapse: 'بستن', @@ -237,9 +257,12 @@ export const faTranslations: DefaultTranslationsObject = { 'این کار ایندکس‌های موجود را حذف کرده و اسناد را در مجموعه‌های {{collections}} بازایندکس می‌کند.', confirmReindexDescriptionAll: 'این کار ایندکس‌های موجود را حذف کرده و اسناد را در همه مجموعه‌ها بازایندکس می‌کند.', + confirmRestoration: 'تأیید بازیابی', copied: 'رونوشت شده', copy: 'رونوشت', + copyField: 'کپی فیلد', copying: 'کپی کردن', + copyRow: 'کپی ردیف', copyWarning: 'شما در حال استفاده از {{from}} به جای {{to}} برای {{label}} {{title}} هستید. آیا مطمئن هستید؟', create: 'ساختن', @@ -255,13 +278,17 @@ export const faTranslations: DefaultTranslationsObject = { dark: 'تاریک', dashboard: 'پیشخوان', delete: 'حذف', + deleted: 'حذف شد', + deletedAt: 'حذف شده در', deletedCountSuccessfully: 'تعداد {{count}} {{label}} با موفقیت پاک گردید.', deletedSuccessfully: 'با موفقیت حذف شد.', + deletePermanently: 'پرش از سطل زباله و حذف دائمی', deleting: 'در حال حذف...', depth: 'عمق', descending: 'رو به پایین', deselectAllRows: 'تمام سطرها را از انتخاب خارج کنید', document: 'سند', + documentIsTrashed: 'این {{label}} حذف شده و فقط قابل خواندن است.', documentLocked: 'سند قفل شده است', documents: 'اسناد', duplicate: 'تکراری', @@ -277,6 +304,8 @@ export const faTranslations: DefaultTranslationsObject = { editLabel: 'نگارش {{label}}', email: 'رایانامه', emailAddress: 'نشانی رایانامه', + emptyTrash: 'خالی کردن سطل زباله', + emptyTrashLabel: 'خالی کردن سطل زباله {{label}}', enterAValue: 'یک مقدار وارد کنید', error: 'خطا', errors: 'خطاها', @@ -289,6 +318,7 @@ export const faTranslations: DefaultTranslationsObject = { filterWhere: 'علامت گذاری کردن {{label}} جایی که', globals: 'سراسری', goBack: 'برگشت', + groupByLabel: 'گروه بندی بر اساس {{label}}', import: 'واردات', isEditing: 'در حال ویرایش است', item: 'مورد', @@ -324,6 +354,7 @@ export const faTranslations: DefaultTranslationsObject = { 'هیچ {{label}} یافت نشد. {{label}} یا هنوز وجود ندارد یا هیچ کدام با علامت‌گذاری‌هایی که در بالا مشخص کرده اید مطابقت ندارد.', notFound: 'یافت نشد', nothingFound: 'چیزی یافت نشد', + noTrashResults: 'به زباله‌دان {{label}} موجود نیست.', noUpcomingEventsScheduled: 'هیچ رویدادی در دست نیست.', noValue: 'بدون مقدار', of: 'از', @@ -334,7 +365,11 @@ export const faTranslations: DefaultTranslationsObject = { overwriteExistingData: 'بازنویسی داده‌های فیلد موجود', pageNotFound: 'برگه یافت نشد', password: 'گذرواژه', + pasteField: 'چسباندن فیلد', + pasteRow: 'چسباندن ردیف', payloadSettings: 'تنظیمات پی‌لود', + permanentlyDelete: 'حذف دائم', + permanentlyDeletedCountSuccessfully: '{{count}} {{label}} با موفقیت حذف همیشگی شد.', perPage: 'هر برگه: {{limit}}', previous: 'قبلی', reindex: 'بازنمایه‌سازی', @@ -345,6 +380,11 @@ export const faTranslations: DefaultTranslationsObject = { resetPreferences: 'بازنشانی تنظیمات', resetPreferencesDescription: 'این تمام تنظیمات شما را به تنظیمات پیش‌فرض بازنشانی خواهد کرد.', resettingPreferences: 'در حال بازنشانی تنظیمات.', + restore: 'بازیابی', + restoreAsPublished: 'بازگردانی به عنوان نسخه منتشر شده', + restoredCountSuccessfully: '{{count}} {{label}} با موفقیت بازیابی شد.', + restoring: + 'درک معنی متن اصلی در زمینه Payload. در اینجا لیستی از اصطلاحات متداول Payload که معانی خاص خاص خود را دارند:\n- مجموعه: مجموعه گروهی از اسناد است که ساختار و هدف مشترکی را به اشتراک می‌گذارند. مجموعه‌ها برای سازماندهی و مدیر', row: 'ردیف', rows: 'ردیف‌ها', save: 'ذخیره', @@ -375,6 +415,10 @@ export const faTranslations: DefaultTranslationsObject = { time: 'زمان', timezone: 'منطقه زمانی', titleDeleted: '{{label}} "{{title}}" با موفقیت پاک شد.', + titleRestored: '{{label}} "{{title}}" با موفقیت بازیابی شد.', + titleTrashed: '{{label}} "{{title}}" به سطل زباله منتقل شد.', + trash: 'زباله', + trashedCountSuccessfully: '{{count}} {{label}} به سطل زباله منتقل شد.', true: 'درست', unauthorized: 'غیرمجاز', unsavedChanges: 'تغییرات ذخیره نشده ای دارید. قبل از ادامه ذخیره کنید یا رد کنید.', @@ -393,6 +437,7 @@ export const faTranslations: DefaultTranslationsObject = { username: 'نام کاربری', users: 'کاربران', value: 'مقدار', + viewing: 'مشاهده', viewReadOnly: 'فقط برای خواندن مشاهده کنید', welcome: 'خوش‌آمدید', yes: 'بله', @@ -516,6 +561,7 @@ export const faTranslations: DefaultTranslationsObject = { noRowsFound: 'هیچ {{label}} یافت نشد', noRowsSelected: 'هیچ {{label}} ای انتخاب نشده است', preview: 'پیش‌نمایش', + previouslyDraft: 'قبلا یک پیش‌نویس', previouslyPublished: 'قبلا منتشر شده', previousVersion: 'نسخه قبلی', problemRestoringVersion: 'مشکلی در بازیابی این نگارش وجود دارد', diff --git a/packages/translations/src/languages/fr.ts b/packages/translations/src/languages/fr.ts index 9b3cd34d7d..1c908c850a 100644 --- a/packages/translations/src/languages/fr.ts +++ b/packages/translations/src/languages/fr.ts @@ -88,10 +88,15 @@ export const frTranslations: DefaultTranslationsObject = { deletingFile: 'Une erreur s’est produite lors de la suppression du fichier.', deletingTitle: 'Une erreur s’est produite lors de la suppression de {{title}}. Veuillez vérifier votre connexion puis réessayer.', + documentNotFound: + "Le document avec l'ID {{id}} n'a pas pu être trouvé. Il a peut-être été supprimé ou n'a jamais existé, ou vous n'avez peut-être pas accès à celui-ci.", emailOrPasswordIncorrect: 'L’adresse e-mail ou le mot de passe fourni est incorrect.', followingFieldsInvalid_one: 'Le champ suivant n’est pas valide :', followingFieldsInvalid_other: 'Les champs suivants ne sont pas valides :', incorrectCollection: 'Collection incorrecte', + insufficientClipboardPermissions: + 'Accès au presse-papiers refusé. Veuillez vérifier vos autorisations pour le presse-papiers.', + invalidClipboardData: 'Données invalides dans le presse-papiers.', invalidFileType: 'Type de fichier invalide', invalidFileTypeValue: 'Type de fichier invalide : {{value}}', invalidRequestArgs: 'Arguments non valides dans la requête : {{args}}', @@ -112,8 +117,11 @@ export const frTranslations: DefaultTranslationsObject = { noUser: 'Aucun utilisateur', previewing: 'Un problème est survenu lors de l’aperçu de ce document.', problemUploadingFile: 'Il y a eu un problème lors du téléversement du fichier.', + restoringTitle: + 'Il y a eu une erreur lors de la restauration de {{title}}. Veuillez vérifier votre connexion et réessayer.', tokenInvalidOrExpired: 'Le jeton n’est soit pas valide ou a expiré.', tokenNotProvided: 'Jeton non fourni.', + unableToCopy: 'Impossible de copier.', unableToDeleteCount: 'Impossible de supprimer {{count}} sur {{total}} {{label}}.', unableToReindexCollection: 'Erreur lors de la réindexation de la collection {{collection}}. Opération annulée.', @@ -186,6 +194,8 @@ export const frTranslations: DefaultTranslationsObject = { deleteFolder: 'Supprimer le dossier', folderName: 'Nom du dossier', folders: 'Dossiers', + folderTypeDescription: + 'Sélectionnez le type de documents de collection qui devraient être autorisés dans ce dossier.', itemHasBeenMoved: '{{title}} a été déplacé vers {{folderName}}', itemHasBeenMovedToRoot: '{{title}} a été déplacé dans le dossier racine', itemsMovedToFolder: '{{title}} déplacé vers {{folderName}}', @@ -213,6 +223,20 @@ export const frTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Vous êtes sur le point de supprimer {{count}} {{label}}', aboutToDeleteCount_one: 'Vous êtes sur le point de supprimer {{count}} {{label}}', aboutToDeleteCount_other: 'Vous êtes sur le point de supprimer {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Vous êtes sur le point de supprimer définitivement le {{label}} <1>{{title}}. Êtes-vous sûr ?', + aboutToPermanentlyDeleteTrash: + 'Vous êtes sur le point de supprimer définitivement <0>{{count}} <1>{{label}} de la corbeille. Êtes-vous sûr ?', + aboutToRestore: + 'Vous êtes sur le point de restaurer le {{label}} <1>{{title}}. Êtes-vous sûr ?', + aboutToRestoreAsDraft: + 'Vous êtes sur le point de restaurer le {{label}} <1>{{title}} en tant que brouillon. Êtes-vous sûr?', + aboutToRestoreAsDraftCount: + 'Vous êtes sur le point de restaurer {{count}} {{label}} en tant que brouillon', + aboutToRestoreCount: 'Vous êtes sur le point de restaurer {{count}} {{label}}', + aboutToTrash: + 'Vous êtes sur le point de déplacer le {{label}} <1>{{title}} dans la corbeille. Êtes-vous sûr ?', + aboutToTrashCount: 'Vous êtes sur le point de déplacer {{count}} {{label}} à la corbeille', addBelow: 'Ajoutez ci-dessous', addFilter: 'Ajouter un filtre', adminTheme: 'Thème d’administration', @@ -229,6 +253,7 @@ export const frTranslations: DefaultTranslationsObject = { cancel: 'Annuler', changesNotSaved: 'Vos modifications n’ont pas été enregistrées. Vous perdrez vos modifications si vous quittez maintenant.', + clear: 'Clair', clearAll: 'Tout effacer', close: 'Fermer', collapse: 'Réduire', @@ -246,9 +271,12 @@ export const frTranslations: DefaultTranslationsObject = { 'Cela supprimera les index existants et réindexera les documents dans les collections {{collections}}.', confirmReindexDescriptionAll: 'Cela supprimera les index existants et réindexera les documents dans toutes les collections.', + confirmRestoration: 'Confirmer la restauration', copied: 'Copié', copy: 'Copie', + copyField: 'Copier le champ', copying: 'Copie', + copyRow: 'Copier la ligne', copyWarning: "Vous êtes sur le point d'écraser {{to}} avec {{from}} pour {{label}} {{title}}. Êtes-vous sûr ?", create: 'Créer', @@ -264,13 +292,17 @@ export const frTranslations: DefaultTranslationsObject = { dark: 'Sombre', dashboard: 'Tableau de bord', delete: 'Supprimer', + deleted: 'Supprimé', + deletedAt: 'Supprimé à', deletedCountSuccessfully: '{{count}} {{label}} supprimé avec succès.', deletedSuccessfully: 'Supprimé(e) avec succès.', + deletePermanently: 'Ignorer la corbeille et supprimer définitivement', deleting: 'Suppression en cours...', depth: 'Profondeur', descending: 'Descendant(e)', deselectAllRows: 'Désélectionner toutes les lignes', document: 'Document', + documentIsTrashed: 'Ce {{label}} est mis à la corbeille et est en lecture seule.', documentLocked: 'Document verrouillé', documents: 'Documents', duplicate: 'Dupliquer', @@ -286,6 +318,8 @@ export const frTranslations: DefaultTranslationsObject = { editLabel: 'Modifier {{label}}', email: 'E-mail', emailAddress: 'Adresse e-mail', + emptyTrash: 'Vider la corbeille', + emptyTrashLabel: 'Vider la corbeille {{label}}', enterAValue: 'Entrez une valeur', error: 'Erreur', errors: 'Erreurs', @@ -298,6 +332,7 @@ export const frTranslations: DefaultTranslationsObject = { filterWhere: 'Filtrer {{label}} où', globals: 'Globals(es)', goBack: 'Retourner', + groupByLabel: 'Regrouper par {{label}}', import: 'Importation', isEditing: 'est en train de modifier', item: 'article', @@ -333,6 +368,7 @@ export const frTranslations: DefaultTranslationsObject = { 'Aucun(e) {{label}} trouvé(e). Soit aucun(e) {{label}} n’existe encore, soit aucun(e) ne correspond aux filtres que vous avez spécifiés ci-dessus', notFound: 'Pas trouvé', nothingFound: 'Rien n’a été trouvé', + noTrashResults: 'Aucun {{label}} dans la corbeille.', noUpcomingEventsScheduled: 'Aucun événement à venir prévu.', noValue: 'Aucune valeur', of: 'de', @@ -343,7 +379,11 @@ export const frTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Écraser les données existantes du champ', pageNotFound: 'Page non trouvée', password: 'Mot de passe', + pasteField: 'Coller le champ', + pasteRow: 'Coller la ligne', payloadSettings: 'Paramètres de Payload', + permanentlyDelete: 'Supprimer définitivement', + permanentlyDeletedCountSuccessfully: 'Supprimé définitivement {{count}} {{label}} avec succès.', perPage: 'Par Page: {{limit}}', previous: 'Précédent', reindex: 'Réindexer', @@ -355,6 +395,10 @@ export const frTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Cela réinitialisera toutes vos préférences aux paramètres par défaut.', resettingPreferences: 'Réinitialisation des préférences.', + restore: 'Restaurer', + restoreAsPublished: 'Restaurer en tant que version publiée', + restoredCountSuccessfully: '{{count}} {{label}} restauré avec succès.', + restoring: 'Restauration...', row: 'Ligne', rows: 'Lignes', save: 'Sauvegarder', @@ -385,6 +429,10 @@ export const frTranslations: DefaultTranslationsObject = { time: 'Temps', timezone: 'Fuseau horaire', titleDeleted: '{{label}} "{{title}}" supprimé(e) avec succès.', + titleRestored: '{{label}} "{{title}}" restauré avec succès.', + titleTrashed: '{{label}} "{{title}}" déplacé vers la corbeille.', + trash: 'Corbeille', + trashedCountSuccessfully: '{{count}} {{label}} déplacé à la corbeille.', true: 'Vrai', unauthorized: 'Non autorisé', unsavedChanges: @@ -405,6 +453,7 @@ export const frTranslations: DefaultTranslationsObject = { username: "Nom d'utilisateur", users: 'Utilisateurs', value: 'Valeur', + viewing: 'Visualisation', viewReadOnly: 'Afficher en lecture seule', welcome: 'Bienvenue', yes: 'Oui', @@ -533,6 +582,7 @@ export const frTranslations: DefaultTranslationsObject = { noRowsFound: 'Aucun(e) {{label}} trouvé(e)', noRowsSelected: 'Aucune {{étiquette}} sélectionnée', preview: 'Aperçu', + previouslyDraft: 'Précédemment un Brouillon', previouslyPublished: 'Précédemment publié', previousVersion: 'Version Précédente', problemRestoringVersion: 'Un problème est survenu lors de la restauration de cette version', diff --git a/packages/translations/src/languages/he.ts b/packages/translations/src/languages/he.ts index 5db6d8be3f..dc372ad11d 100644 --- a/packages/translations/src/languages/he.ts +++ b/packages/translations/src/languages/he.ts @@ -84,10 +84,14 @@ export const heTranslations: DefaultTranslationsObject = { correctInvalidFields: 'נא לתקן שדות לא תקינים.', deletingFile: 'אירעה שגיאה במחיקת הקובץ.', deletingTitle: 'אירעה שגיאה במחיקת {{title}}. נא בדוק את החיבור שלך ונסה שנית.', + documentNotFound: + 'המסמך עם המזהה {{id}} לא נמצא. ייתכן שהוא נמחק או שלעולם לא היה, או שאין לך גישה אליו.', emailOrPasswordIncorrect: 'כתובת הדוא"ל או הסיסמה שסופקו אינם נכונים.', followingFieldsInvalid_one: 'השדה הבא אינו תקין:', followingFieldsInvalid_other: 'השדות הבאים אינם תקינים:', incorrectCollection: 'אוסף שגוי', + insufficientClipboardPermissions: 'הגישה ללוח הרחב נדחתה. אנא בדוק את הרשאות הלוח הרחב שלך.', + invalidClipboardData: 'נתוני לוח רחב לא חוקיים.', invalidFileType: 'סוג קובץ לא תקין', invalidFileTypeValue: 'סוג קובץ לא תקין: {{value}}', invalidRequestArgs: 'ארגומנטים לא חוקיים הועברו בבקשה: {{args}}', @@ -107,8 +111,10 @@ export const heTranslations: DefaultTranslationsObject = { noUser: 'אין משתמש', previewing: 'אירעה בעיה בתצוגה מקדימה של מסמך זה.', problemUploadingFile: 'אירעה בעיה בזמן העלאת הקובץ.', + restoringTitle: 'אירעה שגיאה בעת שחזור {{title}}. אנא בדוק את החיבור שלך ונסה שוב.', tokenInvalidOrExpired: 'הטוקן אינו תקין או שפג תוקפו.', tokenNotProvided: 'טוקן לא סופק.', + unableToCopy: 'לא ניתן להעתיק.', unableToDeleteCount: 'לא ניתן למחוק {{count}} מתוך {{total}} {{label}}.', unableToReindexCollection: 'שגיאה בהחזרת אינדקס של אוסף {{collection}}. הפעולה בוטלה.', unableToUpdateCount: 'לא ניתן לעדכן {{count}} מתוך {{total}} {{label}}.', @@ -176,6 +182,7 @@ export const heTranslations: DefaultTranslationsObject = { deleteFolder: 'מחק תיקייה', folderName: 'שם תיקייה', folders: 'תיקיות', + folderTypeDescription: 'בחר איזה סוג של מסמכים מהאוסף יותרו להיות בתיקייה זו.', itemHasBeenMoved: '"{{title}}" הועבר ל- "{{folderName}}"', itemHasBeenMovedToRoot: '"{{title}}" הועבר לתיקיית השורש', itemsMovedToFolder: '{{title}} הועבר אל {{folderName}}', @@ -201,6 +208,16 @@ export const heTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'אתה עומד למחוק {{count}} {{label}}', aboutToDeleteCount_one: 'אתה עומד למחוק {{label}} אחד', aboutToDeleteCount_other: 'אתה עומד למחוק {{count}} {{label}}', + aboutToPermanentlyDelete: + 'אתה עומד למחוק לצמיתות את ה{{label}} <1>{{title}}. האם אתה בטוח?', + aboutToPermanentlyDeleteTrash: + 'אתה עומד למחוק לצמיתות <0>{{count}} <1>{{label}} מהאשפה. האם אתה בטוח?', + aboutToRestore: 'אתה עומד לשחזר את {{label}} <1>{{title}}. האם אתה בטוח?', + aboutToRestoreAsDraft: 'אתה עומד לשחזר את ה{{label}} <1>{{title}} כטיוטה. האם אתה בטוח?', + aboutToRestoreAsDraftCount: 'אתה עומד לשחזר {{count}} {{label}} כטיוטה', + aboutToRestoreCount: 'אתה עומד לשחזר {{count}} {{label}}', + aboutToTrash: 'אתה עומד להעביר את ה{{label}} <1>{{title}} לפח. האם אתה בטוח?', + aboutToTrashCount: 'אתה עומד להעביר {{count}} {{label}} לפח אשפה', addBelow: 'הוסף מתחת', addFilter: 'הוסף מסנן', adminTheme: 'ערכת נושא ממשק הניהול', @@ -216,6 +233,8 @@ export const heTranslations: DefaultTranslationsObject = { backToDashboard: 'חזרה ללוח המחוונים', cancel: 'ביטול', changesNotSaved: 'השינויים שלך לא נשמרו. אם תצא כעת, תאבד את השינויים שלך.', + clear: + 'בהתחשב במשמעות של הטקסט המקורי בהקשר של Payload. הנה רשימה של מונחים מקוריים של Payload שנושאים משמעויות מסוימות:\n- אוסף: אוסף הוא קבוצה של מסמכים ששותפים למבנה ולמטרה משות', clearAll: 'נקה הכל', close: 'סגור', collapse: 'כווץ', @@ -232,9 +251,12 @@ export const heTranslations: DefaultTranslationsObject = { confirmReindexDescription: 'זה יסיר את האינדקסים הקיימים ויחזיר אינדקס למסמכים באוספים {{collections}}.', confirmReindexDescriptionAll: 'זה יסיר את האינדקסים הקיימים ויחזיר אינדקס למסמכים בכל האוספים.', + confirmRestoration: 'אשר שחזור', copied: 'הועתק', copy: 'העתק', + copyField: 'העתק שדה', copying: 'העתקה', + copyRow: 'העתק שורה', copyWarning: 'אתה עומד לדרוס את {{to}} באמצעות {{from}} עבור {{label}} {{title}}. האם אתה בטוח?', create: 'יצירה', @@ -250,13 +272,17 @@ export const heTranslations: DefaultTranslationsObject = { dark: 'כהה', dashboard: 'לוח מחוונים', delete: 'מחיקה', + deleted: 'נמחק', + deletedAt: 'נמחק ב', deletedCountSuccessfully: 'נמחקו {{count}} {{label}} בהצלחה.', deletedSuccessfully: 'נמחק בהצלחה.', + deletePermanently: 'דלג על פח האשפה ומחק לצמיתות', deleting: 'מוחק...', depth: 'עומק', descending: 'בסדר יורד', deselectAllRows: 'בטל בחירת כל השורות', document: 'מסמך', + documentIsTrashed: 'ה{{label}} הזה במיחזור ובמצב לקריאה בלבד.', documentLocked: 'המסמך ננעל', documents: 'מסמכים', duplicate: 'שכפול', @@ -272,6 +298,8 @@ export const heTranslations: DefaultTranslationsObject = { editLabel: 'עריכת {{label}}', email: 'דוא"ל', emailAddress: 'כתובת דוא"ל', + emptyTrash: 'רוקן את הזבל', + emptyTrashLabel: 'רוקן את האשפה {{label}}', enterAValue: 'הזן ערך', error: 'שגיאה', errors: 'שגיאות', @@ -284,6 +312,7 @@ export const heTranslations: DefaultTranslationsObject = { filterWhere: 'סנן {{label}} בהם', globals: 'גלובלים', goBack: 'חזור', + groupByLabel: 'קבץ לפי {{label}}', import: 'יבוא', isEditing: 'עורך', item: 'פריט', @@ -317,6 +346,7 @@ export const heTranslations: DefaultTranslationsObject = { noResults: 'לא נמצאו {{label}}. אין עדיין {{label}}, או שאינם תואמים למסננים שנבחרו.', notFound: 'לא נמצא', nothingFound: 'לא נמצא כלום', + noTrashResults: 'אין {{label}} בפח.', noUpcomingEventsScheduled: 'אין אירועים מתוכנתים בהמשך.', noValue: 'אין ערך', of: 'מתוך', @@ -327,7 +357,11 @@ export const heTranslations: DefaultTranslationsObject = { overwriteExistingData: 'דרוס את נתוני השדה הקיימים', pageNotFound: 'הדף לא נמצא', password: 'סיסמה', + pasteField: 'הדבק שדה', + pasteRow: 'הדבק שורה', payloadSettings: 'הגדרות מערכת Payload', + permanentlyDelete: 'מחק לצמיתות', + permanentlyDeletedCountSuccessfully: 'נמחקו לצמיתות {{count}} {{label}} בהצלחה.', perPage: '{{limit}} בכל עמוד', previous: 'קודם', reindex: 'החזרת אינדקס', @@ -338,6 +372,11 @@ export const heTranslations: DefaultTranslationsObject = { resetPreferences: 'איפוס העדפות', resetPreferencesDescription: 'זאת תאפס את כל ההעדפות שלך להגדרות ברירת המחדל.', resettingPreferences: 'מאפס העדפות.', + restore: 'שחזור', + restoreAsPublished: 'שחזר כגרסה שפורסמה', + restoredCountSuccessfully: 'שוחזרו בהצלחה {{count}} {{label}}.', + restoring: + 'שמעו למשמעות של הטקסט המקורי בהקשר של Payload. הנה רשימה של מונחים נפוצים של Payload שנושאים משמעויות מאוד מסוימות:\n- אוסף: אוסף הוא קבוצה של מסמכים ששותפים למבנה ולמטרה מש', row: 'שורה', rows: 'שורות', save: 'שמירה', @@ -368,6 +407,10 @@ export const heTranslations: DefaultTranslationsObject = { time: 'זמן', timezone: 'אזור זמן', titleDeleted: '{{label}} "{{title}}" נמחק בהצלחה.', + titleRestored: 'התווית "{{title}}" שוחזרה בהצלחה.', + titleTrashed: '{{label}} "{{title}}" הועבר לפח.', + trash: 'זבל', + trashedCountSuccessfully: '{{count}} {{label}} הועברו לפח.', true: 'True', unauthorized: 'אין הרשאה', unsavedChanges: 'יש לך שינויים שלא נשמרו. שמור או מחק לפני שתמשיך.', @@ -386,6 +429,7 @@ export const heTranslations: DefaultTranslationsObject = { username: 'שם משתמש', users: 'משתמשים', value: 'ערך', + viewing: 'צפיה', viewReadOnly: 'הצג קריאה בלבד', welcome: 'ברוך הבא', yes: 'כן', @@ -504,6 +548,7 @@ export const heTranslations: DefaultTranslationsObject = { noRowsFound: 'לא נמצאו {{label}}', noRowsSelected: 'לא נבחר {{תווית}}', preview: 'תצוגה מקדימה', + previouslyDraft: 'לשעבר טיוטה', previouslyPublished: 'פורסם בעבר', previousVersion: 'גרסה קודמת', problemRestoringVersion: 'הייתה בעיה בשחזור הגרסה הזו', diff --git a/packages/translations/src/languages/hr.ts b/packages/translations/src/languages/hr.ts index f9460d15e5..0276b3c5fd 100644 --- a/packages/translations/src/languages/hr.ts +++ b/packages/translations/src/languages/hr.ts @@ -87,10 +87,15 @@ export const hrTranslations: DefaultTranslationsObject = { deletingFile: 'Dogodila se pogreška pri brisanju datoteke.', deletingTitle: 'Dogodila se pogreška pri brisanju {{title}}. Molimo provjerite svoju internet vezu i pokušajte ponovno.', + documentNotFound: + 'Dokument s ID-om {{id}} nije mogao biti pronađen. Možda je izbrisan ili nikad nije postojao, ili možda nemate pristup njemu.', emailOrPasswordIncorrect: 'E-mail adresa ili lozinka netočni.', followingFieldsInvalid_one: 'Ovo polje je neispravno:', followingFieldsInvalid_other: 'Ova polja su neispravna:', incorrectCollection: 'Neispravna kolekcija', + insufficientClipboardPermissions: + 'Pristup međuspremniku odbijen. Provjerite svoja dopuštenja za međuspremnik.', + invalidClipboardData: 'Nevažeći podaci u međuspremniku.', invalidFileType: 'Neispravan tip datoteke', invalidFileTypeValue: 'Neispravan tip datoteke: {{value}}', invalidRequestArgs: 'Nevažeći argumenti u zahtjevu: {{args}}', @@ -110,8 +115,11 @@ export const hrTranslations: DefaultTranslationsObject = { noUser: 'Nema korisnika', previewing: 'Došlo je do problema pri pregledavanju ovog dokumenta.', problemUploadingFile: 'Došlo je do problema pri učitavanju datoteke.', + restoringTitle: + 'Došlo je do pogreške prilikom vraćanja {{title}}. Provjerite svoju vezu i pokušajte ponovno.', tokenInvalidOrExpired: 'Token je neispravan ili je istekao.', tokenNotProvided: 'Token nije pružen.', + unableToCopy: 'Nije moguće kopirati.', unableToDeleteCount: 'Nije moguće izbrisati {{count}} od {{total}} {{label}}.', unableToReindexCollection: 'Pogreška pri ponovnom indeksiranju kolekcije {{collection}}. Operacija je prekinuta.', @@ -181,6 +189,8 @@ export const hrTranslations: DefaultTranslationsObject = { deleteFolder: 'Izbriši mapu', folderName: 'Naziv mape', folders: 'Mape', + folderTypeDescription: + 'Odaberite koja vrsta dokumenata kolekcije treba biti dozvoljena u ovoj mapi.', itemHasBeenMoved: '{{title}} je premješten u {{folderName}}', itemHasBeenMovedToRoot: '{{title}} je premješten u korijensku mapu.', itemsMovedToFolder: '{{title}} premješteno u {{folderName}}', @@ -207,6 +217,17 @@ export const hrTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Upravo ćete izbrisati {{count}} {{label}}', aboutToDeleteCount_one: 'Upravo ćete izbrisati {{count}} {{label}}', aboutToDeleteCount_other: 'Upravo ćete izbrisati {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Na rubu ste trajnog brisanja {{label}} <1>{{title}}. Jeste li sigurni?', + aboutToPermanentlyDeleteTrash: + 'Na rubu ste trajnog brisanja <0>{{count}} <1>{{label}} iz smeća. Jeste li sigurni?', + aboutToRestore: 'Na rubu ste obnoviti {{label}} <1>{{title}}. Jeste li sigurni?', + aboutToRestoreAsDraft: + 'Uskoro ćete vratiti {{label}} <1>{{title}} kao skicu. Jeste li sigurni?', + aboutToRestoreAsDraftCount: 'Uskoro ćete obnoviti {{count}} {{label}} kao nacrt', + aboutToRestoreCount: 'Uskoro ćete obnoviti {{count}} {{label}}', + aboutToTrash: 'Na rubu ste premještanja {{label}} <1>{{title}} u otpad. Jeste li sigurni?', + aboutToTrashCount: 'Na korak ste od premještanja {{count}} {{label}} u smeće', addBelow: 'Dodaj ispod', addFilter: 'Dodaj filter', adminTheme: 'Administratorska tema', @@ -222,6 +243,7 @@ export const hrTranslations: DefaultTranslationsObject = { backToDashboard: 'Natrag na nadzornu ploču', cancel: 'Otkaži', changesNotSaved: 'Vaše promjene nisu spremljene. Ako izađete sada, izgubit ćete promjene.', + clear: 'Jasan', clearAll: 'Očisti sve', close: 'Zatvori', collapse: 'Sažmi', @@ -239,9 +261,12 @@ export const hrTranslations: DefaultTranslationsObject = { 'Ovo će ukloniti postojeće indekse i ponovno indeksirati dokumente u {{collections}} kolekcijama.', confirmReindexDescriptionAll: 'Ovo će ukloniti postojeće indekse i ponovno indeksirati dokumente u svim kolekcijama.', + confirmRestoration: 'Potvrdite obnovu', copied: 'Kopirano', copy: 'Kopiraj', + copyField: 'Kopiraj polje', copying: 'Kopiranje', + copyRow: 'Kopiraj redak', copyWarning: 'Na rubu ste prepisivanja {{to}} s {{from}} za {{label}} {{title}}. Jeste li sigurni?', create: 'Izradi', @@ -257,13 +282,17 @@ export const hrTranslations: DefaultTranslationsObject = { dark: 'Tamno', dashboard: 'Nadzorna ploča', delete: 'Izbriši', + deleted: 'Izbrisano', + deletedAt: 'Izbrisano U', deletedCountSuccessfully: 'Uspješno izbrisano {{count}} {{label}}.', deletedSuccessfully: 'Uspješno izbrisano.', + deletePermanently: 'Preskoči koš i trajno izbriši', deleting: 'Brisanje...', depth: 'Dubina', descending: 'Silazno', deselectAllRows: 'Odznači sve redove', document: 'Dokument', + documentIsTrashed: 'Ova {{label}} je u smeću i dostupna je samo za čitanje.', documentLocked: 'Dokument je zaključan', documents: 'Dokumenti', duplicate: 'Duplikat', @@ -279,6 +308,8 @@ export const hrTranslations: DefaultTranslationsObject = { editLabel: 'Uredi {{label}}', email: 'Email', emailAddress: 'Email adresa', + emptyTrash: 'Isprazni smeće', + emptyTrashLabel: 'Isprazni {{label}} kantu za smeće', enterAValue: 'Unesi vrijednost', error: 'Greška', errors: 'Greške', @@ -291,6 +322,7 @@ export const hrTranslations: DefaultTranslationsObject = { filterWhere: 'Filter {{label}} gdje', globals: 'Globali', goBack: 'Vrati se', + groupByLabel: 'Grupiraj po {{label}}', import: 'Uvoz', isEditing: 'uređuje', item: 'stavka', @@ -326,6 +358,7 @@ export const hrTranslations: DefaultTranslationsObject = { 'Nije pronađen nijedan {{label}}. Ili {{label}} još uvijek ne postoji ili nijedan od odgovara postavljenim filterima.', notFound: 'Nije pronađeno', nothingFound: 'Ništa nije pronađeno', + noTrashResults: 'Nema {{label}} u smeću.', noUpcomingEventsScheduled: 'Nema zakazanih nadolazećih događanja.', noValue: 'Bez vrijednosti', of: 'od', @@ -336,7 +369,11 @@ export const hrTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Prepišite postojeće podatke u polju', pageNotFound: 'Stranica nije pronađena', password: 'Lozinka', + pasteField: 'Zalijepi polje', + pasteRow: 'Zalijepi redak', payloadSettings: 'Payload postavke', + permanentlyDelete: 'Trajno izbriši', + permanentlyDeletedCountSuccessfully: 'Trajno izbrisano {{count}} {{label}} uspješno.', perPage: 'Po stranici: {{limit}}', previous: 'Prethodni', reindex: 'Ponovno indeksiraj', @@ -347,6 +384,11 @@ export const hrTranslations: DefaultTranslationsObject = { resetPreferences: 'Ponovno postavljanje postavki', resetPreferencesDescription: 'Ovo će vratiti sve vaše postavke na zadane vrijednosti.', resettingPreferences: 'Ponovno postavljanje postavki.', + restore: 'Obnovi', + restoreAsPublished: 'Vrati kao objavljenu verziju', + restoredCountSuccessfully: 'Uspješno obnovljeno {{count}} {{label}}.', + restoring: + 'Poštujte značenje izvornog teksta unutar konteksta Payloada. Evo popisa uobičajenih pojmova Payloada koji imaju vrlo specifična značenja:\n - Kolekcija: Kolekcija je skup dokumenata koji dijele zajedničku strukturu i svrhu. Kolekcije se koriste za organiziranje i upravljanje sadržajem u Payloadu.\n - Polje: Polje je specifičan dio podataka unutar dokumenta u kolekciji. Polja definiraju strukturu i vrstu podataka koji', row: 'Red', rows: 'Redovi', save: 'Spremi', @@ -377,6 +419,10 @@ export const hrTranslations: DefaultTranslationsObject = { time: 'Vrijeme', timezone: 'Vremenska zona', titleDeleted: '{{label}} "{{title}}" uspješno izbrisano.', + titleRestored: '{{label}} "{{title}}" uspješno je obnovljeno.', + titleTrashed: '{{label}} "{{title}}" premješteno u smeće.', + trash: 'Otpad', + trashedCountSuccessfully: '{{count}} {{label}} premješteno u smeće.', true: 'Istinito', unauthorized: 'Neovlašteno', unsavedChanges: 'Imate nespremljene promjene. Spremite ili odbacite prije nastavka.', @@ -395,6 +441,7 @@ export const hrTranslations: DefaultTranslationsObject = { username: 'Korisničko ime', users: 'Korisnici', value: 'Vrijednost', + viewing: 'Pregledavanje', viewReadOnly: 'Pogledaj samo za čitanje', welcome: 'Dobrodošli', yes: 'Da', @@ -517,6 +564,7 @@ export const hrTranslations: DefaultTranslationsObject = { noRowsFound: '{{label}} nije pronađeno', noRowsSelected: 'Nije odabrana {{oznaka}}', preview: 'Pregled', + previouslyDraft: 'Prethodno Nacrt', previouslyPublished: 'Prethodno objavljeno', previousVersion: 'Prethodna verzija', problemRestoringVersion: 'Nastao je problem pri vraćanju ove verzije', diff --git a/packages/translations/src/languages/hu.ts b/packages/translations/src/languages/hu.ts index f398316926..a7d7d37d73 100644 --- a/packages/translations/src/languages/hu.ts +++ b/packages/translations/src/languages/hu.ts @@ -88,10 +88,15 @@ export const huTranslations: DefaultTranslationsObject = { deletingFile: 'Hiba történt a fájl törlésekor.', deletingTitle: 'Hiba történt a {{title}} törlése közben. Kérjük, ellenőrizze a kapcsolatot, és próbálja meg újra.', + documentNotFound: + 'A dokumentum azonosítóval {{id}} nem található. Lehet, hogy törölték, soha nem létezett, vagy Önnek nincs hozzáférése hozzá.', emailOrPasswordIncorrect: 'A megadott e-mail-cím vagy jelszó helytelen.', followingFieldsInvalid_one: 'A következő mező érvénytelen:', followingFieldsInvalid_other: 'A következő mezők érvénytelenek:', incorrectCollection: 'Helytelen gyűjtemény', + insufficientClipboardPermissions: + 'A vágólaphoz való hozzáférés elutasítva. Kérjük, ellenőrizze a vágólap engedélyeit.', + invalidClipboardData: 'Érvénytelen vágólap adat.', invalidFileType: 'Érvénytelen fájltípus', invalidFileTypeValue: 'Érvénytelen fájltípus: {{value}}', invalidRequestArgs: 'Érvénytelen argumentumok a kérésben: {{args}}', @@ -111,8 +116,11 @@ export const huTranslations: DefaultTranslationsObject = { noUser: 'Nincs felhasználó', previewing: 'Hiba történt a dokumentum előnézetének megtekintése közben.', problemUploadingFile: 'Hiba történt a fájl feltöltése közben.', + restoringTitle: + 'Hiba történt a {{title}} visszaállítása közben. Kérjük, ellenőrizze az internetkapcsolatát, és próbálkozzon újra.', tokenInvalidOrExpired: 'A token érvénytelen vagy lejárt.', tokenNotProvided: 'Token nem biztosított.', + unableToCopy: 'Másolás nem lehetséges.', unableToDeleteCount: 'Nem sikerült törölni {{count}}/{{total}} {{label}}.', unableToReindexCollection: 'Hiba a(z) {{collection}} gyűjtemény újraindexelésekor. A művelet megszakítva.', @@ -182,6 +190,8 @@ export const huTranslations: DefaultTranslationsObject = { deleteFolder: 'Mappa törlése', folderName: 'Mappa neve', folders: 'Mappák', + folderTypeDescription: + 'Válassza ki, hogy milyen típusú dokumentumokat engedélyez ebben a mappában.', itemHasBeenMoved: '{{title}} át lett helyezve a {{folderName}} nevű mappába.', itemHasBeenMovedToRoot: 'A(z) {{title}} át lett helyezve a gyökérmappába.', itemsMovedToFolder: '{{title}} áthelyezve a(z) {{folderName}} mappába', @@ -208,6 +218,19 @@ export const huTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Törölni készül {{count}} {{label}}', aboutToDeleteCount_one: 'Törölni készül {{count}} {{label}}', aboutToDeleteCount_other: 'Törölni készül {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Ön véglegesen törölni készül a következőt: {{label}} <1>{{title}}. Biztos benne?', + aboutToPermanentlyDeleteTrash: + 'Ön véglegesen törölni készül <0>{{count}} <1>{{label}} elemet a szemetesből. Biztos benne?', + aboutToRestore: + 'Ön helyreállítja a következőt: {{label}} <1>{{title}}. Biztosan így szeretné?', + aboutToRestoreAsDraft: + 'Ön azzal készül, hogy a következőt: {{label}} <1>{{title}}, vázlatként állítja vissza. Biztos benne?', + aboutToRestoreAsDraftCount: 'Ön hamarosan visszaállít {{count}} {{label}}-t mint vázlat', + aboutToRestoreCount: 'Ön a következők visszaállítására készül: {{count}} {{label}}', + aboutToTrash: + 'Ön azon van, hogy a következőt: {{label}} <1>{{title}} áthelyezze a szemetesbe. Biztos benne?', + aboutToTrashCount: 'Ön a(z) {{count}} {{label}} elemet készül a kukába helyezni.', addBelow: 'Hozzáadás lent', addFilter: 'Szűrő hozzáadása', adminTheme: 'Admin téma', @@ -224,6 +247,7 @@ export const huTranslations: DefaultTranslationsObject = { cancel: 'Mégsem', changesNotSaved: 'A módosítások nem lettek mentve. Ha most távozik, elveszíti a változtatásokat.', + clear: 'Tiszta', clearAll: 'Törölj mindent', close: 'Bezárás', collapse: 'Összecsukás', @@ -241,9 +265,12 @@ export const huTranslations: DefaultTranslationsObject = { 'Ez eltávolítja a meglévő indexeket, és újraindexálja a dokumentumokat a {{collections}} gyűjteményekben.', confirmReindexDescriptionAll: 'Ez eltávolítja a meglévő indexeket, és újraindexálja a dokumentumokat az összes gyűjteményben.', + confirmRestoration: 'Megerősíti a helyreállítást?', copied: 'Másolva', copy: 'Másolás', + copyField: 'Mező másolása', copying: 'Másolás', + copyRow: 'Sor másolása', copyWarning: 'Ön azzal készül felülírni {{to}} -t {{from}} -mal a {{label}} {{title}} számára. Biztos benne?', create: 'Létrehozás', @@ -259,13 +286,17 @@ export const huTranslations: DefaultTranslationsObject = { dark: 'Sötét', dashboard: 'Irányítópult', delete: 'Törlés', + deleted: 'Törölt', + deletedAt: 'Törölve Ekkor', deletedCountSuccessfully: '{{count}} {{label}} sikeresen törölve.', deletedSuccessfully: 'Sikeresen törölve.', + deletePermanently: 'Hagyja ki a kukát és törölje véglegesen', deleting: 'Törlés...', depth: 'Mélység', descending: 'Csökkenő', deselectAllRows: 'Jelölje ki az összes sort', document: 'Dokumentum', + documentIsTrashed: 'Ez a {{label}} szemétdobozba került, és csak olvasható.', documentLocked: 'A dokumentum zárolva van', documents: 'Dokumentumok', duplicate: 'Duplikálás', @@ -281,6 +312,8 @@ export const huTranslations: DefaultTranslationsObject = { editLabel: '{{label}} szerkesztése', email: 'E-mail', emailAddress: 'E-mail cím', + emptyTrash: 'Ürítse ki a szemetet', + emptyTrashLabel: 'Ürítse ki a {{label}} szemetest', enterAValue: 'Adjon meg egy értéket', error: 'Hiba', errors: 'Hibák', @@ -293,6 +326,7 @@ export const huTranslations: DefaultTranslationsObject = { filterWhere: 'Szűrő {{label}} ahol', globals: 'Globálisok', goBack: 'Vissza', + groupByLabel: 'Csoportosítás {{label}} szerint', import: 'Behozatal', isEditing: 'szerkeszt', item: 'tétel', @@ -327,6 +361,7 @@ export const huTranslations: DefaultTranslationsObject = { 'Nem találtunk {{label}}. Vagy még nem létezik {{label}}, vagy egyik sem felel meg a fent megadott szűrőknek.', notFound: 'Nem található', nothingFound: 'Nincs találat', + noTrashResults: 'Nincs {{label}} a szemetesben.', noUpcomingEventsScheduled: 'Nincsenek közelgő események.', noValue: 'Nincs érték', of: 'a', @@ -337,7 +372,11 @@ export const huTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Írja felül a meglévő mezőadatokat', pageNotFound: 'Az oldal nem található', password: 'Jelszó', + pasteField: 'Mező beillesztése', + pasteRow: 'Sor beillesztése', payloadSettings: 'Payload beállítások', + permanentlyDelete: 'Végleges Törlés', + permanentlyDeletedCountSuccessfully: 'Véglegesen törölt {{count}} {{label}} sikeresen.', perPage: 'Oldalanként: {{limit}}', previous: 'Előző', reindex: 'Újraindexelés', @@ -349,6 +388,11 @@ export const huTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Ez visszaállítja az összes beállítást az alapértelmezett értékekre.', resettingPreferences: 'Beállítások visszaállítása.', + restore: 'Visszaállítás', + restoreAsPublished: 'Állítsa vissza közzétett változatként', + restoredCountSuccessfully: 'Sikeresen visszaállított {{count}} {{label}}.', + restoring: + 'Tartsa tiszteletben az eredeti szöveg jelentését a Payload kontextusában. Íme egy lista a Payloadban gyakran használt kifejezésekről, amelyek rendkívül specifikus jelentéssel bírnak:\n - Gyűjtemény: A gyűjtemény egy olyan dokumentumcsoport, amelyek közös struktúrával és céllal rendelkeznek. A gyűjteményeket a tartalom szervezésére és kezelésére használjuk a Payloadban.\n - Mező', row: 'Sor', rows: 'Sorok', save: 'Mentés', @@ -379,6 +423,10 @@ export const huTranslations: DefaultTranslationsObject = { time: 'Idő', timezone: 'Időzóna', titleDeleted: '{{label}} "{{title}}" sikeresen törölve.', + titleRestored: '"{{label}}" "{{title}}" sikeresen visszaállítva.', + titleTrashed: '"{{label}}" "{{title}}" a szemétbe került.', + trash: 'Szemét', + trashedCountSuccessfully: '{{count}} {{label}} átkerült a szemeteskukába.', true: 'Igaz', unauthorized: 'Jogosulatlan', unsavedChanges: 'Vannak mentetlen változtatásai. Mentsen vagy dobja el mielőtt folytatja.', @@ -397,6 +445,7 @@ export const huTranslations: DefaultTranslationsObject = { username: 'Felhasználónév', users: 'Felhasználók', value: 'Érték', + viewing: 'Megtekintés', viewReadOnly: 'Csak olvasható nézet', welcome: 'Üdvözöljük', yes: 'Igen', @@ -524,6 +573,7 @@ export const huTranslations: DefaultTranslationsObject = { noRowsFound: 'Nem található {{label}}', noRowsSelected: 'Nincs {{címke}} kiválasztva', preview: 'Előnézet', + previouslyDraft: 'Korábban egy Vázlat', previouslyPublished: 'Korábban Közzétéve', previousVersion: 'Előző Verzió', problemRestoringVersion: 'Hiba történt a verzió visszaállításakor', diff --git a/packages/translations/src/languages/hy.ts b/packages/translations/src/languages/hy.ts index 149d84490c..4b3843940c 100644 --- a/packages/translations/src/languages/hy.ts +++ b/packages/translations/src/languages/hy.ts @@ -86,10 +86,15 @@ export const hyTranslations: DefaultTranslationsObject = { deletingFile: 'Ֆայլը ջնջելու ժամանակ սխալ է տեղի ունեցել։', deletingTitle: '{{title}}-ը ջնջելու ժամանակ սխալ է տեղի ունեցել։ Խնդրում ենք ստուգել Ձեր կապը և կրկին փորձել։', + documentNotFound: + 'Գրառումը ID-ով {{id}} չի գտնվել։ Այն կարող է ջնջվել կամ նույնիսկ էլ գոյություն չունել։ Ֆո', emailOrPasswordIncorrect: 'Տրամադրված էլ. փոստը կամ գաղտնաբառը սխալ է։', followingFieldsInvalid_one: 'Հետևյալ դաշտն անվավեր է։', followingFieldsInvalid_other: 'Հետևյալ դաշտերն անվավեր են։', incorrectCollection: 'Սխալ հավաքածու', + insufficientClipboardPermissions: + 'Սեղմատախտակին հասանելիությունը մերժվել է։ Խնդրում ենք ստուգել ձեր սեղմատախտակի թույլտվությունները։', + invalidClipboardData: 'Անվավեր սեղմատախտակի տվյալներ։', invalidFileType: 'Անվավեր ֆայլի տեսակ', invalidFileTypeValue: 'Անվավեր ֆայլի տեսակ՝ {{value}}', invalidRequestArgs: 'Հայտում փոխանցված անվավեր արգումենտներ՝ {{args}}', @@ -109,8 +114,11 @@ export const hyTranslations: DefaultTranslationsObject = { noUser: 'Օգտատեր չկա', previewing: 'Այս փաստաթուղթը նախադիտելու ժամանակ խնդիր է առաջացել։', problemUploadingFile: 'Ֆայլը վերբեռնելու ժամանակ խնդիր է առաջացել։', + restoringTitle: + 'Սխալ է տեղի ունեցել {{title}}-ի վերականգնելիս: Խնդրում ենք ստուգել ձեր կապը և կրկին փորձել:', tokenInvalidOrExpired: 'Թոքենն անվավեր է կամ ժամկետանց։', tokenNotProvided: 'Թոքենը տրամադրված չէ։', + unableToCopy: 'Չհաջողվեց պատճենել։', unableToDeleteCount: 'Հնարավոր չէ ջնջել {{count}}-ը {{total}} {{label}}-ից։', unableToReindexCollection: 'Հավաքածու {{collection}}-ը վերաինդեքսավորելու սխալ։ Գործողությունն ընդհատվել է։', @@ -180,6 +188,8 @@ export const hyTranslations: DefaultTranslationsObject = { deleteFolder: 'Ջնջել թղթապանակը', folderName: 'Տեսակավորման անվանում', folders: 'Պատուհաններ', + folderTypeDescription: + 'Ընտրեք, թե որն է հավաքածուի փաստաթղթերը, որոնք պետք է թույլատրվեն այս պանակում:', itemHasBeenMoved: '{{title}}-ը տեղափոխվել է {{folderName}}-ում', itemHasBeenMovedToRoot: '«{{title}}» տեղափոխվել է արմատային պանակ։', itemsMovedToFolder: '{{title}} տեղափոխվեց {{folderName}}', @@ -206,6 +216,17 @@ export const hyTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Դուք պատրաստվում եք ջնջել {{count}} {{label}}', aboutToDeleteCount_one: 'Դուք պատրաստվում եք ջնջել {{count}} {{label}}', aboutToDeleteCount_other: 'Դուք պատրաստվում եք ջնջել {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Դուք պատրաստ եք հաստատականորեն ջնջել {{label}} <1>{{title}}։ Արդյոք վստահ եք:', + aboutToPermanentlyDeleteTrash: + 'Դուք պատրաստ եք ընդմիշտ ջնջել <0>{{count}} <1>{{label}} աղբավաղուց։ Վստահ եք։', + aboutToRestore: 'Մոտեցել եք {{label}} <1>{{title}} վերականգնելուն։ Համոզված ե՞ք։', + aboutToRestoreAsDraft: + 'Ձեր մտադրությունը վերականգնել է {{label}} <1>{{title}} նախագծի որպես: Վստահ եք:', + aboutToRestoreAsDraftCount: 'Դուք պատրաստ եք վերականգնել {{count}} {{label}} նախագծի որպես', + aboutToRestoreCount: 'Դուք պատրաստ եք վերականգնել {{count}} {{label}}', + aboutToTrash: 'Դուք պատրաստ եք տեղափոխել {{label}} <1>{{title}}-ը աղբականջը։ Վստահ եք։', + aboutToTrashCount: 'Դուք պատրաստ եք տեղափոխել {{count}} {{label}} աղբամանը', addBelow: 'Ավելացնել ներքևում', addFilter: 'Ավելացնել ֆիլտր', adminTheme: 'Կառավարման թեմա', @@ -222,6 +243,8 @@ export const hyTranslations: DefaultTranslationsObject = { cancel: 'Չեղարկել', changesNotSaved: 'Ձեր փոփոխությունները չեն պահպանվել։ Եթե հիմա հեռանաք, կկորցնեք չպահպանված փոփոխությունները։', + clear: + 'Հիմնական տեքստի իմաստը պետք է պահպանվի Payload կոնտեքստի մեջ: Այս այս այստեղ են հաճախակի', clearAll: 'Մաքրել բոլորը', close: 'Փակել', collapse: 'Փակել', @@ -239,9 +262,12 @@ export const hyTranslations: DefaultTranslationsObject = { 'Սա կհեռացնի գոյություն ունեցող ինդեքսները և կվերաինդեքսավորի փաստաթղթերը {{collections}} հավաքածուներում։', confirmReindexDescriptionAll: 'Սա կհեռացնի գոյություն ունեցող ինդեքսները և կվերաինդեքսավորի փաստաթղթերը բոլոր հավաքածուներում։', + confirmRestoration: 'Հաստատեք վերականգնումը', copied: 'Պատճենված', copy: 'Պատճենել', + copyField: 'Պատճենել դաշտը', copying: 'Պատճենվում է', + copyRow: 'Պատճենել տողը', copyWarning: 'Դուք պատրաստվում եք վերագրել {{to}}-ը {{from}}-ով {{label}} {{title}}-ի համար։ Համոզվա՞ծ եք։', create: 'Ստեղծել', @@ -257,13 +283,17 @@ export const hyTranslations: DefaultTranslationsObject = { dark: 'Մուգ', dashboard: 'Վահանակ', delete: 'Ջնջել', + deleted: 'Ջնջված', + deletedAt: 'Ջնջված է', deletedCountSuccessfully: '{{count}} {{label}} հաջողությամբ ջնջված է։', deletedSuccessfully: 'Հաջողությամբ ջնջված է։', + deletePermanently: 'Բաց թողեք աղբատուփը և հեռացրեք հավերժ:', deleting: 'Ջնջվում է...', depth: 'Խորություն', descending: 'Նվազող', deselectAllRows: 'Հանել բոլոր տողերի ընտրությունը', document: 'Փաստաթուղթ', + documentIsTrashed: 'Այս {{label}}-ն աղբարկղած է և հասանելի է միայն ընթերցման համար։', documentLocked: 'Փաստաթուղթը կողպված է', documents: 'Փաստաթղթեր', duplicate: 'Կրկնօրինակել', @@ -279,6 +309,8 @@ export const hyTranslations: DefaultTranslationsObject = { editLabel: 'Խմբագրել {{label}}', email: 'Էլ. փոստ', emailAddress: 'Էլ. փոստի հասցե', + emptyTrash: 'Մաքրել աղբաղեցույցը', + emptyTrashLabel: 'Դատարկել {{label}} աղբուկը', enterAValue: 'Մուտքագրեք արժեք', error: 'Սխալ', errors: 'Սխալներ', @@ -291,6 +323,7 @@ export const hyTranslations: DefaultTranslationsObject = { filterWhere: 'Ֆիլտրել {{label}}-ը, որտեղ', globals: 'Համընդհանուրներ', goBack: 'Հետ գնալ', + groupByLabel: 'Խմբավորել {{label}}-ով', import: 'Ներմուծում', isEditing: 'խմբագրում է', item: 'տարր', @@ -326,6 +359,7 @@ export const hyTranslations: DefaultTranslationsObject = { '{{label}}-ը չի գտնվել։ Կա՛մ դեռևս {{label}} չկա, կա՛մ ոչ մեկը չի համապատասխանում վերևում նշված ֆիլտրերին։', notFound: 'Չի գտնվել', nothingFound: 'Ոչինչ չի գտնվել', + noTrashResults: 'Ոչ մի {{label}} աղբարկղում:', noUpcomingEventsScheduled: 'Իրադարձություններ նախատեսված չեն։', noValue: 'Արժեք չկա', of: 'ի', @@ -336,7 +370,11 @@ export const hyTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Վերագրել գոյություն ունեցող դաշտի տվյալները', pageNotFound: 'Էջը չի գտնվել', password: 'Գաղտնաբառ', + pasteField: 'Տեղադրել դաշտը', + pasteRow: 'Տեղադրել տողը', payloadSettings: 'Payload-ի կարգավորումներ', + permanentlyDelete: 'Մշտականությամբ Ջնջել', + permanentlyDeletedCountSuccessfully: '{{count}} {{label}}-ը հաստատապես ջնջվել է հաջողակ:', perPage: 'Էջում՝ {{limit}}', previous: 'Նախորդ', reindex: 'Վերաինդեքսավորել', @@ -348,6 +386,11 @@ export const hyTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Սա կվերակայի Ձեր բոլոր նախընտրությունները դեպի լռելյայն կարգավորումներ։', resettingPreferences: 'Նախընտրությունները վերակայվում են', + restore: 'Վերականգնել', + restoreAsPublished: 'Վերականգնել որպես հրատարակված տարբերակ', + restoredCountSuccessfully: '{{count}} {{label}} հաջողությամբ վերականգնվեց:', + restoring: + 'Payload-i original teksti mijocov achqers, nran avelacnum e urish Payload nshanakutyunner, oronq kangnvec en specifik texer:\n\n- Zuygh: Zuygh e ayd dokumnerneri jmum, oronq kanen arden mek ban u zoracnum en Payload-i nersum u bavararum.\n- Dasht: Dasht e ayd zuyghi bnutyun dokumneri mej. Dashter pahpanum en bnutyunneri banakanutyuny u texy, ete oronq sa patrastvi dokumentnerum.\n- Dokument: Dokument e mi', row: 'Տող', rows: 'Տողեր', save: 'Պահպանել', @@ -378,6 +421,10 @@ export const hyTranslations: DefaultTranslationsObject = { time: 'Ժամ', timezone: 'Ժամային գոտի', titleDeleted: '{{label}} "{{title}}" հաջողությամբ ջնջվել է։', + titleRestored: '"{{label}}" "{{title}}" հաջողությամբ վերականգնվել է։', + titleTrashed: '«{{label}}» «{{title}}» տեղափոխվել է աղբարկղում:', + trash: 'աղբ', + trashedCountSuccessfully: '{{count}} {{label}} տեղափոխվել է աղբարկղում։', true: 'Ճիշտ', unauthorized: 'Չթույլատրված', unsavedChanges: @@ -398,6 +445,7 @@ export const hyTranslations: DefaultTranslationsObject = { username: 'Օգտանուն', users: 'Օգտատերեր', value: 'Արժեք', + viewing: 'Դիտում', viewReadOnly: '«Միայն կարդալու» ռեժիմ', welcome: 'Բարի գալուստ', yes: 'Այո', @@ -527,6 +575,7 @@ export const hyTranslations: DefaultTranslationsObject = { noRowsFound: ' Ոչ մի {{label}} չի գտնվել', noRowsSelected: 'Ոչ մի {{label}} ընտրված չէ', preview: 'Նախադիտում', + previouslyDraft: 'Նախկինում Սևագիր', previouslyPublished: 'Նախկինում հրապարակված', previousVersion: 'Նախորդ Տարբերակ', problemRestoringVersion: 'Այս տարբերակը վերականգնելու ժամանակ խնդիր է առաջացել', diff --git a/packages/translations/src/languages/id.ts b/packages/translations/src/languages/id.ts new file mode 100644 index 0000000000..4d14a50f44 --- /dev/null +++ b/packages/translations/src/languages/id.ts @@ -0,0 +1,620 @@ +import { title } from 'process' + +import type { Language } from '../types.js' + +export const idTranslations = { + authentication: { + account: 'Akun', + accountOfCurrentUser: 'Akun pengguna saat ini', + accountVerified: 'Akun berhasil diverifikasi.', + alreadyActivated: 'Sudah Diaktifkan', + alreadyLoggedIn: 'Sudah masuk', + apiKey: 'API Key', + authenticated: 'Terautentikasi', + backToLogin: 'Kembali ke halaman masuk', + beginCreateFirstUser: 'Untuk memulai, buat pengguna pertama Anda.', + changePassword: 'Ubah Kata Sandi', + checkYourEmailForPasswordReset: + 'Jika alamat email dikaitkan dengan sebuah akun, Anda akan segera menerima instruksi untuk mengatur ulang kata sandi Anda. Silakan periksa folder spam atau junk mail Anda jika Anda tidak melihat email di kotak masuk Anda.', + confirmGeneration: 'Konfirmasi Pembuatan', + confirmPassword: 'Konfirmasi Kata Sandi', + createFirstUser: 'Buat pengguna pertama', + emailNotValid: 'Email yang diberikan tidak valid', + emailOrUsername: 'Email atau Nama Pengguna', + emailSent: 'Email Terkirim', + emailVerified: 'Email berhasil diverifikasi.', + enableAPIKey: 'Aktifkan API Key', + failedToUnlock: 'Gagal membuka kunci', + forceUnlock: 'Paksa Buka Kunci', + forgotPassword: 'Lupa Kata Sandi', + forgotPasswordEmailInstructions: + 'Silakan masukkan email Anda di bawah ini. Anda akan menerima pesan email dengan instruksi tentang cara mengatur ulang kata sandi Anda.', + forgotPasswordUsernameInstructions: + 'Silakan masukkan nama pengguna Anda di bawah ini. Instruksi tentang cara mengatur ulang kata sandi Anda akan dikirim ke alamat email yang terkait dengan nama pengguna Anda.', + usernameNotValid: 'Nama pengguna yang diberikan tidak valid', + + forgotPasswordQuestion: 'Lupa kata sandi?', + generate: 'Buat', + generateNewAPIKey: 'Buat kunci API baru', + generatingNewAPIKeyWillInvalidate: + 'Membuat API Key baru akan <1>membatalkan kunci sebelumnya. Apakah Anda yakin ingin melanjutkan?', + lockUntil: 'Kunci Hingga', + logBackIn: 'Masuk kembali', + loggedIn: 'Untuk masuk dengan pengguna lain, Anda harus <0>keluar terlebih dahulu.', + loggedInChangePassword: + 'Untuk mengubah kata sandi Anda, buka <0>akun Anda dan edit kata sandi Anda di sana.', + loggedOutInactivity: 'Anda telah dikeluarkan karena tidak ada aktivitas.', + loggedOutSuccessfully: 'Anda telah berhasil keluar.', + loggingOut: 'Mengeluarkan...', + login: 'Masuk', + loginAttempts: 'Upaya Masuk', + loginUser: 'Masuk pengguna', + loginWithAnotherUser: + 'Untuk masuk dengan pengguna lain, Anda harus <0>keluar terlebih dahulu.', + logOut: 'Keluar', + logout: 'Keluar', + logoutSuccessful: 'Berhasil keluar.', + logoutUser: 'Keluar pengguna', + newAccountCreated: + 'Akun baru telah dibuat untuk Anda agar dapat mengakses {{serverURL}} Silakan klik tautan berikut atau tempel URL di bawah ini ke browser Anda untuk memverifikasi email Anda: {{verificationURL}}
Setelah memverifikasi email, Anda akan dapat masuk dengan sukses.', + newAPIKeyGenerated: 'API Key Baru Telah Dibuat.', + newPassword: 'Kata Sandi Baru', + passed: 'Autentikasi Lulus', + passwordResetSuccessfully: 'Kata sandi berhasil diatur ulang.', + resetPassword: 'Atur Ulang Kata Sandi', + resetPasswordExpiration: 'Masa Berlaku Token Atur Ulang Kata Sandi', + resetPasswordToken: 'Token Atur Ulang Kata Sandi', + resetYourPassword: 'Atur Ulang Kata Sandi Anda', + stayLoggedIn: 'Tetap masuk', + successfullyRegisteredFirstUser: 'Berhasil mendaftarkan pengguna pertama.', + successfullyUnlocked: 'Berhasil dibuka kuncinya', + tokenRefreshSuccessful: 'Penyegaran token berhasil.', + unableToVerify: 'Tidak Dapat Memverifikasi', + username: 'Nama Pengguna', + verified: 'Terverifikasi', + verifiedSuccessfully: 'Berhasil Diverifikasi', + verify: 'Verifikasi', + verifyUser: 'Verifikasi Pengguna', + verifyYourEmail: 'Verifikasi email Anda', + youAreInactive: + 'Anda sudah beberapa saat tidak aktif dan akan segera dikeluarkan secara otomatis demi keamanan Anda. Apakah Anda ingin tetap masuk?', + youAreReceivingResetPassword: + 'Anda menerima ini karena Anda (atau orang lain) telah meminta pengaturan ulang kata sandi untuk akun Anda. Silakan klik tautan berikut, atau tempel ini ke browser Anda untuk menyelesaikan proses:', + youDidNotRequestPassword: + 'Jika Anda tidak meminta ini, harap abaikan email ini dan kata sandi Anda akan tetap tidak berubah.', + }, + error: { + accountAlreadyActivated: 'Akun ini sudah diaktifkan.', + autosaving: 'Terjadi masalah saat menyimpan otomatis dokumen ini.', + correctInvalidFields: 'Harap perbaiki isian yang tidak valid.', + deletingFile: 'Terjadi kesalahan saat menghapus file.', + deletingTitle: + 'Terjadi kesalahan saat menghapus {{title}}. Harap periksa koneksi Anda dan coba lagi.', + documentNotFound: + 'Dokumen dengan ID {{id}} tidak dapat ditemukan. Mungkin telah dihapus atau tidak pernah ada, atau Anda mungkin tidak memiliki akses ke sana.', + emailOrPasswordIncorrect: 'Email atau kata sandi yang diberikan salah.', + followingFieldsInvalid_one: 'Isian berikut tidak valid:', + followingFieldsInvalid_other: 'Isian-isian berikut tidak valid:', + incorrectCollection: 'Koleksi Salah', + insufficientClipboardPermissions: + 'Akses papan klip ditolak. Silakan periksa izin papan klip Anda.', + invalidClipboardData: 'Data papan klip tidak valid.', + invalidFileType: 'Jenis file tidak valid', + invalidFileTypeValue: 'Jenis file tidak valid: {{value}}', + invalidRequestArgs: 'Argumen yang diteruskan dalam permintaan tidak valid: {{args}}', + loadingDocument: 'Terjadi masalah saat memuat dokumen dengan ID {{id}}.', + localesNotSaved_one: 'Lokal berikut tidak dapat disimpan:', + localesNotSaved_other: 'Lokal-lokal berikut tidak dapat disimpan:', + logoutFailed: 'Gagal keluar.', + missingEmail: 'Email tidak ada.', + missingIDOfDocument: 'ID dokumen yang akan diperbarui tidak ada.', + missingIDOfVersion: 'ID versi tidak ada.', + missingRequiredData: 'Data yang diperlukan tidak ada.', + noFilesUploaded: 'Tidak ada file yang diunggah.', + noMatchedField: 'Tidak ada isian yang cocok ditemukan untuk "{{label}}"', + notAllowedToAccessPage: 'Anda tidak diizinkan mengakses halaman ini.', + notAllowedToPerformAction: 'Anda tidak diizinkan melakukan tindakan ini.', + notFound: 'Data yang diminta tidak ditemukan.', + noUser: 'Tidak Ada Pengguna', + previewing: 'Terjadi masalah saat mempratinjau dokumen ini.', + problemUploadingFile: 'Terjadi masalah saat mengunggah file.', + restoringTitle: + 'Terjadi kesalahan saat memulihkan {{title}}. Harap periksa koneksi Anda dan coba lagi.', + tokenInvalidOrExpired: 'Token tidak valid atau telah kedaluwarsa.', + tokenNotProvided: 'Token tidak disediakan.', + unableToCopy: 'Tidak dapat menyalin.', + unableToDeleteCount: 'Tidak dapat menghapus {{count}} dari {{total}} {{label}}.', + unableToReindexCollection: + 'Kesalahan mengindeks ulang koleksi {{collection}}. Operasi dibatalkan.', + unableToUpdateCount: 'Tidak dapat memperbarui {{count}} dari {{total}} {{label}}.', + unauthorized: 'Tidak sah, Anda harus masuk untuk membuat permintaan ini.', + unauthorizedAdmin: 'Tidak sah, pengguna ini tidak memiliki akses ke panel admin.', + unknown: 'Terjadi kesalahan yang tidak diketahui.', + unPublishingDocument: 'Terjadi masalah saat membatalkan publikasi dokumen ini.', + unspecific: 'Terjadi kesalahan.', + unverifiedEmail: 'Harap verifikasi email Anda sebelum masuk.', + userEmailAlreadyRegistered: 'Pengguna dengan email yang diberikan sudah terdaftar.', + userLocked: 'Pengguna ini terkunci karena terlalu banyak upaya masuk yang gagal.', + usernameAlreadyRegistered: 'Pengguna dengan nama pengguna yang diberikan sudah terdaftar.', + usernameOrPasswordIncorrect: 'Nama pengguna atau kata sandi yang diberikan salah.', + valueMustBeUnique: 'Nilai harus unik', + verificationTokenInvalid: 'Token verifikasi tidak valid.', + }, + fields: { + addLabel: 'Tambah {{label}}', + addLink: 'Tambah Tautan', + addNew: 'Tambah baru', + addNewLabel: 'Tambah {{label}} baru', + addRelationship: 'Tambah Hubungan', + addUpload: 'Tambah Unggahan', + block: 'blok', + blocks: 'blok', + blockType: 'Tipe Blok', + chooseBetweenCustomTextOrDocument: + 'Pilih antara memasukkan URL teks kustom atau menautkan ke dokumen lain.', + chooseDocumentToLink: 'Pilih dokumen untuk ditautkan', + chooseFromExisting: 'Pilih dari yang sudah ada', + chooseLabel: 'Pilih {{label}}', + collapseAll: 'Ciutkan Semua', + customURL: 'URL Kustom', + editLabelData: 'Edit data {{label}}', + editLink: 'Edit Tautan', + editRelationship: 'Edit Hubungan', + enterURL: 'Masukkan URL', + internalLink: 'Tautan Internal', + itemsAndMore: '{{items}} dan {{count}} lainnya', + labelRelationship: 'Hubungan {{label}}', + latitude: 'Lintang', + linkedTo: 'Tertaut ke <0>{{label}}', + linkType: 'Jenis Tautan', + longitude: 'Bujur', + newLabel: '{{label}} Baru', + openInNewTab: 'Buka di tab baru', + passwordsDoNotMatch: 'Kata sandi tidak cocok.', + relatedDocument: 'Dokumen Terkait', + relationTo: 'Hubungan Ke', + removeRelationship: 'Hapus Hubungan', + removeUpload: 'Hapus Unggahan', + saveChanges: 'Simpan perubahan', + searchForBlock: 'Cari blok', + selectExistingLabel: 'Pilih {{label}} yang ada', + selectFieldsToEdit: 'Pilih isian untuk diedit', + showAll: 'Tampilkan Semua', + swapRelationship: 'Tukar Hubungan', + swapUpload: 'Tukar Unggahan', + textToDisplay: 'Teks untuk ditampilkan', + toggleBlock: 'Beralih blok', + uploadNewLabel: 'Unggah {{label}} baru', + }, + folder: { + browseByFolder: 'Jelajahi berdasarkan Folder', + byFolder: 'Berdasarkan Folder', + deleteFolder: 'Hapus Folder', + folderName: 'Nama Folder', + folders: 'Folder', + folderTypeDescription: 'Pilih jenis dokumen koleksi yang diizinkan di folder ini.', + itemHasBeenMoved: '{{title}} telah dipindahkan ke {{folderName}}', + itemHasBeenMovedToRoot: '{{title}} telah dipindahkan ke folder root', + itemsMovedToFolder: '{{title}} dipindahkan ke {{folderName}}', + itemsMovedToRoot: '{{title}} dipindahkan ke folder root', + moveFolder: 'Pindahkan Folder', + moveItemsToFolderConfirmation: + 'Anda akan memindahkan <1>{{count}} {{label}} ke <2>{{toFolder}}. Apakah Anda yakin?', + moveItemsToRootConfirmation: + 'Anda akan memindahkan <1>{{count}} {{label}} ke folder root. Apakah Anda yakin?', + moveItemToFolderConfirmation: + 'Anda akan memindahkan <1>{{title}} ke <2>{{toFolder}}. Apakah Anda yakin?', + moveItemToRootConfirmation: + 'Anda akan memindahkan <1>{{title}} ke folder root. Apakah Anda yakin?', + movingFromFolder: 'Memindahkan {{title}} dari {{fromFolder}}', + newFolder: 'Folder Baru', + noFolder: 'Tidak Ada Folder', + renameFolder: 'Ganti Nama Folder', + searchByNameInFolder: 'Cari berdasarkan Nama di {{folderName}}', + selectFolderForItem: 'Pilih folder untuk {{title}}', + }, + general: { + name: 'Nama', + aboutToDelete: 'Anda akan menghapus {{label}} <1>{{title}}. Apakah Anda yakin?', + aboutToDeleteCount_many: 'Anda akan menghapus {{count}} {{label}}', + aboutToDeleteCount_one: 'Anda akan menghapus {{count}} {{label}}', + aboutToDeleteCount_other: 'Anda akan menghapus {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Anda akan menghapus secara permanen {{label}} <1>{{title}}. Apakah Anda yakin?', + aboutToPermanentlyDeleteTrash: + 'Anda akan menghapus secara permanen <0>{{count}} <1>{{label}} dari tempat sampah. Apakah Anda yakin?', + aboutToRestore: 'Anda akan memulihkan {{label}} <1>{{title}}. Apakah Anda yakin?', + aboutToRestoreAsDraft: + 'Anda akan memulihkan {{label}} <1>{{title}} sebagai draf. Apakah Anda yakin?', + aboutToRestoreAsDraftCount: 'Anda akan memulihkan {{count}} {{label}} sebagai draf', + aboutToRestoreCount: 'Anda akan memulihkan {{count}} {{label}}', + aboutToTrash: + 'Anda akan memindahkan {{label}} <1>{{title}} ke tempat sampah. Apakah Anda yakin?', + aboutToTrashCount: 'Anda akan memindahkan {{count}} {{label}} ke tempat sampah', + addBelow: 'Tambah di Bawah', + addFilter: 'Tambah Filter', + adminTheme: 'Tema Admin', + all: 'Semua', + allCollections: 'Semua Koleksi', + allLocales: 'Semua lokal', + and: 'Dan', + anotherUser: 'Pengguna lain', + anotherUserTakenOver: 'Pengguna lain telah mengambil alih pengeditan dokumen ini.', + applyChanges: 'Terapkan Perubahan', + ascending: 'Naik', + automatic: 'Otomatis', + backToDashboard: 'Kembali ke Dasbor', + cancel: 'Batal', + changesNotSaved: + 'Perubahan Anda belum disimpan. Jika Anda pergi sekarang, Anda akan kehilangan perubahan Anda.', + clear: 'Hapus', + clearAll: 'Hapus Semua', + close: 'Tutup', + collapse: 'Ciutkan', + collections: 'Koleksi', + columns: 'Kolom', + columnToSort: 'Kolom untuk Diurutkan', + confirm: 'Konfirmasi', + confirmCopy: 'Konfirmasi salin', + confirmDeletion: 'Konfirmasi penghapusan', + confirmDuplication: 'Konfirmasi duplikasi', + confirmMove: 'Konfirmasi pindah', + confirmReindex: 'Indeks ulang semua {{collections}}?', + confirmReindexAll: 'Indeks ulang semua koleksi?', + confirmReindexDescription: + 'Ini akan menghapus indeks yang ada dan mengindeks ulang dokumen di koleksi {{collections}}.', + confirmReindexDescriptionAll: + 'Ini akan menghapus indeks yang ada dan mengindeks ulang dokumen di semua koleksi.', + confirmRestoration: 'Konfirmasi pemulihan', + copied: 'Disalin', + copy: 'Salin', + copyField: 'Salin Isian', + copying: 'Menyalin', + copyRow: 'Salin Baris', + copyWarning: + 'Anda akan menimpa {{to}} dengan {{from}} untuk {{label}} {{title}}. Apakah Anda yakin?', + create: 'Buat', + created: 'Dibuat', + createdAt: 'Dibuat Pada', + createNew: 'Buat Baru', + createNewLabel: 'Buat {{label}} baru', + creating: 'Membuat', + creatingNewLabel: 'Membuat {{label}} baru', + currentlyEditing: + 'sedang mengedit dokumen ini. Jika Anda mengambil alih, mereka akan diblokir untuk melanjutkan pengeditan, dan mungkin juga kehilangan perubahan yang belum disimpan.', + custom: 'Kustom', + dark: 'Gelap', + dashboard: 'Dasbor', + delete: 'Hapus', + deleted: 'Dihapus', + deletedAt: 'Dihapus Pada', + deletedCountSuccessfully: 'Berhasil menghapus {{count}} {{label}}.', + deletedSuccessfully: 'Berhasil dihapus.', + deletePermanently: 'Lewati tempat sampah dan hapus secara permanen', + deleting: 'Menghapus...', + depth: 'Kedalaman', + descending: 'Turun', + deselectAllRows: 'Batal pilih semua baris', + document: 'Dokumen', + documentIsTrashed: '{{label}} ini ada di tempat sampah dan bersifat hanya-baca.', + documentLocked: 'Dokumen terkunci', + documents: 'Dokumen', + duplicate: 'Duplikat', + duplicateWithoutSaving: 'Duplikat tanpa menyimpan perubahan', + edit: 'Edit', + editAll: 'Edit semua', + editedSince: 'Diedit sejak', + editing: 'Mengedit', + editingLabel_many: 'Mengedit {{count}} {{label}}', + editingLabel_one: 'Mengedit {{count}} {{label}}', + editingLabel_other: 'Mengedit {{count}} {{label}}', + editingTakenOver: 'Pengeditan diambil alih', + editLabel: 'Edit {{label}}', + email: 'Email', + emailAddress: 'Alamat Email', + emptyTrash: 'Kosongkan tempat sampah', + emptyTrashLabel: 'Kosongkan tempat sampah {{label}}', + enterAValue: 'Masukkan nilai', + error: 'Kesalahan', + errors: 'Kesalahan', + exitLivePreview: 'Keluar dari Pratinjau Langsung', + export: 'Ekspor', + fallbackToDefaultLocale: 'Kembali ke lokal default', + false: 'Salah', + filter: 'Filter', + filters: 'Filter', + filterWhere: 'Filter {{label}} di mana', + globals: 'Global', + goBack: 'Kembali', + groupByLabel: 'Kelompokkan berdasarkan {{label}}', + import: 'Impor', + isEditing: 'sedang mengedit', + item: 'item', + items: 'item', + language: 'Bahasa', + lastModified: 'Terakhir Diubah', + leaveAnyway: 'Tetap pergi', + leaveWithoutSaving: 'Pergi tanpa menyimpan', + light: 'Terang', + livePreview: 'Pratinjau Langsung', + loading: 'Memuat', + locale: 'Lokal', + locales: 'Lokal', + menu: 'Menu', + moreOptions: 'Opsi lainnya', + move: 'Pindah', + moveConfirm: + 'Anda akan memindahkan {{count}} {{label}} ke <1>{{destination}}. Apakah Anda yakin?', + moveCount: 'Pindahkan {{count}} {{label}}', + moveDown: 'Pindah ke Bawah', + moveUp: 'Pindah ke Atas', + moving: 'Memindahkan', + movingCount: 'Memindahkan {{count}} {{label}}', + newPassword: 'Kata Sandi Baru', + next: 'Berikutnya', + no: 'Tidak', + noDateSelected: 'Tidak ada tanggal yang dipilih', + noFiltersSet: 'Tidak ada filter yang diatur', + noLabel: '', + none: 'Tidak ada', + noOptions: 'Tidak ada opsi', + noResults: + 'Tidak ada {{label}} yang ditemukan. Entah belum ada {{label}} atau tidak ada yang cocok dengan filter yang Anda tentukan di atas.', + notFound: 'Tidak Ditemukan', + nothingFound: 'Tidak ada yang ditemukan', + noTrashResults: 'Tidak ada {{label}} di tempat sampah.', + noUpcomingEventsScheduled: 'Tidak ada acara mendatang yang dijadwalkan.', + noValue: 'Tidak ada nilai', + of: 'dari', + only: 'Hanya', + open: 'Buka', + or: 'Atau', + order: 'Urutan', + overwriteExistingData: 'Timpa data isian yang ada', + pageNotFound: 'Halaman tidak ditemukan', + password: 'Kata Sandi', + pasteField: 'Tempel Isian', + pasteRow: 'Tempel Baris', + payloadSettings: 'Pengaturan Payload', + permanentlyDelete: 'Hapus Secara Permanen', + permanentlyDeletedCountSuccessfully: 'Berhasil menghapus secara permanen {{count}} {{label}}.', + perPage: 'Per Halaman: {{limit}}', + previous: 'Sebelumnya', + reindex: 'Indeks Ulang', + reindexingAll: 'Mengindeks ulang semua {{collections}}.', + remove: 'Hapus', + rename: 'Ganti Nama', + reset: 'Atur Ulang', + resetPreferences: 'Atur Ulang Preferensi', + resetPreferencesDescription: + 'Ini akan mengatur ulang semua preferensi Anda ke pengaturan default.', + resettingPreferences: 'Mengatur Ulang Preferensi.', + restore: 'Pulihkan', + restoreAsPublished: 'Pulihkan sebagai versi yang diterbitkan', + restoredCountSuccessfully: 'Berhasil memulihkan {{count}} {{label}}.', + restoring: 'Memulihkan...', + row: 'Baris', + rows: 'Baris', + save: 'Simpan', + saving: 'Menyimpan...', + schedulePublishFor: 'Jadwalkan publikasi untuk {{title}}', + searchBy: 'Cari berdasarkan {{label}}', + select: 'Pilih', + selectAll: 'Pilih semua {{count}} {{label}}', + selectAllRows: 'Pilih semua baris', + selectedCount: '{{count}} {{label}} dipilih', + selectLabel: 'Pilih {{label}}', + selectValue: 'Pilih nilai', + showAllLabel: 'Tampilkan semua {{label}}', + sorryNotFound: 'Maaf—tidak ada yang sesuai dengan permintaan Anda.', + sort: 'Urutkan', + sortByLabelDirection: 'Urutkan berdasarkan {{label}} {{direction}}', + stayOnThisPage: 'Tetap di halaman ini', + submissionSuccessful: 'Pengiriman Berhasil.', + submit: 'Kirim', + submitting: 'Mengirim...', + success: 'Sukses', + successfullyCreated: '{{label}} berhasil dibuat.', + successfullyDuplicated: '{{label}} berhasil diduplikasi.', + successfullyReindexed: + 'Berhasil mengindeks ulang {{count}} dari {{total}} dokumen dari {{collections}}', + takeOver: 'Ambil alih', + thisLanguage: 'Bahasa Indonesia', + time: 'Waktu', + timezone: 'Zona Waktu', + titleDeleted: '{{label}} "{{title}}" berhasil dihapus.', + titleRestored: '{{label}} "{{title}}" berhasil dipulihkan.', + titleTrashed: '{{label}} "{{title}}" dipindahkan ke tempat sampah.', + trash: 'Tempat Sampah', + trashedCountSuccessfully: '{{count}} {{label}} dipindahkan ke tempat sampah.', + true: 'Benar', + unauthorized: 'Tidak Sah', + unsavedChanges: + 'Anda memiliki perubahan yang belum disimpan. Simpan atau buang sebelum melanjutkan.', + unsavedChangesDuplicate: + 'Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin melanjutkan untuk menduplikasi?', + untitled: 'Tanpa Judul', + upcomingEvents: 'Acara Mendatang', + updatedAt: 'Diperbarui Pada', + updatedCountSuccessfully: 'Berhasil memperbarui {{count}} {{label}}.', + updatedLabelSuccessfully: 'Berhasil memperbarui {{label}}.', + updatedSuccessfully: 'Berhasil diperbarui.', + updateForEveryone: 'Perbarui untuk semua orang', + updating: 'Memperbarui', + uploading: 'Mengunggah', + uploadingBulk: 'Mengunggah {{current}} dari {{total}}', + user: 'Pengguna', + username: 'Nama Pengguna', + users: 'Pengguna', + value: 'Nilai', + viewing: 'Melihat', + viewReadOnly: 'Lihat hanya-baca', + welcome: 'Selamat Datang', + yes: 'Ya', + }, + localization: { + cannotCopySameLocale: 'Tidak dapat menyalin ke lokal yang sama', + copyFrom: 'Salin dari', + copyFromTo: 'Menyalin dari {{from}} ke {{to}}', + copyTo: 'Salin ke', + copyToLocale: 'Salin ke lokal', + localeToPublish: 'Lokal untuk dipublikasikan', + selectLocaleToCopy: 'Pilih lokal untuk disalin', + }, + operators: { + contains: 'mengandung', + equals: 'sama dengan', + exists: 'ada', + intersects: 'bersinggungan', + isGreaterThan: 'lebih besar dari', + isGreaterThanOrEqualTo: 'lebih besar dari atau sama dengan', + isIn: 'berada di dalam', + isLessThan: 'lebih kecil dari', + isLessThanOrEqualTo: 'lebih kecil dari atau sama dengan', + isLike: 'seperti', + isNotEqualTo: 'tidak sama dengan', + isNotIn: 'tidak berada di dalam', + isNotLike: 'tidak seperti', + near: 'dekat', + within: 'di dalam', + }, + upload: { + addFile: 'Tambah file', + addFiles: 'Tambah file', + bulkUpload: 'Unggah Massal', + crop: 'Pangkas', + cropToolDescription: + 'Seret sudut area yang dipilih, gambar area baru atau sesuaikan nilai di bawah ini.', + download: 'Unduh', + dragAndDrop: 'Seret dan lepas file', + dragAndDropHere: 'atau seret dan lepas file di sini', + editImage: 'Edit Gambar', + fileName: 'Nama File', + fileSize: 'Ukuran File', + filesToUpload: 'File untuk Diunggah', + fileToUpload: 'File untuk Diunggah', + focalPoint: 'Titik Fokus', + focalPointDescription: + 'Seret titik fokus langsung pada pratinjau atau sesuaikan nilai di bawah ini.', + height: 'Tinggi', + lessInfo: 'Info lebih sedikit', + moreInfo: 'Info lebih lanjut', + noFile: 'Tidak ada file', + pasteURL: 'Tempel URL', + previewSizes: 'Ukuran Pratinjau', + selectCollectionToBrowse: 'Pilih Koleksi untuk Dijelajahi', + selectFile: 'Pilih file', + setCropArea: 'Atur area pangkas', + setFocalPoint: 'Atur titik fokus', + sizes: 'Ukuran', + sizesFor: 'Ukuran untuk {{label}}', + width: 'Lebar', + }, + validation: { + emailAddress: 'Harap masukkan alamat email yang valid.', + enterNumber: 'Harap masukkan nomor yang valid.', + fieldHasNo: 'Isian ini tidak memiliki {{label}}', + greaterThanMax: '{{value}} lebih besar dari {{label}} maksimum yang diizinkan yaitu {{max}}.', + invalidInput: 'Isian ini memiliki masukan yang tidak valid.', + invalidSelection: 'Isian ini memiliki pilihan yang tidak valid.', + invalidSelections: 'Isian ini memiliki pilihan tidak valid berikut:', + lessThanMin: '{{value}} lebih kecil dari {{label}} minimum yang diizinkan yaitu {{min}}.', + limitReached: 'Batas tercapai, hanya {{max}} item yang dapat ditambahkan.', + longerThanMin: 'Nilai ini harus lebih panjang dari panjang minimum {{minLength}} karakter.', + notValidDate: '"{{value}}" bukan tanggal yang valid.', + required: 'Isian ini wajib diisi.', + requiresAtLeast: 'Isian ini membutuhkan setidaknya {{count}} {{label}}.', + requiresNoMoreThan: 'Isian ini membutuhkan tidak lebih dari {{count}} {{label}}.', + requiresTwoNumbers: 'Isian ini membutuhkan dua angka.', + shorterThanMax: 'Nilai ini harus lebih pendek dari panjang maksimum {{maxLength}} karakter.', + timezoneRequired: 'Zona waktu diperlukan.', + trueOrFalse: 'Isian ini hanya bisa sama dengan benar atau salah.', + username: + 'Harap masukkan nama pengguna yang valid. Dapat berisi huruf, angka, tanda hubung, titik, dan garis bawah.', + validUploadID: 'Isian ini bukan ID unggahan yang valid.', + }, + version: { + type: 'Tipe', + aboutToPublishSelection: + 'Anda akan mempublikasikan semua {{label}} dalam pilihan. Apakah Anda yakin?', + aboutToRestore: 'Anda akan memulihkan dokumen {{label}} ini ke keadaan pada {{versionDate}}.', + aboutToRestoreGlobal: 'Anda akan memulihkan global {{label}} ke keadaan pada {{versionDate}}.', + aboutToRevertToPublished: + 'Anda akan mengembalikan perubahan dokumen ini ke keadaan yang dipublikasikan. Apakah Anda yakin?', + aboutToUnpublish: 'Anda akan membatalkan publikasi dokumen ini. Apakah Anda yakin?', + aboutToUnpublishSelection: + 'Anda akan membatalkan publikasi semua {{label}} dalam pilihan. Apakah Anda yakin?', + autosave: 'Simpan Otomatis', + autosavedSuccessfully: 'Berhasil disimpan otomatis.', + autosavedVersion: 'Versi simpan otomatis', + changed: 'Berubah', + changedFieldsCount_one: '{{count}} Isian berubah', + changedFieldsCount_other: '{{count}} Isian berubah', + compareVersion: 'Bandingkan versi dengan:', + compareVersions: 'Bandingkan Versi', + comparingAgainst: 'Membandingkan dengan', + confirmPublish: 'Konfirmasi publikasi', + confirmRevertToSaved: 'Konfirmasi kembali ke yang tersimpan', + confirmUnpublish: 'Konfirmasi pembatalan publikasi', + confirmVersionRestoration: 'Konfirmasi Pemulihan Versi', + currentDocumentStatus: 'Dokumen {{docStatus}} saat ini', + currentDraft: 'Draf Saat Ini', + currentlyPublished: 'Sedang Dipublikasikan', + currentlyViewing: 'Sedang melihat', + currentPublishedVersion: 'Versi Terbitan Saat Ini', + draft: 'Draf', + draftSavedSuccessfully: 'Draf berhasil disimpan.', + lastSavedAgo: 'Terakhir disimpan {{distance}} yang lalu', + modifiedOnly: 'Hanya yang diubah', + moreVersions: 'Versi lainnya...', + noFurtherVersionsFound: 'Tidak ada versi lebih lanjut yang ditemukan', + noRowsFound: 'Tidak ada {{label}} yang ditemukan', + noRowsSelected: 'Tidak ada {{label}} yang dipilih', + preview: 'Pratinjau', + previouslyDraft: 'Sebelumnya Draf', + previouslyPublished: 'Sebelumnya Dipublikasikan', + previousVersion: 'Versi Sebelumnya', + problemRestoringVersion: 'Terjadi masalah saat memulihkan versi ini', + publish: 'Publikasikan', + publishAllLocales: 'Publikasikan semua lokal', + publishChanges: 'Publikasikan perubahan', + published: 'Diterbitkan', + publishIn: 'Publikasikan di {{locale}}', + publishing: 'Mempublikasikan', + restoreAsDraft: 'Pulihkan sebagai draf', + restoredSuccessfully: 'Berhasil dipulihkan.', + restoreThisVersion: 'Pulihkan versi ini', + restoring: 'Memulihkan...', + reverting: 'Mengembalikan...', + revertToPublished: 'Kembali ke yang dipublikasikan', + saveDraft: 'Simpan Draf', + scheduledSuccessfully: 'Berhasil dijadwalkan.', + schedulePublish: 'Jadwalkan Publikasi', + selectLocales: 'Pilih lokal untuk ditampilkan', + selectVersionToCompare: 'Pilih versi untuk dibandingkan', + showingVersionsFor: 'Menampilkan versi untuk:', + showLocales: 'Tampilkan lokal:', + specificVersion: 'Versi Spesifik', + status: 'Status', + unpublish: 'Batalkan Publikasi', + unpublishing: 'Membatalkan publikasi...', + version: 'Versi', + versionAgo: '{{distance}} yang lalu', + versionCount_many: '{{count}} versi ditemukan', + versionCount_none: 'Tidak ada versi yang ditemukan', + versionCount_one: '{{count}} versi ditemukan', + versionCount_other: '{{count}} versi ditemukan', + versionCreatedOn: '{{version}} dibuat pada:', + versionID: 'ID Versi', + versions: 'Versi', + viewingVersion: 'Melihat versi untuk {{entityLabel}} {{documentTitle}}', + viewingVersionGlobal: 'Melihat versi untuk global {{entityLabel}}', + viewingVersions: 'Melihat versi untuk {{entityLabel}} {{documentTitle}}', + viewingVersionsGlobal: 'Melihat versi untuk global {{entityLabel}}', + }, +} + +export const id: Language = { + dateFNSKey: 'id', + translations: idTranslations, +} diff --git a/packages/translations/src/languages/it.ts b/packages/translations/src/languages/it.ts index e8f65f1b2c..af0c399628 100644 --- a/packages/translations/src/languages/it.ts +++ b/packages/translations/src/languages/it.ts @@ -87,10 +87,15 @@ export const itTranslations: DefaultTranslationsObject = { deletingFile: "Si è verificato un errore durante l'eleminazione del file.", deletingTitle: "Si è verificato un errore durante l'eliminazione di {{title}}. Per favore controlla la tua connessione e riprova.", + documentNotFound: + 'Il documento con ID {{id}} non è stato trovato. Potrebbe essere stato eliminato o mai esistito, oppure potresti non avere accesso ad esso.', emailOrPasswordIncorrect: "L'email o la password fornita non è corretta.", followingFieldsInvalid_one: 'Il seguente campo non è valido:', followingFieldsInvalid_other: 'I seguenti campi non sono validi:', incorrectCollection: 'Collezione non corretta', + insufficientClipboardPermissions: + 'Accesso alla clipboard negato. Verifica i permessi della clipboard.', + invalidClipboardData: 'Dati della clipboard non validi.', invalidFileType: 'Tipo di file non valido', invalidFileTypeValue: 'Tipo di file non valido: {{value}}', invalidRequestArgs: 'Argomenti non validi nella richiesta: {{args}}', @@ -111,8 +116,11 @@ export const itTranslations: DefaultTranslationsObject = { noUser: 'Nessun Utente', previewing: "Si è verificato un problema durante l'anteprima di questo documento.", problemUploadingFile: 'Si è verificato un problema durante il caricamento del file.', + restoringTitle: + 'Si è verificato un errore durante il ripristino di {{title}}. Si prega di controllare la connessione e riprovare.', tokenInvalidOrExpired: 'Il token non è valido o è scaduto.', tokenNotProvided: 'Token non fornito.', + unableToCopy: 'Impossibile copiare.', unableToDeleteCount: 'Impossibile eliminare {{count}} su {{total}} {{label}}.', unableToReindexCollection: 'Errore durante la reindicizzazione della collezione {{collection}}. Operazione annullata.', @@ -184,6 +192,8 @@ export const itTranslations: DefaultTranslationsObject = { deleteFolder: 'Elimina cartella', folderName: 'Nome Cartella', folders: 'Cartelle', + folderTypeDescription: + 'Seleziona quale tipo di documenti della collezione dovrebbero essere consentiti in questa cartella.', itemHasBeenMoved: '{{title}} è stato spostato in {{folderName}}', itemHasBeenMovedToRoot: '{{title}} è stato spostato nella cartella principale', itemsMovedToFolder: '{{title}} spostato in {{folderName}}', @@ -210,6 +220,17 @@ export const itTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Stai per eliminare {{count}} {{label}}', aboutToDeleteCount_one: 'Stai per eliminare {{count}} {{label}}', aboutToDeleteCount_other: 'Stai per eliminare {{count}} {{label}}', + aboutToPermanentlyDelete: + "Stai per eliminare definitivamente l'{{label}} <1>{{title}}. Sei sicuro?", + aboutToPermanentlyDeleteTrash: + 'Stai per eliminare definitivamente <0>{{count}} <1>{{label}} dal cestino. Sei sicuro?', + aboutToRestore: 'Stai per ripristinare il {{label}} <1>{{title}}. Sei sicuro?', + aboutToRestoreAsDraft: + "Stai per ripristinare l'etichetta {{label}} <1>{{title}} come bozza. Sei sicuro?", + aboutToRestoreAsDraftCount: 'Stai per ripristinare {{count}} {{label}} come bozza', + aboutToRestoreCount: 'Stai per ripristinare {{count}} {{label}}', + aboutToTrash: 'Stai per spostare il {{label}} <1>{{title}} nel cestino. Sei sicuro?', + aboutToTrashCount: 'Stai per spostare {{count}} {{label}} nel cestino', addBelow: 'Aggiungi sotto', addFilter: 'Aggiungi Filtro', adminTheme: 'Tema Admin', @@ -226,6 +247,7 @@ export const itTranslations: DefaultTranslationsObject = { backToDashboard: 'Torna alla Dashboard', cancel: 'Cancella', changesNotSaved: 'Le tue modifiche non sono state salvate. Se esci ora, verranno perse.', + clear: 'Chiara', clearAll: 'Cancella Tutto', close: 'Chiudere', collapse: 'Comprimi', @@ -243,9 +265,12 @@ export const itTranslations: DefaultTranslationsObject = { "Questo rimuoverà gli indici esistenti e rifarà l'indice dei documenti nelle collezioni {{collections}}.", confirmReindexDescriptionAll: "Questo rimuoverà gli indici esistenti e rifarà l'indice dei documenti in tutte le collezioni.", + confirmRestoration: 'Conferma il ripristino', copied: 'Copiato', copy: 'Copia', + copyField: 'Copia campo', copying: 'Copia', + copyRow: 'Copia riga', copyWarning: 'Stai per sovrascrivere {{to}} con {{from}} per {{label}} {{title}}. Sei sicuro?', create: 'Crea', created: 'Data di creazione', @@ -260,13 +285,17 @@ export const itTranslations: DefaultTranslationsObject = { dark: 'Scuro', dashboard: 'Dashboard', delete: 'Elimina', + deleted: 'Cancellato', + deletedAt: 'Cancellato Alle', deletedCountSuccessfully: '{{count}} {{label}} eliminato con successo.', deletedSuccessfully: 'Eliminato con successo.', + deletePermanently: 'Salta il cestino ed elimina definitivamente', deleting: 'Sto eliminando...', depth: 'Profondità', descending: 'Decrescente', deselectAllRows: 'Deseleziona tutte le righe', document: 'Documento', + documentIsTrashed: 'Questo {{label}} è stato cestinato ed è in sola lettura.', documentLocked: 'Documento bloccato', documents: 'Documenti', duplicate: 'Duplica', @@ -282,6 +311,8 @@ export const itTranslations: DefaultTranslationsObject = { editLabel: 'Modifica {{label}}', email: 'Email', emailAddress: 'Indirizzo Email', + emptyTrash: 'Svuota cestino', + emptyTrashLabel: 'Svuota il cestino {{label}}', enterAValue: 'Inserisci un valore', error: 'Errore', errors: 'Errori', @@ -294,6 +325,7 @@ export const itTranslations: DefaultTranslationsObject = { filterWhere: 'Filtra {{label}} se', globals: 'Globali', goBack: 'Torna indietro', + groupByLabel: 'Raggruppa per {{label}}', import: 'Importare', isEditing: 'sta modificando', item: 'articolo', @@ -328,6 +360,7 @@ export const itTranslations: DefaultTranslationsObject = { 'Nessun {{label}} trovato. Non esiste ancora nessun {{label}} oppure nessuno corrisponde ai filtri che hai specificato sopra.', notFound: 'Non Trovato', nothingFound: 'Non è stato trovato nulla', + noTrashResults: 'Nessun {{label}} nel cestino.', noUpcomingEventsScheduled: 'Nessun evento in programma.', noValue: 'Nessun valore', of: 'di', @@ -338,7 +371,12 @@ export const itTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Sovrascrivi i dati del campo esistente', pageNotFound: 'Pagina non trovata', password: 'Password', + pasteField: 'Incolla campo', + pasteRow: 'Incolla riga', payloadSettings: 'Impostazioni di Payload', + permanentlyDelete: 'Elimina Permanentemente', + permanentlyDeletedCountSuccessfully: + 'Eliminato definitivamente {{count}} {{label}} con successo.', perPage: 'Per Pagina: {{limit}}', previous: 'Precedente', reindex: 'Reindicizza', @@ -350,6 +388,11 @@ export const itTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Questo ripristinerà tutte le tue preferenze alle impostazioni predefinite.', resettingPreferences: 'Ripristinando le preferenze.', + restore: 'Ripristina', + restoreAsPublished: 'Ripristina come versione pubblicata', + restoredCountSuccessfully: 'Ripristinato {{count}} {{label}} con successo.', + restoring: + "Rispetta il significato del testo originale nel contesto di Payload. Ecco una lista di termini comuni di Payload che hanno significati molto specifici:\n - Raccolta: Una raccolta è un gruppo di documenti che condividono una struttura e una finalità comuni. Le raccolte vengono utilizzate per organizzare e gestire i contenuti in Payload.\n - Campo: Un campo è un pezzo specifico di dati all'interno di un documento in una raccolta. I campi definiscono la struttura e il tipo di dati che possono essere memorizzati in un documento.\n - Documento: Un documento", row: 'Riga', rows: 'Righe', save: 'Salva', @@ -380,6 +423,10 @@ export const itTranslations: DefaultTranslationsObject = { time: 'Tempo', timezone: 'Fuso orario', titleDeleted: '{{label}} {{title}} eliminato con successo.', + titleRestored: '{{label}} "{{title}}" ripristinato con successo.', + titleTrashed: '{{label}} "{{title}}" spostato nel cestino.', + trash: 'Cestino', + trashedCountSuccessfully: '{{count}} {{label}} spostati nel cestino.', true: 'Vero', unauthorized: 'Non autorizzato', unsavedChanges: 'Hai delle modifiche non salvate. Salva o scarta prima di continuare.', @@ -398,6 +445,7 @@ export const itTranslations: DefaultTranslationsObject = { username: 'Nome utente', users: 'Utenti', value: 'Valore', + viewing: 'Visualizzazione', viewReadOnly: 'Visualizza solo lettura', welcome: 'Benvenuto', yes: 'Sì', @@ -524,6 +572,7 @@ export const itTranslations: DefaultTranslationsObject = { noRowsFound: 'Nessun {{label}} trovato', noRowsSelected: 'Nessuna {{etichetta}} selezionata', preview: 'Anteprima', + previouslyDraft: 'Precedentemente una Bozza', previouslyPublished: 'Precedentemente Pubblicato', previousVersion: 'Versione Precedente', problemRestoringVersion: 'Si è verificato un problema durante il ripristino di questa versione', diff --git a/packages/translations/src/languages/ja.ts b/packages/translations/src/languages/ja.ts index 9de2d07a17..d4cab9756f 100644 --- a/packages/translations/src/languages/ja.ts +++ b/packages/translations/src/languages/ja.ts @@ -87,10 +87,15 @@ export const jaTranslations: DefaultTranslationsObject = { deletingFile: 'ファイルの削除中にエラーが発生しました。', deletingTitle: '{{title}} を削除する際にエラーが発生しました。接続を確認してからもう一度お試しください。', + documentNotFound: + 'ID {{id}}のドキュメントが見つかりませんでした。削除されたか、存在しなかったか、またはアクセス権限がない可能性があります。', emailOrPasswordIncorrect: 'メールアドレス、または、パスワードが正しくありません。', followingFieldsInvalid_one: '次のフィールドは無効です:', followingFieldsInvalid_other: '次のフィールドは無効です:', incorrectCollection: '不正なコレクション', + insufficientClipboardPermissions: + 'クリップボードへのアクセスが拒否されました。クリップボードの権限を確認してください。', + invalidClipboardData: '無効なクリップボードデータ。', invalidFileType: '無効なファイル形式', invalidFileTypeValue: '無効なファイル形式: {{value}}', invalidRequestArgs: 'リクエストに無効な引数が渡されました: {{args}}', @@ -110,8 +115,11 @@ export const jaTranslations: DefaultTranslationsObject = { noUser: 'ユーザーなし', previewing: 'このデータをプレビューする際に問題が発生しました。', problemUploadingFile: 'ファイルのアップロード中に問題が発生しました。', + restoringTitle: + '{{title}}の復元中にエラーが発生しました。接続を確認して、もう一度お試しください。', tokenInvalidOrExpired: 'トークンが無効、または、有効期限が切れています。', tokenNotProvided: 'トークンが提供されていません。', + unableToCopy: 'コピーできません。', unableToDeleteCount: '{{total}} {{label}} から {{count}} を削除できません。', unableToReindexCollection: 'コレクション {{collection}} の再インデックス中にエラーが発生しました。操作は中止されました。', @@ -181,6 +189,8 @@ export const jaTranslations: DefaultTranslationsObject = { deleteFolder: 'フォルダを削除する', folderName: 'フォルダ名', folders: 'フォルダー', + folderTypeDescription: + 'このフォルダーに許可されるコレクションドキュメントのタイプを選択してください。', itemHasBeenMoved: '{{title}}は{{folderName}}に移動されました', itemHasBeenMovedToRoot: '{{title}}はルートフォルダに移動されました', itemsMovedToFolder: '{{title}}は{{folderName}}に移動されました', @@ -207,6 +217,19 @@ export const jaTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: '{{label}}を{{count}}つ削除しようとしています', aboutToDeleteCount_one: '{{label}}を{{count}}つ削除しようとしています', aboutToDeleteCount_other: '{{label}}を{{count}}つ削除しようとしています', + aboutToPermanentlyDelete: + 'あなたは永久に{{label}} <1>{{title}}を削除しようとしています。よろしいですか?', + aboutToPermanentlyDeleteTrash: + 'あなたはゴミ箱から<0>{{count}} <1>{{label}}を永久に削除しようとしています。よろしいですか?', + aboutToRestore: 'あなたは{{label}} <1>{{title}}を復元しようとしています。よろしいですか?', + aboutToRestoreAsDraft: + 'あなたは {{label}} <1>{{title}} を下書きとして復元しようとしています。よろしいですか?', + aboutToRestoreAsDraftCount: + 'あなたはまもなく、{{count}} {{label}}を下書きとして復元しようとしています。', + aboutToRestoreCount: 'あなたはまもなく{{count}} {{label}}を復元しようとしています。', + aboutToTrash: + 'あなたは{{label}} <1>{{title}}をゴミ箱に移動しようとしています。よろしいですか?', + aboutToTrashCount: 'あなたはまもなく{{count}} {{label}}をゴミ箱に移動しようとしています。', addBelow: '下に追加', addFilter: '絞り込みを追加', adminTheme: '管理画面のテーマ', @@ -222,6 +245,7 @@ export const jaTranslations: DefaultTranslationsObject = { backToDashboard: 'ダッシュボードに戻る', cancel: 'キャンセル', changesNotSaved: '未保存の変更があります。このまま画面を離れると内容が失われます。', + clear: 'クリア', clearAll: 'すべてクリア', close: '閉じる', collapse: '閉じる', @@ -239,9 +263,12 @@ export const jaTranslations: DefaultTranslationsObject = { 'これにより既存のインデックスが削除され、{{collections}}コレクション内のドキュメントが再インデックスされます。', confirmReindexDescriptionAll: 'これにより既存のインデックスが削除され、すべてのコレクション内のドキュメントが再インデックスされます。', + confirmRestoration: '復元を確認してください', copied: 'コピーしました', copy: 'コピー', + copyField: 'フィールドをコピー', copying: 'コピーする', + copyRow: '行をコピー', copyWarning: 'あなたは{{label}} {{title}}の{{to}}を{{from}}で上書きしようとしています。よろしいですか?', create: '作成', @@ -257,13 +284,17 @@ export const jaTranslations: DefaultTranslationsObject = { dark: 'ダークモード', dashboard: 'ダッシュボード', delete: '削除', + deleted: '削除されました', + deletedAt: '削除された時間', deletedCountSuccessfully: '{{count}}つの{{label}}を正常に削除しました。', deletedSuccessfully: '正常に削除されました。', + deletePermanently: 'ゴミ箱をスキップして完全に削除します', deleting: '削除しています...', depth: '深さ', descending: '降順', deselectAllRows: 'すべての行の選択を解除します', document: 'ドキュメント', + documentIsTrashed: 'この{{label}}は廃棄され、読み取り専用です。', documentLocked: 'ドキュメントがロックされました', documents: 'ドキュメント', duplicate: '複製', @@ -279,6 +310,8 @@ export const jaTranslations: DefaultTranslationsObject = { editLabel: '{{label}} を編集', email: 'メールアドレス', emailAddress: 'メールアドレス', + emptyTrash: 'ゴミ箱を空にする', + emptyTrashLabel: '{{label}}のゴミ箱を空にする', enterAValue: '値を入力', error: 'エラー', errors: 'エラー', @@ -291,6 +324,7 @@ export const jaTranslations: DefaultTranslationsObject = { filterWhere: '{{label}} の絞り込み', globals: 'グローバル', goBack: '戻る', + groupByLabel: '{{label}}でグループ化する', import: '輸入', isEditing: '編集中', item: 'アイテム', @@ -326,6 +360,7 @@ export const jaTranslations: DefaultTranslationsObject = { '{{label}} データが見つかりませんでした。データが存在しない、または、絞り込みに一致するものがありません。', notFound: 'Not Found', nothingFound: 'Nothing found', + noTrashResults: 'ゴミ箱に{{label}}はありません。', noUpcomingEventsScheduled: '予定されているイベントはありません。', noValue: '未設定', of: '/', @@ -336,7 +371,11 @@ export const jaTranslations: DefaultTranslationsObject = { overwriteExistingData: '既存のフィールドデータを上書きする', pageNotFound: 'ページが見つかりません', password: 'パスワード', + pasteField: 'フィールドを貼り付け', + pasteRow: '行を貼り付け', payloadSettings: 'Payload 設定', + permanentlyDelete: '永久に削除する', + permanentlyDeletedCountSuccessfully: '{{count}} {{label}}を正常に完全に削除しました。', perPage: '表示件数: {{limit}}', previous: '前の', reindex: '再インデックス', @@ -347,6 +386,11 @@ export const jaTranslations: DefaultTranslationsObject = { resetPreferences: '設定をリセット', resetPreferencesDescription: 'これにより、すべての設定がデフォルト設定にリセットされます。', resettingPreferences: '設定をリセットしています。', + restore: '復元', + restoreAsPublished: '公開バージョンとして復元する', + restoredCountSuccessfully: '{{count}} {{label}} の復元に成功しました。', + restoring: + '以下はPayloadの文脈での原文の意味を尊重してください。以下に、特定の意味を持つ一般的なPayload用語のリストを示します。\n - コレクション: コレクションは、共通の構造と目的を共有する文書のグループです。コレクションは、Payload内のコンテンツを整理および管理するために使用されます。\n - フィールド: フィールドは、コレクション内の文', row: '列', rows: '列', save: '保存', @@ -377,6 +421,10 @@ export const jaTranslations: DefaultTranslationsObject = { time: '時間', timezone: 'タイムゾーン', titleDeleted: '{{label}} "{{title}}" が削除されました。', + titleRestored: '「{{label}}」"{{title}}" が正常に復元されました。', + titleTrashed: '{{label}} "{{title}}"がゴミ箱へ移動されました。', + trash: 'ゴミ', + trashedCountSuccessfully: '{{count}} {{label}}がゴミ箱に移動しました。', true: '真実', unauthorized: '未認証', unsavedChanges: '保存されていない変更があります。続行する前に保存または破棄してください。', @@ -395,6 +443,7 @@ export const jaTranslations: DefaultTranslationsObject = { username: 'ユーザーネーム', users: 'ユーザー', value: '値', + viewing: '閲覧', viewReadOnly: '読み取り専用で表示', welcome: 'ようこそ', yes: 'はい', @@ -518,6 +567,7 @@ export const jaTranslations: DefaultTranslationsObject = { noRowsFound: '{{label}} は未設定です', noRowsSelected: '選択された{{label}}はありません', preview: 'プレビュー', + previouslyDraft: '以前はドラフトでした', previouslyPublished: '以前に公開された', previousVersion: '以前のバージョン', problemRestoringVersion: 'このバージョンの復元に問題がありました。', diff --git a/packages/translations/src/languages/ko.ts b/packages/translations/src/languages/ko.ts index 8d33c629fc..0823560246 100644 --- a/packages/translations/src/languages/ko.ts +++ b/packages/translations/src/languages/ko.ts @@ -86,10 +86,15 @@ export const koTranslations: DefaultTranslationsObject = { deletingFile: '파일을 삭제하는 중에 오류가 발생했습니다.', deletingTitle: '{{title}} 삭제하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도하세요.', + documentNotFound: + 'ID가 {{id}}인 문서를 찾을 수 없습니다. 이 문서는 삭제되었거나 존재하지 않았거나, 당신이 접근 권한이 없을 수 있습니다.', emailOrPasswordIncorrect: '입력한 이메일 또는 비밀번호가 올바르지 않습니다.', followingFieldsInvalid_one: '다음 입력란이 유효하지 않습니다:', followingFieldsInvalid_other: '다음 입력란이 유효하지 않습니다:', incorrectCollection: '잘못된 컬렉션', + insufficientClipboardPermissions: + '클립보드 접근이 거부되었습니다. 클립보드 권한을 확인하십시오.', + invalidClipboardData: '유효하지 않은 클립보드 데이터입니다.', invalidFileType: '잘못된 파일 형식', invalidFileTypeValue: '잘못된 파일 형식: {{value}}', invalidRequestArgs: '요청에 잘못된 인수가 전달되었습니다: {{args}}', @@ -109,8 +114,11 @@ export const koTranslations: DefaultTranslationsObject = { noUser: '사용자가 없습니다.', previewing: '이 문서를 미리보는 중에 문제가 발생했습니다.', problemUploadingFile: '파일 업로드 중에 문제가 발생했습니다.', + restoringTitle: + '{{title}} 복원 중 오류가 발생했습니다. 연결 상태를 확인하고 다시 시도해 주세요.', tokenInvalidOrExpired: '토큰이 유효하지 않거나 만료되었습니다.', tokenNotProvided: '토큰이 제공되지 않았습니다.', + unableToCopy: '복사할 수 없습니다.', unableToDeleteCount: '총 {{total}}개 중 {{count}}개의 {{label}}을(를) 삭제할 수 없습니다.', unableToReindexCollection: '{{collection}} 컬렉션의 재인덱싱 중 오류가 발생했습니다. 작업이 중단되었습니다.', @@ -180,6 +188,7 @@ export const koTranslations: DefaultTranslationsObject = { deleteFolder: '폴더 삭제', folderName: '폴더 이름', folders: '폴더들', + folderTypeDescription: '이 폴더에서 어떤 유형의 컬렉션 문서가 허용되어야 하는지 선택하세요.', itemHasBeenMoved: '{{title}}는 {{folderName}}로 이동되었습니다.', itemHasBeenMovedToRoot: '{{title}}이(가) 루트 폴더로 이동되었습니다.', itemsMovedToFolder: '{{title}}이(가) {{folderName}}로 이동되었습니다.', @@ -205,6 +214,17 @@ export const koTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: '{{label}}를 {{count}}개 삭제하려고 합니다.', aboutToDeleteCount_one: '{{label}}를 {{count}}개 삭제하려고 합니다.', aboutToDeleteCount_other: '{{label}}를 {{count}}개 삭제하려고 합니다.', + aboutToPermanentlyDelete: + '당신은 {{label}} <1>{{title}}을 영구적으로 삭제하려고 합니다. 확실합니까?', + aboutToPermanentlyDeleteTrash: + '휴지통에서 <0>{{count}} <1>{{label}}을(를) 영구적으로 삭제하려고 합니다. 확실합니까?', + aboutToRestore: '당신은 {{label}} <1>{{title}}을 복원하려고 합니다. 확실합니까?', + aboutToRestoreAsDraft: + '당신은 {{label}} <1>{{title}}을 초안으로 복원하려고 합니다. 확실합니까?', + aboutToRestoreAsDraftCount: '당신은 {{count}}개의 {{label}}을 초안으로 복원하려고 합니다.', + aboutToRestoreCount: '당신은 {{count}} {{label}}을 복원하려고 합니다.', + aboutToTrash: '{{label}} <1>{{title}}을 휴지통으로 이동하려고 합니다. 확실합니까?', + aboutToTrashCount: '당신은 곧 {{count}} {{label}}을(를) 휴지통으로 이동하려고 합니다.', addBelow: '아래에 추가', addFilter: '필터 추가', adminTheme: '관리자 테마', @@ -220,6 +240,8 @@ export const koTranslations: DefaultTranslationsObject = { backToDashboard: '대시보드로 돌아가기', cancel: '취소', changesNotSaved: '변경 사항이 저장되지 않았습니다. 지금 떠나면 변경 사항을 잃게 됩니다.', + clear: + '페이로드의 맥락 내에서 원문의 의미를 존중하십시오. 다음은 페이로드에서 사용되는 특정 의미를 내포하는 일반적인 페이로드 용어 목록입니다: \n- Collection: 컬렉션은 공통의 구조와 목적을 공유하는 문서의 그룹입니다. 컬렉션은 페이로드에서 콘텐츠를 정리하고 관리하는 데 사용됩니다.\n- Field: 필드는 컬렉', clearAll: '모두 지우기', close: '닫기', collapse: '접기', @@ -237,9 +259,12 @@ export const koTranslations: DefaultTranslationsObject = { '이 작업은 기존 인덱스를 삭제하고 {{collections}} 컬렉션 내의 문서를 다시 인덱싱합니다.', confirmReindexDescriptionAll: '이 작업은 기존 인덱스를 삭제하고 모든 컬렉션 내의 문서를 다시 인덱싱합니다.', + confirmRestoration: '복구를 확인하십시오', copied: '복사됨', copy: '복사', + copyField: '필드 복사', copying: '복사하기', + copyRow: '행 복사', copyWarning: '{{label}} {{title}}에 대해 {{from}}으로 {{to}}를 덮어쓰려고 합니다. 확실합니까?', create: '생성', created: '생성됨', @@ -254,13 +279,17 @@ export const koTranslations: DefaultTranslationsObject = { dark: '다크', dashboard: '대시보드', delete: '삭제', + deleted: '삭제됨', + deletedAt: '삭제된 시간', deletedCountSuccessfully: '{{count}}개의 {{label}}를 삭제했습니다.', deletedSuccessfully: '삭제되었습니다.', + deletePermanently: '휴지통 건너뛰고 영구적으로 삭제하세요', deleting: '삭제 중...', depth: '깊이', descending: '내림차순', deselectAllRows: '모든 행 선택 해제', document: '문서', + documentIsTrashed: '이 {{label}}은 휴지통에 있으며 읽기 전용입니다.', documentLocked: '문서가 잠겼습니다', documents: '문서들', duplicate: '복제', @@ -276,6 +305,8 @@ export const koTranslations: DefaultTranslationsObject = { editLabel: '{{label}} 수정', email: '이메일', emailAddress: '이메일 주소', + emptyTrash: '휴지통 비우기', + emptyTrashLabel: '{{label}} 휴지통 비우기', enterAValue: '값을 입력하세요', error: '오류', errors: '오류', @@ -288,6 +319,7 @@ export const koTranslations: DefaultTranslationsObject = { filterWhere: '{{label}} 필터링 조건', globals: '글로벌', goBack: '돌아가기', + groupByLabel: '{{label}}로 그룹화', import: '수입', isEditing: '편집 중', item: '항목', @@ -323,6 +355,7 @@ export const koTranslations: DefaultTranslationsObject = { '{{label}}를 찾을 수 없습니다. 아직 {{label}}이 없거나 설정한 필터와 일치하는 것이 없습니다.', notFound: '찾을 수 없음', nothingFound: '찾을 수 없습니다', + noTrashResults: '휴지통에 {{label}}이 없습니다.', noUpcomingEventsScheduled: '예정된 행사가 없습니다.', noValue: '값 없음', of: '의', @@ -333,7 +366,12 @@ export const koTranslations: DefaultTranslationsObject = { overwriteExistingData: '기존 필드 데이터 덮어쓰기', pageNotFound: '페이지를 찾을 수 없음', password: '비밀번호', + pasteField: '필드 붙여넣기', + pasteRow: '행 붙여넣기', payloadSettings: 'Payload 설정', + permanentlyDelete: '영구적으로 삭제', + permanentlyDeletedCountSuccessfully: + '영구적으로 {{count}} {{label}}가 성공적으로 삭제되었습니다.', perPage: '페이지당 개수: {{limit}}', previous: '이전', reindex: '재인덱싱', @@ -344,6 +382,11 @@ export const koTranslations: DefaultTranslationsObject = { resetPreferences: '기본 설정으로 재설정', resetPreferencesDescription: '이렇게 하면 모든 기본 설정이 기본값으로 재설정됩니다.', resettingPreferences: '기본 설정을 재설정하는 중.', + restore: '복원', + restoreAsPublished: '게시된 버전으로 복원하다', + restoredCountSuccessfully: '성공적으로 {{count}} {{label}}를 복원했습니다.', + restoring: + '원래 텍스트의 의미를 Payload 문맥 내에서 존중하십시오. 여기에는 매우 특정한 의미를 가진 일반 Payload 용어 목록이 있습니다:\n - Collection: 컬렉션은 공통 구조와 목적을 공유하는 문서의 그룹입니다. 컬렉션은 Payload에서 컨텐츠를 구성하고 관리하는 데 사용됩니다.\n - Field: 필드는 컬렉션 내의 문서에 있는 특정 데이터 조각입니다.', row: '행', rows: '행', save: '저장', @@ -374,6 +417,10 @@ export const koTranslations: DefaultTranslationsObject = { time: '시간', timezone: '시간대', titleDeleted: '{{label}} "{{title}}"을(를) 삭제했습니다.', + titleRestored: '"{{label}}" "{{title}}"이(가) 성공적으로 복원되었습니다.', + titleTrashed: '"{{label}}" "{{title}}"이(가) 휴지통으로 이동되었습니다.', + trash: '휴지통', + trashedCountSuccessfully: '{{count}} {{label}}가 휴지통으로 이동했습니다.', true: '참', unauthorized: '권한 없음', unsavedChanges: '저장되지 않은 변경 사항이 있습니다. 계속하기 전에 저장하거나 무시하십시오.', @@ -392,6 +439,7 @@ export const koTranslations: DefaultTranslationsObject = { username: '사용자 이름', users: '사용자', value: '값', + viewing: '열람', viewReadOnly: '읽기 전용으로 보기', welcome: '환영합니다', yes: '네', @@ -512,6 +560,7 @@ export const koTranslations: DefaultTranslationsObject = { noRowsFound: '{{label}}을(를) 찾을 수 없음', noRowsSelected: '선택된 {{label}} 없음', preview: '미리보기', + previouslyDraft: '이전에는 초안', previouslyPublished: '이전에 발표된', previousVersion: '이전 버전', problemRestoringVersion: '이 버전을 복원하는 중 문제가 발생했습니다.', diff --git a/packages/translations/src/languages/lt.ts b/packages/translations/src/languages/lt.ts index 1669dcad56..fc2e26bb13 100644 --- a/packages/translations/src/languages/lt.ts +++ b/packages/translations/src/languages/lt.ts @@ -87,10 +87,15 @@ export const ltTranslations: DefaultTranslationsObject = { deletingFile: 'Įvyko klaida trinant failą.', deletingTitle: 'Įvyko klaida bandant ištrinti {{title}}. Patikrinkite savo ryšį ir bandykite dar kartą.', + documentNotFound: + 'Dokumentas su ID {{id}} nerastas. Gali būti, kad jis buvo ištrintas arba niekada neegzistavo, arba jūs neturite prieigos prie jo.', emailOrPasswordIncorrect: 'Pateiktas el. pašto adresas arba slaptažodis yra neteisingi.', followingFieldsInvalid_one: 'Šis laukas yra netinkamas:', followingFieldsInvalid_other: 'Šie laukai yra neteisingi:', incorrectCollection: 'Neteisinga kolekcija', + insufficientClipboardPermissions: + 'Prieiga prie iškarpinės atmesta. Patikrinkite savo iškarpinės teises.', + invalidClipboardData: 'Neteisingi iškarpinės duomenys.', invalidFileType: 'Netinkamas failo tipas', invalidFileTypeValue: 'Neteisingas failo tipas: {{value}}', invalidRequestArgs: 'Netinkami argumentai perduoti užklausoje: {{args}}', @@ -110,8 +115,11 @@ export const ltTranslations: DefaultTranslationsObject = { noUser: 'Nėra vartotojo', previewing: 'Šiam dokumentui peržiūrėti kilo problema.', problemUploadingFile: 'Failo įkelti nepavyko dėl problemos.', + restoringTitle: + 'Įvyko klaida atkuriant {{title}}. Prašome patikrinti savo ryšį ir bandyti dar kartą.', tokenInvalidOrExpired: 'Žetonas yra neteisingas arba jo galiojimas pasibaigė.', tokenNotProvided: 'Žetonas nesuteiktas.', + unableToCopy: 'Nepavyko nukopijuoti.', unableToDeleteCount: 'Negalima ištrinti {{count}} iš {{total}} {{label}}.', unableToReindexCollection: 'Klaida perindeksuojant rinkinį {{collection}}. Operacija nutraukta.', @@ -182,6 +190,8 @@ export const ltTranslations: DefaultTranslationsObject = { deleteFolder: 'Ištrinti aplanką', folderName: 'Aplanko pavadinimas', folders: 'Aplankai', + folderTypeDescription: + 'Pasirinkite, kokio tipo rinkinio dokumentai turėtų būti leidžiami šiame aplanke.', itemHasBeenMoved: '{{title}} buvo perkeltas į {{folderName}}', itemHasBeenMovedToRoot: '{{title}} buvo perkeltas į pagrindinį katalogą', itemsMovedToFolder: '{{title}} perkeltas į {{folderName}}', @@ -208,6 +218,18 @@ export const ltTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Jūs ketinate ištrinti {{count}} {{label}}', aboutToDeleteCount_one: 'Jūs ketinate ištrinti {{count}} {{label}}', aboutToDeleteCount_other: 'Jūs ketinate ištrinti {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Jūs ketinate visam laikui ištrinti {{label}} <1>{{title}}. Ar esate įsitikinęs?', + aboutToPermanentlyDeleteTrash: + 'Jūs ketinate visam laikui ištrinti <0>{{count}} <1>{{label}} iš šiukšliadėžės. Ar esate įsitikinęs?', + aboutToRestore: 'Jūs ketinate atkurti {{label}} <1>{{title}}. Ar esate tikri?', + aboutToRestoreAsDraft: + 'Jūs ketinate atkurti {{label}} <1>{{title}} kaip juodraštį. Ar esate įsitikinęs?', + aboutToRestoreAsDraftCount: 'Jūs ketinate atkurti {{count}} {{label}} kaip juodraštį', + aboutToRestoreCount: 'Jūs ketinate atkurti {{count}} {{label}}', + aboutToTrash: + 'Jūs ketinate perkelti {{label}} <1>{{title}} į šiukšliadėžę. Ar esate tikras?', + aboutToTrashCount: 'Jūs ketinate perkelti {{count}} {{label}} į šiukšlinę', addBelow: 'Pridėti žemiau', addFilter: 'Pridėti filtrą', adminTheme: 'Admin temos', @@ -224,6 +246,7 @@ export const ltTranslations: DefaultTranslationsObject = { cancel: 'Atšaukti', changesNotSaved: 'Jūsų pakeitimai nebuvo išsaugoti. Jei dabar išeisite, prarasite savo pakeitimus.', + clear: 'Aišku', clearAll: 'Išvalyti viską', close: 'Uždaryti', collapse: 'Susikolimas', @@ -241,9 +264,12 @@ export const ltTranslations: DefaultTranslationsObject = { 'Tai pašalins esamus indeksus ir iš naujo indeksuos dokumentus kolekcijose {{collections}}.', confirmReindexDescriptionAll: 'Tai pašalins esamas indeksus ir perindeksuos dokumentus visose kolekcijose.', + confirmRestoration: 'Patvirtinkite atkūrimą', copied: 'Nukopijuota', copy: 'Kopijuoti', + copyField: 'Kopijuoti lauką', copying: 'Kopijavimas', + copyRow: 'Kopijuoti eilutę', copyWarning: 'Jūs ketinate perrašyti {{to}} į {{from}} šildymui {{label}} {{title}}. Ar esate tikri?', create: 'Sukurti', @@ -259,13 +285,17 @@ export const ltTranslations: DefaultTranslationsObject = { dark: 'Tamsus', dashboard: 'Prietaisų skydelis', delete: 'Ištrinti', + deleted: 'Ištrinta', + deletedAt: 'Ištrinta', deletedCountSuccessfully: 'Sėkmingai ištrinta {{count}} {{label}}.', deletedSuccessfully: 'Sėkmingai ištrinta.', + deletePermanently: 'Praleiskite šiukšliadėžę ir ištrinkite visam laikui', deleting: 'Trinama...', depth: 'Gylis', descending: 'Mažėjantis', deselectAllRows: 'Atžymėkite visas eilutes', document: 'Dokumentas', + documentIsTrashed: 'Šis {{label}} yra ištrintas ir yra tik skaitymui.', documentLocked: 'Dokumentas užrakintas', documents: 'Dokumentai', duplicate: 'Dublikatas', @@ -281,6 +311,8 @@ export const ltTranslations: DefaultTranslationsObject = { editLabel: 'Redaguoti {{label}}', email: 'El. paštas', emailAddress: 'El. pašto adresas', + emptyTrash: 'Ištuštinti šiukšliadėžę', + emptyTrashLabel: 'Ištuštuokite {{label}} šiukšliadėžę', enterAValue: 'Įveskite reikšmę', error: 'Klaida', errors: 'Klaidos', @@ -293,6 +325,7 @@ export const ltTranslations: DefaultTranslationsObject = { filterWhere: 'Filtruoti {{label}}, kur', globals: 'Globalai', goBack: 'Grįžkite', + groupByLabel: 'Grupuoti pagal {{label}}', import: 'Importas', isEditing: 'redaguoja', item: 'daiktas', @@ -328,6 +361,7 @@ export const ltTranslations: DefaultTranslationsObject = { 'Nerasta jokių {{label}}. Arba dar nėra sukurtų {{label}}, arba jie neatitinka nurodytų filtrų aukščiau.', notFound: 'Nerasta', nothingFound: 'Nieko nerasta', + noTrashResults: 'Nėra {{label}} šiukšliadėžėje.', noUpcomingEventsScheduled: 'Nėra suplanuotų būsimų renginių.', noValue: 'Nėra vertės', of: 'apie', @@ -338,7 +372,11 @@ export const ltTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Perrašyti esamus lauko duomenis', pageNotFound: 'Puslapis nerastas', password: 'Slaptažodis', + pasteField: 'Įklijuoti lauką', + pasteRow: 'Įklijuoti eilutę', payloadSettings: 'Payload nustatymai', + permanentlyDelete: 'Visam laikui pašalinti', + permanentlyDeletedCountSuccessfully: 'Sėkmingai visam laikui ištrinta {{count}} {{label}}.', perPage: 'Puslapyje: {{limit}}', previous: 'Ankstesnis', reindex: 'Perindeksuoti', @@ -349,6 +387,10 @@ export const ltTranslations: DefaultTranslationsObject = { resetPreferences: 'Atstatyti nuostatas', resetPreferencesDescription: 'Tai atstatys visas jūsų nuostatas į numatytąsias reikšmes.', resettingPreferences: 'Nustatymų atstatymas.', + restore: 'Atkurti', + restoreAsPublished: 'Atkurti kaip publikuotą versiją', + restoredCountSuccessfully: 'Sėkmingai atkurtas {{count}} {{label}}.', + restoring: 'Atkurimas...', row: 'Eilutė', rows: 'Eilutės', save: 'Išsaugoti', @@ -379,6 +421,10 @@ export const ltTranslations: DefaultTranslationsObject = { time: 'Laikas', timezone: 'Laiko juosta', titleDeleted: '{{label}} "{{title}}" sėkmingai ištrinta.', + titleRestored: '{{label}} "{{title}}" sėkmingai atkurta.', + titleTrashed: '{{label}} "{{title}}" perkeltas į šiukšliadėžę.', + trash: 'Šiukšlės', + trashedCountSuccessfully: '{{count}} {{label}} perkeltas į šiukšlinę.', true: 'Tiesa', unauthorized: 'Neleistinas', unsavedChanges: 'Turite neišsaugotų pakeitimų. Išsaugokite arba atmestkite prieš tęsdami.', @@ -397,6 +443,7 @@ export const ltTranslations: DefaultTranslationsObject = { username: 'Vartotojo vardas', users: 'Vartotojai', value: 'Vertė', + viewing: 'Peržiūrėti', viewReadOnly: 'Peržiūrėti tik skaitymui', welcome: 'Sveiki', yes: 'Taip', @@ -524,6 +571,7 @@ export const ltTranslations: DefaultTranslationsObject = { noRowsFound: 'Nerasta {{label}}', noRowsSelected: 'Pasirinkta ne viena {{label}}', preview: 'Peržiūra', + previouslyDraft: 'Ankstesnis juodraštis', previouslyPublished: 'Ankstesnė publikacija', previousVersion: 'Ankstesnė versija', problemRestoringVersion: 'Buvo problema atkuriant šią versiją', diff --git a/packages/translations/src/languages/lv.ts b/packages/translations/src/languages/lv.ts index 4cf52aad6b..44a903d0ed 100644 --- a/packages/translations/src/languages/lv.ts +++ b/packages/translations/src/languages/lv.ts @@ -86,10 +86,15 @@ export const lvTranslations: DefaultTranslationsObject = { deletingFile: 'Radās kļūda, dzēšot failu.', deletingTitle: 'Radās kļūda, dzēšot {{title}}. Lūdzu, pārbaudiet savienojumu un mēģiniet vēlreiz.', + documentNotFound: + 'Dokuments ar ID {{id}} netika atrasts. Iespējams, tas ir izdzēsts vai nekad nav eksistējis, vai arī jums nav pieejas tam.', emailOrPasswordIncorrect: 'Norādītais e-pasts vai parole nav pareiza.', followingFieldsInvalid_one: 'Šis lauks nav derīgs:', followingFieldsInvalid_other: 'Šie lauki nav derīgi:', incorrectCollection: 'Nepareiza kolekcija', + insufficientClipboardPermissions: + 'Piekļuve starpliktuvei liegta. Lūdzu, pārbaudiet savas starpliktuves atļaujas.', + invalidClipboardData: 'Nederīgi starpliktuves dati.', invalidFileType: 'Nederīgs faila tips', invalidFileTypeValue: 'Nederīgs faila tips: {{value}}', invalidRequestArgs: 'Pieprasījumā nodoti nederīgi argumenti: {{args}}', @@ -109,8 +114,11 @@ export const lvTranslations: DefaultTranslationsObject = { noUser: 'Nav lietotāja', previewing: 'Radās problēma, priekšskatot šo dokumentu.', problemUploadingFile: 'Radās problēma, augšupielādējot failu.', + restoringTitle: + 'Notika kļūda, atjaunojot {{title}}. Lūdzu, pārbaudiet savu savienojumu un mēģiniet vēlreiz.', tokenInvalidOrExpired: 'Tokens ir nederīgs vai beidzies.', tokenNotProvided: 'Tokens nav norādīts.', + unableToCopy: 'Neizdevās kopēt.', unableToDeleteCount: 'Neizdevās izdzēst {{count}} no {{total}} {{label}}.', unableToReindexCollection: 'Radās kļūda, pārindeksējot kolekciju {{collection}}. Operācija pārtraukta.', @@ -180,6 +188,8 @@ export const lvTranslations: DefaultTranslationsObject = { deleteFolder: 'Dzēst mapi', folderName: 'Mapes nosaukums', folders: 'Mapes', + folderTypeDescription: + 'Izvēlieties, kāda veida kolekcijas dokumentiem jābūt atļautiem šajā mapē.', itemHasBeenMoved: '{{title}} ir pārvietots uz {{folderName}}', itemHasBeenMovedToRoot: '{{title}} ir pārvietots uz saknes mapi', itemsMovedToFolder: '{{title}} pārvietots uz {{folderName}}', @@ -206,6 +216,18 @@ export const lvTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Jūs grasāties dzēst {{count}} {{label}}', aboutToDeleteCount_one: 'Jūs grasāties dzēst {{count}} {{label}}', aboutToDeleteCount_other: 'Jūs grasāties dzēst {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Jūs esat gatavs neatgriezeniski dzēst {{label}} <1>{{title}}. Vai esat pārliecināts?', + aboutToPermanentlyDeleteTrash: + 'Jūs gatavojaties neatgriezeniski dzēst <0>{{count}} <1>{{label}} no miskastes. Vai esat pārliecināts?', + aboutToRestore: 'Jūs esat gatavs atjaunot {{label}} <1>{{title}}. Vai esat pārliecināts?', + aboutToRestoreAsDraft: + 'Jūs gatavojaties atjaunot {{label}} <1>{{title}} kā melnrakstu. Vai esat pārliecināts?', + aboutToRestoreAsDraftCount: 'Jūs gatavojaties atjaunot {{count}} {{label}} kā melnrakstu', + aboutToRestoreCount: 'Jūs gatavojaties atjaunot {{count}} {{label}}', + aboutToTrash: + 'Jūs gatavojaties pārvietot {{label}} <1>{{title}} uz miskasti. Vai esat pārliecināts?', + aboutToTrashCount: 'Jūs gatavojaties pārvietot {{count}} {{label}} uz miskasti', addBelow: 'Pievienot zemāk', addFilter: 'Pievienot filtru', adminTheme: 'Administratora tēma', @@ -221,6 +243,8 @@ export const lvTranslations: DefaultTranslationsObject = { backToDashboard: 'Atpakaļ uz paneli', cancel: 'Atcelt', changesNotSaved: 'Jūsu izmaiņas nav saglabātas. Ja tagad pametīsiet, izmaiņas tiks zaudētas.', + clear: + 'Izpratiet oriģinālteksta nozīmi Payload kontekstā. Šeit ir saraksts ar Payload terminiem, kas ir ļoti specifiskas nozīmes:\n - Kolekcija: Kolekcija ir dokumentu grupa, kuriem ir kopīga struktūra un mērķis. Kolekcijas tiek izmantotas saturu organizēšanai un pārvaldīšanai Payload.\n - Lauks: Lauks ir konkrēts datu fragments dokumentā iekš kolekcijas. Lauki definē struktūru un dat', clearAll: 'Notīrīt visu', close: 'Aizvērt', collapse: 'Sakļaut', @@ -238,9 +262,12 @@ export const lvTranslations: DefaultTranslationsObject = { 'Tas noņems esošos indeksus un pārindeksēs dokumentus kolekcijās {{collections}}.', confirmReindexDescriptionAll: 'Tas noņems esošos indeksus un pārindeksēs dokumentus visās kolekcijās.', + confirmRestoration: 'Apstipriniet atjaunošanu', copied: 'Nokopēts', copy: 'Kopēt', + copyField: 'Kopēt lauku', copying: 'Kopē...', + copyRow: 'Kopēt rindu', copyWarning: 'Jūs grasāties pārrakstīt {{to}} ar {{from}} priekš {{label}} {{title}}. Vai esat pārliecināts?', create: 'Izveidot', @@ -256,13 +283,17 @@ export const lvTranslations: DefaultTranslationsObject = { dark: 'Tumšs', dashboard: 'Panelis', delete: 'Dzēst', + deleted: 'Dzēsts', + deletedAt: 'Dzēsts datumā', deletedCountSuccessfully: 'Veiksmīgi izdzēsti {{count}} {{label}}.', deletedSuccessfully: 'Veiksmīgi izdzēsts.', + deletePermanently: 'Izlaidiet miskasti un dzēsiet neatgriezeniski', deleting: 'Dzēš...', depth: 'Dziļums', descending: 'Dilstošā secībā', deselectAllRows: 'Atdzēlēt visas rindas', document: 'Dokuments', + documentIsTrashed: 'Šis {{label}} ir miskastē un ir tikai lasāms.', documentLocked: 'Dokuments bloķēts', documents: 'Dokumenti', duplicate: 'Dublēt', @@ -278,6 +309,8 @@ export const lvTranslations: DefaultTranslationsObject = { editLabel: 'Rediģēt {{label}}', email: 'E-pasts', emailAddress: 'E-pasta adrese', + emptyTrash: 'Iztukšot miskasti', + emptyTrashLabel: 'Izrakstīt {{label}} atkritumu', enterAValue: 'Ievadiet vērtību', error: 'Kļūda', errors: 'Kļūdas', @@ -290,6 +323,7 @@ export const lvTranslations: DefaultTranslationsObject = { filterWhere: 'Filtrēt {{label}} kur', globals: 'Globālie', goBack: 'Doties atpakaļ', + groupByLabel: 'Grupēt pēc {{label}}', import: 'Imports', isEditing: 'redzē', item: 'vienība', @@ -325,6 +359,7 @@ export const lvTranslations: DefaultTranslationsObject = { 'Nav atrasts neviens {{label}}. Vai nu vēl nav izveidots, vai neviens neatbilst augstāk norādītajiem filtriem.', notFound: 'Nav atrasts', nothingFound: 'Nekas nav atrasts', + noTrashResults: 'Nav {{label}} miskastē.', noUpcomingEventsScheduled: 'Nav ieplānotu notikumu.', noValue: 'Nav vērtības', of: 'no', @@ -335,7 +370,11 @@ export const lvTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Pārrakstīt esošos datus', pageNotFound: 'Lapa nav atrasta', password: 'Parole', + pasteField: 'Ielīmēt lauku', + pasteRow: 'Ielīmēt rindu', payloadSettings: 'Payload iestatījumi', + permanentlyDelete: 'Pastāvīgi Dzēst', + permanentlyDeletedCountSuccessfully: 'Veiksmīgi neatgriezeniski izdzēsts {{count}} {{label}}.', perPage: 'Lapas ieraksti: {{limit}}', previous: 'Iepriekšējais', reindex: 'Pārindeksēt', @@ -346,6 +385,10 @@ export const lvTranslations: DefaultTranslationsObject = { resetPreferences: 'Atiestatīt iestatījumus', resetPreferencesDescription: 'Tas atjaunos visus jūsu iestatījumus uz noklusētajiem.', resettingPreferences: 'Atiestata iestatījumus...', + restore: 'Atjaunot', + restoreAsPublished: 'Atjaunot kā publicēto versiju', + restoredCountSuccessfully: 'Veiksmīgi atjaunots {{count}} {{label}}.', + restoring: 'Atjaunojot...', row: 'Rinda', rows: 'Rindas', save: 'Saglabāt', @@ -376,6 +419,10 @@ export const lvTranslations: DefaultTranslationsObject = { time: 'Laiks', timezone: 'Laika zona', titleDeleted: '{{label}} "{{title}}" veiksmīgi izdzēsts.', + titleRestored: '{{label}} "{{title}}" veiksmīgi atjaunots.', + titleTrashed: '{{label}} "{{title}}" pārvietots uz miskasti.', + trash: 'Atkritumi', + trashedCountSuccessfully: '{{count}} {{label}} pārvietoti uz miskasti.', true: 'Patiesi', unauthorized: 'Neautorizēts', unsavedChanges: 'Jums ir nesaglabātas izmaiņas. Saglabājiet vai atceliet pirms turpināšanas.', @@ -394,6 +441,7 @@ export const lvTranslations: DefaultTranslationsObject = { username: 'Lietotājvārds', users: 'Lietotāji', value: 'Vērtība', + viewing: 'Skatīšanās', viewReadOnly: 'Skatīt tikai lasāmu', welcome: 'Laipni lūdzam', yes: 'Jā', @@ -519,6 +567,7 @@ export const lvTranslations: DefaultTranslationsObject = { noRowsFound: 'Nav atrasts neviens {{label}}', noRowsSelected: 'Nav atlasīts neviens {{label}}', preview: 'Priekšskatījums', + previouslyDraft: 'Iepriekšējais melnraksts', previouslyPublished: 'Iepriekš publicēts', previousVersion: 'Iepriekšējā versija', problemRestoringVersion: 'Radās problēma, atjaunojot šo versiju', diff --git a/packages/translations/src/languages/my.ts b/packages/translations/src/languages/my.ts index 857cd3f2cb..4374f169c0 100644 --- a/packages/translations/src/languages/my.ts +++ b/packages/translations/src/languages/my.ts @@ -86,10 +86,15 @@ export const myTranslations: DefaultTranslationsObject = { deletingFile: 'ဖိုင်ကိုဖျက်ရာတွင် အမှားအယွင်းရှိနေသည်။', deletingTitle: '{{title}} ကို ဖျက်ရာတွင် အမှားအယွင်းရှိခဲ့သည်။ သင့် အင်တာနက်လိုင်းအား စစ်ဆေးပြီး ထပ်မံကြို့စားကြည့်ပါ။', + documentNotFound: + 'Dokumen dengan ID {{id}} tidak dapat ditemui. Ia mungkin telah dipadam atau tidak pernah wujud, atau anda mungkin tidak mempunyai akses kepadanya.', emailOrPasswordIncorrect: 'ထည့်သွင်းထားသော အီးမေးလ် သို့မဟုတ် စကားဝှက်သည် မမှန်ပါ။', followingFieldsInvalid_one: 'ထည့်သွင်းထားသော အချက်အလက်သည် မမှန်ကန်ပါ။', followingFieldsInvalid_other: 'ထည့်သွင်းထားသော အချက်အလက်များသည် မမှန်ကန်ပါ။', incorrectCollection: 'မှားယွင်းသော စုစည်းမှု', + insufficientClipboardPermissions: + 'ကလစ်ဘုတ်ဝင်ရောက်ခွင့်ပြုချက်မရှိပါ။ ကလစ်ဘုတ်ပြုချက်များကိုစစ်ဆေးပါ။', + invalidClipboardData: 'မမှန်ကန်သောကလစ်ဘုတ်ဒေတာ။', invalidFileType: 'မမှန်ကန်သော ဖိုင်အမျိုးအစား', invalidFileTypeValue: 'မမှန်ကန်သော ဖိုင်အမျိုးအစား: {{value}}', invalidRequestArgs: 'တောင်းဆိုမှုတွင် မှားယွင်းသော အကြောင်းပြချက်များ ပေးပို့ထားသည်: {{args}}', @@ -109,8 +114,11 @@ export const myTranslations: DefaultTranslationsObject = { noUser: 'အသုံးပြုသူ မရှိပါ။', previewing: 'ဖိုင်ကို အစမ်းကြည့်ရန် ပြဿနာရှိနေသည်။', problemUploadingFile: 'ဖိုင်ကို အပ်လုဒ်တင်ရာတွင် ပြဿနာရှိနေသည်။', + restoringTitle: + 'Terdapat ralat semasa memulihkan {{title}}. Sila semak sambungan anda dan cuba lagi.', tokenInvalidOrExpired: 'တိုကင်သည် မမှန်ကန်ပါ သို့မဟုတ် သက်တမ်းကုန်သွားပါပြီ။', tokenNotProvided: 'Token မပေးထားပါ။', + unableToCopy: 'ကူးရန်မဖြစ်နိုင်ပါ။', unableToDeleteCount: '{{total}} {{label}} မှ {{count}} ကို ဖျက်၍မရပါ။', unableToReindexCollection: '{{collection}} စုစည်းမှုကို ပြန်လည်အညွှန်းပြုလုပ်ခြင်း အမှားရှိနေသည်။ လုပ်ဆောင်မှုကို ဖျက်သိမ်းခဲ့သည်။', @@ -181,6 +189,7 @@ export const myTranslations: DefaultTranslationsObject = { deleteFolder: 'Padam Folder', folderName: 'ဖိုင်နာမည်', folders: 'Fail', + folderTypeDescription: 'Pilih jenis dokumen koleksi yang harus diizinkan dalam folder ini.', itemHasBeenMoved: '{{title}} telah dipindahkan ke {{folderName}}', itemHasBeenMovedToRoot: '"{{title}}" က ဗဟိုဖိုလ်ဒါသို့ရွှေ့ပြီးပါပြီ။', itemsMovedToFolder: '{{title}} သို့ {{folderName}} သို့ ရွှေ့လိုက်သွားပါပယ်', @@ -208,6 +217,18 @@ export const myTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'သင်သည် {{count}} {{label}} ကို ဖျက်ပါတော့မည်။', aboutToDeleteCount_one: 'သင်သည် {{count}} {{label}} ကို ဖျက်ပါတော့မည်။', aboutToDeleteCount_other: 'သင်သည် {{count}} {{label}} ကို ဖျက်ပါတော့မည်။', + aboutToPermanentlyDelete: + 'Anda akan menghapuskan secara kekal {{label}} <1>{{title}}. Adakah anda pasti?', + aboutToPermanentlyDeleteTrash: + 'Anda akan menghapus secara kekal <0>{{count}} <1>{{label}} dari tong sampah. Adakah anda pasti?', + aboutToRestore: 'Anda akan memulihkan {{label}} <1>{{title}}. Adakah anda pasti?', + aboutToRestoreAsDraft: + 'Anda akan memulihkan {{label}} <1>{{title}} sebagai draf. Adakah anda pasti?', + aboutToRestoreAsDraftCount: 'Anda akan memulihkan {{count}} {{label}} sebagai draf', + aboutToRestoreCount: 'Anda akan memulihkan {{count}} {{label}}', + aboutToTrash: + 'Anda akan memindahkan {{label}} <1>{{title}} ke tong sampah. Adakah anda pasti?', + aboutToTrashCount: 'Anda akan memindah {{count}} {{label}} ke tong sampah', addBelow: 'အောက်တွင်ထည့်ပါ။', addFilter: 'ဇကာထည့်ပါ။', adminTheme: 'အက်ပ်ဒိုင်များစပ်စွာ', @@ -224,6 +245,7 @@ export const myTranslations: DefaultTranslationsObject = { cancel: 'မလုပ်တော့ပါ။', changesNotSaved: 'သင်၏ပြောင်းလဲမှုများကို မသိမ်းဆည်းရသေးပါ။ ယခု စာမျက်နှာက ထွက်လိုက်ပါက သင်၏ပြောင်းလဲမှုများ အကုန် ဆုံးရှုံးသွားပါမည်။ အကုန်နော်။', + clear: 'Jelas', clearAll: 'အားလုံးကိုရှင်းလင်းပါ', close: 'ပိတ်', collapse: 'ခေါက်သိမ်းပါ။', @@ -241,9 +263,12 @@ export const myTranslations: DefaultTranslationsObject = { 'ဤသည်သည် ရှိပြီးသား အညွှန်းများကို ဖျက်ပစ်ပြီး {{collections}} ကော်လက်ရှင်းများတွင် စာရွက်များကို ထပ်လိပ်ပါလိမ့်မည်။', confirmReindexDescriptionAll: 'ဤသည်သည် ရှိပြီးသား အညွှန်းများကို ဖျက်ပစ်ပြီး အားလုံးသော ကော်လက်ရှင်းများတွင် စာရွက်များကို ထပ်လိပ်ပါလိမ့်မည်။', + confirmRestoration: 'Sahkan pemulihan', copied: 'ကူးယူပြီးပြီ။', copy: 'ကူးယူမည်။', + copyField: 'ကွက်လပ်ကိုကူးပါ', copying: 'ကူးယူခြင်း', + copyRow: 'တန်းကိုကူးပါ', copyWarning: 'Anda akan menulis ganti {{to}} dengan {{from}} untuk {{label}} {{title}}. Adakah anda pasti?', create: 'ဖန်တီးမည်။', @@ -259,13 +284,17 @@ export const myTranslations: DefaultTranslationsObject = { dark: 'အမှောင်', dashboard: 'ပင်မစာမျက်နှာ', delete: 'ဖျက်မည်။', + deleted: 'ဖျက်ထား', + deletedAt: 'Dihapus Pada', deletedCountSuccessfully: '{{count}} {{label}} ကို အောင်မြင်စွာ ဖျက်လိုက်ပါပြီ။', deletedSuccessfully: 'အောင်မြင်စွာ ဖျက်လိုက်ပါပြီ။', + deletePermanently: 'Langkau sampah dan padam secara kekal', deleting: 'ဖျက်နေဆဲ ...', depth: 'ထိုင်းအောက်မှု', descending: 'ဆင်းသက်လာသည်။', deselectAllRows: 'အားလုံးကို မရွေးနိုင်ပါ', document: 'စာရွက်စာတမ်း', + documentIsTrashed: 'Ini {{label}} telah dibuang dan hanya boleh dibaca sahaja.', documentLocked: 'စာရွက်စာတမ်းကိုပိတ်ထားသည်', documents: 'စာရွက်စာတမ်းများ', duplicate: 'ပုံတူပွားမည်။', @@ -281,6 +310,8 @@ export const myTranslations: DefaultTranslationsObject = { editLabel: '{{label}} ပြင်ဆင်မည်။', email: 'အီးမေးလ်', emailAddress: 'အီးမေးလ် လိပ်စာ', + emptyTrash: 'Kosongkan tong sampah', + emptyTrashLabel: 'Kosongkan {{label}} sampah', enterAValue: 'တန်ဖိုးတစ်ခုထည့်ပါ။', error: 'အမှား', errors: 'အမှားများ', @@ -293,6 +324,7 @@ export const myTranslations: DefaultTranslationsObject = { filterWhere: 'နေရာတွင် စစ်ထုတ်ပါ။', globals: 'Globals', goBack: 'နောက်သို့', + groupByLabel: 'Berkumpulkan mengikut {{label}}', import: 'သွင်းကုန်', isEditing: 'ပြင်ဆင်နေသည်', item: 'barang', @@ -328,6 +360,7 @@ export const myTranslations: DefaultTranslationsObject = { '{{label}} မတွေ့ပါ။ {{label}} မရှိသေးသည်ဖြစ်စေ အထက်တွင်ဖော်ပြထားသော စစ်ထုတ်မှုများနှင့် ကိုက်ညီမှုမရှိပါ။', notFound: 'ဘာမှ မရှိတော့ဘူး။', nothingFound: 'ဘာမှလည်း မတွေ့ဘူး။', + noTrashResults: 'Tiada {{label}} dalam tong sampah.', noUpcomingEventsScheduled: 'Tiada acara yang akan datang dijadualkan.', noValue: 'တန်ဖိုး မရှိပါ။', of: '၏', @@ -338,7 +371,12 @@ export const myTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Menulis semula data bidang yang sedia ada', pageNotFound: 'ရောက်ရှိနေသော စာမျက်နှာသည် မရှိပါ။', password: 'စကားဝှက်', + pasteField: 'ကွက်လပ်ကိုတင်ပါ', + pasteRow: 'တန်းကိုတင်ပါ', payloadSettings: 'ရွေးချယ်စရာများ', + permanentlyDelete: 'Padam Selamanya', + permanentlyDeletedCountSuccessfully: + '{{count}} {{label}} telah berjaya dipadamkan secara kekal.', perPage: 'စာမျက်နှာ အလိုက်: {{limit}}', previous: 'ယခင်', reindex: 'ပြန်လည်အညွှန်းပြုလုပ်ပါ', @@ -350,6 +388,11 @@ export const myTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'ဤသည်သည် သင့်၏အကြိုက်များအားလုံးကို အခြားတပ်ဆင်မှုများမှ ပြန်လည်သတ်မှတ်ပေးပါလိမ့်မည်။', resettingPreferences: 'ကြိုတင်သတ်မှတ်ချက်များ ပြန်လည်တပ်ဆင်နေပါသည်။', + restore: 'Pulihkan', + restoreAsPublished: 'Pulihkan sebagai versi yang diterbitkan', + restoredCountSuccessfully: 'Berjaya memulihkan {{count}} {{label}}.', + restoring: + 'Hormati makna teks asal dalam konteks Payload. Berikut adalah senarai istilah Payload yang membawa maksud tertentu :\n - Koleksi : Koleksi ialah kumpulan dokumen yang berkongsi struktur dan tujuan yang sama. Koleksi digunakan untuk mengatur dan menguruskan kandungan dalam Payload.\n - Medan: Medan adalah sebahagian daripada data dalam dokumen dalam suatu koleksi. Medan menentukan struktur dan jenis data yang boleh disimpan dalam dokumen.\n - Dokumen: Dokumen adalah rekod individu dalam suatu koleksi. Ia mengandungi data yang telah', row: 'အတန်း', rows: 'Rows', save: 'သိမ်းဆည်းမည်။', @@ -380,6 +423,10 @@ export const myTranslations: DefaultTranslationsObject = { time: 'Masa', timezone: 'Masa Wilayah', titleDeleted: '{{label}} {{title}} အောင်မြင်စွာ ဖျက်သိမ်းခဲ့သည်။', + titleRestored: '"{{label}}" "{{title}}" အောင်မြင်စွာ ပြန်လည် ထည့်သွင်းပြီး ဖြစ်ပါတယ်။', + titleTrashed: '"{{label}}" "{{title}}" dipindahkan ke tong sampah.', + trash: 'ဖျက်သိမ်းခြင်း', + trashedCountSuccessfully: '{{count}} {{label}} သို့ ဖယ်ရှားလိုက်သည်။', true: 'အမှန်', unauthorized: 'အခွင့်မရှိပါ။', unsavedChanges: @@ -400,6 +447,7 @@ export const myTranslations: DefaultTranslationsObject = { username: 'Nama pengguna', users: 'အသုံးပြုသူများ', value: 'တန်ဖိုး', + viewing: 'Melihat', viewReadOnly: 'ဖတ်ရှုရန်သာကြည့်ပါ', welcome: 'ကြိုဆိုပါတယ်။', yes: 'ဟုတ်ကဲ့', @@ -528,6 +576,7 @@ export const myTranslations: DefaultTranslationsObject = { noRowsFound: '{{label}} အားမတွေ့ပါ။', noRowsSelected: 'Tiada {{label}} yang dipilih', preview: 'နမူနာပြရန်', + previouslyDraft: 'Sebelum ini Draf', previouslyPublished: 'တိုင်းရင်းသားထုတ်ဝေခဲ့', previousVersion: 'Versi Sebelumnya', problemRestoringVersion: 'ဤဗားရှင်းကို ပြန်လည်ရယူရာတွင် ပြဿနာရှိနေသည်။', diff --git a/packages/translations/src/languages/nb.ts b/packages/translations/src/languages/nb.ts index 413514e28d..4c2ef03204 100644 --- a/packages/translations/src/languages/nb.ts +++ b/packages/translations/src/languages/nb.ts @@ -86,10 +86,15 @@ export const nbTranslations: DefaultTranslationsObject = { deletingFile: 'Det oppstod en feil under sletting av filen.', deletingTitle: 'Det oppstod en feil under sletting av {{title}}. Sjekk tilkoblingen og prøv igjen.', + documentNotFound: + 'Dokumentet med ID {{id}} kunne ikke bli funnet. Det kan ha blitt slettet eller aldri eksistert, eller du har kanskje ikke tilgang til det.', emailOrPasswordIncorrect: 'E-postadressen eller passordet er feil.', followingFieldsInvalid_one: 'Følgende felt er ugyldig:', followingFieldsInvalid_other: 'Følgende felter er ugyldige:', incorrectCollection: 'Ugyldig samling', + insufficientClipboardPermissions: + 'Tilgang til utklippstavlen ble nektet. Sjekk utklippstavle-tillatelsene dine.', + invalidClipboardData: 'Ugyldige utklippstavldata.', invalidFileType: 'Ugyldig filtype', invalidFileTypeValue: 'Ugyldig filtype: {{value}}', invalidRequestArgs: 'Ugyldige argumenter i forespørselen: {{args}}', @@ -109,8 +114,11 @@ export const nbTranslations: DefaultTranslationsObject = { noUser: 'Ingen bruker', previewing: 'Det oppstod et problem under forhåndsvisning av dokumentet.', problemUploadingFile: 'Det oppstod et problem under opplasting av filen.', + restoringTitle: + 'Det oppstod en feil under gjenoppretting av {{title}}. Vennligst sjekk din tilkobling og prøv igjen.', tokenInvalidOrExpired: 'Token er enten ugyldig eller har utløpt.', tokenNotProvided: 'Token ikke angitt.', + unableToCopy: 'Kan ikke kopiere.', unableToDeleteCount: 'Kan ikke slette {{count}} av {{total}} {{label}}.', unableToReindexCollection: 'Feil ved reindeksering av samlingen {{collection}}. Operasjonen ble avbrutt.', @@ -180,6 +188,7 @@ export const nbTranslations: DefaultTranslationsObject = { deleteFolder: 'Slett mappe', folderName: 'Mappenavn', folders: 'Mapper', + folderTypeDescription: 'Velg hvilken type samling dokumenter som skal tillates i denne mappen.', itemHasBeenMoved: '{{title}} er flyttet til {{folderName}}', itemHasBeenMovedToRoot: '{{title}} er flyttet til rotmappen', itemsMovedToFolder: '{{title}} flyttet til {{folderName}}', @@ -206,6 +215,17 @@ export const nbTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Du er i ferd med å slette {{count}} {{label}}', aboutToDeleteCount_one: 'Du er i ferd med å slette {{count}} {{label}}', aboutToDeleteCount_other: 'Du er i ferd med å slette {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Du er i ferd med å permanent slette {{label}} <1>{{title}}. Er du sikker?', + aboutToPermanentlyDeleteTrash: + 'Du er i ferd med å permanent slette <0>{{count}} <1>{{label}} fra søppelkassen. Er du sikker?', + aboutToRestore: 'Du er i ferd med å gjenopprette {{label}} <1>{{title}}. Er du sikker?', + aboutToRestoreAsDraft: + 'Du er i ferd med å gjenopprette {{label}} <1>{{title}} som en kladd. Er du sikker?', + aboutToRestoreAsDraftCount: 'Du er i ferd med å gjenopprette {{count}} {{label}} som utkast', + aboutToRestoreCount: 'Du er i ferd med å gjenopprette {{count}} {{label}}', + aboutToTrash: 'Du er i ferd med å flytte {{label}} <1>{{title}} til søppel. Er du sikker?', + aboutToTrashCount: 'Du er i ferd med å flytte {{count}} {{label}} til søppelkurven', addBelow: 'Legg til under', addFilter: 'Legg til filter', adminTheme: 'Admin-tema', @@ -222,6 +242,7 @@ export const nbTranslations: DefaultTranslationsObject = { cancel: 'Avbryt', changesNotSaved: 'Endringene dine er ikke lagret. Hvis du forlater nå, vil du miste endringene dine.', + clear: 'Tydelig', clearAll: 'Tøm alt', close: 'Lukk', collapse: 'Skjul', @@ -239,9 +260,12 @@ export const nbTranslations: DefaultTranslationsObject = { 'Dette vil fjerne eksisterende indekser og reindeksere dokumentene i {{collections}}-samlingene.', confirmReindexDescriptionAll: 'Dette vil fjerne eksisterende indekser og reindeksere dokumentene i alle samlinger.', + confirmRestoration: 'Bekreft gjenoppretting', copied: 'Kopiert', copy: 'Kopiér', + copyField: 'Kopier felt', copying: 'Kopiering', + copyRow: 'Kopier rad', copyWarning: 'Du er i ferd med å overskrive {{to}} med {{from}} for {{label}} {{title}}. Er du sikker?', create: 'Opprett', @@ -257,13 +281,17 @@ export const nbTranslations: DefaultTranslationsObject = { dark: 'Mørk', dashboard: 'Kontrollpanel', delete: 'Slett', + deleted: 'Slettet', + deletedAt: 'Slettet kl.', deletedCountSuccessfully: 'Slettet {{count}} {{label}}.', deletedSuccessfully: 'Slettet.', + deletePermanently: 'Hopp over søppel og slett permanent', deleting: 'Sletter...', depth: 'Dybde', descending: 'Synkende', deselectAllRows: 'Fjern markeringen fra alle rader', document: 'Dokument', + documentIsTrashed: 'Denne {{label}} er søppel og er skrivebeskyttet.', documentLocked: 'Låst dokument', documents: 'Dokumenter', duplicate: 'Dupliser', @@ -279,6 +307,8 @@ export const nbTranslations: DefaultTranslationsObject = { editLabel: 'Rediger {{label}}', email: 'E-post', emailAddress: 'E-postadresse', + emptyTrash: 'Tøm søppelkassen', + emptyTrashLabel: 'Tøm {{label}} søppel', enterAValue: 'Skriv inn en verdi', error: 'Feil', errors: 'Feil', @@ -291,6 +321,7 @@ export const nbTranslations: DefaultTranslationsObject = { filterWhere: 'Filtrer {{label}} der', globals: 'Globale variabler', goBack: 'Gå tilbake', + groupByLabel: 'Grupper etter {{label}}', import: 'Import', isEditing: 'redigerer', item: 'vare', @@ -326,6 +357,7 @@ export const nbTranslations: DefaultTranslationsObject = { 'Ingen {{label}} funnet. Enten finnes det ingen {{label}} enda eller ingen matcher filterne du har spesifisert ovenfor.', notFound: 'Ikke funnet', nothingFound: 'Ingenting funnet', + noTrashResults: 'Ingen {{label}} i søppelkassen.', noUpcomingEventsScheduled: 'Ingen kommende hendelser planlagt.', noValue: 'Ingen verdi', of: 'av', @@ -336,7 +368,11 @@ export const nbTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Overskriv eksisterende feltdata', pageNotFound: 'Siden ble ikke funnet', password: 'Passord', + pasteField: 'Lim inn felt', + pasteRow: 'Lim inn rad', payloadSettings: 'Payload-innstillinger', + permanentlyDelete: 'Permanent slett', + permanentlyDeletedCountSuccessfully: 'Permanent slettet {{count}} {{label}} med suksess.', perPage: 'Per side: {{limit}}', previous: 'Forrige', reindex: 'Reindekser', @@ -348,6 +384,11 @@ export const nbTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Dette vil tilbakestille alle preferansene dine til standardinnstillingene.', resettingPreferences: 'Tilbakestiller preferanser.', + restore: 'Gjenopprett', + restoreAsPublished: 'Gjenopprett som publisert versjon', + restoredCountSuccessfully: 'Gjenopprettet {{count}} {{label}} vellykket.', + restoring: + 'Respekter betydningen av den opprinnelige teksten innenfor konteksten av Payload. Her er en liste over vanlige Payload-uttrykk som har veldig spesifikke betydninger:\n - Samling: En samling er en gruppe dokumenter som deler en felles struktur og formål. Samlinger brukes til å organisere og håndtere innhold i Payload.\n - Felt: Et felt er et bestemt stykke data innenfor et dokument i en samling. Felt definerer strukturen og typen data som kan lagres i et dokument.\n - Dokument: Et dokument er en individuell post innen', row: 'Rad', rows: 'Rader', save: 'Lagre', @@ -378,6 +419,10 @@ export const nbTranslations: DefaultTranslationsObject = { time: 'Tid', timezone: 'Tidssone', titleDeleted: '{{label}} "{{title}}" ble slettet.', + titleRestored: '{{label}} "{{title}}" ble gjenopprettet.', + titleTrashed: '{{label}} "{{title}}" flyttet til søppel.', + trash: 'Søppel', + trashedCountSuccessfully: '{{count}} {{label}} flyttet til søppel.', true: 'Sann', unauthorized: 'Ikke autorisert', unsavedChanges: 'Du har ulagrede endringer. Lagre eller forkast før du fortsetter.', @@ -396,6 +441,7 @@ export const nbTranslations: DefaultTranslationsObject = { username: 'Brukernavn', users: 'Brukere', value: 'Verdi', + viewing: 'Visning', viewReadOnly: 'Vis skrivebeskyttet', welcome: 'Velkommen', yes: 'Ja', @@ -521,6 +567,7 @@ export const nbTranslations: DefaultTranslationsObject = { noRowsFound: 'Ingen {{label}} funnet', noRowsSelected: 'Ingen {{label}} valgt', preview: 'Forhåndsvisning', + previouslyDraft: 'Tidligere et utkast', previouslyPublished: 'Tidligere Publisert', previousVersion: 'Tidligere versjon', problemRestoringVersion: 'Det oppstod et problem med gjenoppretting av denne versjonen', diff --git a/packages/translations/src/languages/nl.ts b/packages/translations/src/languages/nl.ts index f629798e0a..424d6dba4f 100644 --- a/packages/translations/src/languages/nl.ts +++ b/packages/translations/src/languages/nl.ts @@ -87,10 +87,15 @@ export const nlTranslations: DefaultTranslationsObject = { deletingFile: 'Er is een fout opgetreden bij het verwijderen van dit bestand.', deletingTitle: 'Er is een fout opgetreden tijdens het verwijderen van {{title}}. Controleer uw verbinding en probeer het opnieuw.', + documentNotFound: + 'Het document met ID {{id}} kon niet worden gevonden. Het kan zijn verwijderd of heeft nooit bestaan, of u heeft mogelijk geen toegang tot het.', emailOrPasswordIncorrect: 'Het opgegeven e-mailadres of wachtwoord is onjuist.', followingFieldsInvalid_one: 'Het volgende veld is ongeldig:', followingFieldsInvalid_other: 'De volgende velden zijn ongeldig:', incorrectCollection: 'Ongeldige collectie', + insufficientClipboardPermissions: + 'Toegang tot het klembord geweigerd. Controleer je klembordmachtigingen.', + invalidClipboardData: 'Ongeldige klembordgegevens.', invalidFileType: 'Ongeldig bestandstype', invalidFileTypeValue: 'Ongeldig bestandstype: {{value}}', invalidRequestArgs: 'Ongeldige argumenten in verzoek: {{args}}', @@ -110,8 +115,11 @@ export const nlTranslations: DefaultTranslationsObject = { noUser: 'Geen gebruiker', previewing: 'Er was een probleem met het voorvertonen van dit document.', problemUploadingFile: 'Er was een probleem bij het uploaden van het bestand.', + restoringTitle: + 'Er is een fout opgetreden bij het herstellen van {{title}}. Controleer uw verbinding en probeer het opnieuw.', tokenInvalidOrExpired: 'Token is ongeldig of verlopen.', tokenNotProvided: 'Token niet verstrekt.', + unableToCopy: 'Kan niet kopiëren.', unableToDeleteCount: 'Kan {{count}} van {{total}} {{label}} niet verwijderen.', unableToReindexCollection: 'Fout bij het herindexeren van de collectie {{collection}}. De operatie is afgebroken.', @@ -182,6 +190,8 @@ export const nlTranslations: DefaultTranslationsObject = { deleteFolder: 'Verwijder map', folderName: 'Mapnaam', folders: 'Mappen', + folderTypeDescription: + 'Selecteer welk type verzameldocumenten toegestaan zou moeten zijn in deze map.', itemHasBeenMoved: '{{title}} is verplaatst naar {{folderName}}', itemHasBeenMovedToRoot: '{{title}} is verplaatst naar de hoofdmap', itemsMovedToFolder: '{{title}} verplaatst naar {{folderName}}', @@ -209,6 +219,21 @@ export const nlTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Je staat op het punt {{count}} {{label}} te verwijderen', aboutToDeleteCount_one: 'Je staat op het punt {{count}} {{label}} te verwijderen', aboutToDeleteCount_other: 'Je staat op het punt {{count}} {{label}} te verwijderen', + aboutToPermanentlyDelete: + 'U staat op het punt om de {{label}} <1>{{title}} permanent te verwijderen. Bent u zeker?', + aboutToPermanentlyDeleteTrash: + 'U staat op het punt om permanent <0>{{count}} <1>{{label}} uit de prullenbak te verwijderen. Bent u zeker?', + aboutToRestore: + 'U staat op het punt om de {{label}} <1>{{title}} te herstellen. Weet u het zeker?', + aboutToRestoreAsDraft: + 'U staat op het punt om het {{label}} <1>{{title}} als concept te herstellen. Weet u het zeker?', + aboutToRestoreAsDraftCount: + 'U staat op het punt om {{count}} {{label}} als concept te herstellen', + aboutToRestoreCount: 'U staat op het punt om {{count}} {{label}} te herstellen', + aboutToTrash: + 'U staat op het punt om het {{label}} <1>{{title}} naar de prullenbak te verplaatsen. Weet u het zeker?', + aboutToTrashCount: + 'U staat op het punt om {{count}} {{label}} naar de prullenbak te verplaatsen', addBelow: 'Onderaan toevoegen', addFilter: 'Filter toevoegen', adminTheme: 'Adminthema', @@ -225,6 +250,7 @@ export const nlTranslations: DefaultTranslationsObject = { cancel: 'Annuleren', changesNotSaved: 'Uw wijzigingen zijn niet bewaard. Als u weggaat zullen de wijzigingen verloren gaan.', + clear: 'Duidelijk', clearAll: 'Alles wissen', close: 'Dichtbij', collapse: 'Samenvouwen', @@ -242,9 +268,12 @@ export const nlTranslations: DefaultTranslationsObject = { 'Dit verwijdert bestaande indexen en indexeert de documenten in de {{collections}}-collecties opnieuw.', confirmReindexDescriptionAll: 'Dit verwijdert bestaande indexen en indexeert de documenten in alle collecties opnieuw.', + confirmRestoration: 'Bevestig herstel', copied: 'Gekopieerd', copy: 'Kopiëren', + copyField: 'Veld kopiëren', copying: 'Kopiëren', + copyRow: 'Rij kopiëren', copyWarning: 'U staat op het punt om {{to}} te overschrijven met {{from}} voor {{label}} {{title}}. Bent u zeker?', create: 'Aanmaken', @@ -260,13 +289,17 @@ export const nlTranslations: DefaultTranslationsObject = { dark: 'Donker', dashboard: 'Dashboard', delete: 'Verwijderen', + deleted: 'Verwijderd', + deletedAt: 'Verwijderd Op', deletedCountSuccessfully: '{{count}} {{label}} succesvol verwijderd.', deletedSuccessfully: 'Succesvol verwijderd.', + deletePermanently: 'Overslaan prullenbak en permanent verwijderen', deleting: 'Verwijderen...', depth: 'Diepte', descending: 'Aflopend', deselectAllRows: 'Deselecteer alle rijen', document: 'Document', + documentIsTrashed: 'Dit {{label}} is verwijderd en is alleen-lezen.', documentLocked: 'Document vergrendeld', documents: 'Documenten', duplicate: 'Dupliceren', @@ -282,6 +315,8 @@ export const nlTranslations: DefaultTranslationsObject = { editLabel: 'Bewerk {{label}}', email: 'E-mail', emailAddress: 'E-maildres', + emptyTrash: 'Prullenbak legen', + emptyTrashLabel: 'Leeg de prullenbak van {{label}}', enterAValue: 'Waarde invoeren', error: 'Fout', errors: 'Fouten', @@ -294,6 +329,7 @@ export const nlTranslations: DefaultTranslationsObject = { filterWhere: 'Filter {{label}} waar', globals: 'Globalen', goBack: 'Ga terug', + groupByLabel: 'Groepeer op {{label}}', import: 'Importeren', isEditing: 'is aan het bewerken', item: 'artikel', @@ -329,6 +365,7 @@ export const nlTranslations: DefaultTranslationsObject = { 'Geen {{label}} gevonden. Of er bestaat nog geen {{label}}, of niets komt overeen met de hierboven gespecifieerde filters.', notFound: 'Niet gevonden', nothingFound: 'Niets gevonden', + noTrashResults: 'Geen {{label}} in prullenbak.', noUpcomingEventsScheduled: 'Geen aankomende evenementen gepland.', noValue: 'Geen waarde', of: 'van', @@ -339,7 +376,11 @@ export const nlTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Overschrijf bestaande veldgegevens', pageNotFound: 'Pagina niet gevonden', password: 'Wachtwoord', + pasteField: 'Veld plakken', + pasteRow: 'Rij plakken', payloadSettings: 'Payload Instellingen', + permanentlyDelete: 'Permanent Verwijderen', + permanentlyDeletedCountSuccessfully: 'Permanent {{count}} {{label}} succesvol verwijderd.', perPage: 'Per pagina: {{limit}}', previous: 'Vorige', reindex: 'Herindexeren', @@ -351,6 +392,11 @@ export const nlTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Dit zal al je voorkeuren terugzetten naar de standaardinstellingen.', resettingPreferences: 'Voorkeuren worden gereset.', + restore: 'Herstellen', + restoreAsPublished: 'Herstellen als gepubliceerde versie', + restoredCountSuccessfully: '{{count}} {{label}} succesvol hersteld.', + restoring: + 'Respecteer de betekenis van de originele tekst in de context van Payload. Hier volgt een lijst van veelvoorkomende Payload-termen die zeer specifieke betekenissen hebben:\n - Collectie: Een collectie is een groep documenten die een gemeenschappelijke structuur en doel delen. Collecties worden gebruikt om content in Payload te organiseren en beheren.\n - Veld: Een veld is een specifiek stuk data binnen een document in een collectie. Velden bepalen de structuur en het type data dat in een document kan worden opgeslagen.\n - Document: Een document is een individueel record binnen', row: 'Rij', rows: 'Rijen', save: 'Bewaar', @@ -381,6 +427,10 @@ export const nlTranslations: DefaultTranslationsObject = { time: 'Tijd', timezone: 'Tijdzone', titleDeleted: '{{label}} "{{title}}" succesvol verwijderd.', + titleRestored: '{{label}} "{{title}}" succesvol hersteld.', + titleTrashed: '{{label}} "{{title}}" verplaatst naar prullenbak.', + trash: 'Prullenbak', + trashedCountSuccessfully: '{{count}} {{label}} verplaatst naar prullenbak.', true: 'Waar', unauthorized: 'Onbevoegd', unsavedChanges: 'U heeft niet-opgeslagen wijzigingen. Sla op of verwijder voordat u doorgaat.', @@ -399,6 +449,7 @@ export const nlTranslations: DefaultTranslationsObject = { username: 'Gebruikersnaam', users: 'Gebruikers', value: 'Waarde', + viewing: 'Bekijken', viewReadOnly: 'Alleen-lezen weergave', welcome: 'Welkom', yes: 'Ja', @@ -525,6 +576,7 @@ export const nlTranslations: DefaultTranslationsObject = { noRowsFound: 'Geen {{label}} gevonden', noRowsSelected: 'Geen {{label}} geselecteerd', preview: 'Voorbeeld', + previouslyDraft: 'Voorheen een Concept', previouslyPublished: 'Eerder gepubliceerd', previousVersion: 'Vorige Versie', problemRestoringVersion: 'Er was een probleem bij het herstellen van deze versie', diff --git a/packages/translations/src/languages/pl.ts b/packages/translations/src/languages/pl.ts index 2fe46c666a..ee59807154 100644 --- a/packages/translations/src/languages/pl.ts +++ b/packages/translations/src/languages/pl.ts @@ -86,10 +86,14 @@ export const plTranslations: DefaultTranslationsObject = { deletingFile: '', deletingTitle: 'Wystąpił błąd podczas usuwania {{title}}. Proszę, sprawdź swoje połączenie i spróbuj ponownie.', + documentNotFound: + 'Dokument o ID {{id}} nie mógł zostać znaleziony. Mogło zostać usunięte lub nigdy nie istniało, lub może nie masz do niego dostępu.', emailOrPasswordIncorrect: 'Podany adres e-mail lub hasło jest nieprawidłowe.', followingFieldsInvalid_one: 'To pole jest nieprawidłowe:', followingFieldsInvalid_other: 'Następujące pola są nieprawidłowe:', incorrectCollection: 'Nieprawidłowa kolekcja', + insufficientClipboardPermissions: 'Odmowa dostępu do schowka. Sprawdź uprawnienia schowka.', + invalidClipboardData: 'Nieprawidłowe dane schowka.', invalidFileType: 'Nieprawidłowy typ pliku', invalidFileTypeValue: 'Nieprawidłowy typ pliku: {{value}}', invalidRequestArgs: 'Nieprawidłowe argumenty w żądaniu: {{args}}', @@ -109,8 +113,11 @@ export const plTranslations: DefaultTranslationsObject = { noUser: 'Brak użytkownika', previewing: 'Wystąpił problem podczas podglądu tego dokumentu.', problemUploadingFile: 'Wystąpił problem podczas przesyłania pliku.', + restoringTitle: + 'Wystąpił błąd podczas przywracania {{title}}. Sprawdź swoje połączenie i spróbuj ponownie.', tokenInvalidOrExpired: 'Token jest nieprawidłowy lub wygasł.', tokenNotProvided: 'Token nie został dostarczony.', + unableToCopy: 'Nie można skopiować.', unableToDeleteCount: 'Nie można usunąć {{count}} z {{total}} {{label}}.', unableToReindexCollection: 'Błąd podczas ponownego indeksowania kolekcji {{collection}}. Operacja została przerwana.', @@ -180,6 +187,8 @@ export const plTranslations: DefaultTranslationsObject = { deleteFolder: 'Usuń folder', folderName: 'Nazwa folderu', folders: 'Foldery', + folderTypeDescription: + 'Wybierz, które typy dokumentów z kolekcji powinny być dozwolone w tym folderze.', itemHasBeenMoved: '{{title}} został przeniesiony do {{folderName}}', itemHasBeenMovedToRoot: '{{title}} został przeniesiony do folderu głównego', itemsMovedToFolder: '{{title}} przeniesiono do {{folderName}}', @@ -206,6 +215,17 @@ export const plTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Zamierzasz usunąć {{count}} {{label}}', aboutToDeleteCount_one: 'Zamierzasz usunąć {{count}} {{label}}', aboutToDeleteCount_other: 'Zamierzasz usunąć {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Zamierzasz na stałe usunąć {{label}} <1>{{title}}. Czy jesteś pewien?', + aboutToPermanentlyDeleteTrash: + 'Zamierzasz na stałe usunąć <0>{{count}} <1>{{label}} z kosza. Czy jesteś pewny?', + aboutToRestore: 'Zamierzasz przywrócić {{label}} <1>{{title}}. Czy jesteś pewny?', + aboutToRestoreAsDraft: + 'Zamierzasz przywrócić {{label}} <1>{{title}} jako szkic. Czy jesteś pewien?', + aboutToRestoreAsDraftCount: 'Za chwilę przywrócisz {{count}} {{label}} jako szkic', + aboutToRestoreCount: 'Za chwilę przywrócisz {{count}} {{label}}', + aboutToTrash: 'Zamierzasz przenieść {{label}} <1>{{title}} do kosza. Czy jesteś pewien?', + aboutToTrashCount: 'Zamierzasz przenieść {{count}} {{label}} do kosza.', addBelow: 'Dodaj poniżej', addFilter: 'Dodaj filtr', adminTheme: 'Motyw administratora', @@ -222,6 +242,7 @@ export const plTranslations: DefaultTranslationsObject = { cancel: 'Anuluj', changesNotSaved: 'Twoje zmiany nie zostały zapisane. Jeśli teraz wyjdziesz, stracisz swoje zmiany.', + clear: 'Jasne', clearAll: 'Wyczyść wszystko', close: 'Zamknij', collapse: 'Zwiń', @@ -239,9 +260,12 @@ export const plTranslations: DefaultTranslationsObject = { 'Spowoduje to usunięcie istniejących indeksów i ponowne zaindeksowanie dokumentów w kolekcjach {{collections}}.', confirmReindexDescriptionAll: 'Spowoduje to usunięcie istniejących indeksów i ponowne zaindeksowanie dokumentów we wszystkich kolekcjach.', + confirmRestoration: 'Potwierdź przywrócenie', copied: 'Skopiowano', copy: 'Skopiuj', + copyField: 'Kopiuj pole', copying: 'Kopiowanie', + copyRow: 'Kopiuj wiersz', copyWarning: 'Zamierzasz nadpisać {{to}} na {{from}} dla {{label}} {{title}}. Czy jesteś pewny?', create: 'Stwórz', @@ -257,13 +281,17 @@ export const plTranslations: DefaultTranslationsObject = { dark: 'Ciemny', dashboard: 'Panel', delete: 'Usuń', + deleted: 'Usunięte', + deletedAt: 'Usunięto o', deletedCountSuccessfully: 'Pomyślnie usunięto {{count}} {{label}}.', deletedSuccessfully: 'Pomyślnie usunięto.', + deletePermanently: 'Pomiń kosz i usuń na stałe', deleting: 'Usuwanie...', depth: 'Głębokość', descending: 'Malejąco', deselectAllRows: 'Odznacz wszystkie wiersze', document: 'Dokument', + documentIsTrashed: 'To {{label}} jest w koszu i jest tylko do odczytu.', documentLocked: 'Dokument zablokowany', documents: 'Dokumenty', duplicate: 'Zduplikuj', @@ -279,6 +307,8 @@ export const plTranslations: DefaultTranslationsObject = { editLabel: 'Edytuj {{label}}', email: 'Email', emailAddress: 'Adres email', + emptyTrash: 'Opróżnij kosz', + emptyTrashLabel: 'Opróżnij śmieci {{label}}', enterAValue: 'Wpisz wartość', error: 'Błąd', errors: 'Błędy', @@ -291,6 +321,7 @@ export const plTranslations: DefaultTranslationsObject = { filterWhere: 'Filtruj gdzie', globals: 'Globalne', goBack: 'Wróć', + groupByLabel: 'Grupuj według {{label}}', import: 'Import', isEditing: 'edytuje', item: 'przedmiot', @@ -326,6 +357,7 @@ export const plTranslations: DefaultTranslationsObject = { 'Nie znaleziono {{label}}. Być może {{label}} jeszcze nie istnieje, albo żaden nie pasuje do filtrów określonych powyżej.', notFound: 'Nie znaleziono', nothingFound: 'Nic nie znaleziono', + noTrashResults: 'Brak {{label}} w koszu.', noUpcomingEventsScheduled: 'Nie zaplanowano żadnych nadchodzących wydarzeń.', noValue: 'Brak wartości', of: 'z', @@ -336,7 +368,11 @@ export const plTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Nadpisz istniejące dane pola', pageNotFound: 'Strona nie znaleziona', password: 'Hasło', + pasteField: 'Wklej pole', + pasteRow: 'Wklej wiersz', payloadSettings: 'Ustawienia Payload', + permanentlyDelete: 'Trwale Usuń', + permanentlyDeletedCountSuccessfully: 'Trwale usunięto {{count}} {{label}} pomyślnie.', perPage: 'Na stronę: {{limit}}', previous: 'Poprzedni', reindex: 'Ponowne indeksowanie', @@ -347,6 +383,10 @@ export const plTranslations: DefaultTranslationsObject = { resetPreferences: 'Zresetuj preferencje', resetPreferencesDescription: 'To zresetuje wszystkie Twoje preferencje do ustawień domyślnych.', resettingPreferences: 'Resetowanie preferencji.', + restore: 'Przywróć', + restoreAsPublished: 'Przywróć jako opublikowaną wersję', + restoredCountSuccessfully: 'Pomyślnie przywrócono {{count}} {{label}}.', + restoring: 'Przywracanie...', row: 'Wiersz', rows: 'Wiersze', save: 'Zapisz', @@ -377,6 +417,10 @@ export const plTranslations: DefaultTranslationsObject = { time: 'Czas', timezone: 'Strefa czasowa', titleDeleted: 'Pomyślnie usunięto {{label}} {{title}}', + titleRestored: 'Etykieta "{{title}}" została pomyślnie przywrócona.', + titleTrashed: '{{label}} "{{title}}" przeniesiony do kosza.', + trash: 'Śmieci', + trashedCountSuccessfully: '{{count}} {{label}} przeniesiono do kosza.', true: 'Prawda', unauthorized: 'Brak autoryzacji', unsavedChanges: 'Masz niezapisane zmiany. Zapisz lub odrzuć, zanim kontynuujesz.', @@ -395,6 +439,7 @@ export const plTranslations: DefaultTranslationsObject = { username: 'Nazwa użytkownika', users: 'użytkownicy', value: 'Wartość', + viewing: 'Podgląd', viewReadOnly: 'Widok tylko do odczytu', welcome: 'Witaj', yes: 'Tak', @@ -520,6 +565,7 @@ export const plTranslations: DefaultTranslationsObject = { noRowsFound: 'Nie znaleziono {{label}}', noRowsSelected: 'Nie wybrano {{etykieta}}', preview: 'Podgląd', + previouslyDraft: 'Poprzednio Szkic', previouslyPublished: 'Wcześniej opublikowane', previousVersion: 'Poprzednia Wersja', problemRestoringVersion: 'Wystąpił problem podczas przywracania tej wersji', diff --git a/packages/translations/src/languages/pt.ts b/packages/translations/src/languages/pt.ts index eeb889a213..ca01657d8d 100644 --- a/packages/translations/src/languages/pt.ts +++ b/packages/translations/src/languages/pt.ts @@ -87,10 +87,15 @@ export const ptTranslations: DefaultTranslationsObject = { deletingFile: 'Ocorreu um erro ao excluir o arquivo.', deletingTitle: 'Ocorreu um erro ao excluir {{title}}. Por favor, verifique sua conexão e tente novamente.', + documentNotFound: + 'O documento com o ID {{id}} não pôde ser encontrado. Ele pode ter sido deletado ou nunca ter existido, ou você pode não ter acesso a ele.', emailOrPasswordIncorrect: 'O email ou senha fornecido está incorreto.', followingFieldsInvalid_one: 'O campo a seguir está inválido:', followingFieldsInvalid_other: 'Os campos a seguir estão inválidos:', incorrectCollection: 'Coleção Incorreta', + insufficientClipboardPermissions: + 'Acesso à área de transferência negado. Verifique suas permissões da área de transferência.', + invalidClipboardData: 'Dados inválidos na área de transferência.', invalidFileType: 'Tipo de arquivo inválido', invalidFileTypeValue: 'Tipo de arquivo inválido: {{value}}', invalidRequestArgs: 'Argumentos inválidos passados na solicitação: {{args}}', @@ -110,8 +115,11 @@ export const ptTranslations: DefaultTranslationsObject = { noUser: 'Nenhum Usuário', previewing: 'Ocorreu um problema ao visualizar esse documento.', problemUploadingFile: 'Ocorreu um problema ao carregar o arquivo.', + restoringTitle: + 'Ocorreu um erro ao restaurar {{title}}. Por favor, verifique sua conexão e tente novamente.', tokenInvalidOrExpired: 'Token expirado ou inválido.', tokenNotProvided: 'Token não fornecido.', + unableToCopy: 'Não é possível copiar.', unableToDeleteCount: 'Não é possível excluir {{count}} de {{total}} {{label}}.', unableToReindexCollection: 'Erro ao reindexar a coleção {{collection}}. Operação abortada.', unableToUpdateCount: 'Não foi possível atualizar {{count}} de {{total}} {{label}}.', @@ -180,6 +188,8 @@ export const ptTranslations: DefaultTranslationsObject = { deleteFolder: 'Apagar Pasta', folderName: 'Nome da Pasta', folders: 'Pastas', + folderTypeDescription: + 'Selecione qual tipo de documentos da coleção devem ser permitidos nesta pasta.', itemHasBeenMoved: '{{title}} foi movido para {{folderName}}', itemHasBeenMovedToRoot: '{{title}} foi movido para a pasta raiz', itemsMovedToFolder: '{{title}} movido para {{folderName}}', @@ -206,6 +216,18 @@ export const ptTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Você está prestes a deletar {{count}} {{label}}', aboutToDeleteCount_one: 'Você está prestes a deletar {{count}} {{label}}', aboutToDeleteCount_other: 'Você está prestes a deletar {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Está prestes a apagar permanentemente o {{label}} <1>{{title}}. Tem certeza?', + aboutToPermanentlyDeleteTrash: + 'Você está prestes a excluir permanentemente <0>{{count}} <1>{{label}} da lixeira. Você tem certeza?', + aboutToRestore: 'Está prestes a restaurar o {{label}} <1>{{title}}. Tem certeza?', + aboutToRestoreAsDraft: + 'Está prestes a restaurar o {{label}} <1>{{title}} como um rascunho. Tem certeza?', + aboutToRestoreAsDraftCount: 'Está prestes a restaurar {{count}} {{label}} como rascunho', + aboutToRestoreCount: 'Você está prestes a restaurar {{count}} {{label}}', + aboutToTrash: + 'Você está prestes a mover o {{label}} <1>{{title}} para a lixeira. Tem certeza?', + aboutToTrashCount: 'Estás prestes a mover {{count}} {{label}} para o lixo', addBelow: 'Adicionar abaixo', addFilter: 'Adicionar Filtro', adminTheme: 'Tema do Admin', @@ -222,6 +244,7 @@ export const ptTranslations: DefaultTranslationsObject = { cancel: 'Cancelar', changesNotSaved: 'Suas alterações não foram salvas. Se você sair agora, essas alterações serão perdidas.', + clear: 'Claro', clearAll: 'Limpar Tudo', close: 'Fechar', collapse: 'Recolher', @@ -239,9 +262,12 @@ export const ptTranslations: DefaultTranslationsObject = { 'Isso removerá os índices existentes e reindexará os documentos nas coleções {{collections}}.', confirmReindexDescriptionAll: 'Isso removerá os índices existentes e reindexará os documentos em todas as coleções.', + confirmRestoration: 'Confirme a restauração', copied: 'Copiado', copy: 'Copiar', + copyField: 'Copiar campo', copying: 'Copiando', + copyRow: 'Copiar linha', copyWarning: 'Você está prestes a sobrescrever {{to}} com {{from}} para {{label}} {{title}}. Tem certeza?', create: 'Criar', @@ -257,13 +283,17 @@ export const ptTranslations: DefaultTranslationsObject = { dark: 'Escuro', dashboard: 'Painel de Controle', delete: 'Excluir', + deleted: 'Excluído', + deletedAt: 'Excluído Em', deletedCountSuccessfully: 'Excluído {{count}} {{label}} com sucesso.', deletedSuccessfully: 'Apagado com sucesso.', + deletePermanently: 'Pular lixeira e excluir permanentemente', deleting: 'Excluindo...', depth: 'Profundidade', descending: 'Decrescente', deselectAllRows: 'Desmarcar todas as linhas', document: 'Documento', + documentIsTrashed: 'Este {{label}} está na lixeira e é somente para leitura.', documentLocked: 'Documento bloqueado', documents: 'Documentos', duplicate: 'Duplicar', @@ -279,6 +309,8 @@ export const ptTranslations: DefaultTranslationsObject = { editLabel: 'Editar {{label}}', email: 'Email', emailAddress: 'Endereço de Email', + emptyTrash: 'Esvaziar lixo', + emptyTrashLabel: 'Esvazie o lixo {{label}}', enterAValue: 'Insira um valor', error: 'Erro', errors: 'Erros', @@ -291,6 +323,7 @@ export const ptTranslations: DefaultTranslationsObject = { filterWhere: 'Filtrar {{label}} em que', globals: 'Globais', goBack: 'Voltar', + groupByLabel: 'Agrupar por {{label}}', import: 'Importar', isEditing: 'está editando', item: 'item', @@ -326,6 +359,7 @@ export const ptTranslations: DefaultTranslationsObject = { 'Nenhum {{label}} encontrado. Ou nenhum(a) {{label}} existe ainda, ou nenhum(a) corresponde aos filtros que você especificou acima.', notFound: 'Não Encontrado', nothingFound: 'Nada encontrado', + noTrashResults: 'Não há {{label}} no lixo.', noUpcomingEventsScheduled: 'Não há eventos futuros agendados.', noValue: 'Nenhum valor', of: 'de', @@ -336,7 +370,11 @@ export const ptTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Sobrescrever dados de campo existentes', pageNotFound: 'Página não encontrada', password: 'Senha', + pasteField: 'Colar campo', + pasteRow: 'Colar linha', payloadSettings: 'Configurações do Payload', + permanentlyDelete: 'Excluir Permanentemente', + permanentlyDeletedCountSuccessfully: 'Apagou permanentemente {{count}} {{label}} com sucesso.', perPage: 'Itens por Página: {{limit}}', previous: 'Anterior', reindex: 'Reindexar', @@ -348,6 +386,11 @@ export const ptTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Isso redefinirá todas as suas preferências para as configurações padrão.', resettingPreferences: 'Redefinindo preferências.', + restore: 'Restaurar', + restoreAsPublished: 'Restaurar como versão publicada', + restoredCountSuccessfully: 'Restaurado {{count}} {{label}} com sucesso.', + restoring: + 'Respeite o significado do texto original dentro do contexto do Payload. Aqui está uma lista de termos comuns do Payload que possuem significados muito específicos:\n - Collection: Uma coleção é um grupo de documentos que compartilham uma estrutura e propósito comuns. As coleções são usadas para organizar e gerenciar conteúdo no Payload.\n - Field: Um campo é uma peça específica de dados dentro de um documento em uma coleção. Os campos definem a estrutura e o tipo de dados que podem ser armazenados em um documento.\n - Document: Um documento é um registro individual dentro de uma coleção. Ele contém dados estruturados de acordo', row: 'Linha', rows: 'Linhas', save: 'Salvar', @@ -378,6 +421,10 @@ export const ptTranslations: DefaultTranslationsObject = { time: 'Tempo', timezone: 'Fuso horário', titleDeleted: '{{label}} {{title}} excluído com sucesso.', + titleRestored: '{{label}} "{{title}}" restaurado com sucesso.', + titleTrashed: '{{label}} "{{title}}" movido para a lixeira.', + trash: 'Lixo', + trashedCountSuccessfully: '{{count}} {{label}} movido para o lixo.', true: 'Verdadeiro', unauthorized: 'Não autorizado', unsavedChanges: 'Você tem alterações não salvas. Salve ou descarte antes de continuar.', @@ -396,6 +443,7 @@ export const ptTranslations: DefaultTranslationsObject = { username: 'Nome de usuário', users: 'usuários', value: 'Valor', + viewing: 'Visualização', viewReadOnly: 'Visualizar somente leitura', welcome: 'Boas vindas', yes: 'Sim', @@ -521,6 +569,7 @@ export const ptTranslations: DefaultTranslationsObject = { noRowsFound: 'Nenhum(a) {{label}} encontrado(a)', noRowsSelected: 'Nenhum {{rótulo}} selecionado', preview: 'Pré-visualização', + previouslyDraft: 'Anteriormente um Rascunho', previouslyPublished: 'Publicado Anteriormente', previousVersion: 'Versão Anterior', problemRestoringVersion: 'Ocorreu um problema ao restaurar essa versão', diff --git a/packages/translations/src/languages/ro.ts b/packages/translations/src/languages/ro.ts index 85fbf75393..9b559551bf 100644 --- a/packages/translations/src/languages/ro.ts +++ b/packages/translations/src/languages/ro.ts @@ -88,10 +88,15 @@ export const roTranslations: DefaultTranslationsObject = { deletingFile: 'S-a produs o eroare la ștergerea fișierului.', deletingTitle: 'S-a produs o eroare în timpul ștergerii {{title}}. Vă rugăm să verificați conexiunea și să încercați din nou.', + documentNotFound: + 'Documentul cu ID-ul {{id}} nu a putut fi găsit. S-ar putea să fi fost șters sau să nu fi existat niciodată, sau s-ar putea să nu aveți acces la acesta.', emailOrPasswordIncorrect: 'Adresa de e-mail sau parola este incorectă.', followingFieldsInvalid_one: 'Următorul câmp nu este valid:', followingFieldsInvalid_other: 'Următoarele câmpuri nu sunt valabile:', incorrectCollection: 'Colecție incorectă', + insufficientClipboardPermissions: + 'Accesul la clipboard a fost refuzat. Verificați permisiunile clipboard-ului.', + invalidClipboardData: 'Date invalide în clipboard.', invalidFileType: 'Tip de fișier invalid', invalidFileTypeValue: 'Tip de fișier invalid: {{value}}', invalidRequestArgs: 'Argumente invalide transmise în cerere: {{args}}', @@ -111,8 +116,11 @@ export const roTranslations: DefaultTranslationsObject = { noUser: 'Nici un utilizator', previewing: 'A existat o problemă la previzualizarea acestui document.', problemUploadingFile: 'A existat o problemă în timpul încărcării fișierului.', + restoringTitle: + 'A survenit o eroare în timpul restaurării {{title}}. Verificați conexiunea și încercați din nou.', tokenInvalidOrExpired: 'Tokenul este invalid sau a expirat.', tokenNotProvided: 'Tokenul nu a fost furnizat.', + unableToCopy: 'Imposibil de copiat.', unableToDeleteCount: 'Nu se poate șterge {{count}} din {{total}} {{label}}.', unableToReindexCollection: 'Eroare la reindexarea colecției {{collection}}. Operațiune anulată.', @@ -184,6 +192,8 @@ export const roTranslations: DefaultTranslationsObject = { deleteFolder: 'Ștergeți dosarul', folderName: 'Nume dosar', folders: 'Dosare', + folderTypeDescription: + 'Selectați ce tip de documente din colecție ar trebui să fie permise în acest dosar.', itemHasBeenMoved: '{{title}} a fost mutat în {{folderName}}', itemHasBeenMovedToRoot: '{{title}} a fost mutat în dosarul rădăcină', itemsMovedToFolder: '{{title}} a fost mutat în {{folderName}}', @@ -210,6 +220,18 @@ export const roTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Sunteți pe cale să ștergeți {{count}} {{label}}', aboutToDeleteCount_one: 'Sunteți pe cale să ștergeți {{count}} {{label}}', aboutToDeleteCount_other: 'Sunteți pe cale să ștergeți {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Sunteți pe cale să ștergeți definitiv {{label}} <1>{{title}}. Sunteți sigur?', + aboutToPermanentlyDeleteTrash: + 'Sunteți pe cale să ștergeți definitiv <0>{{count}} <1>{{label}} din coșul de gunoi. Sunteți sigur?', + aboutToRestore: 'Sunteți pe cale să restaurați {{label}} <1>{{title}}. Sunteți sigur?', + aboutToRestoreAsDraft: + 'Sunteți pe cale să restaurați {{label}} <1>{{title}} ca o versiune preliminară. Sunteți sigur?', + aboutToRestoreAsDraftCount: 'Sunteți pe cale să restaurați {{count}} {{label}} ca proiect', + aboutToRestoreCount: 'Sunteți pe cale să restaurați {{count}} {{label}}', + aboutToTrash: + 'Sunteți pe cale să mutați {{label}} <1>{{title}} în coșul de gunoi. Sunteți sigur?', + aboutToTrashCount: 'Sunteți pe cale să mutați {{count}} {{label}} la gunoi.', addBelow: 'Adaugă mai jos', addFilter: 'Adaugă filtru', adminTheme: 'Tema Admin', @@ -226,6 +248,7 @@ export const roTranslations: DefaultTranslationsObject = { cancel: 'Anulați', changesNotSaved: 'Modificările dvs. nu au fost salvate. Dacă plecați acum, vă veți pierde modificările.', + clear: 'Clar', clearAll: 'Șterge tot', close: 'Închide', collapse: 'Colaps', @@ -243,9 +266,12 @@ export const roTranslations: DefaultTranslationsObject = { 'Aceasta va elimina indexurile existente și va reindexa documentele din colecțiile {{collections}}.', confirmReindexDescriptionAll: 'Aceasta va elimina indexurile existente și va reindexa documentele din toate colecțiile.', + confirmRestoration: 'Confirmă restaurarea', copied: 'Copiat', copy: 'Copiați', + copyField: 'Copiază câmpul', copying: 'Copiere', + copyRow: 'Copiază rândul', copyWarning: 'Sunteți pe cale să suprascrieți {{to}} cu {{from}} pentru {{label}} {{title}}. Sunteți sigur?', create: 'Creează', @@ -261,13 +287,17 @@ export const roTranslations: DefaultTranslationsObject = { dark: 'Dark', dashboard: 'Panoul de bord', delete: 'Șterge', + deleted: 'Șters', + deletedAt: 'Șters la', deletedCountSuccessfully: 'Șterse cu succes {{count}} {{label}}.', deletedSuccessfully: 'Șters cu succes.', + deletePermanently: 'Omite coșul și șterge definitiv', deleting: 'Deleting...', depth: 'Adâncime', descending: 'Descendentă', deselectAllRows: 'Deselectează toate rândurile', document: 'Document', + documentIsTrashed: 'Acest {{label}} este la gunoi și poate fi doar citit.', documentLocked: 'Document blocat', documents: 'Documente', duplicate: 'Duplicați', @@ -283,6 +313,8 @@ export const roTranslations: DefaultTranslationsObject = { editLabel: 'Editați {{label}}', email: 'Email', emailAddress: 'Adresa de email', + emptyTrash: 'Golește coșul de gunoi', + emptyTrashLabel: 'Goliți coșul {{label}}', enterAValue: 'Introduceți o valoare', error: 'Eroare', errors: 'Erori', @@ -295,6 +327,7 @@ export const roTranslations: DefaultTranslationsObject = { filterWhere: 'Filtrează {{label}} unde', globals: 'Globale', goBack: 'Înapoi', + groupByLabel: 'Grupare după {{label}}', import: 'Import', isEditing: 'editează', item: 'articol', @@ -330,6 +363,7 @@ export const roTranslations: DefaultTranslationsObject = { 'Nici un {{label}} găsit. Fie nu există încă niciun {{label}}, fie niciunul nu se potrivește cu filtrele pe care le-ați specificat mai sus..', notFound: 'Nu a fost găsit', nothingFound: 'Nimic găsit', + noTrashResults: 'Niciun {{label}} în coșul de gunoi.', noUpcomingEventsScheduled: 'Nu sunt evenimente programate în viitor.', noValue: 'Nici o valoare', of: 'de', @@ -340,7 +374,11 @@ export const roTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Suprascrieți datele existente din câmp', pageNotFound: 'Pagina nu a fost găsită', password: 'Parola', + pasteField: 'Lipește câmpul', + pasteRow: 'Lipește rândul', payloadSettings: 'Setări de Payload', + permanentlyDelete: 'Șterge definitiv', + permanentlyDeletedCountSuccessfully: 'Șters permanent cu succes {{count}} {{label}}.', perPage: 'Pe pagină: {{limit}}', previous: 'Anterior', reindex: 'Reindexare', @@ -351,6 +389,11 @@ export const roTranslations: DefaultTranslationsObject = { resetPreferences: 'Resetare preferințe', resetPreferencesDescription: 'Aceasta va reseta toate preferințele tale la setările implicite.', resettingPreferences: 'Resetare preferințe.', + restore: 'Restaurare', + restoreAsPublished: 'Restabilește ca versiune publicată', + restoredCountSuccessfully: '{{count}} {{label}} restabilite cu succes.', + restoring: + 'Respectați semnificația textului original în contextul Payload. Iată o listă de termeni obișnuiți Payload care au semnificații foarte specifice:\n - Colectie: O colectie este un grup de documente care împart o structură și un scop comun. Colectiile sunt utilizate pentru a organiza și gestiona conținutul în Payload.\n - Câmp: Un câmp este o piesă specifică de date dintr-un document dintr-o colecție. Câmpurile definesc structura și tipul de date care pot fi stocate într-un document.\n - Document', row: 'Rând', rows: 'Rânduri', save: 'Salvează', @@ -381,6 +424,10 @@ export const roTranslations: DefaultTranslationsObject = { time: 'Timp', timezone: 'Fus orar', titleDeleted: '{{label}} "{{title}}" șters cu succes.', + titleRestored: '{{label}} "{{title}}" a fost restaurat cu succes.', + titleTrashed: '{{label}} "{{title}}" a fost mutat la coșul de gunoi.', + trash: 'Gunoi', + trashedCountSuccessfully: '{{count}} {{label}} mutate la coșul de gunoi.', true: 'Adevărat', unauthorized: 'neautorizat(ă)', unsavedChanges: 'Aveți modificări nesalvate. Salvați sau renunțați înainte de a continua.', @@ -399,6 +446,7 @@ export const roTranslations: DefaultTranslationsObject = { username: 'Nume de utilizator', users: 'Utilizatori', value: 'Valoare', + viewing: 'Vizualizare', viewReadOnly: 'Vizualizare doar pentru citire', welcome: 'Bine ați venit', yes: 'Da', @@ -528,6 +576,7 @@ export const roTranslations: DefaultTranslationsObject = { noRowsFound: 'Nu s-a găsit niciun {{label}}', noRowsSelected: 'Niciun {{etichetă}} selectat', preview: 'Previzualizare', + previouslyDraft: 'Anterior un Proiect', previouslyPublished: 'Publicat anterior', previousVersion: 'Versiune Anterioară', problemRestoringVersion: 'A existat o problemă la restaurarea acestei versiuni', diff --git a/packages/translations/src/languages/rs.ts b/packages/translations/src/languages/rs.ts index d90726c8f6..449cf51b8c 100644 --- a/packages/translations/src/languages/rs.ts +++ b/packages/translations/src/languages/rs.ts @@ -87,10 +87,15 @@ export const rsTranslations: DefaultTranslationsObject = { deletingFile: 'Догодила се грешка при брисању датотеке.', deletingTitle: 'Догодила се грешка при брисању {{title}}. Проверите интернет конекцију и покушајте поново.', + documentNotFound: + 'Dokument sa ID-om {{id}} nije mogao biti pronađen. Moguće je da je obrisan ili nikada nije postojao, ili možda nemate pristup njemu.', emailOrPasswordIncorrect: 'Емаил или лозинка су неисправни.', followingFieldsInvalid_one: 'Ово поље је невалидно:', followingFieldsInvalid_other: 'Ова поља су невалидна:', incorrectCollection: 'Невалидна колекција', + insufficientClipboardPermissions: + 'Приступ к клипборду је одбијен. Провјерите своја овлашћења за клипборд.', + invalidClipboardData: 'Неважећи подаци у клипборду.', invalidFileType: 'Невалидан тип датотеке', invalidFileTypeValue: 'Невалидан тип датотеке: {{value}}', invalidRequestArgs: 'Неважећи аргументи прослеђени у захтеву: {{args}}', @@ -110,8 +115,11 @@ export const rsTranslations: DefaultTranslationsObject = { noUser: 'Нема корисника', previewing: 'Постоји проблем при прегледу овог документа.', problemUploadingFile: 'Постоји проблем при учитавању датотеке.', + restoringTitle: + 'Došlo je do greške prilikom vraćanja {{title}}. Proverite svoju vezu i pokušajte ponovo.', tokenInvalidOrExpired: 'Токен је невалидан или је истекао.', tokenNotProvided: 'Token nije dostavljen.', + unableToCopy: 'Није могуће копирати.', unableToDeleteCount: 'Није могуће избрисати {{count}} од {{total}} {{label}}.', unableToReindexCollection: 'Грешка при реиндексирању колекције {{collection}}. Операција је прекинута.', @@ -181,6 +189,8 @@ export const rsTranslations: DefaultTranslationsObject = { deleteFolder: 'Obriši fasciklu', folderName: 'Ime fascikle', folders: 'Fascikle', + folderTypeDescription: + 'Odaberite koja vrsta dokumenata iz kolekcije treba biti dozvoljena u ovom folderu.', itemHasBeenMoved: '{{title}} je premješten u {{folderName}}', itemHasBeenMovedToRoot: '{{title}} je premešten u osnovni direktorijum.', itemsMovedToFolder: '{{title}} premešten u {{folderName}}', @@ -207,6 +217,18 @@ export const rsTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Избрисаћете {{count}} {{label}}', aboutToDeleteCount_one: 'Избрисаћете {{count}} {{label}}', aboutToDeleteCount_other: 'Избрисаћете {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Управо ћете заувек избрисати {{label}} <1>{{title}}. Јесте ли сигурни?', + aboutToPermanentlyDeleteTrash: + 'На путу сте да трајно обришете <0>{{count}} <1>{{label}} из смећа. Да ли сте сигурни?', + aboutToRestore: 'На путу сте да вратите {{label}} <1>{{title}}. Јесте ли сигурни?', + aboutToRestoreAsDraft: + 'Na korak ste od obnavljanja {{label}} <1>{{title}} kao nacrta. Da li ste sigurni?', + aboutToRestoreAsDraftCount: 'Upravo ste na koraku da povratite {{count}} {{label}} kao skicu', + aboutToRestoreCount: 'Uskoro ćete obnoviti {{count}} {{label}}', + aboutToTrash: + 'Na korak ste da premestite {{label}} <1>{{title}} u otpad. Da li ste sigurni?', + aboutToTrashCount: 'Upravo ćete premestiti {{count}} {{label}} u smeće', addBelow: 'Додај испод', addFilter: 'Додај филтер', adminTheme: 'Администраторска тема', @@ -222,6 +244,7 @@ export const rsTranslations: DefaultTranslationsObject = { backToDashboard: 'Назад на контролни панел', cancel: 'Откажи', changesNotSaved: 'Ваше промене нису сачуване. Ако изађете сада, изгубићете промене.', + clear: 'Jasno', clearAll: 'Obriši sve', close: 'Затвори', collapse: 'Скупи', @@ -239,9 +262,12 @@ export const rsTranslations: DefaultTranslationsObject = { 'Ovo će ukloniti postojeće indekse i ponovo indeksirati dokumente u kolekcijama {{collections}}.', confirmReindexDescriptionAll: 'Ovo će ukloniti postojeće indekse i ponovo indeksirati dokumente u svim kolekcijama.', + confirmRestoration: 'Potvrdite obnovu', copied: 'Копирано', copy: 'Копирај', + copyField: 'Копирај поље', copying: 'Kopiranje', + copyRow: 'Копирај ред', copyWarning: 'На путу сте да препишете {{to}} са {{from}} за {{label}} {{title}}. Да ли сте сигурни?', create: 'Креирај', @@ -257,13 +283,17 @@ export const rsTranslations: DefaultTranslationsObject = { dark: 'Тамно', dashboard: 'Контролни панел', delete: 'Обриши', + deleted: 'Obrisano', + deletedAt: 'Obrisano u', deletedCountSuccessfully: 'Успешно избрисано {{count}} {{label}}.', deletedSuccessfully: 'Успешно избрисано.', + deletePermanently: 'Preskoči otpad i trajno izbriši', deleting: 'Брисање...', depth: 'Dubina', descending: 'Опадајуће', deselectAllRows: 'Деселектујте све редове', document: 'Dokument', + documentIsTrashed: 'Ova {{label}} je odbačena i samo je za čitanje.', documentLocked: 'Документ је закључан', documents: 'Dokumenti', duplicate: 'Дупликат', @@ -279,6 +309,8 @@ export const rsTranslations: DefaultTranslationsObject = { editLabel: 'Уреди {{label}}', email: 'Е-пошта', emailAddress: 'Адреса е-поште', + emptyTrash: 'Isprazni korpu', + emptyTrashLabel: 'Isprazni {{label}} korpu za smeće', enterAValue: 'Унеси вредност', error: 'Грешка', errors: 'Грешке', @@ -291,6 +323,7 @@ export const rsTranslations: DefaultTranslationsObject = { filterWhere: 'Филтер {{label}} где', globals: 'Глобали', goBack: 'Врати се', + groupByLabel: 'Grupiši po {{label}}', import: 'Uvoz', isEditing: 'уређује', item: 'artikal', @@ -326,6 +359,7 @@ export const rsTranslations: DefaultTranslationsObject = { 'Нема пронађених {{label}}. Могуће да {{label}} још увек не постоји или нема резултата у складу са постављеним филтерима.', notFound: 'Није пронађено', nothingFound: 'Ништа није пронађено', + noTrashResults: 'Nema {{label}} u otpadu.', noUpcomingEventsScheduled: 'Nema zakazanih predstojećih događaja.', noValue: 'Без вредности', of: 'Од', @@ -336,7 +370,11 @@ export const rsTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Prepišite postojeće podatke u polju', pageNotFound: 'Страница није пронађена', password: 'Лозинка', + pasteField: 'Залепи поље', + pasteRow: 'Залепи ред', payloadSettings: 'Payload поставке', + permanentlyDelete: 'Trajno Izbriši', + permanentlyDeletedCountSuccessfully: 'Trajno obrisano {{count}} {{label}} uspešno.', perPage: 'По страници: {{limit}}', previous: 'Prethodni', reindex: 'Реиндексирај', @@ -347,6 +385,10 @@ export const rsTranslations: DefaultTranslationsObject = { resetPreferences: 'Поништи подешавања', resetPreferencesDescription: 'Ово ће поништити сва ваша подешавања на подразумеване вредности.', resettingPreferences: 'Поништавање подешавања.', + restore: 'Vrati', + restoreAsPublished: 'Vrati na objavljenu verziju', + restoredCountSuccessfully: 'Uspješno obnovljeno {{count}} {{label}}.', + restoring: 'Vraćanje u prvobitno stanje...', row: 'Ред', rows: 'Редови', save: 'Сачувај', @@ -377,6 +419,10 @@ export const rsTranslations: DefaultTranslationsObject = { time: 'Vreme', timezone: 'Vremenska zona', titleDeleted: '{{label}} "{{title}}" успешно обрисано.', + titleRestored: '{{label}} "{{title}}" uspešno obnovljen.', + titleTrashed: '{{label}} "{{title}}" premešten u otpad.', + trash: 'Smeće', + trashedCountSuccessfully: '{{count}} {{label}} premješteno u smeće.', true: 'Istinito', unauthorized: 'Нисте ауторизовани', unsavedChanges: 'Imate nesačuvane izmene. Sačuvajte ili odbacite pre nego što nastavite.', @@ -395,6 +441,7 @@ export const rsTranslations: DefaultTranslationsObject = { username: 'Korisničko ime', users: 'Корисници', value: 'Вредност', + viewing: 'Pregled', viewReadOnly: 'Прегледај само за читање', welcome: 'Добродошли', yes: 'Да', @@ -516,6 +563,7 @@ export const rsTranslations: DefaultTranslationsObject = { noRowsFound: '{{label}} није пронађено', noRowsSelected: 'Nije odabrana {{label}}', preview: 'Преглед', + previouslyDraft: 'Prethodno Nacrt', previouslyPublished: 'Prethodno objavljeno', previousVersion: 'Prethodna verzija', problemRestoringVersion: 'Настао је проблем при враћању ове верзије', diff --git a/packages/translations/src/languages/rsLatin.ts b/packages/translations/src/languages/rsLatin.ts index 096fdf31a2..4d0b9ca22a 100644 --- a/packages/translations/src/languages/rsLatin.ts +++ b/packages/translations/src/languages/rsLatin.ts @@ -87,10 +87,15 @@ export const rsLatinTranslations: DefaultTranslationsObject = { deletingFile: 'Dogodila se greška pri brisanju datoteke.', deletingTitle: 'Dogodila se greška pri brisanju {{title}}. Proverite internet konekciju i pokušajte ponovo.', + documentNotFound: + 'Dokument sa ID {{id}} nije mogao biti pronađen. Moguće je da je obrisan ili nikad nije postojao, ili možda nemate pristup njemu.', emailOrPasswordIncorrect: 'Adresa e-pošte ili lozinka su neispravni.', followingFieldsInvalid_one: 'Ovo polje je nevalidno:', followingFieldsInvalid_other: 'Ova polja su nevalidna:', incorrectCollection: 'Nevalidna kolekcija', + insufficientClipboardPermissions: + 'Pristup clipboard-u odbijen. Proverite svoja dopuštenja za clipboard.', + invalidClipboardData: 'Nevažeći podaci u clipboard-u.', invalidFileType: 'Nevalidan tip datoteke', invalidFileTypeValue: 'Nevalidan tip datoteke: {{value}}', invalidRequestArgs: 'Nevažeći argumenti prosleđeni u zahtevu: {{args}}', @@ -110,8 +115,11 @@ export const rsLatinTranslations: DefaultTranslationsObject = { noUser: 'Nema korisnika', previewing: 'Postoji problem pri pregledu ovog dokumenta.', problemUploadingFile: 'Postoji problem pri učitavanju datoteke.', + restoringTitle: + 'Došlo je do greške prilikom vraćanja {{title}}. Molimo vas da proverite svoju vezu i pokušate ponovo.', tokenInvalidOrExpired: 'Token je nevalidan ili je istekao.', tokenNotProvided: 'Token nije obezbeđen.', + unableToCopy: 'Kopiranje nije moguće.', unableToDeleteCount: 'Nije moguće izbrisati {{count}} od {{total}} {{label}}.', unableToReindexCollection: 'Greška pri reindeksiranju kolekcije {{collection}}. Operacija je prekinuta.', @@ -181,6 +189,8 @@ export const rsLatinTranslations: DefaultTranslationsObject = { deleteFolder: 'Obriši mapu', folderName: 'Naziv fascikle', folders: 'Fascikle', + folderTypeDescription: + 'Odaberite koja vrsta dokumenta iz kolekcije bi trebala biti dozvoljena u ovoj fascikli.', itemHasBeenMoved: '{{title}} je premesten u {{folderName}}', itemHasBeenMovedToRoot: '{{title}} je premešten u osnovnu fasciklu', itemsMovedToFolder: '{{title}} premešteno u {{folderName}}', @@ -207,6 +217,18 @@ export const rsLatinTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Izbrisaćete {{count}} {{label}}', aboutToDeleteCount_one: 'Izbrisaćete {{count}} {{label}}', aboutToDeleteCount_other: 'Izbrisaćete {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Na korak ste da trajno izbrišete {{label}} <1>{{title}}. Da li ste sigurni?', + aboutToPermanentlyDeleteTrash: + 'Na korak ste da trajno obrišete <0>{{count}} <1>{{label}} iz otpada. Da li ste sigurni?', + aboutToRestore: 'Na korak ste da vratite {{label}} <1>{{title}}. Da li ste sigurni?', + aboutToRestoreAsDraft: + 'Uskoro ćete obnoviti {{label}} <1>{{title}} kao skicu. Da li ste sigurni?', + aboutToRestoreAsDraftCount: 'Uskoro ćete vratiti {{count}} {{label}} kao nacrt', + aboutToRestoreCount: 'Uskoro ćete obnoviti {{count}} {{label}}', + aboutToTrash: + 'Na korak ste da premestite {{label}} <1>{{title}} u otpad. Da li ste sigurni?', + aboutToTrashCount: 'Upravo ćete prebaciti {{count}} {{label}} u smeće', addBelow: 'Dodaj ispod', addFilter: 'Dodaj filter', adminTheme: 'Administratorska tema', @@ -222,6 +244,7 @@ export const rsLatinTranslations: DefaultTranslationsObject = { backToDashboard: 'Nazad na kontrolni panel', cancel: 'Otkaži', changesNotSaved: 'Vaše promene nisu sačuvane. Ako izađete sada, izgubićete promene.', + clear: 'Jasno', clearAll: 'Očisti sve', close: 'Zatvori', collapse: 'Skupi', @@ -239,9 +262,12 @@ export const rsLatinTranslations: DefaultTranslationsObject = { 'Ovo će ukloniti postojeće indekse i ponovo indeksirati dokumente u kolekcijama {{collections}}.', confirmReindexDescriptionAll: 'Ovo će ukloniti postojeće indekse i ponovo indeksirati dokumente u svim kolekcijama.', + confirmRestoration: 'Potvrdite obnovu', copied: 'Kopirano', copy: 'Kopiraj', + copyField: 'Kopiraj polje', copying: 'Kopiranje', + copyRow: 'Kopiraj red', copyWarning: 'Na korak ste da prepišete {{to}} sa {{from}} za {{label}} {{title}}. Da li ste sigurni?', create: 'Kreiraj', @@ -257,13 +283,17 @@ export const rsLatinTranslations: DefaultTranslationsObject = { dark: 'Tamno', dashboard: 'Kontrolni panel', delete: 'Obriši', + deleted: 'Obrisano', + deletedAt: 'Obrisano U', deletedCountSuccessfully: 'Uspešno izbrisano {{count}} {{label}}.', deletedSuccessfully: 'Uspešno izbrisano.', + deletePermanently: 'Preskoči kantu za smeće i trajno izbriši', deleting: 'Brisanje...', depth: 'Dubina', descending: 'Opadajuće', deselectAllRows: 'Deselektujte sve redove', document: 'Dokument', + documentIsTrashed: 'Ova {{label}} je odbačena i može se samo čitati.', documentLocked: 'Dokument je zaključan', documents: 'Dokumenti', duplicate: 'Duplikat', @@ -279,6 +309,8 @@ export const rsLatinTranslations: DefaultTranslationsObject = { editLabel: 'Uredi {{label}}', email: 'E-pošta', emailAddress: 'Аdresa e-pošte', + emptyTrash: 'Isprazni otpad', + emptyTrashLabel: 'Isprazni {{label}} korpu za smeće', enterAValue: 'Unesi vrednost', error: 'Greška', errors: 'Greške', @@ -291,6 +323,7 @@ export const rsLatinTranslations: DefaultTranslationsObject = { filterWhere: 'Filter {{label}} gde', globals: 'Globali', goBack: 'Vrati se', + groupByLabel: 'Grupiši po {{label}}', import: 'Uvoz', isEditing: 'uređuje', item: 'stavka', @@ -326,6 +359,7 @@ export const rsLatinTranslations: DefaultTranslationsObject = { 'Nema pronađenih {{label}}. Moguće da {{label}} još uvek ne postoji ili nema rezultata u skladu sa postavljenim filterima.', notFound: 'Nije pronađeno', nothingFound: 'Ništa nije pronađeno', + noTrashResults: 'Nema {{label}} u otpadu.', noUpcomingEventsScheduled: 'Nema zakazanih predstojećih događaja.', noValue: 'Bez vrednosti', of: 'Od', @@ -336,7 +370,11 @@ export const rsLatinTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Prepiši postojeće podatke iz polja', pageNotFound: 'Stranica nije pronađena', password: 'Lozinka', + pasteField: 'Zalepi polje', + pasteRow: 'Zalepi red', payloadSettings: 'Payload postavke', + permanentlyDelete: 'Trajno Obriši', + permanentlyDeletedCountSuccessfully: 'Trajno obrisano {{count}} {{label}} uspešno.', perPage: 'Po stranici: {{limit}}', previous: 'Prethodni', reindex: 'Reindeksiraj', @@ -348,6 +386,10 @@ export const rsLatinTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Ovo će poništiti sva vaša podešavanja na podrazumevane vrednosti.', resettingPreferences: 'Poništavanje podešavanja.', + restore: 'Vrati', + restoreAsPublished: 'Vrati kao objavljenu verziju', + restoredCountSuccessfully: 'Uspešno obnovljeno {{count}} {{label}}.', + restoring: 'Vraćanje na prethodno stanje...', row: 'Red', rows: 'Redovi', save: 'Sačuvaj', @@ -378,6 +420,10 @@ export const rsLatinTranslations: DefaultTranslationsObject = { time: 'Vreme', timezone: 'Vremenska zona', titleDeleted: '{{label}} "{{title}}" uspešno obrisano.', + titleRestored: 'Oznaka "{{title}}" uspešno obnovljena.', + titleTrashed: '{{label}} "{{title}}" premešteno u smeće.', + trash: 'Otpad', + trashedCountSuccessfully: '{{count}} {{label}} premešteno u kantu za smeće.', true: 'Istinito', unauthorized: 'Niste autorizovani', unsavedChanges: 'Imate nesačuvane promene. Sačuvajte ili odbacite pre nego što nastavite.', @@ -396,6 +442,7 @@ export const rsLatinTranslations: DefaultTranslationsObject = { username: 'Korisničko ime', users: 'Korisnici', value: 'Vrednost', + viewing: 'Pregled', viewReadOnly: 'Pregledaj samo za čitanje', welcome: 'Dobrodošli', yes: 'Da', @@ -511,13 +558,14 @@ export const rsLatinTranslations: DefaultTranslationsObject = { currentPublishedVersion: 'Trenutna Objavljena Verzija', draft: 'Nacrt', draftSavedSuccessfully: 'Nacrt uspešno sačuvan.', - lastSavedAgo: 'Zadnji put sačuvano pre {{distance}', + lastSavedAgo: 'Zadnji put sačuvano pre {{distance}}', modifiedOnly: 'Samo izmenjen', moreVersions: 'Više verzija...', noFurtherVersionsFound: 'Nisu pronađene naredne verzije', noRowsFound: '{{label}} nije pronađeno', noRowsSelected: 'Nije odabrana {{label}}', preview: 'Pregled', + previouslyDraft: 'Prethodno Nacrt', previouslyPublished: 'Prethodno objavljeno', previousVersion: 'Prethodna Verzija', problemRestoringVersion: 'Nastao je problem pri vraćanju ove verzije', diff --git a/packages/translations/src/languages/ru.ts b/packages/translations/src/languages/ru.ts index 53aa502e18..a50df7f591 100644 --- a/packages/translations/src/languages/ru.ts +++ b/packages/translations/src/languages/ru.ts @@ -87,10 +87,15 @@ export const ruTranslations: DefaultTranslationsObject = { deletingFile: 'Произошла ошибка при удалении файла.', deletingTitle: 'При удалении {{title}} произошла ошибка. Пожалуйста, проверьте соединение и повторите попытку.', + documentNotFound: + 'Документ с ID {{id}} не удалось найти. Возможно, он был удален или никогда не существовал, или у вас нет доступа к нему.', emailOrPasswordIncorrect: 'Указанный email или пароль неверен.', followingFieldsInvalid_one: 'Следующее поле недействительно:', followingFieldsInvalid_other: 'Следующие поля недействительны:', incorrectCollection: 'Неправильная Коллекция', + insufficientClipboardPermissions: + 'Доступ к буферу обмена отклонен. Проверьте разрешения буфера обмена.', + invalidClipboardData: 'Неверные данные в буфере обмена.', invalidFileType: 'Недопустимый тип файла', invalidFileTypeValue: 'Недопустимый тип файла: {{value}}', invalidRequestArgs: 'В запрос переданы недопустимые аргументы: {{args}}', @@ -110,8 +115,11 @@ export const ruTranslations: DefaultTranslationsObject = { noUser: 'Нет Пользователя', previewing: 'При предварительном просмотре этого документа возникла проблема.', problemUploadingFile: 'Возникла проблема при загрузке файла.', + restoringTitle: + 'Произошла ошибка при восстановлении {{title}}. Пожалуйста, проверьте свое соединение и попробуйте снова.', tokenInvalidOrExpired: 'Токен либо недействителен, либо срок его действия истек.', tokenNotProvided: 'Токен не предоставлен.', + unableToCopy: 'Не удалось скопировать.', unableToDeleteCount: 'Не удалось удалить {{count}} из {{total}} {{label}}.', unableToReindexCollection: 'Ошибка при переиндексации коллекции {{collection}}. Операция прервана.', @@ -182,6 +190,8 @@ export const ruTranslations: DefaultTranslationsObject = { deleteFolder: 'Удалить папку', folderName: 'Название папки', folders: 'Папки', + folderTypeDescription: + 'Выберите, какие типы документов коллекции должны быть разрешены в этой папке.', itemHasBeenMoved: '{{title}} был перемещен в {{folderName}}', itemHasBeenMovedToRoot: '{{title}} был перемещен в корневую папку', itemsMovedToFolder: '{{title}} перемещен в {{folderName}}', @@ -208,6 +218,17 @@ export const ruTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Вы собираетесь удалить {{count}} {{label}}', aboutToDeleteCount_one: 'Вы собираетесь удалить {{count}} {{label}}', aboutToDeleteCount_other: 'Вы собираетесь удалить {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Вы собираетесь навсегда удалить {{label}} <1>{{title}}. Вы уверены?', + aboutToPermanentlyDeleteTrash: + 'Вы собираетесь навсегда удалить <0>{{count}} <1>{{label}} из корзины. Вы уверены?', + aboutToRestore: 'Вы собираетесь восстановить {{label}} <1>{{title}}. Вы уверены?', + aboutToRestoreAsDraft: + 'Вы собираетесь восстановить {{label}} <1>{{title}} как черновик. Вы уверены?', + aboutToRestoreAsDraftCount: 'Вы собираетесь восстановить {{count}} {{label}} как черновик', + aboutToRestoreCount: 'Вы собираетесь восстановить {{count}} {{label}}', + aboutToTrash: 'Вы собираетесь переместить {{label}} <1>{{title}} в корзину. Вы уверены?', + aboutToTrashCount: 'Вы собираетесь переместить {{count}} {{label}} в корзину', addBelow: 'Добавить ниже', addFilter: 'Добавить фильтр', adminTheme: 'Тема Панели', @@ -224,6 +245,7 @@ export const ruTranslations: DefaultTranslationsObject = { cancel: 'Отмена', changesNotSaved: 'Ваши изменения не были сохранены. Если вы сейчас уйдете, то потеряете свои изменения.', + clear: 'Четкий', clearAll: 'Очистить все', close: 'Закрыть', collapse: 'Свернуть', @@ -241,9 +263,12 @@ export const ruTranslations: DefaultTranslationsObject = { 'Это удалит существующие индексы и переиндексирует документы в коллекциях {{collections}}.', confirmReindexDescriptionAll: 'Это удалит существующие индексы и переиндексирует документы во всех коллекциях.', + confirmRestoration: 'Подтвердите восстановление', copied: 'Скопировано', copy: 'Скопировать', + copyField: 'Копировать поле', copying: 'Копирование', + copyRow: 'Копировать строку', copyWarning: 'Вы собираетесь перезаписать {{to}} на {{from}} для {{label}} {{title}}. Вы уверены?', create: 'Создать', @@ -259,13 +284,17 @@ export const ruTranslations: DefaultTranslationsObject = { dark: 'Тёмная', dashboard: 'Панель', delete: 'Удалить', + deleted: 'Удалено', + deletedAt: 'Удалено В', deletedCountSuccessfully: 'Удалено {{count}} {{label}} успешно.', deletedSuccessfully: 'Удален успешно.', + deletePermanently: 'Пропустить корзину и удалить навсегда', deleting: 'Удаление...', depth: 'Глубина', descending: 'Уменьшение', deselectAllRows: 'Снять выделение со всех строк', document: 'Документ', + documentIsTrashed: 'Этот {{label}} находится в корзине и доступен только для чтения.', documentLocked: 'Документ заблокирован', documents: 'Документы', duplicate: 'Дублировать', @@ -281,6 +310,8 @@ export const ruTranslations: DefaultTranslationsObject = { editLabel: 'Редактировать {{label}}', email: 'Email', emailAddress: 'Email', + emptyTrash: 'Очистить корзину', + emptyTrashLabel: 'Очистить корзину для {{label}}', enterAValue: 'Введите значение', error: 'Ошибка', errors: 'Ошибки', @@ -293,6 +324,7 @@ export const ruTranslations: DefaultTranslationsObject = { filterWhere: 'Где фильтровать', globals: 'Глобальные', goBack: 'Назад', + groupByLabel: 'Группировать по {{label}}', import: 'Импорт', isEditing: 'редактирует', item: 'предмет', @@ -328,6 +360,7 @@ export const ruTranslations: DefaultTranslationsObject = { 'Ничего не найдено. Возможно, {{label}} еще не существует или не соответствует указанным фильтрам.', notFound: 'Не найдено', nothingFound: 'Ничего не найдено', + noTrashResults: 'Нет {{label}} в корзине.', noUpcomingEventsScheduled: 'Нет запланированных предстоящих событий.', noValue: 'Нет значения', of: 'из', @@ -338,7 +371,11 @@ export const ruTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Перезаписать существующие данные поля', pageNotFound: 'Страница не найдена', password: 'Пароль', + pasteField: 'Вставить поле', + pasteRow: 'Вставить строку', payloadSettings: 'Настройки Payload', + permanentlyDelete: 'Удалить Навсегда', + permanentlyDeletedCountSuccessfully: 'Успешно удалено {{count}} {{label}} навсегда.', perPage: 'На странице: {{limit}}', previous: 'Предыдущий', reindex: 'Переиндексировать', @@ -349,6 +386,10 @@ export const ruTranslations: DefaultTranslationsObject = { resetPreferences: 'Сбросить настройки', resetPreferencesDescription: 'Это сбросит все ваши настройки до значений по умолчанию.', resettingPreferences: 'Сброс настроек.', + restore: 'Восстановить', + restoreAsPublished: 'Восстановить как опубликованную версию', + restoredCountSuccessfully: 'Восстановлено успешно {{count}} {{label}}.', + restoring: 'Восстановление...', row: 'Строка', rows: 'Строки', save: 'Сохранить', @@ -379,6 +420,10 @@ export const ruTranslations: DefaultTranslationsObject = { time: 'Время', timezone: 'Часовой пояс', titleDeleted: '{{label}} {{title}} успешно удалено.', + titleRestored: '{{label}} "{{title}}" успешно восстановлен.', + titleTrashed: '{{label}} "{{title}}" перемещен в корзину.', + trash: 'Мусор', + trashedCountSuccessfully: '{{count}} {{label}} перемещено в корзину.', true: 'Правда', unauthorized: 'Нет доступа', unsavedChanges: @@ -399,6 +444,7 @@ export const ruTranslations: DefaultTranslationsObject = { username: 'Имя пользователя', users: 'пользователи', value: 'Значение', + viewing: 'Просмотр', viewReadOnly: 'Просмотр только для чтения', welcome: 'Добро пожаловать', yes: 'Да', @@ -523,6 +569,7 @@ export const ruTranslations: DefaultTranslationsObject = { noRowsFound: 'Не найдено {{label}}', noRowsSelected: 'Не выбран {{label}}', preview: 'Предпросмотр', + previouslyDraft: 'Ранее был черновик', previouslyPublished: 'Ранее опубликовано', previousVersion: 'Предыдущая версия', problemRestoringVersion: 'Возникла проблема с восстановлением этой версии', diff --git a/packages/translations/src/languages/sk.ts b/packages/translations/src/languages/sk.ts index f0dd04d0d6..43fbe8b04f 100644 --- a/packages/translations/src/languages/sk.ts +++ b/packages/translations/src/languages/sk.ts @@ -87,10 +87,15 @@ export const skTranslations: DefaultTranslationsObject = { deletingFile: 'Pri mazaní súboru došlo k chybe.', deletingTitle: 'Pri mazaní {{title}} došlo k chybe. Skontrolujte svoje pripojenie a skúste to znova.', + documentNotFound: + 'Dokument s ID {{id}} sa nepodarilo nájsť. Možno bol vymazaný, nikdy neexistoval, alebo k nemu nemáte prístup.', emailOrPasswordIncorrect: 'Zadaný email alebo heslo nie je správne.', followingFieldsInvalid_one: 'Nasledujúce pole je neplatné:', followingFieldsInvalid_other: 'Nasledujúce polia sú neplatné:', incorrectCollection: 'Nesprávna kolekcia', + insufficientClipboardPermissions: + 'Prístup do schránky bol zamietnutý. Skontrolujte svoje oprávnenia pre schránku.', + invalidClipboardData: 'Neplatné dáta v schránke.', invalidFileType: 'Neplatný typ súboru', invalidFileTypeValue: 'Neplatný typ súboru: {{value}}', invalidRequestArgs: 'Neplatné argumenty odoslané v požiadavke: {{args}}', @@ -110,8 +115,11 @@ export const skTranslations: DefaultTranslationsObject = { noUser: 'Žiadny používateľ', previewing: 'Pri náhľade tohto dokumentu došlo k chybe.', problemUploadingFile: 'Pri nahrávaní súboru došlo k chybe.', + restoringTitle: + 'Pri obnovovaní {{title}} sa vyskytla chyba. Skontrolujte prosím svoje pripojenie a skúste to znova.', tokenInvalidOrExpired: 'Token je neplatný alebo vypršal.', tokenNotProvided: 'Token nie je poskytnutý.', + unableToCopy: 'Kopírovanie nie je možné.', unableToDeleteCount: 'Nie je možné zmazať {{count}} z {{total}} {{label}}.', unableToReindexCollection: 'Chyba pri reindexácii kolekcie {{collection}}. Operácia bola prerušená.', @@ -183,6 +191,8 @@ export const skTranslations: DefaultTranslationsObject = { deleteFolder: 'Odstrániť priečinok', folderName: 'Názov priečinka', folders: 'Priečinky', + folderTypeDescription: + 'Vyberte, ktorý typ dokumentov z kolekcie by mal byť povolený v tejto zložke.', itemHasBeenMoved: '{{title}} bol presunutý do {{folderName}}', itemHasBeenMovedToRoot: '{{title}} bol presunutý do koreňového priečinka', itemsMovedToFolder: '{{title}} presunuté do {{folderName}}', @@ -209,6 +219,17 @@ export const skTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Chystáte sa zmazať {{count}} {{label}}', aboutToDeleteCount_one: 'Chystáte sa zmazať {{count}} {{label}}', aboutToDeleteCount_other: 'Chystáte sa zmazať {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Chystáte sa natrvalo vymazať {{label}} <1>{{title}}. Ste si istý?', + aboutToPermanentlyDeleteTrash: + 'Chystáte sa natrvalo odstrániť <0>{{count}} <1>{{label}} z koša. Ste si istý?', + aboutToRestore: 'Chystáte sa obnoviť {{label}} <1>{{title}}. Ste si istý?', + aboutToRestoreAsDraft: + 'Chystáte sa obnoviť {{label}} <1>{{title}} ako koncept. Ste si istý?', + aboutToRestoreAsDraftCount: 'Chystáte sa obnoviť {{count}} {{label}} ako koncept', + aboutToRestoreCount: 'Chystáte sa obnoviť {{count}} {{label}}', + aboutToTrash: 'Chystáte sa presunúť {{label}} <1>{{title}} do koša. Ste si istý?', + aboutToTrashCount: 'Chystáte sa presunúť {{count}} {{label}} do koša', addBelow: 'Pridať pod', addFilter: 'Pridať filter', adminTheme: 'Motív administračného rozhrania', @@ -224,6 +245,7 @@ export const skTranslations: DefaultTranslationsObject = { backToDashboard: 'Späť na nástenku', cancel: 'Zrušiť', changesNotSaved: 'Vaše zmeny neboli uložené. Ak teraz odídete, stratíte svoje zmeny.', + clear: 'Jasný', clearAll: 'Vymazať všetko', close: 'Zavrieť', collapse: 'Zbaliť', @@ -241,9 +263,12 @@ export const skTranslations: DefaultTranslationsObject = { 'Týmto sa odstránia existujúce indexy a znova sa zaindexujú dokumenty v kolekciách {{collections}}.', confirmReindexDescriptionAll: 'Týmto sa odstránia existujúce indexy a znova sa zaindexujú dokumenty vo všetkých kolekciách.', + confirmRestoration: 'Potvrďte obnovenie', copied: 'Skopírované', copy: 'Kopírovať', + copyField: 'Kopírovať pole', copying: 'Kopírovanie', + copyRow: 'Kopírovať riadok', copyWarning: 'Chystáte sa prepísať {{to}} na {{from}} pre {{label}} {{title}}. Ste si istý?', create: 'Vytvoriť', created: 'Vytvořeno', @@ -258,13 +283,17 @@ export const skTranslations: DefaultTranslationsObject = { dark: 'Tmavý', dashboard: 'Nástenka', delete: 'Odstrániť', + deleted: 'Vymazané', + deletedAt: 'Vymazané dňa', deletedCountSuccessfully: 'Úspešne zmazané {{count}} {{label}}.', deletedSuccessfully: 'Úspešne odstránené.', + deletePermanently: 'Preskočiť kôš a odstrániť natrvalo', deleting: 'Odstraňovanie...', depth: 'Hĺbka', descending: 'Zostupne', deselectAllRows: 'Zrušiť výber všetkých riadkov', document: 'Dokument', + documentIsTrashed: 'Táto {{label}} je v koši a je iba na čítanie.', documentLocked: 'Dokument je zamknutý', documents: 'Dokumenty', duplicate: 'Duplikovať', @@ -280,6 +309,8 @@ export const skTranslations: DefaultTranslationsObject = { editLabel: 'Upraviť {{label}}', email: 'E-mail', emailAddress: 'E-mailová adresa', + emptyTrash: 'Vyprázdniť koš', + emptyTrashLabel: 'Vyprázdniť koš {{label}}', enterAValue: 'Zadajte hodnotu', error: 'Chyba', errors: 'Chyby', @@ -292,6 +323,7 @@ export const skTranslations: DefaultTranslationsObject = { filterWhere: 'Filtrovat kde je {{label}}', globals: 'Globalné', goBack: 'Vrátiť sa', + groupByLabel: 'Zoskupiť podľa {{label}}', import: 'Dovoz', isEditing: 'upravuje', item: 'položka', @@ -326,6 +358,7 @@ export const skTranslations: DefaultTranslationsObject = { 'Neboli nájdené žiadne {{label}}. Buď neexistujú žiadne {{label}}, alebo žiadne nespĺňajú filtre, ktoré ste zadali vyššie.', notFound: 'Nenájdené', nothingFound: 'Nič nenájdené', + noTrashResults: 'Žiadne {{label}} v koši.', noUpcomingEventsScheduled: 'Nie sú naplánované žiadne nadchádzajúce udalosti.', noValue: 'Žiadna hodnota', of: 'z', @@ -336,7 +369,11 @@ export const skTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Prepísať existujúce pole dát', pageNotFound: 'Stránka nenájdená', password: 'Heslo', + pasteField: 'Prilepiť pole', + pasteRow: 'Prilepiť riadok', payloadSettings: 'Nastavenia dátového záznamu', + permanentlyDelete: 'Trvalo odstrániť', + permanentlyDeletedCountSuccessfully: 'Úspešne ste natrvalo odstránili {{count}} {{label}}.', perPage: 'Na stránku: {{limit}}', previous: 'Predchádzajúci', reindex: 'Reindexovať', @@ -347,6 +384,10 @@ export const skTranslations: DefaultTranslationsObject = { resetPreferences: 'Obnoviť nastavenia', resetPreferencesDescription: 'Týmto sa všetky vaše nastavenia vrátia na predvolené hodnoty.', resettingPreferences: 'Obnovovanie nastavení.', + restore: 'Obnoviť', + restoreAsPublished: 'Obnoviť ako publikovanú verziu', + restoredCountSuccessfully: 'Úspešne obnovené {{count}} {{label}}.', + restoring: 'Obnovovanie...', row: 'Riadok', rows: 'Riadky', save: 'Uložiť', @@ -377,6 +418,10 @@ export const skTranslations: DefaultTranslationsObject = { time: 'Čas', timezone: 'Časové pásmo', titleDeleted: '{{label}} "{{title}}" úspešne zmazané.', + titleRestored: '{{label}} "{{title}}" úspešne obnovený.', + titleTrashed: '{{label}} "{{title}}" presunuté do koša.', + trash: 'Koš', + trashedCountSuccessfully: '{{count}} {{label}} presunuté do koša.', true: 'Pravda', unauthorized: 'Neoprávnený prístup', unsavedChanges: 'Máte neuložené zmeny. Uložte alebo zahoďte pred pokračovaním.', @@ -395,6 +440,7 @@ export const skTranslations: DefaultTranslationsObject = { username: 'Používateľské meno', users: 'Používatelia', value: 'Hodnota', + viewing: 'Prezeranie', viewReadOnly: 'Zobraziť iba na čítanie', welcome: 'Vitajte', yes: 'Áno', @@ -519,6 +565,7 @@ export const skTranslations: DefaultTranslationsObject = { noRowsFound: 'Nenájdené {{label}}', noRowsSelected: 'Nie je vybraté žiadne {{označenie}}', preview: 'Náhľad', + previouslyDraft: 'Predtým Koncept', previouslyPublished: 'Predtým publikované', previousVersion: 'Predchádzajúca verzia', problemRestoringVersion: 'Pri obnovovaní tejto verzie došlo k problému', diff --git a/packages/translations/src/languages/sl.ts b/packages/translations/src/languages/sl.ts index 5f388f0c17..5ce531d84a 100644 --- a/packages/translations/src/languages/sl.ts +++ b/packages/translations/src/languages/sl.ts @@ -86,10 +86,15 @@ export const slTranslations: DefaultTranslationsObject = { deletingFile: 'Pri brisanju datoteke je prišlo do napake.', deletingTitle: 'Pri brisanju {{title}} je prišlo do napake. Prosimo, preverite povezavo in poskusite znova.', + documentNotFound: + 'Dokumenta z ID {{id}} ni bilo mogoče najti. Morda je bil izbrisan ali nikoli ni obstajal, ali pa do njega nimate dostopa.', emailOrPasswordIncorrect: 'Vnesena e-pošta ali geslo je napačno.', followingFieldsInvalid_one: 'Naslednje polje je neveljavno:', followingFieldsInvalid_other: 'Naslednja polja so neveljavna:', incorrectCollection: 'Napačna zbirka', + insufficientClipboardPermissions: + 'Dostop do odložišča je bil zavrnjen. Preverite dovoljenja za odložišče.', + invalidClipboardData: 'Neveljavni podatki v odložišču.', invalidFileType: 'Neveljaven tip datoteke', invalidFileTypeValue: 'Neveljaven tip datoteke: {{value}}', invalidRequestArgs: 'V zahtevi so bili poslani neveljavni argumenti: {{args}}', @@ -109,8 +114,11 @@ export const slTranslations: DefaultTranslationsObject = { noUser: 'Ni uporabnika', previewing: 'Pri predogledu tega dokumenta je prišlo do težave.', problemUploadingFile: 'Pri nalaganju datoteke je prišlo do težave.', + restoringTitle: + 'Pri obnavljanju {{title}} je prišlo do napake. Prosimo, preverite svojo povezavo in poskusite znova.', tokenInvalidOrExpired: 'Žeton je neveljaven ali je potekel.', tokenNotProvided: 'Žeton ni bil posredovan.', + unableToCopy: 'Kopiranje ni mogoče.', unableToDeleteCount: 'Ni bilo mogoče izbrisati {{count}} od {{total}} {{label}}.', unableToReindexCollection: 'Napaka pri reindeksiranju zbirke {{collection}}. Operacija je bila prekinjena.', @@ -180,6 +188,8 @@ export const slTranslations: DefaultTranslationsObject = { deleteFolder: 'Izbriši mapo', folderName: 'Ime mape', folders: 'Mape', + folderTypeDescription: + 'Izberite, katere vrste dokumentov zbirke naj bodo dovoljene v tej mapi.', itemHasBeenMoved: '{{title}} je bil premaknjen v {{folderName}}', itemHasBeenMovedToRoot: '{{title}} je bil premaknjen v korensko mapo.', itemsMovedToFolder: '{{title}} premaknjeno v {{folderName}}', @@ -206,6 +216,17 @@ export const slTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Izbrisali boste {{count}} {{label}}', aboutToDeleteCount_one: 'Izbrisali boste {{count}} {{label}}', aboutToDeleteCount_other: 'Izbrisali boste {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Ravno boste trajno izbrisali {{label}} <1>{{title}}. Ste prepričani?', + aboutToPermanentlyDeleteTrash: + 'Pravkar boste trajno izbrisali <0>{{count}} <1>{{label}} iz smetnjaka. Ali ste prepričani?', + aboutToRestore: 'Ravno se odpravljate na obnovitev {{label}} <1>{{title}}. Ste prepričani?', + aboutToRestoreAsDraft: + 'Pravkar boste obnovili {{label}} <1>{{title}} kot osnutek. Ali ste prepričani?', + aboutToRestoreAsDraftCount: 'Pravkar boste obnovili {{count}} {{label}} kot osnutek.', + aboutToRestoreCount: 'Pravkar boste obnovili {{count}} {{label}}', + aboutToTrash: 'Pravkar boste premaknili {{label}} <1>{{title}} v smeti. Ste prepričani?', + aboutToTrashCount: 'Pravkar boste premaknili {{count}} {{label}} v smeti.', addBelow: 'Dodaj spodaj', addFilter: 'Dodaj filter', adminTheme: 'Tema skrbnika', @@ -222,6 +243,7 @@ export const slTranslations: DefaultTranslationsObject = { cancel: 'Prekliči', changesNotSaved: 'Vaše spremembe niso shranjene. Če zapustite zdaj, boste izgubili svoje spremembe.', + clear: 'Čisto', clearAll: 'Počisti vse', close: 'Zapri', collapse: 'Strni', @@ -239,9 +261,12 @@ export const slTranslations: DefaultTranslationsObject = { 'To bo odstranilo obstoječe indekse in ponovno indeksiralo dokumente v zbirkah {{collections}}.', confirmReindexDescriptionAll: 'To bo odstranilo obstoječe indekse in ponovno indeksiralo dokumente v vseh zbirkah.', + confirmRestoration: 'Potrdite obnovitev', copied: 'Kopirano', copy: 'Kopiraj', + copyField: 'Kopiraj polje', copying: 'Kopiranje', + copyRow: 'Kopiraj vrstico', copyWarning: 'Prepisali boste {{to}} z {{from}} za {{label}} {{title}}. Ste prepričani?', create: 'Ustvari', created: 'Ustvarjeno', @@ -256,13 +281,17 @@ export const slTranslations: DefaultTranslationsObject = { dark: 'Temno', dashboard: 'Nadzorna plošča', delete: 'Izbriši', + deleted: 'Izbrisano', + deletedAt: 'Izbrisano ob', deletedCountSuccessfully: 'Uspešno izbrisano {{count}} {{label}}.', deletedSuccessfully: 'Uspešno izbrisano.', + deletePermanently: 'Preskoči smetnjak in trajno izbriši', deleting: 'Brisanje...', depth: 'Globina', descending: 'Padajoče', deselectAllRows: 'Odznači vse vrstice', document: 'Dokument', + documentIsTrashed: 'Ta {{label}} je v smetnjaku in je samo za branje.', documentLocked: 'Dokument zaklenjen', documents: 'Dokumenti', duplicate: 'Podvoji', @@ -278,6 +307,8 @@ export const slTranslations: DefaultTranslationsObject = { editLabel: 'Uredi {{label}}', email: 'E-pošta', emailAddress: 'E-poštni naslov', + emptyTrash: 'Izprazni koš', + emptyTrashLabel: 'Izprazni {{label}} smeti', enterAValue: 'Vnesite vrednost', error: 'Napaka', errors: 'Napake', @@ -290,6 +321,7 @@ export const slTranslations: DefaultTranslationsObject = { filterWhere: 'Filtriraj {{label}} kjer', globals: 'Globalne nastavitve', goBack: 'Nazaj', + groupByLabel: 'Razvrsti po {{label}}', import: 'Uvoz', isEditing: 'ureja', item: 'predmet', @@ -325,6 +357,7 @@ export const slTranslations: DefaultTranslationsObject = { 'Ni najdenih {{label}}. Ali {{label}} še ne obstajajo ali pa ne ustrezajo filtrom, ki ste jih določili zgoraj.', notFound: 'Ni najdeno', nothingFound: 'Nič ni najdeno', + noTrashResults: 'Ni {{label}} v smetnjaku.', noUpcomingEventsScheduled: 'Ni načrtovanih prihajajočih dogodkov.', noValue: 'Ni vrednosti', of: 'od', @@ -335,7 +368,11 @@ export const slTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Prepišite obstoječe podatke polja', pageNotFound: 'Stran ni najdena', password: 'Geslo', + pasteField: 'Prilepi polje', + pasteRow: 'Prilepi vrstico', payloadSettings: 'Nastavitve Payloada', + permanentlyDelete: 'Trajno Izbrisano', + permanentlyDeletedCountSuccessfully: 'Uspešno trajno izbrisano {{count}} {{label}}.', perPage: 'Na stran: {{limit}}', previous: 'Prejšnji', reindex: 'Reindeksiraj', @@ -346,6 +383,11 @@ export const slTranslations: DefaultTranslationsObject = { resetPreferences: 'Ponastavi nastavitve', resetPreferencesDescription: 'To bo ponastavilo vse vaše nastavitve na privzete vrednosti.', resettingPreferences: 'Ponastavitev nastavitve.', + restore: 'Obnovi', + restoreAsPublished: 'Obnovi kot objavljeno različico', + restoredCountSuccessfully: 'Uspešno obnovljeno {{count}} {{label}}.', + restoring: + 'Spoštujte pomen izvirnega besedila znotraj konteksta Payload. Tu je seznam pogostih izrazov Payload, ki imajo zelo specifične pomene:\n - Zbirka: Zbirka je skupina dokumentov, ki delijo skupno strukturo in namen. Zbirke se uporabljajo za organizacijo in upravljanje vsebine v Payload.\n - Polje: Polje je določen del podatkov znotraj dokumenta v zbirki. Polja opredeljujejo strukturo in vrsto podatkov, ki jih je mogoče sh', row: 'Vrstica', rows: 'Vrstice', save: 'Shrani', @@ -376,6 +418,10 @@ export const slTranslations: DefaultTranslationsObject = { time: 'Čas', timezone: 'Časovni pas', titleDeleted: '{{label}} "{{title}}" uspešno izbrisan.', + titleRestored: 'Oznaka "{{title}}" je bila uspešno obnovljena.', + titleTrashed: '{{label}} "{{title}}" premaknjeno v smeti.', + trash: 'Smeti', + trashedCountSuccessfully: '{{count}} {{label}} premaknjeno v smeti.', true: 'Da', unauthorized: 'Nepooblaščeno', unsavedChanges: 'Neshranjene spremembe', @@ -394,6 +440,7 @@ export const slTranslations: DefaultTranslationsObject = { username: 'Uporabniško ime', users: 'Uporabniki', value: 'Vrednost', + viewing: 'Ogled', viewReadOnly: 'Ogled samo za branje', welcome: 'Dobrodošli', yes: 'Da', @@ -517,6 +564,7 @@ export const slTranslations: DefaultTranslationsObject = { noRowsFound: 'Ni najdenih {{label}}', noRowsSelected: 'Ni izbranih {{label}}', preview: 'Predogled', + previouslyDraft: 'Prej osnutek', previouslyPublished: 'Predhodno objavljeno', previousVersion: 'Prejšnja različica', problemRestoringVersion: 'Pri obnavljanju te različice je prišlo do težave', diff --git a/packages/translations/src/languages/sv.ts b/packages/translations/src/languages/sv.ts index 1eec3056ab..860586970a 100644 --- a/packages/translations/src/languages/sv.ts +++ b/packages/translations/src/languages/sv.ts @@ -71,7 +71,7 @@ export const svTranslations: DefaultTranslationsObject = { verifiedSuccessfully: 'Verifierad', verify: 'Verifiera', verifyUser: 'Verifiera användare', - verifyYourEmail: 'Verifiera din epost', + verifyYourEmail: 'Verifiera din e-post', youAreInactive: 'Du har inte varit aktiv på ett tag och kommer inom kort att automatiskt loggas ut för din egen säkerhet. Vill du forsätta att vara inloggad?', youAreReceivingResetPassword: @@ -86,10 +86,15 @@ export const svTranslations: DefaultTranslationsObject = { deletingFile: 'Det gick inte att ta bort filen', deletingTitle: 'Det uppstod ett fel vid borttagningen av {{title}}. Vänligen kontrollera din anslutning och försök igen.', + documentNotFound: + 'Dokumentet med ID {{id}} kunde inte hittas. Det kan ha raderats eller aldrig existerat, eller så kanske du inte har tillgång till det.', emailOrPasswordIncorrect: 'E-postadressen eller lösenordet som angivits är felaktigt.', followingFieldsInvalid_one: 'Följande fält är ogiltigt:', followingFieldsInvalid_other: 'Följande fält är ogiltiga:', incorrectCollection: 'Felaktig samling', + insufficientClipboardPermissions: + 'Åtkomst till urklipp nekades. Kontrollera dina behörigheter för urklipp.', + invalidClipboardData: 'Ogiltiga urklippsdata.', invalidFileType: 'Ogiltig filtyp', invalidFileTypeValue: 'Ogiltig filtyp: {{value}}', invalidRequestArgs: 'Ogiltiga argument har skickats i begäran: {{args}}', @@ -109,8 +114,11 @@ export const svTranslations: DefaultTranslationsObject = { noUser: 'Ingen Användare', previewing: 'Det uppstod ett problem när det här dokumentet skulle förhandsgranskas.', problemUploadingFile: 'Det uppstod ett problem när filen laddades upp.', + restoringTitle: + 'Det uppstod ett fel vid återställning av {{title}}. Vänligen kontrollera din anslutning och försök igen.', tokenInvalidOrExpired: 'Token är antingen ogiltig eller har löpt ut.', tokenNotProvided: 'Token inte tillhandahållet.', + unableToCopy: 'Kan inte kopiera.', unableToDeleteCount: 'Det gick inte att ta bort {{count}} av {{total}} {{label}}.', unableToReindexCollection: 'Fel vid omindexering av samlingen {{collection}}. Operationen avbröts.', @@ -180,6 +188,7 @@ export const svTranslations: DefaultTranslationsObject = { deleteFolder: 'Ta bort mapp', folderName: 'Mappnamn', folders: 'Mappar', + folderTypeDescription: 'Välj vilken typ av samlingsdokument som ska tillåtas i denna mapp.', itemHasBeenMoved: '{{title}} har flyttats till {{folderName}}', itemHasBeenMovedToRoot: '{{title}} har flyttats till rotmappen', itemsMovedToFolder: '{{title}} flyttad till {{folderName}}', @@ -206,12 +215,24 @@ export const svTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Du är på väg att ta bort {{count}} {{label}}', aboutToDeleteCount_one: 'Du är på väg att ta bort {{count}} {{label}}', aboutToDeleteCount_other: 'Du är på väg att ta bort {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Du är på väg att permanent radera {{label}} <1>{{title}}. Är du säker?', + aboutToPermanentlyDeleteTrash: + 'Du är på väg att permanent radera <0>{{count}} <1>{{label}} från papperskorgen. Är du säker?', + aboutToRestore: 'Du är på väg att återställa {{label}} <1>{{title}}. Är du säker?', + aboutToRestoreAsDraft: + 'Du är på väg att återställa {{label}} <1>{{title}} som ett utkast. Är du säker?', + aboutToRestoreAsDraftCount: 'Du är på väg att återställa {{count}} {{label}} som utkast', + aboutToRestoreCount: 'Du är på väg att återställa {{count}} {{label}}', + aboutToTrash: + 'Du håller på att flytta {{label}} <1>{{title}} till papperskorgen. Är du säker?', + aboutToTrashCount: 'Du håller på att flytta {{count}} {{label}} till papperskorgen', addBelow: 'Lägg till nedanför', addFilter: 'Lägg till filter', adminTheme: 'Adminutseende', all: 'Alla', allCollections: 'Alla samlingar', - allLocales: 'Alla platser', + allLocales: 'Alla språk', and: 'Och', anotherUser: 'En annan användare', anotherUserTakenOver: 'En annan användare har tagit över redigeringen av detta dokument.', @@ -222,9 +243,10 @@ export const svTranslations: DefaultTranslationsObject = { cancel: 'Avbryt', changesNotSaved: 'Dina ändringar har inte sparats. Om du lämnar nu kommer du att förlora dina ändringar.', + clear: 'Rensa', clearAll: 'Rensa alla', - close: 'Stänga', - collapse: 'Kollapsa', + close: 'Stäng', + collapse: 'Fäll ihop', collections: 'Samlingar', columns: 'Kolumner', columnToSort: 'Kolumn att sortera', @@ -239,9 +261,12 @@ export const svTranslations: DefaultTranslationsObject = { 'Detta kommer att ta bort befintliga index och omindexera dokumenten i {{collections}}-samlingarna.', confirmReindexDescriptionAll: 'Detta kommer att ta bort befintliga index och omindexera dokumenten i alla samlingar.', + confirmRestoration: 'Bekräfta återställning', copied: 'Kopierad', copy: 'Kopiera', + copyField: 'Kopiera fält', copying: 'Kopierar...', + copyRow: 'Kopiera rad', copyWarning: 'Du håller på att skriva över {{to}} med {{from}} för {{label}} {{title}}. Är du säker?', create: 'Skapa', @@ -257,13 +282,17 @@ export const svTranslations: DefaultTranslationsObject = { dark: 'Mörkt', dashboard: 'Översikt', delete: 'Ta bort', + deleted: 'Raderad', + deletedAt: 'Raderad Vid', deletedCountSuccessfully: 'Raderade {{count}} {{label}}', deletedSuccessfully: 'Borttaget', + deletePermanently: 'Hoppa över papperskorgen och radera permanent', deleting: 'Tar bort...', depth: 'Djup', descending: 'Fallande', deselectAllRows: 'Avmarkera alla rader', document: 'Dokument', + documentIsTrashed: 'Det här {{label}} har slagits i spill och är skrivskyddad.', documentLocked: 'Dokument låst', documents: 'Dokument', duplicate: 'Duplicera', @@ -279,10 +308,12 @@ export const svTranslations: DefaultTranslationsObject = { editLabel: 'Redigera {{label}}', email: 'E-post', emailAddress: 'E-postadress', + emptyTrash: 'Töm papperskorgen', + emptyTrashLabel: 'Töm {{label}} papperskorgen', enterAValue: 'Ange ett värde', error: 'Fel', errors: 'Fel', - exitLivePreview: 'Avsluta Live förhandsgranskning', + exitLivePreview: 'Avsluta förhandsgranskning', export: 'Exportera', fallbackToDefaultLocale: 'Återgå till standardspråk', false: 'Falskt', @@ -291,6 +322,7 @@ export const svTranslations: DefaultTranslationsObject = { filterWhere: 'Filtrera {{label}} där', globals: 'Globala', goBack: 'Gå tillbaka', + groupByLabel: 'Gruppera efter {{label}}', import: 'Importera', isEditing: 'redigerar', item: 'artikel', @@ -300,7 +332,7 @@ export const svTranslations: DefaultTranslationsObject = { leaveAnyway: 'Lämna ändå', leaveWithoutSaving: 'Lämna utan att spara', light: 'Ljust', - livePreview: 'Förhandsvisning', + livePreview: 'Förhandsgranskning', loading: 'Laddar...', locale: 'Språk', locales: 'Språk', @@ -326,6 +358,7 @@ export const svTranslations: DefaultTranslationsObject = { 'Inga {{label}} hittades. Antingen finns inga {{label}} ännu eller så matchar inga filtren du har angett ovan.', notFound: 'Hittades inte', nothingFound: 'Inget hittades', + noTrashResults: 'Inget {{label}} i papperskorgen.', noUpcomingEventsScheduled: 'Inga kommande händelser är planerade.', noValue: 'Inget värde', of: 'av', @@ -333,10 +366,14 @@ export const svTranslations: DefaultTranslationsObject = { open: 'Öppna', or: 'Eller', order: 'Ordning', - overwriteExistingData: 'Skriv över befintlig fältdatabas', + overwriteExistingData: 'Skriv över befintlig fältdata', pageNotFound: 'Sidan hittas inte', password: 'Lösenord', - payloadSettings: 'Programinställningar', + pasteField: 'Klistra in fält', + pasteRow: 'Klistra in rad', + payloadSettings: 'Systeminställningar', + permanentlyDelete: 'Radera Permanent', + permanentlyDeletedCountSuccessfully: '{{count}} {{label}} har raderats permanent.', perPage: 'Per Sida: {{limit}}', previous: 'Föregående', reindex: 'Omindexera', @@ -348,6 +385,11 @@ export const svTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Detta kommer att återställa alla dina preferenser till standardinställningarna.', resettingPreferences: 'Återställer preferenser...', + restore: 'Återställ', + restoreAsPublished: 'Återställ som publicerad version', + restoredCountSuccessfully: 'Återställde {{count}} {{label}} framgångsrikt.', + restoring: + 'Respektera innebörden av den ursprungliga texten inom kontexten av Payload. Här är en lista över gemensamma Payload-termer som bär väldigt specifika betydelser:\n - Samling: En samling är en grupp dokument som delar en gemensam struktur och syfte. Samlingar används för att organisera och hantera innehåll i Payload.\n - Fält: Ett fält är en specifik data inom ett dokument i en samling. Fält definierar strukturen och typen av data som kan lagras i ett dokument.\n - Dokument: Ett dokument är en', row: 'Rad', rows: 'Rader', save: 'Spara', @@ -361,7 +403,7 @@ export const svTranslations: DefaultTranslationsObject = { selectLabel: 'Välj {{label}}', selectValue: 'Välj ett värde', showAllLabel: 'Visa alla {{label}}', - sorryNotFound: 'Tyvärr–det finns inget som motsvarar din begäran.', + sorryNotFound: 'Tyvärr, det finns inget som motsvarar din begäran.', sort: 'Sortera', sortByLabelDirection: 'Sortera efter {{label}} {{direction}}', stayOnThisPage: 'Stanna på denna sida', @@ -378,6 +420,10 @@ export const svTranslations: DefaultTranslationsObject = { time: 'Tid', timezone: 'Tidszon', titleDeleted: '{{label}} "{{title}}" togs bort', + titleRestored: '{{label}} "{{title}}" har framgångsrikt återställts.', + titleTrashed: '{{label}} "{{title}}" flyttades till papperskorgen.', + trash: 'Skräp', + trashedCountSuccessfully: '{{count}} {{label}} flyttades till papperskorgen.', true: 'Sann', unauthorized: 'Obehörig', unsavedChanges: 'Du har osparade ändringar. Spara innan du fortsätter.', @@ -396,7 +442,8 @@ export const svTranslations: DefaultTranslationsObject = { username: 'Användarnamn', users: 'Användare', value: 'Värde', - viewReadOnly: 'Visa endast läsning', + viewing: 'Visar', + viewReadOnly: 'Visa som skrivskyddad', welcome: 'Välkommen', yes: 'Ja', }, @@ -520,6 +567,7 @@ export const svTranslations: DefaultTranslationsObject = { noRowsFound: 'Inga {{label}} hittades', noRowsSelected: 'Inget {{etikett}} valt', preview: 'Förhandsgranska', + previouslyDraft: 'Tidigare ett Utkast', previouslyPublished: 'Tidigare publicerad', previousVersion: 'Föregående version', problemRestoringVersion: 'Det uppstod ett problem när den här versionen skulle återställas', diff --git a/packages/translations/src/languages/th.ts b/packages/translations/src/languages/th.ts index a1855b951e..da336bc15c 100644 --- a/packages/translations/src/languages/th.ts +++ b/packages/translations/src/languages/th.ts @@ -84,10 +84,15 @@ export const thTranslations: DefaultTranslationsObject = { correctInvalidFields: 'โปรดแก้ไขช่องที่ไม่ถูกต้อง', deletingFile: 'เกิดปัญหาระหว่างการลบไฟล์', deletingTitle: 'เกิดปัญหาระหว่างการลบ {{title}} โปรดตรวจสอบการเชื่อมต่อของคุณแล้วลองอีกครั้ง', + documentNotFound: + 'ไม่พบเอกสารที่มี ID {{id}} อาจจะถูกลบหรือไม่เคยมีอยู่ หรือคุณอาจไม่มีสิทธิ์เข้าถึง', emailOrPasswordIncorrect: 'อีเมลหรือรหัสผ่านไม่ถูกต้อง', followingFieldsInvalid_one: 'ช่องต่อไปนี้ไม่ถูกต้อง:', followingFieldsInvalid_other: 'ช่องต่อไปนี้ไม่ถูกต้อง:', incorrectCollection: 'Collection ไม่ถูกต้อง', + insufficientClipboardPermissions: + 'การเข้าถึงคลิปบอร์ดถูกปฏิเสธ กรุณาตรวจสอบสิทธิ์การเข้าถึงคลิปบอร์ดของคุณ', + invalidClipboardData: 'ข้อมูลคลิปบอร์ดไม่ถูกต้อง', invalidFileType: 'ประเภทของไฟล์ไม่ถูกต้อง', invalidFileTypeValue: 'ประเภทของไฟล์ไม่ถูกต้อง: {{value}}', invalidRequestArgs: 'มีการส่งอาร์กิวเมนต์ที่ไม่ถูกต้องในคำขอ: {{args}}', @@ -107,8 +112,11 @@ export const thTranslations: DefaultTranslationsObject = { noUser: 'ไม่พบผู้ใช้', previewing: 'เกิดปัญหาระหว่างการแสดงตัวอย่างเอกสาร', problemUploadingFile: 'เกิดปัญหาระหว่างการอัปโหลดไฟล์', + restoringTitle: + 'เกิดข้อผิดพลาดขณะกำลังคืนค่า {{title}} กรุณาตรวจสอบการเชื่อมต่อของคุณและลองอีกครั้ง', tokenInvalidOrExpired: 'Token ไม่ถูกต้องหรือหมดอายุ', tokenNotProvided: 'ไม่ได้รับโทเค็น', + unableToCopy: 'ไม่สามารถคัดลอกได้', unableToDeleteCount: 'ไม่สามารถลบ {{count}} จาก {{total}} {{label}}', unableToReindexCollection: 'เกิดข้อผิดพลาดในการจัดทำดัชนีใหม่ของคอลเลกชัน {{collection}}. การดำเนินการถูกยกเลิก', @@ -177,6 +185,7 @@ export const thTranslations: DefaultTranslationsObject = { deleteFolder: 'ลบโฟลเดอร์', folderName: 'ชื่อโฟลเดอร์', folders: 'โฟลเดอร์', + folderTypeDescription: 'เลือกประเภทของเอกสารคอลเลกชันที่ควรอนุญาตในโฟลเดอร์นี้', itemHasBeenMoved: '{{title}} ได้ถูกย้ายไปที่ {{folderName}}', itemHasBeenMovedToRoot: '"{{title}}" ได้ถูกย้ายไปยังโฟลเดอร์ราก', itemsMovedToFolder: '{{title}} ถูกย้ายไปยัง {{folderName}}', @@ -202,6 +211,15 @@ export const thTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'คุณกำลังจะลบ {{count}} {{label}}', aboutToDeleteCount_one: 'คุณกำลังจะลบ {{count}} {{label}}', aboutToDeleteCount_other: 'คุณกำลังจะลบ {{count}} {{label}}', + aboutToPermanentlyDelete: 'คุณกำลังจะลบ {{label}} <1>{{title}} อย่างถาวร คุณแน่ใจหรือไม่?', + aboutToPermanentlyDeleteTrash: + 'คุณกำลังจะลบ <0>{{count}} <1>{{label}} อย่างถาวรจากถังขยะ คุณแน่ใจหรือไม่?', + aboutToRestore: 'คุณกำลังจะกู้คืน {{label}} <1>{{title}} คุณแน่ใจไหม?', + aboutToRestoreAsDraft: 'คุณกำลังจะกู้คืน {{label}} <1>{{title}} เป็นร่างฉบับ คุณแน่ใจไหม?', + aboutToRestoreAsDraftCount: 'คุณกำลังจะกู้คืน {{count}} {{label}} เป็นร่าง', + aboutToRestoreCount: 'คุณกำลังจะกู้คืน {{count}} {{label}}', + aboutToTrash: 'คุณกำลังจะย้าย {{label}} <1>{{title}} ไปยังถังขยะ คุณแน่ใจไหม?', + aboutToTrashCount: 'คุณกำลังจะย้าย {{count}} {{label}} ไปที่ถังขยะ', addBelow: 'เพิ่มด้านล่าง', addFilter: 'เพิ่มการกรอง', adminTheme: 'ธีมผู้ดูแลระบบ', @@ -217,6 +235,8 @@ export const thTranslations: DefaultTranslationsObject = { backToDashboard: 'กลับไปหน้าแดชบอร์ด', cancel: 'ยกเลิก', changesNotSaved: 'การเปลี่ยนแปลงยังไม่ได้ถูกบันทึก ถ้าคุณออกตอนนี้ สิ่งที่แก้ไขไว้จะหายไป', + clear: + 'ให้เคารพความหมายของข้อความต้นฉบับภายในบริบทของ Payload นี่คือรายการของคำที่มักใช้ใน Payload ที่มีความหมายที่เฉพาะเจาะจงมาก:\n - Collection: Collection คือกลุ่มของเอกสารที่มีโครงสร้างและจุดประสงค์ท', clearAll: 'ล้างทั้งหมด', close: 'ปิด', collapse: 'ยุบ', @@ -234,9 +254,12 @@ export const thTranslations: DefaultTranslationsObject = { 'การดำเนินการนี้จะลบดัชนีที่มีอยู่และทำการจัดทำดัชนีใหม่ในเอกสารของคอลเลกชัน {{collections}}.', confirmReindexDescriptionAll: 'การดำเนินการนี้จะลบดัชนีที่มีอยู่และทำการจัดทำดัชนีใหม่ในเอกสารของทุกคอลเลกชัน.', + confirmRestoration: 'ยืนยันการคืนค่าให้ครบถ้วน', copied: 'คัดลอกแล้ว', copy: 'คัดลอก', + copyField: 'คัดลอกฟิลด์', copying: 'การคัดลอก', + copyRow: 'คัดลอกแถว', copyWarning: 'คุณกำลังจะเขียนทับ {{to}} ด้วย {{from}} สำหรับ {{label}} {{title}}. คุณแน่ใจหรือไม่?', create: 'สร้าง', @@ -252,13 +275,17 @@ export const thTranslations: DefaultTranslationsObject = { dark: 'มืด', dashboard: 'แดชบอร์ด', delete: 'ลบ', + deleted: 'ถูกลบ', + deletedAt: 'ถูกลบที่', deletedCountSuccessfully: 'Deleted {{count}} {{label}} successfully.', deletedSuccessfully: 'ลบสำเร็จ', + deletePermanently: 'ข้ามถังขยะและลบอย่างถาวร', deleting: 'กำลังลบ...', depth: 'ความลึก', descending: 'มากไปน้อย', deselectAllRows: 'ยกเลิกการเลือกทุกแถว', document: 'เอกสาร', + documentIsTrashed: 'ป้ายนี้ {{label}} ถูกทำให้เป็นขยะและอ่านอย่างเดียว', documentLocked: 'เอกสารถูกล็อค', documents: 'เอกสาร', duplicate: 'สำเนา', @@ -274,6 +301,8 @@ export const thTranslations: DefaultTranslationsObject = { editLabel: 'แก้ไข {{label}}', email: 'อีเมล', emailAddress: 'อีเมล', + emptyTrash: 'ลบถังขยะ', + emptyTrashLabel: 'ลบ {{label}} ที่อยู่ในถังขยะ', enterAValue: 'ระบุค่า', error: 'ข้อผิดพลาด', errors: 'ข้อผิดพลาด', @@ -286,6 +315,7 @@ export const thTranslations: DefaultTranslationsObject = { filterWhere: 'กรอง {{label}} เฉพาะ', globals: 'Globals', goBack: 'กลับไป', + groupByLabel: 'จัดกลุ่มตาม {{label}}', import: 'นำเข้า', isEditing: 'กำลังแก้ไข', item: 'รายการ', @@ -320,6 +350,7 @@ export const thTranslations: DefaultTranslationsObject = { 'ไม่พบ {{label}} เนื่องจากยังไม่มี {{label}} หรือไม่มี {{label}} ใดตรงกับการกรองด้านบน', notFound: 'ไม่พบ', nothingFound: 'ไม่พบสิ่งใด', + noTrashResults: 'ไม่มี {{label}} ในถังขยะ.', noUpcomingEventsScheduled: 'ไม่มีกิจกรรมที่จะมาถึงถูกกำหนดไว้', noValue: 'ไม่มีค่า', of: 'จาก', @@ -330,7 +361,11 @@ export const thTranslations: DefaultTranslationsObject = { overwriteExistingData: 'เขียนทับข้อมูลในฟิลด์ที่มีอยู่แล้ว', pageNotFound: 'ไม่พบหน้าที่ต้องการ', password: 'รหัสผ่าน', + pasteField: 'วางฟิลด์', + pasteRow: 'วางแถว', payloadSettings: 'การตั้งค่า Payload', + permanentlyDelete: 'ลบถาวร', + permanentlyDeletedCountSuccessfully: 'ลบ {{label}} {{count}} รายการอย่างถาวรสำเร็จแล้ว', perPage: 'จำนวนต่อหน้า: {{limit}}', previous: 'ก่อนหน้านี้', reindex: 'จัดทำดัชนีใหม่', @@ -341,6 +376,11 @@ export const thTranslations: DefaultTranslationsObject = { resetPreferences: 'รีเซ็ตการตั้งค่า', resetPreferencesDescription: 'การกระทำนี้จะรีเซ็ตการตั้งค่าทั้งหมดของคุณเป็นค่าเริ่มต้น', resettingPreferences: 'กำลังรีเซ็ตการตั้งค่า', + restore: 'กู้คืน', + restoreAsPublished: 'เรียกคืนเป็นเวอร์ชันที่เผยแพร่', + restoredCountSuccessfully: 'ได้ทำการกู้คืน {{count}} {{label}} สำเร็จแล้ว', + restoring: + 'สนับสนุนความหมายของข้อความต้นฉบับในบริบทของ Payload นี่คือรายการของคำที่เกี่ยวข้องกับ Payload ที่มีความหมายเฉพาะเจาะจง:\n - Collection: Collection เป็นกลุ่มของเอกสารที่มีโครงสร้างและจุดประสงค์ที่เหมือน', row: 'แถว', rows: 'แถว', save: 'บันทึก', @@ -371,6 +411,10 @@ export const thTranslations: DefaultTranslationsObject = { time: 'เวลา', timezone: 'เขตเวลา', titleDeleted: 'ลบ {{label}} "{{title}}" สำเร็จ', + titleRestored: '{{label}} "{{title}}" ถูกกู้คืนสำเร็จแล้ว.', + titleTrashed: '{{label}} "{{title}}" ถูกย้ายไปถังขยะ', + trash: 'ถังขยะ', + trashedCountSuccessfully: '{{count}} {{label}} ถูกย้ายไปยังถังขยะ', true: 'จริง', unauthorized: 'ไม่ได้รับอนุญาต', unsavedChanges: 'คุณมีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก บันทึกหรือทิ้งก่อนที่จะดำเนินการต่อ', @@ -389,6 +433,7 @@ export const thTranslations: DefaultTranslationsObject = { username: 'ชื่อผู้ใช้', users: 'ผู้ใช้', value: 'ค่า', + viewing: 'การดู', viewReadOnly: 'ดูในโหมดอ่านอย่างเดียว', welcome: 'ยินดีต้อนรับ', yes: 'ใช่', @@ -509,6 +554,7 @@ export const thTranslations: DefaultTranslationsObject = { noRowsFound: 'ไม่พบ {{label}}', noRowsSelected: 'ไม่มี {{label}} ที่ถูกเลือก', preview: 'ตัวอย่าง', + previouslyDraft: 'ก่อนหน้านี้เป็นร่าง', previouslyPublished: 'เผยแพร่ก่อนหน้านี้', previousVersion: 'เวอร์ชันก่อนหน้านี้', problemRestoringVersion: 'เกิดปัญหาระหว่างการกู้คืนเวอร์ชันนี้', diff --git a/packages/translations/src/languages/tr.ts b/packages/translations/src/languages/tr.ts index b05c8b2129..b3bb2e6923 100644 --- a/packages/translations/src/languages/tr.ts +++ b/packages/translations/src/languages/tr.ts @@ -87,10 +87,15 @@ export const trTranslations: DefaultTranslationsObject = { deletingFile: 'Dosya silinirken bir hatayla karşılaşıldı.', deletingTitle: '{{title}} silinirken bir sorun yaşandı. Lütfen internet bağlantınızı kontrol edip tekrar deneyin.', + documentNotFound: + "ID'si {{id}} olan belge bulunamadı. Silinmiş olabilir, hiç var olmamış olabilir veya belgeye erişiminiz olmayabilir.", emailOrPasswordIncorrect: 'Girilen e-posta veya parola hatalı', followingFieldsInvalid_one: 'Lütfen geçersiz alanı düzeltin:', followingFieldsInvalid_other: 'Lütfen geçersiz alanları düzeltin:', incorrectCollection: 'Hatalı koleksiyon', + insufficientClipboardPermissions: + 'Pano erişim reddedildi. Lütfen pano izinlerinizi kontrol edin.', + invalidClipboardData: 'Geçersiz pano verisi.', invalidFileType: 'Geçersiz dosya türü', invalidFileTypeValue: 'Geçersiz dosya türü: {{value}}', invalidRequestArgs: 'İstek içerisinde geçersiz argümanlar iletildi: {{args}}', @@ -110,8 +115,11 @@ export const trTranslations: DefaultTranslationsObject = { noUser: 'Kullanıcı yok', previewing: 'Önizleme başarısız oldu', problemUploadingFile: 'Dosya yüklenirken bir sorun oluştu.', + restoringTitle: + '{{title}} geri yüklenirken bir hata oluştu. Lütfen bağlantınızı kontrol edin ve tekrar deneyin.', tokenInvalidOrExpired: 'Geçersiz veya süresi dolmuş token.', tokenNotProvided: 'Jeton sağlanmadı.', + unableToCopy: 'Kopyalanamıyor.', unableToDeleteCount: '{{total}} {{label}} içinden {{count}} silinemiyor.', unableToReindexCollection: '{{collection}} koleksiyonunun yeniden indekslenmesinde hata oluştu. İşlem durduruldu.', @@ -182,6 +190,8 @@ export const trTranslations: DefaultTranslationsObject = { deleteFolder: 'Klasörü Sil', folderName: 'Klasör Adı', folders: 'Klasörler', + folderTypeDescription: + 'Bu klasörde hangi türden koleksiyon belgelerine izin verilmesi gerektiğini seçin.', itemHasBeenMoved: '{{title}} {{folderName}} klasörüne taşındı.', itemHasBeenMovedToRoot: '{{title}} kök klasöre taşındı.', itemsMovedToFolder: "{{title}} {{folderName}}'ye taşındı.", @@ -209,6 +219,17 @@ export const trTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: '{{count}} {{label}} silmek üzeresiniz', aboutToDeleteCount_one: '{{count}} {{label}} silmek üzeresiniz', aboutToDeleteCount_other: '{{count}} {{label}} silmek üzeresiniz', + aboutToPermanentlyDelete: + '{{label}} <1>{{title}} kalıcı olarak silmek üzeresiniz. Emin misiniz?', + aboutToPermanentlyDeleteTrash: + 'Çöpten <0>{{count}} <1>{{label}} kalıcı olarak silmek üzeresiniz. Emin misiniz?', + aboutToRestore: "{{label}} <1>{{title}}'yi geri yüklemek üzeresiniz. Emin misiniz?", + aboutToRestoreAsDraft: + '{{label}} <1>{{title}} taslağı olarak geri yüklemek üzeresiniz. Emin misiniz?', + aboutToRestoreAsDraftCount: 'Taslağı olarak geri yükleme üzeresiniz: {{count}} {{label}}', + aboutToRestoreCount: '{{count}} {{label}} geri yüklemek üzeresiniz.', + aboutToTrash: '{{label}} <1>{{title}} çöp kutusuna taşımayı düşünüyorsunuz. Emin misiniz?', + aboutToTrashCount: '{{count}} {{label}} çöp kutusuna taşımayı düşünüyorsunuz.', addBelow: 'Altına ekle', addFilter: 'Filtre ekle', adminTheme: 'Admin arayüzü', @@ -225,6 +246,7 @@ export const trTranslations: DefaultTranslationsObject = { cancel: 'İptal', changesNotSaved: 'Değişiklikleriniz henüz kaydedilmedi. Eğer bu sayfayı terk ederseniz değişiklikleri kaybedeceksiniz.', + clear: 'Temiz', clearAll: 'Hepsini Temizle', close: 'Kapat', collapse: 'Daralt', @@ -242,9 +264,12 @@ export const trTranslations: DefaultTranslationsObject = { 'Bu işlem mevcut dizinleri kaldıracak ve {{collections}} koleksiyonlarındaki belgeleri yeniden dizine alacaktır.', confirmReindexDescriptionAll: 'Bu işlem mevcut dizinleri kaldıracak ve tüm koleksiyonlardaki belgeleri yeniden dizine alacaktır.', + confirmRestoration: 'Onarımı onaylayın', copied: 'Kopyalandı', copy: 'Kopyala', + copyField: 'Alanı kopyala', copying: 'Kopyalama', + copyRow: 'Satırı kopyala', copyWarning: "{{to}}'yu {{from}} ile {{label}} {{title}} için üstüne yazmak üzeresiniz. Emin misiniz?", create: 'Oluştur', @@ -260,13 +285,17 @@ export const trTranslations: DefaultTranslationsObject = { dark: 'Karanlık', dashboard: 'Anasayfa', delete: 'Sil', + deleted: 'Silindi', + deletedAt: 'Silindiği Tarih', deletedCountSuccessfully: '{{count}} {{label}} başarıyla silindi.', deletedSuccessfully: 'Başarıyla silindi.', + deletePermanently: 'Çöpü atlayın ve kalıcı olarak silin', deleting: 'Siliniyor...', depth: 'Derinlik', descending: 'Azalan', deselectAllRows: 'Tüm satırların seçimini kaldır', document: 'Belge', + documentIsTrashed: 'Bu {{label}} çöpe atıldı ve sadece okuma modunda.', documentLocked: 'Belge kilitlendi', documents: 'Belgeler', duplicate: 'Çoğalt', @@ -282,6 +311,8 @@ export const trTranslations: DefaultTranslationsObject = { editLabel: '{{label}} düzenle', email: 'E-posta', emailAddress: 'E-posta adresi', + emptyTrash: 'Çöpü Boşalt', + emptyTrashLabel: '{{label}} çöp kutusunu boşaltın', enterAValue: 'Değer girin', error: 'Hata', errors: 'Hatalar', @@ -294,6 +325,7 @@ export const trTranslations: DefaultTranslationsObject = { filterWhere: '{{label}} filtrele:', globals: 'Globaller', goBack: 'Geri dön', + groupByLabel: "{{label}}'ye göre grupla", import: 'İthalat', isEditing: 'düzenliyor', item: 'öğe', @@ -329,6 +361,7 @@ export const trTranslations: DefaultTranslationsObject = { '{{label}} bulunamadı. Henüz bir {{label}} eklenmemiş olabilir veya seçtiğiniz filtrelerle eşleşen bir sonuç bulunamamış olabilir.', notFound: 'Bulunamadı', nothingFound: 'Hiçbir şey bulunamadı', + noTrashResults: 'Çöpte hiç {{label}} yok.', noUpcomingEventsScheduled: 'Planlanan gelecek etkinlik yok.', noValue: 'Değer yok', of: 'of', @@ -339,7 +372,11 @@ export const trTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Mevcut alan verilerinin üzerine yazın', pageNotFound: 'Sayfa bulunamadı', password: 'Parola', + pasteField: 'Alanı yapıştır', + pasteRow: 'Satırı yapıştır', payloadSettings: 'Ayarlar', + permanentlyDelete: 'Kalıcı Olarak Sil', + permanentlyDeletedCountSuccessfully: 'Kalıcı olarak {{count}} {{label}} başarıyla silindi.', perPage: 'Sayfa başına: {{limit}}', previous: 'Önceki', reindex: 'Yeniden İndeksle', @@ -351,6 +388,11 @@ export const trTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Bu, tüm tercihlerinizin varsayılan ayarlara sıfırlanmasını sağlar.', resettingPreferences: 'Tercihler sıfırlanıyor.', + restore: 'Geri Yükle', + restoreAsPublished: 'Yayınlanan sürüm olarak geri yükle', + restoredCountSuccessfully: '{{count}} {{label}} başarıyla geri yüklendi.', + restoring: + "Özgün metnin anlamını Payload bağlamında saygıyla yeniden oluşturun. İşte çok belirli anlamlar taşıyan yaygın Payload terimlerinin bir listesi:\n - Koleksiyon: Bir koleksiyon, ortak bir yapı ve amaca sahip belgelerin grubudur. Koleksiyonlar içerik organizasyonu ve yönetiminde Payload'da kullanılır.\n - Alan: Bir alan, bir koleksiyon içindeki belgedeki belirli bir veri parçasıdır. Alanlar, bir belgede saklanabilen ver", row: 'Satır', rows: 'Satır', save: 'Kaydet', @@ -381,6 +423,10 @@ export const trTranslations: DefaultTranslationsObject = { time: 'Zaman', timezone: 'Saat dilimi', titleDeleted: '{{label}} {{title}} başarıyla silindi.', + titleRestored: '"{{title}}" başarıyla geri yüklendi.', + titleTrashed: '{{label}} "{{title}}" çöpe taşındı.', + trash: 'Çöp', + trashedCountSuccessfully: '{{count}} {{label}} çöp kutusuna taşındı.', true: 'Doğru', unauthorized: 'Yetkisiz', unsavedChanges: 'Kaydedilmemiş değişiklikleriniz var. Devam etmeden önce kaydedin veya atın.', @@ -400,6 +446,7 @@ export const trTranslations: DefaultTranslationsObject = { username: 'Kullanıcı Adı', users: 'kullanıcı', value: 'Değer', + viewing: 'Görüntüleme', viewReadOnly: 'Salt okunur olarak görüntüle', welcome: 'Hoşgeldiniz', yes: 'Evet', @@ -522,6 +569,7 @@ export const trTranslations: DefaultTranslationsObject = { noRowsFound: '{{label}} bulunamadı', noRowsSelected: 'Seçilen {{label}} yok', preview: 'Önizleme', + previouslyDraft: 'Daha önce bir Taslak', previouslyPublished: 'Daha Önce Yayınlanmış', previousVersion: 'Önceki Sürüm', problemRestoringVersion: 'Bu sürüme geri döndürürken bir hatayla karşılaşıldı.', diff --git a/packages/translations/src/languages/uk.ts b/packages/translations/src/languages/uk.ts index 9864931d43..677f085495 100644 --- a/packages/translations/src/languages/uk.ts +++ b/packages/translations/src/languages/uk.ts @@ -87,10 +87,15 @@ export const ukTranslations: DefaultTranslationsObject = { deletingFile: 'Виникла помилка під час видалення файлу', deletingTitle: "Виникла помилка під час видалення {{title}}. Будь ласка, перевірте ваше з'єднання та спробуйте ще раз.", + documentNotFound: + 'Документ з ID {{id}} не вдалося знайти. Можливо, він був видалений або ніколи не існував, або у вас немає доступу до нього.', emailOrPasswordIncorrect: 'Вказана адреса електронної пошти або пароль є невірними', followingFieldsInvalid_one: 'Наступне поле невірне:', followingFieldsInvalid_other: 'Наступні поля невірні', incorrectCollection: 'Неправильна колекція', + insufficientClipboardPermissions: + 'Доступ до буфера обміну відхилено. Перевірте свої дозволи на буфер обміну.', + invalidClipboardData: 'Невірні дані в буфері обміну.', invalidFileType: 'Невірний тип файлу', invalidFileTypeValue: 'Невірний тип файлу: {{value}}', invalidRequestArgs: 'Неправильні аргументи передано в запиті: {{args}}', @@ -110,8 +115,11 @@ export const ukTranslations: DefaultTranslationsObject = { noUser: 'Немає користувача', previewing: 'Виникла помилка під час попереднього перегляду цього документа.', problemUploadingFile: 'Виникла помилка під час завантаження файлу.', + restoringTitle: + "Виникла помилка при відновленні {{title}}. Будь ласка, перевірте своє з'єднання і спробуйте ще раз.", tokenInvalidOrExpired: 'Токен недійсний, або його строк дії закінчився.', tokenNotProvided: 'Токен не надано.', + unableToCopy: 'Неможливо скопіювати.', unableToDeleteCount: 'Не вдалося видалити {{count}} із {{total}} {{label}}.', unableToReindexCollection: 'Помилка при повторному індексуванні колекції {{collection}}. Операцію скасовано.', @@ -181,6 +189,8 @@ export const ukTranslations: DefaultTranslationsObject = { deleteFolder: 'Видалити папку', folderName: 'Назва папки', folders: 'Папки', + folderTypeDescription: + 'Виберіть, який тип документів колекції повинен бути дозволений у цій папці.', itemHasBeenMoved: '{{title}} було переміщено до {{folderName}}', itemHasBeenMovedToRoot: '{{title}} був переміщений до кореневої папки', itemsMovedToFolder: '{{title}} перенесено до {{folderName}}', @@ -207,6 +217,17 @@ export const ukTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Ви бажаєте видалити {{count}} {{label}}', aboutToDeleteCount_one: 'Ви бажаєте видалити {{count}} {{label}}', aboutToDeleteCount_other: 'Ви бажаєте видалити {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Ви збираєтесь остаточно видалити {{label}} <1>{{title}}. Ви впевнені?', + aboutToPermanentlyDeleteTrash: + 'Ви збираєтеся назавжди видалити <0>{{count}} <1>{{label}} з кошика. Ви впевнені?', + aboutToRestore: 'Ви збираєтеся відновити {{label}} <1>{{title}}. Ви впевнені?', + aboutToRestoreAsDraft: + 'Ви збираєтеся відновити {{label}} <1>{{title}} як чернетку. Ви впевнені?', + aboutToRestoreAsDraftCount: 'Ви збираєтеся відновити {{count}} {{label}} як чернетку', + aboutToRestoreCount: 'Ви збираєтеся відновити {{count}} {{label}}', + aboutToTrash: 'Ви збираєтеся перемістити {{label}} <1>{{title}} у смітник. Ви впевнені?', + aboutToTrashCount: 'Ви збираєтеся перемістити {{count}} {{label}} до кошика', addBelow: 'Додати нижче', addFilter: 'Додати фільтр', adminTheme: 'Тема адмін панелі', @@ -222,6 +243,7 @@ export const ukTranslations: DefaultTranslationsObject = { backToDashboard: 'Повернутись до головної сторінки', cancel: 'Скасувати', changesNotSaved: 'Ваши зміни не були збережені. Якщо ви вийдете зараз, то втратите свої зміни.', + clear: 'Чітко', clearAll: 'Очистити все', close: 'Закрити', collapse: 'Згорнути', @@ -239,9 +261,12 @@ export const ukTranslations: DefaultTranslationsObject = { 'Це видалить наявні індекси та перебудує індекси документів у колекціях {{collections}}.', confirmReindexDescriptionAll: 'Це видалить наявні індекси та перебудує індекси документів у всіх колекціях.', + confirmRestoration: 'Підтвердіть відновлення', copied: 'Скопійовано', copy: 'Скопіювати', + copyField: 'Копіювати поле', copying: 'Копіювання', + copyRow: 'Копіювати рядок', copyWarning: 'Ви збираєтесь замінити {{to}} на {{from}} для {{label}} {{title}}. Ви впевнені?', create: 'Створити', created: 'Створено', @@ -256,13 +281,17 @@ export const ukTranslations: DefaultTranslationsObject = { dark: 'Темна', dashboard: 'Головна', delete: 'Видалити', + deleted: 'Видалено', + deletedAt: 'Видалено в', deletedCountSuccessfully: 'Успішно видалено {{count}} {{label}}.', deletedSuccessfully: 'Успішно видалено.', + deletePermanently: 'Пропустити кошик та видалити назавжди', deleting: 'Видалення...', depth: 'Глибина', descending: 'В порядку спадання', deselectAllRows: 'Скасувати вибір всіх рядків', document: 'Документ', + documentIsTrashed: 'Цей {{label}} видалено та доступний лише для читання.', documentLocked: 'Документ заблоковано', documents: 'Документи', duplicate: 'Дублювати', @@ -278,6 +307,8 @@ export const ukTranslations: DefaultTranslationsObject = { editLabel: 'Редагувати {{label}}', email: 'Електронна пошта', emailAddress: 'Адреса електронної пошти', + emptyTrash: 'Спорожнити кошик', + emptyTrashLabel: 'Спорожнити кошик для {{label}}', enterAValue: 'Введіть значення', error: 'Помилка', errors: 'Помилки', @@ -290,6 +321,7 @@ export const ukTranslations: DefaultTranslationsObject = { filterWhere: 'Де фільтрувати {{label}}', globals: 'Глобальні', goBack: 'Повернутися', + groupByLabel: 'Групувати за {{label}}', import: 'Імпорт', isEditing: 'редагує', item: 'предмет', @@ -325,6 +357,7 @@ export const ukTranslations: DefaultTranslationsObject = { 'Жодного {{label}} не знайдено. Або {{label}} ще не існує, або жодна з них не відповідає фільтрам, що ви задали више.', notFound: 'Не знайдено', nothingFound: 'Нічого не знайдено', + noTrashResults: 'Немає {{label}} у смітнику.', noUpcomingEventsScheduled: 'Не заплановано жодних майбутніх подій.', noValue: 'Немає значення', of: 'з', @@ -335,7 +368,11 @@ export const ukTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Перезаписати існуючі дані поля', pageNotFound: 'Сторінка не знайдена', password: 'Пароль', + pasteField: 'Вставити поле', + pasteRow: 'Вставити рядок', payloadSettings: 'Налаштування Payload', + permanentlyDelete: 'Назавжди видалити', + permanentlyDeletedCountSuccessfully: 'Успішно видалено назавжди {{count}} {{label}}.', perPage: 'На сторінці: {{limit}}', previous: 'Попередній', reindex: 'Повторне індексування', @@ -346,6 +383,11 @@ export const ukTranslations: DefaultTranslationsObject = { resetPreferences: 'Скинути налаштування', resetPreferencesDescription: 'Це скине всі ваші налаштування до значень за замовчуванням.', resettingPreferences: 'Скидання налаштувань.', + restore: 'Відновити', + restoreAsPublished: 'Відновити як опубліковану версію', + restoredCountSuccessfully: 'Відновлено {{count}} {{label}} успішно.', + restoring: + 'Поважайте сенс оригінального тексту в контексті Payload. Ось список поширених термінів Payload, які мають дуже специфічні значення:\n - Колекція: Колекцією є група документів, які мають спільну структуру та сенс. Колекції використовуються для організації й керування контент', row: 'Рядок', rows: 'Рядки', save: 'Зберегти', @@ -376,6 +418,10 @@ export const ukTranslations: DefaultTranslationsObject = { time: 'Час', timezone: 'Часовий пояс', titleDeleted: '{{label}} "{{title}}" успішно видалено.', + titleRestored: '{{label}} "{{title}}" успішно відновлено.', + titleTrashed: '{{label}} "{{title}}" переміщено до кошика.', + trash: 'Сміття', + trashedCountSuccessfully: '{{count}} {{label}} перенесено в кошик.', true: 'Правда', unauthorized: 'Немає доступу', unsavedChanges: 'У вас є незбережені зміни. Збережіть або скасуйте перед продовженням.', @@ -394,6 +440,7 @@ export const ukTranslations: DefaultTranslationsObject = { username: "Ім'я користувача", users: 'Користувачі', value: 'Значення', + viewing: 'Перегляд', viewReadOnly: 'Перегляд тільки для читання', welcome: 'Вітаю', yes: 'Так', @@ -518,6 +565,7 @@ export const ukTranslations: DefaultTranslationsObject = { noRowsFound: 'Не знайдено {{label}}', noRowsSelected: 'Не вибрано {{label}}', preview: 'Попередній перегляд', + previouslyDraft: 'Раніше був проект', previouslyPublished: 'Раніше опубліковано', previousVersion: 'Попередня версія', problemRestoringVersion: 'Виникла проблема з відновленням цієї версії', diff --git a/packages/translations/src/languages/vi.ts b/packages/translations/src/languages/vi.ts index 2c59447e76..c0842cf49b 100644 --- a/packages/translations/src/languages/vi.ts +++ b/packages/translations/src/languages/vi.ts @@ -86,10 +86,15 @@ export const viTranslations: DefaultTranslationsObject = { deletingFile: 'Lỗi - Đã xảy ra vấn đề khi xóa tệp này.', deletingTitle: 'Lỗi - Đã xảy ra vấn đề khi xóa {{title}}. Hãy kiểm tra kết nối mạng và thử lại.', + documentNotFound: + 'Tài liệu có ID {{id}} không thể tìm thấy. Nó có thể đã bị xóa hoặc chưa từng tồn tại, hoặc bạn có thể không có quyền truy cập vào nó.', emailOrPasswordIncorrect: 'Lỗi - Email hoặc mật khẩu không chính xác.', followingFieldsInvalid_one: 'Lỗi - Field sau không hợp lệ:', followingFieldsInvalid_other: 'Lỗi - Những fields sau không hợp lệ:', incorrectCollection: 'Lỗi - Collection không hợp lệ.', + insufficientClipboardPermissions: + 'Truy cập vào bộ nhớ tạm bị từ chối. Vui lòng kiểm tra quyền truy cập bộ nhớ tạm của bạn.', + invalidClipboardData: 'Dữ liệu bộ nhớ tạm không hợp lệ.', invalidFileType: 'Lỗi - Định dạng tệp không hợp lệ.', invalidFileTypeValue: 'Lỗi - Định dạng tệp không hợp lệ: {{value}}.', invalidRequestArgs: 'Các đối số không hợp lệ đã được truyền trong yêu cầu: {{args}}', @@ -109,8 +114,11 @@ export const viTranslations: DefaultTranslationsObject = { noUser: 'Lỗi - Request thiếu thông tin người dùng.', previewing: 'Lỗi - Đã xảy ra vấn đề khi xem trước bản tài liệu này.', problemUploadingFile: 'Lỗi - Đã xảy ra vấn để khi tải lên file sau.', + restoringTitle: + 'Đã xảy ra lỗi trong quá trình khôi phục {{title}}. Vui lòng kiểm tra kết nối của bạn và thử lại.', tokenInvalidOrExpired: 'Lỗi - Token không hợp lệ hoặc đã hết hạn.', tokenNotProvided: 'Không cung cấp mã thông báo.', + unableToCopy: 'Không thể sao chép.', unableToDeleteCount: 'Không thể xóa {{count}} trong số {{total}} {{label}}.', unableToReindexCollection: 'Lỗi khi tái lập chỉ mục bộ sưu tập {{collection}}. Quá trình bị hủy.', @@ -180,6 +188,7 @@ export const viTranslations: DefaultTranslationsObject = { deleteFolder: 'Xóa Thư mục', folderName: 'Tên thư mục', folders: 'Thư mục', + folderTypeDescription: 'Chọn loại tài liệu bộ sưu tập nào nên được cho phép trong thư mục này.', itemHasBeenMoved: '{{title}} đã được chuyển đến {{folderName}}', itemHasBeenMovedToRoot: '{{title}} đã được chuyển đến thư mục gốc', itemsMovedToFolder: '{{title}} đã được di chuyển vào {{folderName}}', @@ -206,6 +215,18 @@ export const viTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: 'Bạn sắp xóa {{count}} {{label}}', aboutToDeleteCount_one: 'Bạn sắp xóa {{count}} {{label}}', aboutToDeleteCount_other: 'Bạn sắp xóa {{count}} {{label}}', + aboutToPermanentlyDelete: + 'Bạn đang chuẩn bị xóa vĩnh viễn {{label}} <1>{{title}}. Bạn có chắc không?', + aboutToPermanentlyDeleteTrash: + 'Bạn sắp xóa vĩnh viễn <0>{{count}} <1>{{label}} từ thùng rác. Bạn có chắc chắn không?', + aboutToRestore: 'Bạn đang chuẩn bị khôi phục {{label}} <1>{{title}}. Bạn có chắc không?', + aboutToRestoreAsDraft: + 'Bạn đang chuẩn bị khôi phục {{label}} <1>{{title}} dưới dạng bản nháp. Bạn có chắc không?', + aboutToRestoreAsDraftCount: 'Bạn sắp khôi phục {{count}} {{label}} dưới dạng bản nháp', + aboutToRestoreCount: 'Bạn sắp khôi phục {{count}} {{label}}', + aboutToTrash: + 'Bạn đang chuẩn bị di chuyển {{label}} <1>{{title}} vào thùng rác. Bạn có chắc không?', + aboutToTrashCount: 'Bạn đang chuẩn bị chuyển {{count}} {{label}} vào thùng rác', addBelow: 'Thêm bên dưới', addFilter: 'Thêm bộ lọc', adminTheme: 'Giao diện bảng điều khiển', @@ -221,6 +242,7 @@ export const viTranslations: DefaultTranslationsObject = { backToDashboard: 'Quay lại bảng điều khiển', cancel: 'Hủy', changesNotSaved: 'Thay đổi chưa được lưu lại. Bạn sẽ mất bản chỉnh sửa nếu thoát bây giờ.', + clear: 'Rõ ràng', clearAll: 'Xóa tất cả', close: 'Gần', collapse: 'Thu gọn', @@ -238,9 +260,12 @@ export const viTranslations: DefaultTranslationsObject = { 'Điều này sẽ xóa các chỉ mục hiện tại và tái lập chỉ mục các tài liệu trong các bộ sưu tập {{collections}}.', confirmReindexDescriptionAll: 'Điều này sẽ xóa các chỉ mục hiện tại và tái lập chỉ mục các tài liệu trong tất cả các bộ sưu tập.', + confirmRestoration: 'Xác nhận khôi phục', copied: 'Đâ sao chép', copy: 'Sao chép', + copyField: 'Sao chép trường', copying: 'Sao chép', + copyRow: 'Sao chép dòng', copyWarning: 'Bạn đang chuẩn bị ghi đè {{to}} bằng {{from}} cho {{label}} {{title}}. Bạn có chắc chắn không?', create: 'Tạo', @@ -256,13 +281,17 @@ export const viTranslations: DefaultTranslationsObject = { dark: 'Nền tối', dashboard: 'Bảng điều khiển', delete: 'Xóa', + deleted: 'Đã xóa', + deletedAt: 'Đã Xóa Lúc', deletedCountSuccessfully: 'Đã xóa thành công {{count}} {{label}}.', deletedSuccessfully: 'Đã xoá thành công.', + deletePermanently: 'Bỏ qua thùng rác và xóa vĩnh viễn', deleting: 'Đang xóa...', depth: 'Độ sâu', descending: 'Xếp theo thứ tự giảm dần', deselectAllRows: 'Bỏ chọn tất cả các hàng', document: 'Tài liệu', + documentIsTrashed: 'Nhãn này {{label}} đã bị xóa và chỉ được phép đọc.', documentLocked: 'Tài liệu bị khóa', documents: 'Tài liệu', duplicate: 'Tạo bản sao', @@ -278,6 +307,8 @@ export const viTranslations: DefaultTranslationsObject = { editLabel: 'Chỉnh sửa: {{label}}', email: 'Email', emailAddress: 'Địa chỉ Email', + emptyTrash: 'Dọn rác', + emptyTrashLabel: 'Dọn rác {{label}}', enterAValue: 'Nhập một giá trị', error: 'Lỗi', errors: 'Lỗi', @@ -290,6 +321,7 @@ export const viTranslations: DefaultTranslationsObject = { filterWhere: 'Lọc {{label}} với điều kiện:', globals: 'Toàn thể (globals)', goBack: 'Quay lại', + groupByLabel: 'Nhóm theo {{label}}', import: 'Nhập khẩu', isEditing: 'đang chỉnh sửa', item: 'mặt hàng', @@ -325,6 +357,7 @@ export const viTranslations: DefaultTranslationsObject = { 'Danh sách rỗng: {{label}}. Có thể {{label}} chưa tồn tại hoặc không có dữ kiện trùng với bộ lọc hiện tại.', notFound: 'Không tìm thấy', nothingFound: 'Không tìm thấy', + noTrashResults: 'Không có {{label}} trong thùng rác.', noUpcomingEventsScheduled: 'Không có sự kiện sắp tới được lên lịch.', noValue: 'Không có giá trị', of: 'trong số', @@ -335,7 +368,11 @@ export const viTranslations: DefaultTranslationsObject = { overwriteExistingData: 'Ghi đè dữ liệu trường hiện tại', pageNotFound: 'Không tìm thấy trang', password: 'Mật khẩu', + pasteField: 'Dán trường', + pasteRow: 'Dán dòng', payloadSettings: 'Cài đặt', + permanentlyDelete: 'Xóa vĩnh viễn', + permanentlyDeletedCountSuccessfully: 'Đã xóa vĩnh viễn {{count}} {{label}} thành công.', perPage: 'Hiển thị mỗi trang: {{limit}}', previous: 'Trước đó', reindex: 'Tái lập chỉ mục', @@ -346,6 +383,11 @@ export const viTranslations: DefaultTranslationsObject = { resetPreferences: 'Đặt lại sở thích', resetPreferencesDescription: 'Điều này sẽ đặt lại tất cả sở thích của bạn về cài đặt mặc định.', resettingPreferences: 'Đang đặt lại sở thích.', + restore: 'Khôi phục', + restoreAsPublished: 'Khôi phục thành phiên bản đã xuất bản', + restoredCountSuccessfully: 'Đã khôi phục {{count}} {{label}} thành công.', + restoring: + 'Tôn trọng ý nghĩa của văn bản gốc trong bối cảnh của Payload. Dưới đây là danh sách các thuật ngữ Payload thông thường mang ý nghĩa rất cụ thể:\n- Collection: Collection (tạm dịch: Bộ sưu tập) là một nhóm các tài liệu chia sẻ cấu trúc và mục đích chung. Các Collection được sử dụng để tổ chức và quản lý nội dung trong Payload.\n- Field: Field là một phần cụ th', row: 'Hàng', rows: 'Những hàng', save: 'Luu', @@ -376,6 +418,10 @@ export const viTranslations: DefaultTranslationsObject = { time: 'Thời gian', timezone: 'Múi giờ', titleDeleted: '{{label}} {{title}} đã được xóa thành công.', + titleRestored: '{{label}} "{{title}}" được khôi phục thành công.', + titleTrashed: '{{label}} "{{title}}" đã được chuyển vào thùng rác.', + trash: 'Rác', + trashedCountSuccessfully: '{{count}} {{label}} đã được chuyển vào thùng rác.', true: 'Thật', unauthorized: 'Không có quyền truy cập.', unsavedChanges: 'Bạn có những thay đổi chưa được lưu. Lưu hoặc hủy trước khi tiếp tục.', @@ -394,6 +440,7 @@ export const viTranslations: DefaultTranslationsObject = { username: 'Tên đăng nhập', users: 'Người dùng', value: 'Giá trị', + viewing: 'Xem', viewReadOnly: 'Xem chỉ đọc', welcome: 'Xin chào', yes: 'Đúng', @@ -515,6 +562,7 @@ export const viTranslations: DefaultTranslationsObject = { noRowsFound: 'Không tìm thấy: {{label}}', noRowsSelected: 'Không có {{label}} được chọn', preview: 'Bản xem trước', + previouslyDraft: 'Trước đây là Bản nháp', previouslyPublished: 'Đã xuất bản trước đây', previousVersion: 'Phiên bản Trước', problemRestoringVersion: 'Đã xảy ra vấn đề khi khôi phục phiên bản này', diff --git a/packages/translations/src/languages/zh.ts b/packages/translations/src/languages/zh.ts index a654befe06..2ea4fe9e3d 100644 --- a/packages/translations/src/languages/zh.ts +++ b/packages/translations/src/languages/zh.ts @@ -82,10 +82,14 @@ export const zhTranslations: DefaultTranslationsObject = { correctInvalidFields: '请更正无效字段。', deletingFile: '删除文件时出现了错误。', deletingTitle: '删除{{title}}时出现了错误。请检查您的连接并重试。', + documentNotFound: + '无法找到ID为{{id}}的文档。可能是已经被删除,或者从未存在,或者您可能无法访问它。', emailOrPasswordIncorrect: '提供的电子邮件或密码不正确。', followingFieldsInvalid_one: '下面的字段是无效的:', followingFieldsInvalid_other: '以下字段是无效的:', incorrectCollection: '不正确的集合', + insufficientClipboardPermissions: '剪贴板访问被拒绝。请检查您的剪贴板权限。', + invalidClipboardData: '剪贴板数据无效。', invalidFileType: '无效的文件类型', invalidFileTypeValue: '无效的文件类型: {{value}}', invalidRequestArgs: '请求中传递了无效的参数:{{args}}', @@ -105,8 +109,10 @@ export const zhTranslations: DefaultTranslationsObject = { noUser: '没有该用户', previewing: '预览文档时出现了问题。', problemUploadingFile: '上传文件时出现了问题。', + restoringTitle: '恢复{{title}}时出现错误。请检查您的连接并再试一次。', tokenInvalidOrExpired: '令牌无效或已过期。', tokenNotProvided: '未提供令牌。', + unableToCopy: '无法复制。', unableToDeleteCount: '无法从 {{total}} {{label}} 中删除 {{count}}。', unableToReindexCollection: '重新索引集合 {{collection}} 时出错。操作已中止。', unableToUpdateCount: '无法更新 {{count}} 个,共 {{total}} 个 {{label}}。', @@ -174,6 +180,7 @@ export const zhTranslations: DefaultTranslationsObject = { deleteFolder: '删除文件夹', folderName: '文件夹名称', folders: '文件夹', + folderTypeDescription: '在此文件夹中选择应允许哪种类型的集合文档。', itemHasBeenMoved: '{{title}}已被移至{{folderName}}', itemHasBeenMovedToRoot: '{{title}}已被移至根文件夹', itemsMovedToFolder: '{{title}}已移至{{folderName}}', @@ -197,6 +204,15 @@ export const zhTranslations: DefaultTranslationsObject = { aboutToDeleteCount_many: '您即将删除 {{count}}个{{label}}', aboutToDeleteCount_one: '您即将删除 {{count}}个{{label}}', aboutToDeleteCount_other: '您即将删除 {{count}}个{{label}}', + aboutToPermanentlyDelete: '您即将永久删除{{label}} <1>{{title}}。你确定吗?', + aboutToPermanentlyDeleteTrash: + '您即将从垃圾箱中永久删除<0>{{count}} <1>{{label}}。你确定吗?', + aboutToRestore: '您即将恢复{{label}} <1>{{title}}。你确定吗?', + aboutToRestoreAsDraft: '您即将将{{label}} <1>{{title}} 恢复为草稿。您确定吗?', + aboutToRestoreAsDraftCount: '您即将将 {{count}} {{label}} 恢复为草稿', + aboutToRestoreCount: '您即将恢复 {{count}} {{label}}', + aboutToTrash: '您即将将 {{label}} <1>{{title}} 移至垃圾箱。您确定吗?', + aboutToTrashCount: '您即将将{{count}}个{{label}}移至垃圾箱', addBelow: '添加到下面', addFilter: '添加过滤条件', adminTheme: '管理页面主题', @@ -212,6 +228,7 @@ export const zhTranslations: DefaultTranslationsObject = { backToDashboard: '返回到仪表板', cancel: '取消', changesNotSaved: '您的更改尚未保存。您确定要离开吗?', + clear: '清晰', clearAll: '清除全部', close: '关闭', collapse: '折叠', @@ -227,9 +244,12 @@ export const zhTranslations: DefaultTranslationsObject = { confirmReindexAll: '重新索引所有集合?', confirmReindexDescription: '此操作将删除现有索引,并重新索引{{collections}}集合中的文档。', confirmReindexDescriptionAll: '此操作将删除现有索引,并重新索引所有集合中的文档。', + confirmRestoration: '确认恢复', copied: '已复制', copy: '复制', + copyField: '复制字段', copying: '复制中', + copyRow: '复制行', copyWarning: '您即将用{{from}}覆盖{{to}},用于{{label}} {{title}}。您确定吗?', create: '创建', created: '已创建', @@ -244,13 +264,17 @@ export const zhTranslations: DefaultTranslationsObject = { dark: '深色', dashboard: '仪表板', delete: '删除', - deletedCountSuccessfully: '已成功删除 {{count}}个{{label}}。', + deleted: '已删除', + deletedAt: '已删除时间', + deletedCountSuccessfully: '已成功删除 {{count}} {{label}}。', deletedSuccessfully: '已成功删除。', + deletePermanently: '跳过垃圾箱并永久删除', deleting: '删除中...', depth: '深度', descending: '降序', deselectAllRows: '取消选择所有行', document: '文档', + documentIsTrashed: '此 {{label}} 已被丢弃,为只读状态。', documentLocked: '文档已锁定', documents: '文档', duplicate: '复制', @@ -266,6 +290,8 @@ export const zhTranslations: DefaultTranslationsObject = { editLabel: '编辑{{label}}', email: '电子邮件', emailAddress: '电子邮件地址', + emptyTrash: '清空垃圾桶', + emptyTrashLabel: '清空 {{label}} 垃圾箱', enterAValue: '输入一个值', error: '错误', errors: '错误', @@ -278,6 +304,7 @@ export const zhTranslations: DefaultTranslationsObject = { filterWhere: '过滤{{label}}', globals: '全局', goBack: '返回', + groupByLabel: '按{{label}}分组', import: '导入', isEditing: '正在编辑', item: '条目', @@ -311,6 +338,7 @@ export const zhTranslations: DefaultTranslationsObject = { noResults: '没有找到{{label}}。{{label}}并不存在或没有符合您上面所指定的过滤条件。', notFound: '未找到', nothingFound: '没有找到任何东西', + noTrashResults: '回收站中没有 {{label}}。', noUpcomingEventsScheduled: '没有即将进行的活动计划。', noValue: '没有值', of: '共', @@ -321,7 +349,11 @@ export const zhTranslations: DefaultTranslationsObject = { overwriteExistingData: '覆盖现有字段数据', pageNotFound: '未找到页面', password: '密码', + pasteField: '粘贴字段', + pasteRow: '粘贴行', payloadSettings: 'Payload设置', + permanentlyDelete: '永久删除', + permanentlyDeletedCountSuccessfully: '已成功永久删除 {{count}} {{label}}。', perPage: '每一页: {{limit}}', previous: '前一个', reindex: '重新索引', @@ -332,6 +364,10 @@ export const zhTranslations: DefaultTranslationsObject = { resetPreferences: '重置偏好设置', resetPreferencesDescription: '这将把您的所有偏好设置恢复为默认值。', resettingPreferences: '正在重置偏好设置。', + restore: '恢复', + restoreAsPublished: '恢复为已发布版本', + restoredCountSuccessfully: '成功恢复了{{count}} {{label}}。', + restoring: '恢复中...', row: '行', rows: '行', save: '保存', @@ -362,6 +398,10 @@ export const zhTranslations: DefaultTranslationsObject = { time: '时间', timezone: '时区', titleDeleted: '{{label}} "{{title}}"已被成功删除。', + titleRestored: '"{{label}}" "{{title}}" 成功恢复。', + titleTrashed: '{{label}} "{{title}}" 已移至垃圾桶。', + trash: '垃圾', + trashedCountSuccessfully: '{{count}} {{label}} 被移至垃圾桶。', true: '是', unauthorized: '未经授权', unsavedChanges: '您有未保存的更改。请在继续之前保存或放弃。', @@ -380,6 +420,7 @@ export const zhTranslations: DefaultTranslationsObject = { username: '用户名', users: '用户', value: '值', + viewing: '查看', viewReadOnly: '只读查看', welcome: '欢迎', yes: '是的', @@ -497,6 +538,7 @@ export const zhTranslations: DefaultTranslationsObject = { noRowsFound: '没有发现{{label}}', noRowsSelected: '未选择{{label}}', preview: '预览', + previouslyDraft: '以前的草稿', previouslyPublished: '先前发布过的', previousVersion: '以前的版本', problemRestoringVersion: '恢复这个版本时发生了问题', diff --git a/packages/translations/src/languages/zhTw.ts b/packages/translations/src/languages/zhTw.ts index f23ff13a1d..749186aaa0 100644 --- a/packages/translations/src/languages/zhTw.ts +++ b/packages/translations/src/languages/zhTw.ts @@ -5,63 +5,63 @@ export const zhTwTranslations: DefaultTranslationsObject = { account: '帳戶', accountOfCurrentUser: '目前使用者的帳戶', accountVerified: '帳戶驗證成功。', - alreadyActivated: '已經啟用了', - alreadyLoggedIn: '已經登入了', - apiKey: 'API金鑰', - authenticated: '經過身份驗證的', - backToLogin: '返回登入頁面', - beginCreateFirstUser: '首先,請建立您的第一個使用者。', - changePassword: '更改密碼', + alreadyActivated: '已啟用', + alreadyLoggedIn: '已登入', + apiKey: 'API 金鑰', + authenticated: '已驗證', + backToLogin: '返回登入', + beginCreateFirstUser: '請先建立第一位使用者。', + changePassword: '變更密碼', checkYourEmailForPasswordReset: - '如果此電子郵件地址已與一個帳戶相關聯,您將很快收到重設密碼的指示。如果您在收件箱中看不到該電子郵件,請檢查您的垃圾郵件或垃圾郵件夾。', - confirmGeneration: '確認生成', + '如果這個電子郵件地址有對應的帳戶,您將會收到重設密碼的說明。請檢查垃圾郵件或垃圾郵件匣以免錯過郵件。', + confirmGeneration: '確認產生', confirmPassword: '確認密碼', - createFirstUser: '建立第一個使用者', + createFirstUser: '建立第一位使用者', emailNotValid: '提供的電子郵件無效', emailOrUsername: '電子郵件或使用者名稱', emailSent: '電子郵件已寄出', emailVerified: '電子郵件驗證成功。', - enableAPIKey: '啟用API金鑰', - failedToUnlock: '解鎖失敗', - forceUnlock: '強制解鎖', + enableAPIKey: '啟用 API 金鑰', + failedToUnlock: '解除鎖定失敗', + forceUnlock: '強制解除鎖定', forgotPassword: '忘記密碼', forgotPasswordEmailInstructions: - '請在下方輸入您的電子郵件。您將收到一封有關如何重設密碼的說明電子郵件。', + '請輸入您的電子郵件。您將會收到一封包含密碼重設指示的郵件。', forgotPasswordQuestion: '忘記密碼?', forgotPasswordUsernameInstructions: - '請在下方輸入您的使用者名稱。關於如何重設密碼的指示將會發送到與您的使用者名稱相關的電子郵件地址。', - generate: '生成', - generateNewAPIKey: '生成新的API金鑰', - generatingNewAPIKeyWillInvalidate: '生成新的API金鑰將使之前的金鑰<1>失效。您確定要繼續嗎?', - lockUntil: '鎖定直到', + '請輸入您的使用者名稱。重設密碼的指示將會寄送至該帳號所綁定的電子郵件。', + generate: '產生', + generateNewAPIKey: '產生新的 API 金鑰', + generatingNewAPIKeyWillInvalidate: '產生新的 API 金鑰將會使原本的金鑰<1>失效。確定要繼續嗎?', + lockUntil: '鎖定至', logBackIn: '重新登入', - loggedIn: '要使用另一個使用者登入前,您需要先<0>登出。', - loggedInChangePassword: '要更改您的密碼,請前往您的<0>帳戶頁面並在那裡編輯您的密碼。', - loggedOutInactivity: '您由於不活躍而被登出了。', + loggedIn: '若要使用其他使用者登入,請先<0>登出。', + loggedInChangePassword: '若要變更密碼,請前往<0>帳戶設定進行修改。', + loggedOutInactivity: '由於閒置過久,您已被自動登出。', loggedOutSuccessfully: '您已成功登出。', - loggingOut: '登出中...', + loggingOut: '登出中…', login: '登入', - loginAttempts: '登入次數', + loginAttempts: '登入嘗試次數', loginUser: '登入使用者', - loginWithAnotherUser: '要使用另一個使用者登入前,您需要先<0>登出。', + loginWithAnotherUser: '若要使用其他使用者登入,請先<0>登出。', logOut: '登出', logout: '登出', logoutSuccessful: '成功登出。', logoutUser: '登出使用者', newAccountCreated: - '剛剛為您建立了一個可以存取 {{serverURL}} 的新帳戶。請點擊以下連結或在瀏覽器中貼上以下網址以驗證您的電子郵件:{{verificationURL}}
驗證您的電子郵件後,您將能夠成功登入。', - newAPIKeyGenerated: '新的API金鑰已生成。', - newPassword: '新的密碼', - passed: '身份驗證通過', - passwordResetSuccessfully: '成功重設密碼。', + '已為您建立新帳戶,可前往 {{serverURL}} 登入。請點選以下連結或將網址貼到瀏覽器以完成電子郵件驗證:{{verificationURL}}
完成驗證後即可成功登入。', + newAPIKeyGenerated: '已產生新的 API 金鑰。', + newPassword: '新密碼', + passed: '驗證通過', + passwordResetSuccessfully: '密碼重設成功。', resetPassword: '重設密碼', - resetPasswordExpiration: '重設密碼的有效期', - resetPasswordToken: '重設密碼令牌', + resetPasswordExpiration: '密碼重設有效期限', + resetPasswordToken: '密碼重設憑證', resetYourPassword: '重設您的密碼', - stayLoggedIn: '保持登入狀態', - successfullyRegisteredFirstUser: '成功註冊了第一個使用者。', - successfullyUnlocked: '已成功解鎖', - tokenRefreshSuccessful: '令牌刷新成功。', + stayLoggedIn: '維持登入狀態', + successfullyRegisteredFirstUser: '已成功註冊第一位使用者。', + successfullyUnlocked: '已成功解除鎖定', + tokenRefreshSuccessful: '存取權杖更新成功。', unableToVerify: '無法驗證', username: '使用者名稱', usernameNotValid: '提供的使用者名稱無效', @@ -69,310 +69,349 @@ export const zhTwTranslations: DefaultTranslationsObject = { verifiedSuccessfully: '成功驗證', verify: '驗證', verifyUser: '驗證使用者', - verifyYourEmail: '驗證您的電子郵件', + verifyYourEmail: '請驗證您的電子郵件', youAreInactive: - '您已經有一段時間沒有活動了,為了您的安全,很快就會自動登出。您想保持登入狀態嗎?', + '您已有一段時間沒有操作系統,為了您的帳戶安全,系統即將自動登出。是否要維持登入狀態?', youAreReceivingResetPassword: - '您收到此郵件是因為您(或其他人)已請求重設您帳戶的密碼。請點擊以下連結,或將其貼上到您的瀏覽器中以完成該過程:', - youDidNotRequestPassword: '如果您沒有要求這樣做,請忽略這封郵件,您的密碼將保持不變。', + '您會收到這封郵件是因為您(或其他人)請求重設此帳戶的密碼。請點選以下連結,或將該連結貼至瀏覽器以完成操作:', + youDidNotRequestPassword: '如果這不是您本人操作,請忽略這封郵件,您的密碼將不會改變。', }, error: { - accountAlreadyActivated: '該帳戶已被啟用。', - autosaving: '自動儲存該文件時出現了問題。', - correctInvalidFields: '請更正無效區塊。', - deletingFile: '刪除文件時出現了錯誤。', - deletingTitle: '刪除{{title}}時出現了錯誤。請檢查您的網路連線並重試。', - emailOrPasswordIncorrect: '提供的電子郵件或密碼不正確。', - followingFieldsInvalid_one: '下面的字串是無效的:', - followingFieldsInvalid_other: '以下字串是無效的:', - incorrectCollection: '不正確的集合', - invalidFileType: '無效的文件類型', - invalidFileTypeValue: '無效的文件類型: {{value}}', - invalidRequestArgs: '請求中傳遞了無效的參數:{{args}}', - loadingDocument: '加載ID為{{id}}的文件時出現了問題。', - localesNotSaved_one: '以下的地區設定無法保存:', - localesNotSaved_other: '以下地區無法保存:', + accountAlreadyActivated: '此帳戶已啟用。', + autosaving: '自動儲存文件時發生問題。', + correctInvalidFields: '請修正無效欄位。', + deletingFile: '刪除檔案時發生錯誤。', + deletingTitle: '刪除 {{title}} 時發生錯誤。請檢查網路連線後再試一次。', + documentNotFound: '找不到 ID 為 {{id}} 的文件。可能已被刪除、不存在,或您沒有權限存取。', + emailOrPasswordIncorrect: '電子郵件或密碼錯誤。', + followingFieldsInvalid_one: '以下欄位無效:', + followingFieldsInvalid_other: '以下欄位無效:', + incorrectCollection: '集合不正確', + insufficientClipboardPermissions: '無法存取剪貼簿。請確認您的剪貼簿權限。', + invalidClipboardData: '剪貼簿資料無效。', + invalidFileType: '不支援的檔案類型', + invalidFileTypeValue: '檔案類型無效:{{value}}', + invalidRequestArgs: '請求中傳入的參數無效:{{args}}', + loadingDocument: '載入 ID 為 {{id}} 的文件時發生問題。', + localesNotSaved_one: '以下語言地區無法儲存:', + localesNotSaved_other: '以下語言地區無法儲存:', logoutFailed: '登出失敗。', missingEmail: '缺少電子郵件。', - missingIDOfDocument: '缺少需要更新的文檔的ID。', - missingIDOfVersion: '缺少版本的ID。', - missingRequiredData: '缺少必要的數據。', - noFilesUploaded: '沒有上傳文件。', - noMatchedField: '找不到與"{{label}}"匹配的字串', - notAllowedToAccessPage: '您沒有權限訪問此頁面。', - notAllowedToPerformAction: '您不被允許執行此操作。', - notFound: '沒有找到請求的資源。', - noUser: '沒有該使用者', - previewing: '預覽文件時出現了問題。', - problemUploadingFile: '上傳文件時出現了問題。', - tokenInvalidOrExpired: '令牌無效或已過期。', - tokenNotProvided: '未提供令牌。', - unableToDeleteCount: '無法從 {{total}} 個中刪除 {{count}} 個 {{label}}。', - unableToReindexCollection: '重新索引集合 {{collection}} 時出現錯誤。操作已中止。', - unableToUpdateCount: '無法從 {{total}} 個中更新 {{count}} 個 {{label}}。', - unauthorized: '未經授權,您必須登錄才能提出這個請求。', - unauthorizedAdmin: '未經授權,此使用者無法訪問管理面板。', - unknown: '發生了一個未知的錯誤。', - unPublishingDocument: '取消發布此文件時出現了問題。', - unspecific: '發生了一個錯誤。', - unverifiedEmail: '請在登入前驗證您的電子郵件。', - userEmailAlreadyRegistered: '給定電子郵件的用戶已經註冊。', - userLocked: '該使用者由於有太多次失敗的登錄嘗試而被鎖定。', - usernameAlreadyRegistered: '已有使用者使用所提供的用戶名註冊。', - usernameOrPasswordIncorrect: '提供的使用者名稱或密碼不正確。', - valueMustBeUnique: '數值必須是唯一的', - verificationTokenInvalid: '驗證令牌無效。', + missingIDOfDocument: '缺少要更新之文件的 ID。', + missingIDOfVersion: '缺少版本 ID。', + missingRequiredData: '缺少必要資料。', + noFilesUploaded: '尚未上傳任何檔案。', + noMatchedField: '找不到對應欄位:「{{label}}」', + notAllowedToAccessPage: '您沒有權限存取此頁面。', + notAllowedToPerformAction: '您沒有執行此操作的權限。', + notFound: '找不到所請求的資源。', + noUser: '找不到使用者', + previewing: '預覽文件時發生問題。', + problemUploadingFile: '上傳檔案時發生問題。', + restoringTitle: '還原 {{title}} 時發生錯誤。請檢查連線並再試一次。', + tokenInvalidOrExpired: '憑證無效或已過期。', + tokenNotProvided: '未提供憑證。', + unableToCopy: '無法複製。', + unableToDeleteCount: '無法刪除 {{total}} 個 {{label}} 中的 {{count}} 個。', + unableToReindexCollection: '重新索引集合 {{collection}} 時發生錯誤。作業已中止。', + unableToUpdateCount: '無法更新 {{total}} 個 {{label}} 中的 {{count}} 個。', + unauthorized: '未經授權,您必須先登入才能執行此請求。', + unauthorizedAdmin: '未授權,該使用者無法存取管理後台。', + unknown: '發生未知錯誤。', + unPublishingDocument: '取消發佈文件時發生問題。', + unspecific: '發生錯誤。', + unverifiedEmail: '請先完成電子郵件驗證後再登入。', + userEmailAlreadyRegistered: '該電子郵件已註冊為使用者帳號。', + userLocked: '此使用者因登入失敗次數過多而被鎖定。', + usernameAlreadyRegistered: '該使用者名稱已被註冊。', + usernameOrPasswordIncorrect: '使用者名稱或密碼錯誤。', + valueMustBeUnique: '此欄位的值必須是唯一的', + verificationTokenInvalid: '驗證憑證無效。', }, fields: { - addLabel: '新增{{label}}', + addLabel: '新增 {{label}}', addLink: '新增連結', addNew: '新增', - addNewLabel: '新增{{label}}', + addNewLabel: '新增 {{label}}', addRelationship: '新增關聯', - addUpload: '上傳', + addUpload: '新增上傳', block: '區塊', blocks: '區塊', blockType: '區塊類型', - chooseBetweenCustomTextOrDocument: '選擇自定義文件或連結到另一個文件。', + chooseBetweenCustomTextOrDocument: '選擇輸入自訂文字網址,或連結到其他文件。', chooseDocumentToLink: '選擇要連結的文件', - chooseFromExisting: '從現有的選擇', - chooseLabel: '選擇{{label}}', - collapseAll: '全部折疊', - customURL: '自定義連結', - editLabelData: '編輯{{label}}資料', + chooseFromExisting: '從現有項目中選擇', + chooseLabel: '選擇 {{label}}', + collapseAll: '全部收合', + customURL: '自訂網址', + editLabelData: '編輯 {{label}} 資料', editLink: '編輯連結', editRelationship: '編輯關聯', - enterURL: '輸入連結', + enterURL: '輸入網址', internalLink: '內部連結', - itemsAndMore: '{{items}} 個,還有 {{count}} 個', - labelRelationship: '{{label}}關聯', + itemsAndMore: '{{items}} 及另外 {{count}} 項', + labelRelationship: '{{label}} 關聯', latitude: '緯度', - linkedTo: '連結到<0>{{label}}', + linkedTo: '已連結至 <0>{{label}}', linkType: '連結類型', longitude: '經度', - newLabel: '新的{{label}}', - openInNewTab: '在新標籤中打開', - passwordsDoNotMatch: '密碼不匹配。', + newLabel: '新增 {{label}}', + openInNewTab: '在新分頁中開啟', + passwordsDoNotMatch: '密碼不一致。', relatedDocument: '相關文件', - relationTo: '關聯到', + relationTo: '關聯對象', removeRelationship: '移除關聯', - removeUpload: '移除上傳', + removeUpload: '移除上傳項目', saveChanges: '儲存變更', - searchForBlock: '搜尋一個區塊', - selectExistingLabel: '選擇現有的{{label}}', - selectFieldsToEdit: '選擇要編輯的字串', - showAll: '顯示全部', - swapRelationship: '替換關聯', - swapUpload: '替換上傳', - textToDisplay: '要顯示的文字', + searchForBlock: '搜尋區塊', + selectExistingLabel: '選取現有 {{label}}', + selectFieldsToEdit: '選取要編輯的欄位', + showAll: '全部展開', + swapRelationship: '交換關聯', + swapUpload: '交換上傳項目', + textToDisplay: '顯示文字', toggleBlock: '切換區塊', - uploadNewLabel: '上傳新的{{label}}', + uploadNewLabel: '上傳新 {{label}}', }, folder: { - browseByFolder: '按資料夾瀏覽', - byFolder: '按資料夾', + browseByFolder: '依資料夾瀏覽', + byFolder: '依資料夾', deleteFolder: '刪除資料夾', folderName: '資料夾名稱', folders: '資料夾', - itemHasBeenMoved: '{{title}}已被移至{{folderName}}', - itemHasBeenMovedToRoot: '{{title}}已被移至根文件夾', + folderTypeDescription: '選取此資料夾中允許存放哪種類型的集合文件。', + itemHasBeenMoved: '{{title}} 已移至 {{folderName}}', + itemHasBeenMovedToRoot: '{{title}} 已移至根資料夾', itemsMovedToFolder: '{{title}} 已移至 {{folderName}}', - itemsMovedToRoot: '{{title}}已經移至根資料夾', + itemsMovedToRoot: '{{title}} 已移至根資料夾', moveFolder: '移動資料夾', moveItemsToFolderConfirmation: - '您即將將 <1>{{count}} {{label}} 移至 <2>{{toFolder}}。您確定嗎?', - moveItemsToRootConfirmation: '您即將移動<1>{{count}} {{label}}至根文件夾。您確定嗎?', - moveItemToFolderConfirmation: '您即將將<1>{{title}}移至<2>{{toFolder}}。您確定嗎?', - moveItemToRootConfirmation: '您即將把<1>{{title}}移至根目錄。您確定嗎?', - movingFromFolder: '將 {{title}} 從 {{fromFolder}} 移出', - newFolder: '新資料夾', - noFolder: '沒有資料夾', - renameFolder: '重命名資料夾', - searchByNameInFolder: '在{{folderName}}中按名稱搜尋', - selectFolderForItem: '選擇{{title}}的資料夾', + '您即將移動 <1>{{count}} 個 {{label}} 到 <2>{{toFolder}}。確定要繼續?', + moveItemsToRootConfirmation: '您即將移動 <1>{{count}} 個 {{label}} 到根資料夾。確定要繼續?', + moveItemToFolderConfirmation: '您即將移動 <1>{{title}} 到 <2>{{toFolder}}。確定要繼續?', + moveItemToRootConfirmation: '您即將移動 <1>{{title}} 到根資料夾。確定要繼續?', + movingFromFolder: '正在從 {{fromFolder}} 移動 {{title}}', + newFolder: '新增資料夾', + noFolder: '無資料夾', + renameFolder: '重新命名資料夾', + searchByNameInFolder: '在 {{folderName}} 中依名稱搜尋', + selectFolderForItem: '為 {{title}} 選取資料夾', }, general: { name: '名稱', - aboutToDelete: '您即將刪除{{label}} <1>{{title}}。您確定要繼續嗎?', + aboutToDelete: '您即將刪除 {{label}} <1>{{title}}。確定要繼續?', aboutToDeleteCount_many: '您即將刪除 {{count}} 個 {{label}}', aboutToDeleteCount_one: '您即將刪除 {{count}} 個 {{label}}', aboutToDeleteCount_other: '您即將刪除 {{count}} 個 {{label}}', - addBelow: '新增到下方', - addFilter: '新增過濾器', - adminTheme: '管理頁面主題', - all: '所有', + aboutToPermanentlyDelete: '您即將永久刪除 {{label}} <1>{{title}}。確定要繼續?', + aboutToPermanentlyDeleteTrash: + '您即將從垃圾桶中永久刪除 <0>{{count}} 個 <1>{{label}}。確定要繼續?', + aboutToRestore: '您即將還原 {{label}} <1>{{title}}。確定要繼續?', + aboutToRestoreAsDraft: '您即將以草稿狀態還原 {{label}} <1>{{title}}。確定要繼續?', + aboutToRestoreAsDraftCount: '您即將還原 {{count}} 個 {{label}} 為草稿', + aboutToRestoreCount: '您即將還原 {{count}} 個 {{label}}', + aboutToTrash: '您即將將 {{label}} <1>{{title}} 移至垃圾桶。確定要繼續?', + aboutToTrashCount: '您即將將 {{count}} 個 {{label}} 移至垃圾桶', + addBelow: '在下方新增', + addFilter: '新增篩選條件', + adminTheme: '管理主題', + all: '全部', allCollections: '所有集合', - allLocales: '所有地區', - and: '和', - anotherUser: '另一位使用者', - anotherUserTakenOver: '另一位使用者接管了此文件的編輯。', - applyChanges: '套用更改', - ascending: '升冪', + allLocales: '所有語言地區', + and: '與', + anotherUser: '其他使用者', + anotherUserTakenOver: '其他使用者已接手編輯此文件。', + applyChanges: '套用變更', + ascending: '遞增', automatic: '自動', - backToDashboard: '返回到控制面板', + backToDashboard: '返回儀表板', cancel: '取消', - changesNotSaved: '您還有尚未儲存的變更。您確定要離開嗎?', - clearAll: '清除全部', + changesNotSaved: '變更尚未儲存。若您現在離開,將會遺失所有變更。', + clear: '清除', + clearAll: '全部清除', close: '關閉', - collapse: '折疊', + collapse: '收合', collections: '集合', - columns: '欄位', - columnToSort: '要排序的欄位', + columns: '欄', + columnToSort: '排序欄位', confirm: '確認', - confirmCopy: '確認副本', + confirmCopy: '確認複製', confirmDeletion: '確認刪除', confirmDuplication: '確認複製', confirmMove: '確認移動', - confirmReindex: '重新索引所有{{collections}}?', - confirmReindexAll: '重新索引所有集合?', - confirmReindexDescription: '此操作將刪除現有索引並重新索引{{collections}}集合中的文件。', - confirmReindexDescriptionAll: '此操作將刪除現有索引並重新索引所有集合中的文件。', + confirmReindex: '重新索引 {{collections}}?', + confirmReindexAll: '重新索引所有集合?', + confirmReindexDescription: '此操作將移除現有索引,並重新索引 {{collections}} 集合中的文件。', + confirmReindexDescriptionAll: '此操作將移除所有索引,並重新索引所有集合中的文件。', + confirmRestoration: '確認還原', copied: '已複製', copy: '複製', - copying: '複製', - copyWarning: '您即將以{{from}}覆蓋{{to}},這將影響{{label}} {{title}}。您確定要這麼做嗎?', + copyField: '複製欄位', + copying: '複製中', + copyRow: '複製列', + copyWarning: '您即將使用 {{from}} 覆寫 {{label}} {{title}} 中的 {{to}}。確定要繼續?', create: '建立', created: '已建立', - createdAt: '建立於', - createNew: '建立新的', - createNewLabel: '建立新的{{label}}', + createdAt: '建立時間', + createNew: '建立新項目', + createNewLabel: '建立新的 {{label}}', creating: '建立中', - creatingNewLabel: '正在建立新的{{label}}', + creatingNewLabel: '正在建立新的 {{label}}', currentlyEditing: - '目前正在編輯此文件。如果您接管,他們將無法繼續編輯,並且可能會丟失未保存的更改。', + '目前正在編輯此文件。若您接手,對方將無法繼續編輯,且未儲存的變更可能會遺失。', custom: '自訂', dark: '深色', - dashboard: '控制面板', + dashboard: '儀表板', delete: '刪除', + deleted: '已刪除', + deletedAt: '刪除時間', deletedCountSuccessfully: '已成功刪除 {{count}} 個 {{label}}。', - deletedSuccessfully: '已成功刪除。', - deleting: '刪除中...', - depth: '深度', - descending: '降冪', - deselectAllRows: '取消選擇全部', + deletedSuccessfully: '刪除成功。', + deletePermanently: '略過垃圾桶並永久刪除', + deleting: '刪除中…', + depth: '層級', + descending: '遞減', + deselectAllRows: '取消全選列', document: '文件', + documentIsTrashed: '此 {{label}} 已移至垃圾桶,僅供讀取。', documentLocked: '文件已鎖定', documents: '文件', duplicate: '複製', - duplicateWithoutSaving: '複製而不儲存變更。', + duplicateWithoutSaving: '不儲存變更直接複製', edit: '編輯', - editAll: '編輯全部', - editedSince: '自...以來編輯', + editAll: '全部編輯', + editedSince: '上次編輯時間', editing: '編輯中', - editingLabel_many: '編輯 {{count}} 個 {{label}}', - editingLabel_one: '編輯 {{count}} 個 {{label}}', - editingLabel_other: '編輯 {{count}} 個 {{label}}', - editingTakenOver: '編輯已被接管', - editLabel: '編輯{{label}}', + editingLabel_many: '正在編輯 {{count}} 個 {{label}}', + editingLabel_one: '正在編輯 {{count}} 個 {{label}}', + editingLabel_other: '正在編輯 {{count}} 個 {{label}}', + editingTakenOver: '編輯權已被接手', + editLabel: '編輯 {{label}}', email: '電子郵件', emailAddress: '電子郵件地址', - enterAValue: '輸入一個值', + emptyTrash: '清空垃圾桶', + emptyTrashLabel: '清空 {{label}} 垃圾桶', + enterAValue: '請輸入數值', error: '錯誤', errors: '錯誤', - exitLivePreview: '退出即時預覽', - export: '出口', - fallbackToDefaultLocale: '回到預設的語言', - false: '假的', - filter: '過濾器', - filters: '過濾器', - filterWhere: '過濾{{label}}', + exitLivePreview: '離開即時預覽', + export: '匯出', + fallbackToDefaultLocale: '回復至預設語言', + false: '否', + filter: '篩選', + filters: '篩選條件', + filterWhere: '篩選 {{label}},條件為', globals: '全域', goBack: '返回', - import: '進口', - isEditing: '正在編輯', - item: '物品', + groupByLabel: '依 {{label}} 分組', + import: '匯入', + isEditing: '正在編輯中', + item: '項目', items: '項目', language: '語言', - lastModified: '最後修改', - leaveAnyway: '無論如何都要離開', - leaveWithoutSaving: '不儲存直接離開', - light: '亮色', - livePreview: '預覽', - loading: '載入中...', - locale: '語言環境', - locales: '語言環境', - menu: '菜單', + lastModified: '最後修改時間', + leaveAnyway: '仍要離開', + leaveWithoutSaving: '離開且不儲存', + light: '淺色', + livePreview: '即時預覽', + loading: '載入中', + locale: '語言地區', + locales: '語言地區', + menu: '選單', moreOptions: '更多選項', move: '移動', - moveConfirm: '您即將移動 {{count}} {{label}} 到 <1>{{destination}}。您確定嗎?', - moveCount: '移動 {{count}} {{label}}', - moveDown: '向下移動', - moveUp: '向上移動', - moving: '移動', - movingCount: '移動 {{count}} {{label}}', + moveConfirm: '您即將移動 {{count}} 個 {{label}} 至 <1>{{destination}}。確定要繼續?', + moveCount: '移動 {{count}} 個 {{label}}', + moveDown: '下移', + moveUp: '上移', + moving: '移動中', + movingCount: '正在移動 {{count}} 個 {{label}}', newPassword: '新密碼', - next: '下一個', + next: '下一頁', no: '否', - noDateSelected: '未選擇日期', - noFiltersSet: '沒有設定過濾器', - noLabel: '<沒有{{label}}>', + noDateSelected: '尚未選取日期', + noFiltersSet: '尚未設定篩選條件', + noLabel: '<無 {{label}}>', none: '無', - noOptions: '沒有選項', - noResults: '沒有找到{{label}}。{{label}}並不存在或沒有符合您上面所指定的過濾器。', - notFound: '未找到', - nothingFound: '沒有找到任何東西', - noUpcomingEventsScheduled: '沒有即將到來的活動。', - noValue: '沒有數值', - of: '的', + noOptions: '無選項', + noResults: '找不到符合條件的 {{label}}。可能尚未建立或篩選條件不符。', + notFound: '找不到', + nothingFound: '查無資料', + noTrashResults: '{{label}} 垃圾桶為空。', + noUpcomingEventsScheduled: '沒有即將到來的事件。', + noValue: '無值', + of: '之', only: '僅限', - open: '打開', + open: '開啟', or: '或', - order: '排序', - overwriteExistingData: '覆蓋現有欄位資料', - pageNotFound: '未找到頁面', + order: '順序', + overwriteExistingData: '覆寫現有欄位資料', + pageNotFound: '找不到此頁面', password: '密碼', - payloadSettings: 'Payload設定', - perPage: '每一頁: {{limit}} 個', - previous: '先前的', + pasteField: '貼上欄位', + pasteRow: '貼上列', + payloadSettings: 'Payload 設定', + permanentlyDelete: '永久刪除', + permanentlyDeletedCountSuccessfully: '已成功永久刪除 {{count}} 個 {{label}}。', + perPage: '每頁顯示:{{limit}}', + previous: '上一頁', reindex: '重新索引', - reindexingAll: '正在重新索引所有{{collections}}。', + reindexingAll: '正在重新索引 {{collections}}。', remove: '移除', rename: '重新命名', reset: '重設', resetPreferences: '重設偏好設定', - resetPreferencesDescription: '這將把您的所有偏好設定恢復為預設值。', + resetPreferencesDescription: '此操作會將所有偏好設定恢復為預設值。', resettingPreferences: '正在重設偏好設定。', - row: '行', - rows: '行', + restore: '還原', + restoreAsPublished: '還原為已發佈版本', + restoredCountSuccessfully: '已成功還原 {{count}} 個 {{label}}。', + restoring: '還原中…', + row: '列', + rows: '列', save: '儲存', - saving: '儲存中...', - schedulePublishFor: '為{{title}}設定發佈時間', - searchBy: '搜尋{{label}}', - select: '選擇', - selectAll: '選擇所有 {{count}} 個 {{label}}', - selectAllRows: '選擇所有行', - selectedCount: '已選擇 {{count}} 個 {{label}}', - selectLabel: '選擇 {{label}}', - selectValue: '選擇一個值', - showAllLabel: '顯示所有{{label}}', - sorryNotFound: '對不起,沒有找到您請求的東西。', + saving: '儲存中…', + schedulePublishFor: '排程發佈 {{title}}', + searchBy: '依 {{label}} 搜尋', + select: '選取', + selectAll: '選取全部 {{count}} 個 {{label}}', + selectAllRows: '選取所有列', + selectedCount: '已選取 {{count}} 個 {{label}}', + selectLabel: '選取 {{label}}', + selectValue: '請選取一個值', + showAllLabel: '顯示所有 {{label}}', + sorryNotFound: '很抱歉,找不到符合條件的內容。', sort: '排序', - sortByLabelDirection: '按{{label}} {{direction}}排序', - stayOnThisPage: '停留在此頁面', - submissionSuccessful: '成功送出。', + sortByLabelDirection: '依 {{label}} {{direction}} 排序', + stayOnThisPage: '留在此頁面', + submissionSuccessful: '送出成功。', submit: '送出', - submitting: '提交中...', + submitting: '送出中…', success: '成功', - successfullyCreated: '成功建立{{label}}', - successfullyDuplicated: '成功複製{{label}}', + successfullyCreated: '已成功建立 {{label}}。', + successfullyDuplicated: '已成功複製 {{label}}。', successfullyReindexed: - '成功重新索引了 {{collections}} 集合中 {{total}} 個文檔中的 {{count}} 個。', - takeOver: '接管', - thisLanguage: '中文 (繁體)', + '已成功重新索引 {{collections}} 中 {{total}} 筆文件中的 {{count}} 筆。', + takeOver: '接手編輯', + thisLanguage: '中文(繁體)', time: '時間', timezone: '時區', - titleDeleted: '{{label}} "{{title}}"已被成功刪除。', - true: '真實', + titleDeleted: '{{label}}「{{title}}」已成功刪除。', + titleRestored: '{{label}}「{{title}}」已成功還原。', + titleTrashed: '{{label}}「{{title}}」已移至垃圾桶。', + trash: '垃圾桶', + trashedCountSuccessfully: '{{count}} 個 {{label}} 已移至垃圾桶。', + true: '是', unauthorized: '未經授權', - unsavedChanges: '您有未保存的更改。繼續前請保存或放棄。', - unsavedChangesDuplicate: '您有還沒儲存的修改,確定要繼續複製嗎?', - untitled: '無標題', - upcomingEvents: '即將來臨的活動', - updatedAt: '更新於', + unsavedChanges: '您有尚未儲存的變更。請先儲存或捨棄後再繼續。', + unsavedChangesDuplicate: '您有尚未儲存的變更。是否仍要繼續複製?', + untitled: '未命名', + upcomingEvents: '即將到來的事件', + updatedAt: '更新時間', updatedCountSuccessfully: '已成功更新 {{count}} 個 {{label}}。', - updatedLabelSuccessfully: '成功更新了{{label}}。', + updatedLabelSuccessfully: '已成功更新 {{label}}。', updatedSuccessfully: '更新成功。', - updateForEveryone: '給所有人的更新', + updateForEveryone: '為所有人更新', updating: '更新中', uploading: '上傳中', uploadingBulk: '正在上傳 {{current}} / {{total}}', @@ -380,162 +419,164 @@ export const zhTwTranslations: DefaultTranslationsObject = { username: '使用者名稱', users: '使用者', value: '值', - viewReadOnly: '僅檢視', + viewing: '檢視中', + viewReadOnly: '唯讀檢視', welcome: '歡迎', - yes: '是的', + yes: '是', }, localization: { - cannotCopySameLocale: '無法複製到相同的地區', - copyFrom: '從...複製', - copyFromTo: '從{{from}}複製到{{to}}', + cannotCopySameLocale: '無法複製到相同語言地區', + copyFrom: '複製來源', + copyFromTo: '從 {{from}} 複製到 {{to}}', copyTo: '複製到', - copyToLocale: '複製到區域設定', - localeToPublish: '發布地區', - selectLocaleToCopy: '選擇要複製的地區設定', + copyToLocale: '複製至語言地區', + localeToPublish: '要發佈的語言地區', + selectLocaleToCopy: '選取要複製的語言地區', }, operators: { contains: '包含', equals: '等於', exists: '存在', - intersects: '交叉點', + intersects: '相交', isGreaterThan: '大於', - isGreaterThanOrEqualTo: '大於等於', - isIn: '在', + isGreaterThanOrEqualTo: '大於或等於', + isIn: '屬於', isLessThan: '小於', isLessThanOrEqualTo: '小於或等於', - isLike: '就像', + isLike: '類似', isNotEqualTo: '不等於', - isNotIn: '不在', - isNotLike: '不像', - near: '附近', - within: '在...之內', + isNotIn: '不屬於', + isNotLike: '不類似', + near: '接近', + within: '範圍內', }, upload: { - addFile: '添加文件', - addFiles: '添加檔案', - bulkUpload: '批量上傳', + addFile: '新增檔案', + addFiles: '新增多個檔案', + bulkUpload: '大量上傳', crop: '裁剪', - cropToolDescription: '拖動所選區域的角落,繪製一個新區域或調整以下的值。', + cropToolDescription: '拖曳選取區域的角落、重新繪製區域或調整下方數值以進行裁剪。', download: '下載', - dragAndDrop: '拖放一個檔案', - dragAndDropHere: '或在這裡拖放一個檔案', - editImage: '編輯圖像', + dragAndDrop: '拖放檔案', + dragAndDropHere: '或將檔案拖放到此處', + editImage: '編輯圖片', fileName: '檔案名稱', fileSize: '檔案大小', - filesToUpload: '上傳的文件', - fileToUpload: '上傳檔案', - focalPoint: '焦點', - focalPointDescription: '直接在預覽中拖動焦點或調整下面的值。', + filesToUpload: '待上傳的檔案', + fileToUpload: '待上傳的檔案', + focalPoint: '對焦點', + focalPointDescription: '直接在預覽圖上拖曳以設定對焦點,或調整下方數值。', height: '高度', - lessInfo: '更少資訊', - moreInfo: '更多資訊', - noFile: '沒有檔案', + lessInfo: '顯示較少資訊', + moreInfo: '顯示更多資訊', + noFile: '無檔案', pasteURL: '貼上網址', previewSizes: '預覽尺寸', - selectCollectionToBrowse: '選擇一個要瀏覽的集合', - selectFile: '選擇一個文件', - setCropArea: '設置裁剪區域', - setFocalPoint: '設置焦點', + selectCollectionToBrowse: '選取要瀏覽的集合', + selectFile: '選取檔案', + setCropArea: '設定裁剪區域', + setFocalPoint: '設定對焦點', sizes: '尺寸', - sizesFor: '{{label}}的尺寸', + sizesFor: '{{label}} 的尺寸', width: '寬度', }, validation: { - emailAddress: '請輸入一個有效的電子郵件地址。', - enterNumber: '請輸入一個有效的數字。', - fieldHasNo: '這個字串沒有{{label}}', - greaterThanMax: '{{value}}超過了允許的最大{{label}},該最大值為{{max}}。', - invalidInput: '這個字串有一個無效的輸入。', - invalidSelection: '這個字串有一個無效的選擇。', - invalidSelections: '這個字串有以下無效的選擇:', - lessThanMin: '{{value}}小於允許的最小{{label}},該最小值為{{min}}。', - limitReached: '已達限制,只能添加{{max}}個項目。', - longerThanMin: '該值必須大於{{minLength}}字串的最小長度', - notValidDate: '"{{value}}"不是一個有效的日期。', - required: '該字串為必填項目。', - requiresAtLeast: '該字串至少需要 {{count}} 個 {{label}}。', - requiresNoMoreThan: '該字串要求不超過 {{count}} 個 {{label}。', - requiresTwoNumbers: '該字串需要兩個數字。', - shorterThanMax: '該值長度必須小於{{maxLength}}個字元', - timezoneRequired: '需要時間區。', - trueOrFalse: '該字串只能等於是或否。', - username: '請輸入有效的使用者名稱。可以包含字母、數字、連字號、句點和底線。', - validUploadID: '該字串不是有效的上傳ID。', + emailAddress: '請輸入有效的電子郵件地址。', + enterNumber: '請輸入有效的數字。', + fieldHasNo: '此欄位沒有 {{label}}', + greaterThanMax: '{{value}} 超過允許的最大 {{label}} 值 {{max}}。', + invalidInput: '此欄位的輸入無效。', + invalidSelection: '此欄位的選取項目無效。', + invalidSelections: '此欄位包含以下無效選項:', + lessThanMin: '{{value}} 低於允許的最小 {{label}} 值 {{min}}。', + limitReached: '已達上限,最多只能新增 {{max}} 個項目。', + longerThanMin: '此值必須超過最小長度 {{minLength}} 個字元。', + notValidDate: '「{{value}}」不是有效的日期格式。', + required: '此欄位為必填項目。', + requiresAtLeast: '此欄位至少需要 {{count}} 個 {{label}}。', + requiresNoMoreThan: '此欄位最多只能有 {{count}} 個 {{label}}。', + requiresTwoNumbers: '此欄位需包含兩個數字。', + shorterThanMax: '此值必須少於最大長度 {{maxLength}} 個字元。', + timezoneRequired: '請選取一個時區。', + trueOrFalse: '此欄位只能為 true 或 false。', + username: '請輸入有效的使用者名稱。可包含英文字母、數字、連字號、句點與底線。', + validUploadID: '此欄位不是有效的上傳 ID。', }, version: { type: '類型', - aboutToPublishSelection: '您確定即將發佈所選的 {{label}} 嗎?', - aboutToRestore: '您將把這個文件{{label}}回復到{{versionDate}}時的狀態', - aboutToRestoreGlobal: '您要將痊域的{{label}}回復到{{versionDate}}時的狀態', - aboutToRevertToPublished: '您將要將這個文件的內容還原到它的發佈狀態。您確定嗎?', - aboutToUnpublish: '您即將取消發佈這個文件。您確定嗎?', - aboutToUnpublishSelection: '您即將取消發佈所選內容中的所有 {{label}}。您確定嗎?', + aboutToPublishSelection: '您即將發佈所有選取的 {{label}}。確定要繼續?', + aboutToRestore: '您即將將此 {{label}} 文件還原至 {{versionDate}} 的狀態。', + aboutToRestoreGlobal: '您即將將全域 {{label}} 還原至 {{versionDate}} 的狀態。', + aboutToRevertToPublished: '您即將還原此文件至已發佈狀態。確定要繼續?', + aboutToUnpublish: '您即將取消發佈此文件。確定要繼續?', + aboutToUnpublishSelection: '您即將取消發佈所有選取的 {{label}}。確定要繼續?', autosave: '自動儲存', - autosavedSuccessfully: '自動儲存成功。', - autosavedVersion: '自動儲存的版本', - changed: '已更改', - changedFieldsCount_one: '{{count}} 更改了字段', - changedFieldsCount_other: '{{count}}個已更改的欄位', - compareVersion: '對比版本:', + autosavedSuccessfully: '已成功自動儲存。', + autosavedVersion: '自動儲存版本', + changed: '已變更', + changedFieldsCount_one: '{{count}} 個欄位已變更', + changedFieldsCount_other: '{{count}} 個欄位已變更', + compareVersion: '比較以下版本:', compareVersions: '比較版本', - comparingAgainst: '相對於', + comparingAgainst: '正在與下列版本比較', confirmPublish: '確認發佈', - confirmRevertToSaved: '確認回復到儲存狀態', + confirmRevertToSaved: '確認還原為已儲存版本', confirmUnpublish: '確認取消發佈', - confirmVersionRestoration: '確認版本回復', - currentDocumentStatus: '目前{{docStatus}}文件', - currentDraft: '目前的草稿', - currentlyPublished: '目前已發布', - currentlyViewing: '目前正在查看', - currentPublishedVersion: '目前已發布的版本', + confirmVersionRestoration: '確認還原此版本', + currentDocumentStatus: '目前為 {{docStatus}} 文件', + currentDraft: '目前草稿', + currentlyPublished: '目前已發佈', + currentlyViewing: '目前檢視', + currentPublishedVersion: '目前已發佈版本', draft: '草稿', - draftSavedSuccessfully: '草稿儲存成功。', - lastSavedAgo: '上次儲存在{{distance}}之前', - modifiedOnly: '僅修改過的', - moreVersions: '更多版本...', - noFurtherVersionsFound: '沒有發現其他版本', - noRowsFound: '沒有發現{{label}}', - noRowsSelected: '未選擇 {{label}}', + draftSavedSuccessfully: '草稿已成功儲存。', + lastSavedAgo: '上次儲存時間:{{distance}} 前', + modifiedOnly: '僅顯示已變更的內容', + moreVersions: '更多版本…', + noFurtherVersionsFound: '找不到更多版本', + noRowsFound: '找不到 {{label}}', + noRowsSelected: '尚未選取任何 {{label}}', preview: '預覽', - previouslyPublished: '先前出版過的', - previousVersion: '先前版本', - problemRestoringVersion: '回復這個版本時發生了問題', + previouslyDraft: '先前為草稿', + previouslyPublished: '先前已發佈', + previousVersion: '前一版本', + problemRestoringVersion: '還原此版本時發生問題', publish: '發佈', - publishAllLocales: '發布所有地區設定', - publishChanges: '發佈修改', + publishAllLocales: '發佈所有語言版本', + publishChanges: '發佈變更', published: '已發佈', - publishIn: '在 {{locale}} 發佈', - publishing: '發布', - restoreAsDraft: '恢復為草稿', - restoredSuccessfully: '回復成功。', + publishIn: '發佈於 {{locale}}', + publishing: '發佈中', + restoreAsDraft: '還原為草稿', + restoredSuccessfully: '還原成功。', restoreThisVersion: '回復此版本', - restoring: '回復中...', - reverting: '還原中...', - revertToPublished: '還原到已發佈的版本', + restoring: '還原中…', + reverting: '還原中…', + revertToPublished: '還原至已發佈版本', saveDraft: '儲存草稿', - scheduledSuccessfully: '成功安排。', - schedulePublish: '排程發布', - selectLocales: '選擇要顯示的語言', + scheduledSuccessfully: '排程成功。', + schedulePublish: '排程發佈', + selectLocales: '選擇要顯示的語言版本', selectVersionToCompare: '選擇要比較的版本', - showingVersionsFor: '顯示版本為:', - showLocales: '顯示語言:', - specificVersion: '特定版本', + showingVersionsFor: '顯示版本:', + showLocales: '顯示語言版本:', + specificVersion: '指定版本', status: '狀態', unpublish: '取消發佈', - unpublishing: '取消發佈中...', + unpublishing: '取消發佈中…', version: '版本', - versionAgo: '{{distance}}前', - versionCount_many: '發現 {{count}}個版本', - versionCount_none: '沒有發現任何版本', + versionAgo: '{{distance}} 前', + versionCount_many: '找到 {{count}} 個版本', + versionCount_none: '找不到版本', versionCount_one: '找到 {{count}} 個版本', versionCount_other: '找到 {{count}} 個版本', - versionCreatedOn: '版本 {{version}} 建立於:', - versionID: '版本ID', - versions: '版本', - viewingVersion: '正在查看{{entityLabel}} {{documentTitle}}的版本', - viewingVersionGlobal: '正在查看全域{{entityLabel}}的版本', - viewingVersions: '正在查看{{entityLabel}} {{documentTitle}}的版本', - viewingVersionsGlobal: '正在查看全域{{entityLabel}}的版本', + versionCreatedOn: '{{version}} 建立於:', + versionID: '版本 ID', + versions: '版本記錄', + viewingVersion: '正在檢視 {{entityLabel}}「{{documentTitle}}」的版本', + viewingVersionGlobal: '正在檢視全域 {{entityLabel}} 的版本', + viewingVersions: '正在檢視 {{entityLabel}}「{{documentTitle}}」的所有版本', + viewingVersionsGlobal: '正在檢視全域 {{entityLabel}} 的所有版本', }, } diff --git a/packages/translations/src/types.ts b/packages/translations/src/types.ts index eff7e644f0..10ce5edf96 100644 --- a/packages/translations/src/types.ts +++ b/packages/translations/src/types.ts @@ -23,6 +23,7 @@ type DateFNSKeys = | 'hr' | 'hu' | 'hy-AM' + | 'id' | 'it' | 'ja' | 'ko' diff --git a/packages/translations/src/utilities/languages.ts b/packages/translations/src/utilities/languages.ts index 39b719437e..98df5fa1b1 100644 --- a/packages/translations/src/utilities/languages.ts +++ b/packages/translations/src/utilities/languages.ts @@ -23,6 +23,7 @@ export const acceptedLanguages = [ 'hr', 'hu', 'hy', + 'id', 'it', 'ja', 'ko', @@ -76,7 +77,6 @@ export const acceptedLanguages = [ * 'gu', * 'ha-Latn', * 'hi', - * 'id', * 'ig-Latn', * 'is', * 'it-it', diff --git a/packages/ui/package.json b/packages/ui/package.json index aa5acaef1f..8cd23dd5a8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/ui", - "version": "3.46.0", + "version": "3.50.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", @@ -168,8 +168,8 @@ "@babel/preset-typescript": "7.27.1", "@hyrious/esbuild-plugin-commonjs": "0.2.6", "@payloadcms/eslint-config": "workspace:*", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.2", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", "@types/uuid": "10.0.0", "babel-plugin-react-compiler": "19.1.0-rc.2", "esbuild": "0.25.5", diff --git a/packages/ui/src/elements/AddNewRelation/index.tsx b/packages/ui/src/elements/AddNewRelation/index.tsx index 225db273f6..34e2659a56 100644 --- a/packages/ui/src/elements/AddNewRelation/index.tsx +++ b/packages/ui/src/elements/AddNewRelation/index.tsx @@ -7,6 +7,7 @@ import React, { Fragment, useCallback, useEffect, useState } from 'react' import type { DocumentDrawerContextType } from '../DocumentDrawer/Provider.js' import type { Props } from './types.js' +import { useRelatedCollections } from '../../hooks/useRelatedCollections.js' import { PlusIcon } from '../../icons/Plus/index.js' import { useAuth } from '../../providers/Auth/index.js' import { useTranslation } from '../../providers/Translation/index.js' @@ -16,7 +17,6 @@ import { Popup } from '../Popup/index.js' import * as PopupList from '../Popup/PopupButtonList/index.js' import { Tooltip } from '../Tooltip/index.js' import './index.scss' -import { useRelatedCollections } from './useRelatedCollections.js' const baseClass = 'relationship-add-new' diff --git a/packages/ui/src/elements/ArrayAction/index.tsx b/packages/ui/src/elements/ArrayAction/index.tsx index 60d5986eb0..81e778bb05 100644 --- a/packages/ui/src/elements/ArrayAction/index.tsx +++ b/packages/ui/src/elements/ArrayAction/index.tsx @@ -7,29 +7,34 @@ import { MoreIcon } from '../../icons/More/index.js' import { PlusIcon } from '../../icons/Plus/index.js' import { XIcon } from '../../icons/X/index.js' import { useTranslation } from '../../providers/Translation/index.js' -import { Popup, PopupList } from '../Popup/index.js' +import { ClipboardActionLabel } from '../ClipboardAction/ClipboardActionLabel.js' import './index.scss' +import { Popup, PopupList } from '../Popup/index.js' const baseClass = 'array-actions' export type Props = { addRow: (current: number, blockType?: string) => Promise | void + copyRow: (index: number) => void duplicateRow: (current: number) => void hasMaxRows: boolean index: number isSortable?: boolean moveRow: (from: number, to: number) => void + pasteRow: (index: number) => void removeRow: (index: number) => void rowCount: number } export const ArrayAction: React.FC = ({ addRow, + copyRow, duplicateRow, hasMaxRows, index, isSortable, moveRow, + pasteRow, removeRow, rowCount, }) => { @@ -96,6 +101,24 @@ export const ArrayAction: React.FC = ({
)} + { + copyRow(index) + close() + }} + > + + + { + pasteRow(index) + close() + }} + > + + { diff --git a/packages/ui/src/elements/ClipboardAction/ClipboardActionLabel.tsx b/packages/ui/src/elements/ClipboardAction/ClipboardActionLabel.tsx new file mode 100644 index 0000000000..5a01f4ca65 --- /dev/null +++ b/packages/ui/src/elements/ClipboardAction/ClipboardActionLabel.tsx @@ -0,0 +1,32 @@ +'use client' + +import { Fragment } from 'react' + +import { CopyIcon } from '../../icons/Copy/index.js' +import { EditIcon } from '../../icons/Edit/index.js' +import { useTranslation } from '../../providers/Translation/index.js' + +export const ClipboardActionLabel = ({ + isPaste, + isRow, +}: { + isPaste?: boolean + isRow?: boolean +}) => { + const { t } = useTranslation() + + let label = t('general:copyField') + if (!isRow && isPaste) { + label = t('general:pasteField') + } else if (isRow && !isPaste) { + label = t('general:copyRow') + } else if (isRow && isPaste) { + label = t('general:pasteRow') + } + + return ( + + {isPaste ? : } {label} + + ) +} diff --git a/packages/ui/src/elements/ClipboardAction/clipboardUtilities.ts b/packages/ui/src/elements/ClipboardAction/clipboardUtilities.ts new file mode 100644 index 0000000000..3d9915af3f --- /dev/null +++ b/packages/ui/src/elements/ClipboardAction/clipboardUtilities.ts @@ -0,0 +1,67 @@ +import type { + ClipboardCopyActionArgs, + ClipboardPasteActionArgs, + ClipboardPasteActionValidateArgs, + ClipboardPasteData, +} from './types.js' + +import { isClipboardDataValid } from './isClipboardDataValid.js' + +const localStorageClipboardKey = '_payloadClipboard' + +/** + * @note This function doesn't use the Clipboard API, but localStorage. See rationale in #11513 + */ +export function clipboardCopy(args: ClipboardCopyActionArgs): string | true { + const { getDataToCopy, t, ...rest } = args + + const dataToWrite = { + data: getDataToCopy(), + ...rest, + } + + try { + localStorage.setItem(localStorageClipboardKey, JSON.stringify(dataToWrite)) + return true + } catch (_err) { + return t('error:unableToCopy') + } +} + +/** + * @note This function doesn't use the Clipboard API, but localStorage. See rationale in #11513 + */ +export function clipboardPaste({ + onPaste, + path: fieldPath, + t, + ...args +}: ClipboardPasteActionArgs): string | true { + let dataToPaste: ClipboardPasteData + + try { + const jsonFromClipboard = localStorage.getItem(localStorageClipboardKey) + + if (!jsonFromClipboard) { + return t('error:invalidClipboardData') + } + + dataToPaste = JSON.parse(jsonFromClipboard) + } catch (_err) { + return t('error:invalidClipboardData') + } + + const dataToValidate = { + ...dataToPaste, + ...args, + fieldPath, + } as ClipboardPasteActionValidateArgs + + if (!isClipboardDataValid(dataToValidate)) { + return t('error:invalidClipboardData') + } + + onPaste(dataToPaste) + + return true +} diff --git a/packages/ui/src/elements/ClipboardAction/index.tsx b/packages/ui/src/elements/ClipboardAction/index.tsx new file mode 100644 index 0000000000..0f0fff0f88 --- /dev/null +++ b/packages/ui/src/elements/ClipboardAction/index.tsx @@ -0,0 +1,124 @@ +'use client' + +import type { FormStateWithoutComponents } from 'payload' + +import { type FC, useCallback } from 'react' +import { toast } from 'sonner' + +import type { ClipboardCopyData, OnPasteFn } from './types.js' + +import { MoreIcon } from '../../icons/More/index.js' +import { useTranslation } from '../../providers/Translation/index.js' +import { Popup, PopupList } from '../Popup/index.js' +import { ClipboardActionLabel } from './ClipboardActionLabel.js' +import { clipboardCopy, clipboardPaste } from './clipboardUtilities.js' + +const baseClass = 'clipboard-action' + +type Props = { + allowCopy?: boolean + allowPaste?: boolean + className?: string + copyClassName?: string + disabled?: boolean + getDataToCopy: () => FormStateWithoutComponents + isRow?: boolean + onPaste: OnPasteFn + pasteClassName?: string +} & ClipboardCopyData + +/** + * Menu actions for copying and pasting fields. Currently, this is only used in Arrays and Blocks. + * @note This component doesn't use the Clipboard API, but localStorage. See rationale in #11513 + */ +export const ClipboardAction: FC = ({ + allowCopy, + allowPaste, + className, + copyClassName, + disabled, + isRow, + onPaste, + pasteClassName, + path, + ...rest +}) => { + const { t } = useTranslation() + + const classes = [`${baseClass}__popup`, className].filter(Boolean).join(' ') + + const handleCopy = useCallback(() => { + const clipboardResult = clipboardCopy({ + path, + t, + ...rest, + }) + + if (typeof clipboardResult === 'string') { + toast.error(clipboardResult) + } else { + toast.success(t('general:copied')) + } + }, [t, rest, path]) + + const handlePaste = useCallback(() => { + const clipboardResult = clipboardPaste( + rest.type === 'array' + ? { + onPaste, + path, + schemaFields: rest.fields, + t, + } + : { + onPaste, + path, + schemaBlocks: rest.blocks, + t, + }, + ) + + if (typeof clipboardResult === 'string') { + toast.error(clipboardResult) + } + }, [onPaste, rest, path, t]) + + if (!allowPaste && !allowCopy) { + return null + } + + return ( + } + className={classes} + disabled={disabled} + horizontalAlign="center" + render={({ close }) => ( + + { + void handleCopy() + close() + }} + > + + + { + void handlePaste() + close() + }} + > + + + + )} + size="large" + verticalAlign="bottom" + /> + ) +} diff --git a/packages/ui/src/elements/ClipboardAction/isClipboardDataValid.ts b/packages/ui/src/elements/ClipboardAction/isClipboardDataValid.ts new file mode 100644 index 0000000000..1ad9489435 --- /dev/null +++ b/packages/ui/src/elements/ClipboardAction/isClipboardDataValid.ts @@ -0,0 +1,109 @@ +import type { ClientBlock, ClientField } from 'payload' + +import { fieldAffectsData, fieldHasSubFields } from 'payload/shared' + +import type { ClipboardPasteActionValidateArgs } from './types.js' + +/** + * Validates whether clipboard data is compatible with the target schema. + * For this to be true, the copied field and the target to be pasted must + * be structurally equivalent (same schema) + * + * @returns True if the clipboard data is valid and can be pasted, false otherwise + */ +export function isClipboardDataValid({ data, path, ...args }: ClipboardPasteActionValidateArgs) { + if (typeof data === 'undefined' || !path || !args.type) { + return false + } + + if (args.type === 'blocks') { + return isClipboardBlocksValid({ + blocksFromClipboard: args.blocks, + blocksFromConfig: args.schemaBlocks, + }) + } else { + return isClipboardFieldsValid({ + fieldsFromClipboard: args.fields, + fieldsFromConfig: args.schemaFields, + }) + } +} + +function isClipboardFieldsValid({ + fieldsFromClipboard, + fieldsFromConfig, +}: { + fieldsFromClipboard: ClientField[] + fieldsFromConfig?: ClientField[] +}): boolean { + if (!fieldsFromConfig || fieldsFromClipboard.length !== fieldsFromConfig?.length) { + return false + } + + return fieldsFromClipboard.every((clipboardField, i) => { + const configField = fieldsFromConfig[i] + + if (clipboardField.type !== configField.type) { + return false + } + + const affectsData = fieldAffectsData(clipboardField) && fieldAffectsData(configField) + if (affectsData && clipboardField.name !== configField.name) { + return false + } + + const hasNestedFieldsConfig = fieldHasSubFields(configField) + const hasNestedFieldsClipboard = fieldHasSubFields(clipboardField) + if (hasNestedFieldsClipboard !== hasNestedFieldsConfig) { + return false + } + + if (hasNestedFieldsClipboard && hasNestedFieldsConfig) { + return isClipboardFieldsValid({ + fieldsFromClipboard: clipboardField.fields, + fieldsFromConfig: configField.fields, + }) + } + + return true + }) +} + +function isClipboardBlocksValid({ + blocksFromClipboard, + blocksFromConfig, +}: { + blocksFromClipboard: ClientBlock[] + blocksFromConfig?: ClientBlock[] +}) { + const configBlockMap = new Map(blocksFromConfig?.map((block) => [block.slug, block])) + + if (!configBlockMap.size) { + return false + } + + const checkedSlugs = new Set() + + for (const currBlock of blocksFromClipboard) { + const currSlug = currBlock.slug + + if (!checkedSlugs.has(currSlug)) { + const configBlock = configBlockMap.get(currSlug) + if (!configBlock) { + return false + } + + if ( + !isClipboardFieldsValid({ + fieldsFromClipboard: currBlock.fields, + fieldsFromConfig: configBlock.fields, + }) + ) { + return false + } + + checkedSlugs.add(currSlug) + } + } + return true +} diff --git a/packages/ui/src/elements/ClipboardAction/mergeFormStateFromClipboard.ts b/packages/ui/src/elements/ClipboardAction/mergeFormStateFromClipboard.ts new file mode 100644 index 0000000000..5a8d01c1f2 --- /dev/null +++ b/packages/ui/src/elements/ClipboardAction/mergeFormStateFromClipboard.ts @@ -0,0 +1,131 @@ +import type { FieldState, FormState } from 'payload' + +import type { ClipboardPasteData } from './types.js' + +export function reduceFormStateByPath({ + formState, + path, + rowIndex, +}: { + formState: FormState + path: string + rowIndex?: number +}) { + const filteredState: Record = {} + const prefix = typeof rowIndex !== 'number' ? path : `${path}.${rowIndex}` + + for (const key in formState) { + if (!key.startsWith(prefix)) { + continue + } + + const { customComponents: _, validate: __, ...field } = formState[key] + + if (Array.isArray(field.rows)) { + field.rows = field.rows.map((row) => { + if (!row || typeof row !== 'object') { + return row + } + const { customComponents: _, ...serializableRow } = row + return serializableRow + }) + } + + filteredState[key] = field + } + + return filteredState +} + +export function mergeFormStateFromClipboard({ + dataFromClipboard: clipboardData, + formState, + path, + rowIndex, +}: { + dataFromClipboard: ClipboardPasteData + formState: FormState + path: string + rowIndex?: number +}) { + const { + type: typeFromClipboard, + data: dataFromClipboard, + path: pathFromClipboard, + rowIndex: rowIndexFromClipboard, + } = clipboardData + + const copyFromField = typeof rowIndexFromClipboard !== 'number' + const pasteIntoField = typeof rowIndex !== 'number' + const fromRowToField = !copyFromField && pasteIntoField + const isArray = typeFromClipboard === 'array' + + let pathToReplace: string + if (copyFromField && pasteIntoField) { + pathToReplace = pathFromClipboard + } else if (copyFromField) { + pathToReplace = `${pathFromClipboard}.${rowIndex}` + } else { + pathToReplace = `${pathFromClipboard}.${rowIndexFromClipboard}` + } + + let targetSegment: string + if (!pasteIntoField) { + targetSegment = `${path}.${rowIndex}` + } else if (fromRowToField) { + targetSegment = `${path}.0` + } else { + targetSegment = path + } + + if (fromRowToField) { + const lastRenderedPath = `${path}.0` + const rowIDFromClipboard = dataFromClipboard[`${pathToReplace}.id`].value as string + const hasRows = formState[path].rows?.length + + formState[path].rows = [ + { + ...(hasRows && isArray ? formState[path].rows[0] : {}), + id: rowIDFromClipboard, + isLoading: false, + lastRenderedPath, + }, + ] + formState[path].value = 1 + formState[path].initialValue = 1 + formState[path].disableFormData = true + + for (const fieldPath in formState) { + if ( + fieldPath !== path && + !fieldPath.startsWith(lastRenderedPath) && + fieldPath.startsWith(path) + ) { + delete formState[fieldPath] + } + } + } + + for (const clipboardPath in dataFromClipboard) { + // Pasting a row id, skip overwriting + if ( + (!pasteIntoField && clipboardPath.endsWith('.id')) || + !clipboardPath.startsWith(pathToReplace) + ) { + continue + } + + const newPath = clipboardPath.replace(pathToReplace, targetSegment) + + const customComponents = isArray ? formState[newPath]?.customComponents : undefined + const validate = isArray ? formState[newPath]?.validate : undefined + + formState[newPath] = { + customComponents, + validate, + ...dataFromClipboard[clipboardPath], + } + } + + return formState +} diff --git a/packages/ui/src/elements/ClipboardAction/types.ts b/packages/ui/src/elements/ClipboardAction/types.ts new file mode 100644 index 0000000000..e0e6a03678 --- /dev/null +++ b/packages/ui/src/elements/ClipboardAction/types.ts @@ -0,0 +1,58 @@ +import type { TFunction } from '@payloadcms/translations' +import type { ClientBlock, ClientField, FormStateWithoutComponents } from 'payload' + +export type ClipboardCopyBlocksSchema = { + schemaBlocks: ClientBlock[] +} + +export type ClipboardCopyBlocksData = { + blocks: ClientBlock[] + type: 'blocks' +} + +export type ClipboardCopyFieldsSchema = { + schemaFields: ClientField[] +} + +export type ClipboardCopyFieldsData = { + fields: ClientField[] + type: 'array' +} + +export type ClipboardCopyData = { + path: string + rowIndex?: number +} & (ClipboardCopyBlocksData | ClipboardCopyFieldsData) + +export type ClipboardCopyActionArgs = { + getDataToCopy: () => FormStateWithoutComponents + t: TFunction +} & ClipboardCopyData + +export type ClipboardPasteData = { + data: FormStateWithoutComponents + path: string + rowIndex?: number +} & (ClipboardCopyBlocksData | ClipboardCopyFieldsData) + +export type OnPasteFn = (data: ClipboardPasteData) => void + +export type ClipboardPasteActionArgs = { + onPaste: OnPasteFn + path: string + t: TFunction +} & (ClipboardCopyBlocksSchema | ClipboardCopyFieldsSchema) + +export type ClipboardPasteActionValidateArgs = { + fieldPath: string +} & ( + | { + schemaBlocks: ClientBlock[] + type: 'blocks' + } + | { + schemaFields: ClientField[] + type: 'array' + } +) & + ClipboardPasteData diff --git a/packages/ui/src/elements/ColumnSelector/index.tsx b/packages/ui/src/elements/ColumnSelector/index.tsx index caffabe9d7..f208d87e4c 100644 --- a/packages/ui/src/elements/ColumnSelector/index.tsx +++ b/packages/ui/src/elements/ColumnSelector/index.tsx @@ -21,7 +21,7 @@ export const ColumnSelector: React.FC = ({ collectionSlug }) => { const filteredColumns = useMemo( () => - columns.filter( + columns?.filter( (col) => !(fieldIsHiddenOrDisabled(col.field) && !fieldIsID(col.field)) && !col?.field?.admin?.disableListColumn, diff --git a/packages/ui/src/elements/DatePicker/index.scss b/packages/ui/src/elements/DatePicker/index.scss index c8ce001343..1f7e0b2a1e 100644 --- a/packages/ui/src/elements/DatePicker/index.scss +++ b/packages/ui/src/elements/DatePicker/index.scss @@ -330,6 +330,16 @@ $cal-icon-width: 18px; border-radius: 0; } + .react-datepicker__month .react-datepicker__day { + &.react-datepicker__day--disabled { + color: var(--theme-elevation-200); + + &:hover { + background: none; + } + } + } + .react-datepicker__navigation--next--with-time:not( .react-datepicker__navigation--next--with-today-button ) { @@ -343,6 +353,13 @@ $cal-icon-width: 18px; li.react-datepicker__time-list-item { line-height: 20px; font-size: base(0.5); + + &.react-datepicker__time-list-item--disabled { + color: var(--theme-elevation-200); + &:hover { + background: none; + } + } } &__appearance--dayOnly, diff --git a/packages/ui/src/elements/DeleteDocument/index.scss b/packages/ui/src/elements/DeleteDocument/index.scss index 671eda49af..31444f1e60 100644 --- a/packages/ui/src/elements/DeleteDocument/index.scss +++ b/packages/ui/src/elements/DeleteDocument/index.scss @@ -11,5 +11,15 @@ &__toggle { @extend %btn-reset; } + + &__checkbox { + padding: calc(var(--base) * 0.5) 0; + + .checkbox-input { + label { + padding-bottom: 0; + } + } + } } } diff --git a/packages/ui/src/elements/DeleteDocument/index.tsx b/packages/ui/src/elements/DeleteDocument/index.tsx index 6e9b605762..c9be0899af 100644 --- a/packages/ui/src/elements/DeleteDocument/index.tsx +++ b/packages/ui/src/elements/DeleteDocument/index.tsx @@ -5,11 +5,12 @@ import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' import { useRouter } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' -import React, { useCallback } from 'react' +import React, { Fragment, useCallback, useState } from 'react' import { toast } from 'sonner' import type { DocumentDrawerContextType } from '../DocumentDrawer/Provider.js' +import { CheckboxInput } from '../../fields/Checkbox/Input.js' import { useForm } from '../../forms/Form/context.js' import { useConfig } from '../../providers/Config/index.js' import { useDocumentTitle } from '../../providers/DocumentTitle/index.js' @@ -21,6 +22,8 @@ import { PopupList } from '../Popup/index.js' import { Translation } from '../Translation/index.js' import './index.scss' +const baseClass = 'delete-document' + export type Props = { readonly buttonId?: string readonly collectionSlug: SanitizedCollectionConfig['slug'] @@ -62,6 +65,8 @@ export const DeleteDocument: React.FC = (props) => { const modalSlug = `delete-${id}` + const [deletePermanently, setDeletePermanently] = useState(false) + const addDefaultError = useCallback(() => { toast.error(t('error:deletingTitle', { title })) }, [t, title]) @@ -70,58 +75,69 @@ export const DeleteDocument: React.FC = (props) => { setModified(false) try { - await requests - .delete(`${serverURL}${api}/${collectionSlug}/${id}`, { - headers: { - 'Accept-Language': i18n.language, - 'Content-Type': 'application/json', - }, - }) - .then(async (res) => { - try { - const json = await res.json() + const res = + deletePermanently || !collectionConfig.trash + ? await requests.delete(`${serverURL}${api}/${collectionSlug}/${id}`, { + headers: { + 'Accept-Language': i18n.language, + 'Content-Type': 'application/json', + }, + }) + : await requests.patch(`${serverURL}${api}/${collectionSlug}/${id}`, { + body: JSON.stringify({ + deletedAt: new Date().toISOString(), + }), + headers: { + 'Accept-Language': i18n.language, + 'Content-Type': 'application/json', + }, + }) - if (res.status < 400) { - toast.success( - t('general:titleDeleted', { - label: getTranslation(singularLabel, i18n), - title, - }) || json.message, - ) + const json = await res.json() - if (redirectAfterDelete) { - return startRouteTransition(() => - router.push( - formatAdminURL({ - adminRoute, - path: `/collections/${collectionSlug}`, - }), - ), - ) - } + if (res.status < 400) { + toast.success( + t( + deletePermanently || !collectionConfig.trash + ? 'general:titleDeleted' + : 'general:titleTrashed', + { + label: getTranslation(singularLabel, i18n), + title, + }, + ) || json.message, + ) - if (typeof onDelete === 'function') { - await onDelete({ id, collectionConfig }) - } + if (redirectAfterDelete) { + return startRouteTransition(() => + router.push( + formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}`, + }), + ), + ) + } - return - } + if (typeof onDelete === 'function') { + await onDelete({ id, collectionConfig }) + } - if (json.errors) { - json.errors.forEach((error) => toast.error(error.message)) - } else { - addDefaultError() - } + return + } - return false - } catch (_err) { - return addDefaultError() - } - }) + if (json.errors) { + json.errors.forEach((error) => toast.error(error.message)) + } else { + addDefaultError() + } + + return } catch (_err) { return addDefaultError() } }, [ + deletePermanently, setModified, serverURL, api, @@ -142,7 +158,7 @@ export const DeleteDocument: React.FC = (props) => { if (id) { return ( - + { @@ -153,24 +169,38 @@ export const DeleteDocument: React.FC = (props) => { {children}, - }} - i18nKey="general:aboutToDelete" - t={t} - variables={{ - label: getTranslation(singularLabel, i18n), - title: titleFromProps || title || id, - }} - /> + + {children}, + }} + i18nKey={collectionConfig.trash ? 'general:aboutToTrash' : 'general:aboutToDelete'} + t={t} + variables={{ + label: getTranslation(singularLabel, i18n), + title: titleFromProps || title || id, + }} + /> + {collectionConfig.trash && ( +
+ setDeletePermanently(e.target.checked)} + /> +
+ )} +
} + className={baseClass} confirmingLabel={t('general:deleting')} heading={t('general:confirmDeletion')} modalSlug={modalSlug} onConfirm={handleDelete} /> -
+ ) } diff --git a/packages/ui/src/elements/DeleteMany/index.scss b/packages/ui/src/elements/DeleteMany/index.scss new file mode 100644 index 0000000000..703566ff00 --- /dev/null +++ b/packages/ui/src/elements/DeleteMany/index.scss @@ -0,0 +1,13 @@ +@import '../../scss/styles.scss'; + +@layer payload-default { + .delete-documents__checkbox { + padding: calc(var(--base) * 0.5) 0; + + .checkbox-input { + label { + padding-bottom: 0; + } + } + } +} diff --git a/packages/ui/src/elements/DeleteMany/index.tsx b/packages/ui/src/elements/DeleteMany/index.tsx index 561cf39645..f1e29df9b0 100644 --- a/packages/ui/src/elements/DeleteMany/index.tsx +++ b/packages/ui/src/elements/DeleteMany/index.tsx @@ -1,5 +1,5 @@ 'use client' -import type { ClientCollectionConfig, Where } from 'payload' +import type { ClientCollectionConfig, ViewTypes, Where } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' @@ -9,6 +9,7 @@ import * as qs from 'qs-esm' import React from 'react' import { toast } from 'sonner' +import { CheckboxInput } from '../../fields/Checkbox/Input.js' import { useAuth } from '../../providers/Auth/index.js' import { useConfig } from '../../providers/Config/index.js' import { useLocale } from '../../providers/Locale/index.js' @@ -19,19 +20,28 @@ import { requests } from '../../utilities/api.js' import { parseSearchParams } from '../../utilities/parseSearchParams.js' import { ConfirmationModal } from '../ConfirmationModal/index.js' import { ListSelectionButton } from '../ListSelection/index.js' - -const confirmManyDeleteDrawerSlug = `confirm-delete-many-docs` +import { Translation } from '../Translation/index.js' +import './index.scss' export type Props = { collection: ClientCollectionConfig + /** + * When multiple DeleteMany components are rendered on the page, this will differentiate them. + */ + modalPrefix?: string + /** + * When multiple PublishMany components are rendered on the page, this will differentiate them. + */ title?: string + viewType?: ViewTypes } export const DeleteMany: React.FC = (props) => { - const { collection: { slug } = {} } = props + const { viewType } = props + const { collection: { slug, trash } = {}, modalPrefix } = props const { permissions } = useAuth() - const { count, getSelectedIds, selectAll, toggleAll } = useSelection() + const { count, selectAll, selectedIDs, toggleAll } = useSelection() const router = useRouter() const searchParams = useSearchParams() const { clearRouteCache } = useRouteCache() @@ -39,12 +49,25 @@ export const DeleteMany: React.FC = (props) => { const collectionPermissions = permissions?.collections?.[slug] const hasDeletePermission = collectionPermissions?.delete + const selectingAll = selectAll === SelectAllStatus.AllAvailable + + const ids = selectingAll ? [] : selectedIDs + if (selectAll === SelectAllStatus.None || !hasDeletePermission) { return null } - const selectingAll = selectAll === SelectAllStatus.AllAvailable - const selectedIDs = !selectingAll ? getSelectedIds() : [] + const baseWhere = parseSearchParams(searchParams)?.where as Where + + const finalWhere = + viewType === 'trash' + ? { + and: [ + ...(Array.isArray(baseWhere?.and) ? baseWhere.and : baseWhere ? [baseWhere] : []), + { deletedAt: { exists: true } }, + ], + } + : baseWhere return ( @@ -64,15 +87,18 @@ export const DeleteMany: React.FC = (props) => { clearRouteCache() }} + modalPrefix={modalPrefix} search={parseSearchParams(searchParams)?.search as string} selections={{ [slug]: { all: selectAll === SelectAllStatus.AllAvailable, - ids: selectedIDs, - totalCount: selectingAll ? count : selectedIDs.length, + ids, + totalCount: selectingAll ? count : ids.length, }, }} - where={parseSearchParams(searchParams)?.where as Where} + trash={trash} + viewType={viewType} + where={finalWhere} /> ) @@ -91,6 +117,10 @@ type DeleteMany_v4Props = { * A callback function to be called after the delete request is completed. */ afterDelete?: (result: AfterDeleteResult) => void + /** + * When multiple DeleteMany components are rendered on the page, this will differentiate them. + */ + modalPrefix?: string /** * Optionally pass a search string to filter the documents to be deleted. * @@ -111,6 +141,8 @@ type DeleteMany_v4Props = { totalCount?: number } } + trash?: boolean + viewType?: ViewTypes /** * Optionally pass a where clause to filter the documents to be deleted. * This will be ignored if multiple relations are selected. @@ -126,8 +158,17 @@ type DeleteMany_v4Props = { * * If you are deleting monomorphic documents, shape your `selections` to match the polymorphic structure. */ -export function DeleteMany_v4({ afterDelete, search, selections, where }: DeleteMany_v4Props) { +export function DeleteMany_v4({ + afterDelete, + modalPrefix, + search, + selections, + trash, + viewType, + where, +}: DeleteMany_v4Props) { const { t } = useTranslation() + const { config: { collections, @@ -135,56 +176,87 @@ export function DeleteMany_v4({ afterDelete, search, selections, where }: Delete serverURL, }, } = useConfig() + const { code: locale } = useLocale() const { i18n } = useTranslation() const { openModal } = useModal() + const [deletePermanently, setDeletePermanently] = React.useState(false) + const confirmManyDeleteDrawerSlug = `${modalPrefix ? `${modalPrefix}-` : ''}confirm-delete-many-docs` + const handleDelete = React.useCallback(async () => { const deletingOneCollection = Object.keys(selections).length === 1 const result: AfterDeleteResult = {} + for (const [relationTo, { all, ids = [] }] of Object.entries(selections)) { const collectionConfig = collections.find(({ slug }) => slug === relationTo) + if (collectionConfig) { let whereConstraint: Where if (all) { // selecting all documents with optional where filter if (deletingOneCollection && where) { - whereConstraint = where + whereConstraint = + viewType === 'trash' + ? { + and: [ + ...(Array.isArray(where.and) ? where.and : [where]), + { deletedAt: { exists: true } }, + ], + } + : where } else { - whereConstraint = { - id: { not_equals: '' }, - } + whereConstraint = + viewType === 'trash' + ? { + and: [{ id: { not_equals: '' } }, { deletedAt: { exists: true } }], + } + : { + id: { not_equals: '' }, + } } } else { // selecting specific documents whereConstraint = { - id: { - in: ids, - }, + and: [ + { id: { in: ids } }, + ...(viewType === 'trash' ? [{ deletedAt: { exists: true } }] : []), + ], } } - const deleteManyResponse = await requests.delete( - `${serverURL}${api}/${relationTo}${qs.stringify( - { - limit: 0, - locale, - where: mergeListSearchAndWhere({ - collectionConfig, - search, - where: whereConstraint, - }), - }, - { addQueryPrefix: true }, - )}`, + const url = `${serverURL}${api}/${relationTo}${qs.stringify( { - headers: { - 'Accept-Language': i18n.language, - 'Content-Type': 'application/json', - }, + limit: 0, + locale, + where: mergeListSearchAndWhere({ + collectionConfig, + search, + where: whereConstraint, + }), + ...(viewType === 'trash' ? { trash: true } : {}), }, - ) + { addQueryPrefix: true }, + )}` + + const deleteManyResponse = + viewType === 'trash' || deletePermanently || !collectionConfig.trash + ? await requests.delete(url, { + headers: { + 'Accept-Language': i18n.language, + 'Content-Type': 'application/json', + }, + }) + : await requests.patch(url, { + body: JSON.stringify({ + deletedAt: new Date().toISOString(), + }), + headers: { + 'Accept-Language': i18n.language, + 'Content-Type': 'application/json', + }, + }) try { const { plural, singular } = collectionConfig.labels @@ -194,8 +266,23 @@ export function DeleteMany_v4({ afterDelete, search, selections, where }: Delete const successLabel = deletedDocs > 1 ? plural : singular if (deleteManyResponse.status < 400 || deletedDocs > 0) { + const wasTrashed = collectionConfig.trash && !deletePermanently && viewType !== 'trash' + + let successKey: + | 'general:deletedCountSuccessfully' + | 'general:permanentlyDeletedCountSuccessfully' + | 'general:trashedCountSuccessfully' + + if (wasTrashed) { + successKey = 'general:trashedCountSuccessfully' + } else if (viewType === 'trash' || deletePermanently) { + successKey = 'general:permanentlyDeletedCountSuccessfully' + } else { + successKey = 'general:deletedCountSuccessfully' + } + toast.success( - t('general:deletedCountSuccessfully', { + t(successKey, { count: deletedDocs, label: getTranslation(successLabel, i18n), }), @@ -219,6 +306,7 @@ export function DeleteMany_v4({ afterDelete, search, selections, where }: Delete toast.error(t('error:unknown')) result[relationTo].errors = [t('error:unknown')] } + continue } catch (_err) { toast.error(t('error:unknown')) @@ -236,7 +324,20 @@ export function DeleteMany_v4({ afterDelete, search, selections, where }: Delete if (typeof afterDelete === 'function') { afterDelete(result) } - }, [selections, afterDelete, collections, locale, search, serverURL, api, i18n, where, t]) + }, [ + selections, + afterDelete, + collections, + deletePermanently, + locale, + search, + serverURL, + api, + i18n, + viewType, + where, + t, + ]) const { label: labelString, labelCount } = Object.entries(selections).reduce( (acc, [key, value], index, array) => { @@ -247,7 +348,9 @@ export function DeleteMany_v4({ afterDelete, search, selections, where }: Delete value.totalCount > 1 ? collectionConfig.labels.plural : collectionConfig.labels.singular, i18n, )}` + let newLabel + if (index === array.length - 1 && index !== 0) { newLabel = `${acc.label} and ${collectionLabel}` } else if (index > 0) { @@ -281,10 +384,43 @@ export function DeleteMany_v4({ afterDelete, search, selections, where }: Delete {t('general:delete')} +

+ {trash ? ( + viewType === 'trash' ? ( + {children}, + '1': ({ children }) => {children}, + }} + i18nKey="general:aboutToPermanentlyDeleteTrash" + t={t} + variables={{ + count: labelCount ?? 0, + label: labelString, + }} + /> + ) : ( + t('general:aboutToTrashCount', { count: labelCount, label: labelString }) + ) + ) : ( + t('general:aboutToDeleteCount', { count: labelCount, label: labelString }) + )} +

+ {trash && viewType !== 'trash' && ( +
+ setDeletePermanently(e.target.checked)} + /> +
+ )} + + } confirmingLabel={t('general:deleting')} heading={t('general:confirmDeletion')} modalSlug={confirmManyDeleteDrawerSlug} diff --git a/packages/ui/src/elements/DocumentControls/index.scss b/packages/ui/src/elements/DocumentControls/index.scss index edd5a9287f..98b7155868 100644 --- a/packages/ui/src/elements/DocumentControls/index.scss +++ b/packages/ui/src/elements/DocumentControls/index.scss @@ -187,6 +187,12 @@ } } + &__popup { + [dir='rtl'] & { + padding-left: var(--gutter-h); + } + } + &__meta { width: auto; gap: calc(var(--base) / 2); @@ -216,7 +222,9 @@ } &__controls { - padding-left: var(--gutter-h); + [dir='ltr'] & { + padding-left: var(--gutter-h); + } overflow: auto; // do not show scrollbar because the parent container has a static height diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index 7f80b772a7..08f463263a 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -28,14 +28,16 @@ import { MoveDocToFolder } from '../FolderView/MoveDocToFolder/index.js' import { Gutter } from '../Gutter/index.js' import { LivePreviewToggler } from '../LivePreview/Toggler/index.js' import { Locked } from '../Locked/index.js' +import { PermanentlyDeleteButton } from '../PermanentlyDeleteButton/index.js' import { Popup, PopupList } from '../Popup/index.js' import { PreviewButton } from '../PreviewButton/index.js' import { PublishButton } from '../PublishButton/index.js' import { RenderCustomComponent } from '../RenderCustomComponent/index.js' +import { RestoreButton } from '../RestoreButton/index.js' import { SaveButton } from '../SaveButton/index.js' +import './index.scss' import { SaveDraftButton } from '../SaveDraftButton/index.js' import { Status } from '../Status/index.js' -import './index.scss' const baseClass = 'doc-controls' @@ -58,16 +60,19 @@ export const DocumentControls: React.FC<{ readonly isAccountView?: boolean readonly isEditing?: boolean readonly isInDrawer?: boolean + readonly isTrashed?: boolean readonly onDelete?: DocumentDrawerContextType['onDelete'] readonly onDrawerCreateNew?: () => void /* Only available if `redirectAfterDuplicate` is `false` */ readonly onDuplicate?: DocumentDrawerContextType['onDuplicate'] + readonly onRestore?: DocumentDrawerContextType['onRestore'] readonly onSave?: DocumentDrawerContextType['onSave'] readonly onTakeOver?: () => void readonly permissions: null | SanitizedCollectionPermission | SanitizedGlobalPermission readonly readOnlyForIncomingUser?: boolean readonly redirectAfterDelete?: boolean readonly redirectAfterDuplicate?: boolean + readonly redirectAfterRestore?: boolean readonly slug: SanitizedCollectionConfig['slug'] readonly user?: ClientUser }> = (props) => { @@ -89,14 +94,17 @@ export const DocumentControls: React.FC<{ isAccountView, isEditing, isInDrawer, + isTrashed, onDelete, onDrawerCreateNew, onDuplicate, + onRestore, onTakeOver, permissions, readOnlyForIncomingUser, redirectAfterDelete, redirectAfterDuplicate, + redirectAfterRestore, user, } = props @@ -177,7 +185,7 @@ export const DocumentControls: React.FC<{ {showLockedMetaIcon && ( )} - {showFolderMetaIcon && config.folders && ( + {showFolderMetaIcon && config.folders && !isTrashed && ( )} - {(collectionConfig?.versions?.drafts || globalConfig?.versions?.drafts) && ( {(globalConfig || (collectionConfig && isEditing)) && ( @@ -210,16 +217,19 @@ export const DocumentControls: React.FC<{ )} - {hasSavePermission && autosaveEnabled && !unsavedDraftWithValidations && ( -
  • - -
  • - )} + {hasSavePermission && + autosaveEnabled && + !unsavedDraftWithValidations && + !isTrashed && ( +
  • + +
  • + )}
    )} {collectionConfig?.timestamps && (isEditing || isAccountView) && ( @@ -230,7 +240,10 @@ export const DocumentControls: React.FC<{ .join(' ')} title={data?.updatedAt ? updatedAt : ''} > -

    {i18n.t('general:lastModified')}: 

    +

    + {i18n.t(isTrashed ? 'general:deleted' : 'general:lastModified')}:  +

    + {data?.updatedAt &&

    {updatedAt}

    }
  • } /> )} - {hasSavePermission && ( + {hasSavePermission && !isTrashed && ( {collectionConfig?.versions?.drafts || globalConfig?.versions?.drafts ? ( @@ -281,6 +294,26 @@ export const DocumentControls: React.FC<{ )} )} + {hasDeletePermission && isTrashed && ( + + )} + {hasSavePermission && isTrashed && ( + + )} {user && readOnlyForIncomingUser && ( + )} +
  • +
    + + ((option?.data?.plainTextLabel as string) || option.label) + .toLowerCase() + .includes(inputValue.toLowerCase()) + } + id="group-by--field-select" + isClearable + isMulti={false} + onChange={async (v: { value: string } | null) => { + const value = v === null ? undefined : v.value + + // value is being cleared + if (v === null) { + await refineListData({ + groupBy: '', + page: 1, + }) + } + + await refineListData({ + groupBy: value ? (query.groupBy?.startsWith('-') ? `-${value}` : value) : undefined, + page: 1, + }) + }} + options={reducedFields.filter( + (field) => + !field.field.admin.disableListFilter && + field.value !== 'id' && + supportedFieldTypes.includes(field.field.type), + )} + value={{ + label: groupByField?.label || t('general:selectValue'), + value: groupByFieldName || '', + }} + /> + { + if (!groupByFieldName) { + return + } + + await refineListData({ + groupBy: value === 'asc' ? groupByFieldName : `-${groupByFieldName}`, + page: 1, + }) + }} + options={[ + { label: t('general:ascending'), value: 'asc' }, + { label: t('general:descending'), value: 'desc' }, + ]} + path="direction" + readOnly={!groupByFieldName} + value={ + !query.groupBy + ? 'asc' + : typeof query.groupBy === 'string' + ? `${query.groupBy.startsWith('-') ? 'desc' : 'asc'}` + : '' + } + /> +
    +
    + ) +} diff --git a/packages/ui/src/elements/LeaveWithoutSaving/index.tsx b/packages/ui/src/elements/LeaveWithoutSaving/index.tsx index a5186f0611..017cec5c38 100644 --- a/packages/ui/src/elements/LeaveWithoutSaving/index.tsx +++ b/packages/ui/src/elements/LeaveWithoutSaving/index.tsx @@ -12,7 +12,12 @@ import { usePreventLeave } from './usePreventLeave.js' const modalSlug = 'leave-without-saving' -export const LeaveWithoutSaving: React.FC = () => { +type LeaveWithoutSavingProps = { + onConfirm?: () => Promise | void + onPrevent?: (nextHref: null | string) => void +} + +export const LeaveWithoutSaving: React.FC = ({ onConfirm, onPrevent }) => { const { closeModal, openModal } = useModal() const modified = useFormModified() const { isValid } = useForm() @@ -22,23 +27,34 @@ export const LeaveWithoutSaving: React.FC = () => { const prevent = Boolean((modified || !isValid) && user) - const onPrevent = useCallback(() => { + const handlePrevent = useCallback(() => { + const activeHref = (document.activeElement as HTMLAnchorElement)?.href || null + if (onPrevent) { + onPrevent(activeHref) + } openModal(modalSlug) - }, [openModal]) + }, [openModal, onPrevent]) const handleAccept = useCallback(() => { closeModal(modalSlug) }, [closeModal]) - usePreventLeave({ hasAccepted, onAccept: handleAccept, onPrevent, prevent }) + usePreventLeave({ hasAccepted, onAccept: handleAccept, onPrevent: handlePrevent, prevent }) const onCancel: OnCancel = useCallback(() => { closeModal(modalSlug) }, [closeModal]) - const onConfirm = useCallback(() => { + const handleConfirm = useCallback(async () => { + if (onConfirm) { + try { + await onConfirm() + } catch (err) { + console.error('Error in LeaveWithoutSaving onConfirm:', err) + } + } setHasAccepted(true) - }, []) + }, [onConfirm]) return ( { heading={t('general:leaveWithoutSaving')} modalSlug={modalSlug} onCancel={onCancel} - onConfirm={onConfirm} + onConfirm={handleConfirm} /> ) } diff --git a/packages/ui/src/elements/ListControls/index.scss b/packages/ui/src/elements/ListControls/index.scss index 6443e5f162..86c2009f05 100644 --- a/packages/ui/src/elements/ListControls/index.scss +++ b/packages/ui/src/elements/ListControls/index.scss @@ -36,7 +36,8 @@ .pill-selector, .where-builder, - .sort-complex { + .sort-complex, + .group-by-builder { margin-top: base(1); } @@ -90,7 +91,8 @@ &__toggle-columns, &__toggle-where, - &__toggle-sort { + &__toggle-sort, + &__toggle-group-by { flex: 1; } } diff --git a/packages/ui/src/elements/ListControls/index.tsx b/packages/ui/src/elements/ListControls/index.tsx index a5adc88ce5..ebdb31d6ae 100644 --- a/packages/ui/src/elements/ListControls/index.tsx +++ b/packages/ui/src/elements/ListControls/index.tsx @@ -16,6 +16,7 @@ import { useListQuery } from '../../providers/ListQuery/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { AnimateHeight } from '../AnimateHeight/index.js' import { ColumnSelector } from '../ColumnSelector/index.js' +import { GroupByBuilder } from '../GroupByBuilder/index.js' import { Pill } from '../Pill/index.js' import { SearchFilter } from '../SearchFilter/index.js' import { WhereBuilder } from '../WhereBuilder/index.js' @@ -97,7 +98,8 @@ export const ListControls: React.FC = (props) => { const hasWhereParam = useRef(Boolean(query?.where)) const shouldInitializeWhereOpened = validateWhereQuery(query?.where) - const [visibleDrawer, setVisibleDrawer] = useState<'columns' | 'sort' | 'where'>( + + const [visibleDrawer, setVisibleDrawer] = useState<'columns' | 'group-by' | 'sort' | 'where'>( shouldInitializeWhereOpened ? 'where' : undefined, ) @@ -140,7 +142,7 @@ export const ListControls: React.FC = (props) => { let listMenuItems: React.ReactNode[] = listMenuItemsFromProps if ( - collectionConfig?.enableQueryPresets && + collectionConfig.enableQueryPresets && !disableQueryPresets && queryPresetMenuItems?.length > 0 ) { @@ -160,7 +162,6 @@ export const ListControls: React.FC = (props) => { @@ -176,6 +177,7 @@ export const ListControls: React.FC = (props) => { aria-expanded={visibleDrawer === 'columns'} className={`${baseClass}__toggle-columns`} icon={} + id="toggle-columns" onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined) } @@ -191,6 +193,7 @@ export const ListControls: React.FC = (props) => { aria-expanded={visibleDrawer === 'where'} className={`${baseClass}__toggle-where`} icon={} + id="toggle-list-filters" onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)} pillStyle="light" size="small" @@ -218,6 +221,24 @@ export const ListControls: React.FC = (props) => { resetPreset={resetPreset} /> )} + {collectionConfig.admin.groupBy && ( + } + id="toggle-group-by" + onClick={() => + setVisibleDrawer(visibleDrawer !== 'group-by' ? 'group-by' : undefined) + } + pillStyle="light" + size="small" + > + {t('general:groupByLabel', { + label: '', + })} + + )} {listMenuItems && Array.isArray(listMenuItems) && listMenuItems.length > 0 && ( } @@ -250,13 +271,25 @@ export const ListControls: React.FC = (props) => { id={`${baseClass}-where`} > + {collectionConfig.admin.groupBy && ( + + + + )} {PresetListDrawer} {EditPresetDrawer} diff --git a/packages/ui/src/elements/ListControls/useQueryPresets.tsx b/packages/ui/src/elements/ListControls/useQueryPresets.tsx index 694d6f68d9..587eac6784 100644 --- a/packages/ui/src/elements/ListControls/useQueryPresets.tsx +++ b/packages/ui/src/elements/ListControls/useQueryPresets.tsx @@ -3,7 +3,7 @@ import type { CollectionSlug, QueryPreset, SanitizedCollectionPermission } from import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' import { transformColumnsToPreferences, transformColumnsToSearchParams } from 'payload/shared' -import React, { Fragment, useCallback, useMemo } from 'react' +import React, { useCallback, useMemo } from 'react' import { toast } from 'sonner' import { useConfig } from '../../providers/Config/index.js' @@ -103,9 +103,9 @@ export const useQueryPresets = ({ const resetQueryPreset = useCallback(async () => { await refineListData( { - columns: undefined, - preset: undefined, - where: undefined, + columns: [], + preset: '', + where: {}, }, false, ) diff --git a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx index 19c7c26c30..37232b8b82 100644 --- a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx +++ b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx @@ -1,10 +1,11 @@ 'use client' -import type { ListQuery } from 'payload' +import type { CollectionSlug, ListQuery } from 'payload' import { useModal } from '@faceless-ui/modal' import { hoistQueryParamsToAnd } from 'payload/shared' import React, { useCallback, useEffect, useState } from 'react' +import type { ListDrawerContextProps, ListDrawerContextType } from '../ListDrawer/Provider.js' import type { ListDrawerProps } from './types.js' import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js' @@ -25,7 +26,7 @@ export const ListDrawerContent: React.FC = ({ onBulkSelect, onSelect, overrideEntityVisibility = true, - selectedCollection: selectedCollectionFromProps, + selectedCollection: collectionSlugFromProps, }) => { const { closeModal, isModalOpen } = useModal() @@ -45,7 +46,7 @@ export const ListDrawerContent: React.FC = ({ }) const [selectedOption, setSelectedOption] = useState>(() => { - const initialSelection = selectedCollectionFromProps || enabledCollections[0]?.slug + const initialSelection = collectionSlugFromProps || enabledCollections[0]?.slug const found = getEntityConfig({ collectionSlug: initialSelection }) return found @@ -61,20 +62,25 @@ export const ListDrawerContent: React.FC = ({ collectionSlug: selectedOption.value, }) - const updateSelectedOption = useEffectEvent((selectedCollectionFromProps: string) => { - if (selectedCollectionFromProps && selectedCollectionFromProps !== selectedOption?.value) { + const updateSelectedOption = useEffectEvent((collectionSlug: CollectionSlug) => { + if (collectionSlug && collectionSlug !== selectedOption?.value) { setSelectedOption({ - label: getEntityConfig({ collectionSlug: selectedCollectionFromProps })?.labels, - value: selectedCollectionFromProps, + label: getEntityConfig({ collectionSlug })?.labels, + value: collectionSlug, }) } }) useEffect(() => { - updateSelectedOption(selectedCollectionFromProps) - }, [selectedCollectionFromProps]) + updateSelectedOption(collectionSlugFromProps) + }, [collectionSlugFromProps]) - const renderList = useCallback( + /** + * This performs a full server round trip to get the list view for the selected collection. + * On the server, the data is freshly queried for the list view and all components are fully rendered. + * This work includes building column state, rendering custom components, etc. + */ + const refresh = useCallback( async ({ slug, query }: { query?: ListQuery; slug: string }) => { try { const newQuery: ListQuery = { ...(query || {}), where: { ...(query?.where || {}) } } @@ -129,9 +135,9 @@ export const ListDrawerContent: React.FC = ({ useEffect(() => { if (!ListView) { - void renderList({ slug: selectedOption?.value }) + void refresh({ slug: selectedOption?.value }) } - }, [renderList, ListView, selectedOption.value]) + }, [refresh, ListView, selectedOption.value]) const onCreateNew = useCallback( ({ doc }) => { @@ -149,19 +155,33 @@ export const ListDrawerContent: React.FC = ({ [closeModal, documentDrawerSlug, drawerSlug, onSelect, selectedOption.value], ) - const onQueryChange = useCallback( - (query: ListQuery) => { - void renderList({ slug: selectedOption?.value, query }) + const onQueryChange: ListDrawerContextProps['onQueryChange'] = useCallback( + (query) => { + void refresh({ slug: selectedOption?.value, query }) }, - [renderList, selectedOption.value], + [refresh, selectedOption.value], ) - const setMySelectedOption = useCallback( - (incomingSelection: Option) => { + const setMySelectedOption: ListDrawerContextProps['setSelectedOption'] = useCallback( + (incomingSelection) => { setSelectedOption(incomingSelection) - void renderList({ slug: incomingSelection?.value }) + void refresh({ slug: incomingSelection?.value }) }, - [renderList], + [refresh], + ) + + const refreshSelf: ListDrawerContextType['refresh'] = useCallback( + async (incomingCollectionSlug) => { + if (incomingCollectionSlug) { + setSelectedOption({ + label: getEntityConfig({ collectionSlug: incomingCollectionSlug })?.labels, + value: incomingCollectionSlug, + }) + } + + await refresh({ slug: selectedOption.value || incomingCollectionSlug }) + }, + [getEntityConfig, refresh, selectedOption.value], ) if (isLoading) { @@ -178,6 +198,7 @@ export const ListDrawerContent: React.FC = ({ onBulkSelect={onBulkSelect} onQueryChange={onQueryChange} onSelect={onSelect} + refresh={refreshSelf} selectedOption={selectedOption} setSelectedOption={setMySelectedOption} > diff --git a/packages/ui/src/elements/ListDrawer/Provider.tsx b/packages/ui/src/elements/ListDrawer/Provider.tsx index 8aeb4ed7ae..7a0156ed80 100644 --- a/packages/ui/src/elements/ListDrawer/Provider.tsx +++ b/packages/ui/src/elements/ListDrawer/Provider.tsx @@ -24,12 +24,17 @@ export type ListDrawerContextProps = { */ docID: string }) => void - readonly selectedOption?: Option - readonly setSelectedOption?: (option: Option) => void + readonly selectedOption?: Option + readonly setSelectedOption?: (option: Option) => void } export type ListDrawerContextType = { - isInDrawer: boolean + readonly isInDrawer: boolean + /** + * When called, will either refresh the list view with its currently selected collection. + * If an collection slug is provided, will use that instead of the currently selected one. + */ + readonly refresh: (collectionSlug?: CollectionSlug) => Promise } & ListDrawerContextProps export const ListDrawerContext = createContext({} as ListDrawerContextType) @@ -37,6 +42,7 @@ export const ListDrawerContext = createContext({} as ListDrawerContextType) export const ListDrawerContextProvider: React.FC< { children: React.ReactNode + refresh: ListDrawerContextType['refresh'] } & ListDrawerContextProps > = ({ children, ...rest }) => { return ( diff --git a/packages/ui/src/elements/ListDrawer/index.tsx b/packages/ui/src/elements/ListDrawer/index.tsx index c4eaae6188..342bdb5600 100644 --- a/packages/ui/src/elements/ListDrawer/index.tsx +++ b/packages/ui/src/elements/ListDrawer/index.tsx @@ -51,6 +51,25 @@ export const ListDrawer: React.FC = (props) => { ) } +/** + * Returns an array containing the ListDrawer component, the ListDrawerToggler component, and an object with state and methods for controlling the drawer. + * @example + * import { useListDrawer } from '@payloadcms/ui' + * + * // inside a React component + * const [ListDrawer, ListDrawerToggler, { closeDrawer, openDrawer }] = useListDrawer({ + * collectionSlugs: ['users'], + * selectedCollection: 'users', + * }) + * + * // inside the return statement + * return ( + * <> + * + * Open List Drawer + * + * ) + */ export const useListDrawer: UseListDrawer = ({ collectionSlugs: collectionSlugsFromProps, filterOptions, diff --git a/packages/ui/src/elements/ListHeader/TitleActions/ListCreateNewDocInFolderButton.tsx b/packages/ui/src/elements/ListHeader/TitleActions/ListCreateNewDocInFolderButton.tsx index ff8fe24c9a..626ff0c649 100644 --- a/packages/ui/src/elements/ListHeader/TitleActions/ListCreateNewDocInFolderButton.tsx +++ b/packages/ui/src/elements/ListHeader/TitleActions/ListCreateNewDocInFolderButton.tsx @@ -18,11 +18,13 @@ const baseClass = 'create-new-doc-in-folder' export function ListCreateNewDocInFolderButton({ buttonLabel, collectionSlugs, + folderAssignedCollections, onCreateSuccess, slugPrefix, }: { buttonLabel: string collectionSlugs: CollectionSlug[] + folderAssignedCollections: CollectionSlug[] onCreateSuccess: (args: { collectionSlug: CollectionSlug doc: Record @@ -133,6 +135,9 @@ export function ListCreateNewDocInFolderButton({ { await onCreateSuccess({ diff --git a/packages/ui/src/elements/ListHeader/TitleActions/ListEmptyTrashButton.tsx b/packages/ui/src/elements/ListHeader/TitleActions/ListEmptyTrashButton.tsx new file mode 100644 index 0000000000..a3cbf05d53 --- /dev/null +++ b/packages/ui/src/elements/ListHeader/TitleActions/ListEmptyTrashButton.tsx @@ -0,0 +1,197 @@ +'use client' +import type { ClientCollectionConfig } from 'payload' + +import { useModal } from '@faceless-ui/modal' +import { getTranslation } from '@payloadcms/translations' +import { useRouter, useSearchParams } from 'next/navigation.js' +import * as qs from 'qs-esm' +import React from 'react' +import { toast } from 'sonner' + +import { useConfig } from '../../../providers/Config/index.js' +import { useLocale } from '../../../providers/Locale/index.js' +import { useRouteCache } from '../../../providers/RouteCache/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' +import { requests } from '../../../utilities/api.js' +import { Button } from '../../Button/index.js' +import { ConfirmationModal } from '../../ConfirmationModal/index.js' +import { Translation } from '../../Translation/index.js' + +const confirmEmptyTrashSlug = 'confirm-empty-trash' + +export function ListEmptyTrashButton({ + collectionConfig, + hasDeletePermission, +}: { + collectionConfig: ClientCollectionConfig + hasDeletePermission: boolean +}) { + const { i18n, t } = useTranslation() + const { code: locale } = useLocale() + const { config } = useConfig() + const { openModal } = useModal() + const router = useRouter() + const searchParams = useSearchParams() + const { clearRouteCache } = useRouteCache() + + const [trashCount, setTrashCount] = React.useState(null) + + React.useEffect(() => { + const fetchTrashCount = async () => { + const queryString = qs.stringify( + { + depth: 0, + limit: 0, + locale, + trash: true, + where: { + deletedAt: { + exists: true, + }, + }, + }, + { addQueryPrefix: true }, + ) + + try { + const res = await requests.get( + `${config.serverURL}${config.routes.api}/${collectionConfig.slug}${queryString}`, + { + headers: { + 'Accept-Language': i18n.language, + 'Content-Type': 'application/json', + }, + }, + ) + + const json = await res.json() + setTrashCount(json?.totalDocs ?? 0) + } catch { + setTrashCount(0) + } + } + + void fetchTrashCount() + }, [collectionConfig.slug, config, i18n.language, locale]) + + const handleEmptyTrash = React.useCallback(async () => { + if (!hasDeletePermission) { + return + } + + const { slug, labels } = collectionConfig + + const queryString = qs.stringify( + { + limit: 0, + locale, + trash: true, + where: { + deletedAt: { + exists: true, + }, + }, + }, + { addQueryPrefix: true }, + ) + + const res = await requests.delete( + `${config.serverURL}${config.routes.api}/${slug}${queryString}`, + { + headers: { + 'Accept-Language': i18n.language, + 'Content-Type': 'application/json', + }, + }, + ) + + try { + const json = await res.json() + const deletedCount = json?.docs?.length || 0 + + if (res.status < 400) { + toast.success( + t('general:permanentlyDeletedCountSuccessfully', { + count: deletedCount, + label: getTranslation(labels?.plural, i18n), + }), + ) + } + + if (json?.errors?.length > 0) { + toast.error(json.message, { + description: json.errors.map((err) => err.message).join('\n'), + }) + } + + router.replace( + qs.stringify( + { + ...Object.fromEntries(searchParams.entries()), + page: '1', + }, + { addQueryPrefix: true }, + ), + ) + + clearRouteCache() + } catch { + toast.error(t('error:unknown')) + } + }, [ + collectionConfig, + config, + hasDeletePermission, + i18n, + t, + locale, + searchParams, + router, + clearRouteCache, + ]) + + return ( + + + {children}, + '1': ({ children }) => {children}, + }} + i18nKey="general:aboutToPermanentlyDeleteTrash" + t={t} + variables={{ + count: trashCount ?? 0, + label: getTranslation( + trashCount === 1 + ? collectionConfig.labels?.singular + : collectionConfig.labels?.plural, + i18n, + ), + }} + /> + } + confirmingLabel={t('general:deleting')} + heading={t('general:confirmDeletion')} + modalSlug={confirmEmptyTrashSlug} + onConfirm={handleEmptyTrash} + /> + + ) +} diff --git a/packages/ui/src/elements/ListHeader/TitleActions/index.tsx b/packages/ui/src/elements/ListHeader/TitleActions/index.tsx index 8989634388..dd8a4e2a7b 100644 --- a/packages/ui/src/elements/ListHeader/TitleActions/index.tsx +++ b/packages/ui/src/elements/ListHeader/TitleActions/index.tsx @@ -1,3 +1,4 @@ export { ListBulkUploadButton } from './ListBulkUploadButton.js' export { ListCreateNewButton } from './ListCreateNewDocButton.js' export { ListCreateNewDocInFolderButton } from './ListCreateNewDocInFolderButton.js' +export { ListEmptyTrashButton } from './ListEmptyTrashButton.js' diff --git a/packages/ui/src/elements/ListFolderPills/index.tsx b/packages/ui/src/elements/ListHeaderTabs/ByFolderPill.tsx similarity index 53% rename from packages/ui/src/elements/ListFolderPills/index.tsx rename to packages/ui/src/elements/ListHeaderTabs/ByFolderPill.tsx index 02f904886d..0f3138c2cb 100644 --- a/packages/ui/src/elements/ListFolderPills/index.tsx +++ b/packages/ui/src/elements/ListHeaderTabs/ByFolderPill.tsx @@ -1,8 +1,7 @@ 'use client' -import type { ClientCollectionConfig } from 'payload' +import type { ClientCollectionConfig, ViewTypes } from 'payload' -import { getTranslation } from '@payloadcms/translations' import { formatAdminURL } from 'payload/shared' import { useConfig } from '../../providers/Config/index.js' @@ -10,20 +9,20 @@ import { useTranslation } from '../../providers/Translation/index.js' import { Button } from '../Button/index.js' import './index.scss' -const baseClass = 'list-folder-pills' +const baseClass = 'list-pills' -type ListFolderPillsProps = { +type ByFolderPillProps = { readonly collectionConfig: ClientCollectionConfig readonly folderCollectionSlug: string - readonly viewType: 'folders' | 'list' + readonly viewType: ViewTypes } -export function ListFolderPills({ +export function ByFolderPill({ collectionConfig, folderCollectionSlug, viewType, -}: ListFolderPillsProps) { - const { i18n, t } = useTranslation() +}: ByFolderPillProps) { + const { t } = useTranslation() const { config } = useConfig() if (!folderCollectionSlug) { @@ -41,7 +40,7 @@ export function ListFolderPills({ .filter(Boolean) .join(' ')} disabled={viewType === 'folders'} - el={viewType === 'list' ? 'link' : 'div'} + el={viewType === 'list' || viewType === 'trash' ? 'link' : 'div'} to={formatAdminURL({ adminRoute: config.routes.admin, path: `/collections/${collectionConfig.slug}/${folderCollectionSlug}`, @@ -50,21 +49,6 @@ export function ListFolderPills({ > {t('folder:byFolder')} - ) } diff --git a/packages/ui/src/elements/ListHeaderTabs/DefaultListPill.tsx b/packages/ui/src/elements/ListHeaderTabs/DefaultListPill.tsx new file mode 100644 index 0000000000..147eb48085 --- /dev/null +++ b/packages/ui/src/elements/ListHeaderTabs/DefaultListPill.tsx @@ -0,0 +1,47 @@ +'use client' + +import type { ClientCollectionConfig, ViewTypes } from 'payload' + +import { getTranslation } from '@payloadcms/translations' +import { formatAdminURL } from 'payload/shared' + +import { useConfig } from '../../providers/Config/index.js' +import { useTranslation } from '../../providers/Translation/index.js' +import { Button } from '../Button/index.js' +import './index.scss' + +const baseClass = 'list-pills' + +type DefaultListPillProps = { + readonly collectionConfig: ClientCollectionConfig + readonly viewType: ViewTypes +} + +export function DefaultListPill({ collectionConfig, viewType }: DefaultListPillProps) { + const { i18n, t } = useTranslation() + const { config } = useConfig() + + const buttonLabel = `${t('general:all')} ${getTranslation(collectionConfig?.labels?.plural, i18n)}` + const buttonId = buttonLabel.toLowerCase().replace(/\s+/g, '-') + + return ( +
    + +
    + ) +} diff --git a/packages/ui/src/elements/ListHeaderTabs/TrashPill.tsx b/packages/ui/src/elements/ListHeaderTabs/TrashPill.tsx new file mode 100644 index 0000000000..b800a7527c --- /dev/null +++ b/packages/ui/src/elements/ListHeaderTabs/TrashPill.tsx @@ -0,0 +1,41 @@ +'use client' + +import type { ClientCollectionConfig, ViewTypes } from 'payload' + +import { formatAdminURL } from 'payload/shared' + +import { useConfig } from '../../providers/Config/index.js' +import { useTranslation } from '../../providers/Translation/index.js' +import { Button } from '../Button/index.js' + +export function TrashPill({ + collectionConfig, + viewType, +}: { + collectionConfig: ClientCollectionConfig + readonly viewType: ViewTypes +}) { + const { t } = useTranslation() + const { config } = useConfig() + + if (!collectionConfig.trash) { + return null + } + + return ( + + ) +} diff --git a/packages/ui/src/elements/ListFolderPills/index.scss b/packages/ui/src/elements/ListHeaderTabs/index.scss similarity index 56% rename from packages/ui/src/elements/ListFolderPills/index.scss rename to packages/ui/src/elements/ListHeaderTabs/index.scss index 90e26c62f2..bcf32448db 100644 --- a/packages/ui/src/elements/ListFolderPills/index.scss +++ b/packages/ui/src/elements/ListHeaderTabs/index.scss @@ -1,7 +1,6 @@ @layer payload-default { - .list-folder-pills { + .list-pills { display: flex; gap: calc(var(--base) * 0.5); - margin-left: calc(var(--base) * 0.5); } } diff --git a/packages/ui/src/elements/ListSelection/index.scss b/packages/ui/src/elements/ListSelection/index.scss index 951999210e..30ea5eafd0 100644 --- a/packages/ui/src/elements/ListSelection/index.scss +++ b/packages/ui/src/elements/ListSelection/index.scss @@ -10,7 +10,7 @@ &__actions { display: flex; - gap: calc(var(--base) * 0.3); + gap: calc(var(--base) * 0.5); } &__button { diff --git a/packages/ui/src/elements/PageControls/GroupByPageControls.tsx b/packages/ui/src/elements/PageControls/GroupByPageControls.tsx new file mode 100644 index 0000000000..e9ec5c878f --- /dev/null +++ b/packages/ui/src/elements/PageControls/GroupByPageControls.tsx @@ -0,0 +1,62 @@ +'use client' +import type { ClientCollectionConfig, PaginatedDocs } from 'payload' + +import React, { useCallback } from 'react' + +import type { IListQueryContext } from '../../providers/ListQuery/types.js' + +import { useListQuery } from '../../providers/ListQuery/context.js' +import { PageControlsComponent } from './index.js' + +/** + * If `groupBy` is set in the query, multiple tables will render, one for each group. + * In this case, each table needs its own `PageControls` to handle pagination. + * These page controls, however, should not modify the global `ListQuery` state. + * Instead, they should only handle the pagination for the current group. + * To do this, build a wrapper around `PageControlsComponent` that handles the pagination logic for the current group. + */ +export const GroupByPageControls: React.FC<{ + AfterPageControls?: React.ReactNode + collectionConfig: ClientCollectionConfig + data: PaginatedDocs + groupByValue?: number | string +}> = ({ AfterPageControls, collectionConfig, data, groupByValue }) => { + const { refineListData } = useListQuery() + + const handlePageChange: IListQueryContext['handlePageChange'] = useCallback( + async (page) => { + await refineListData({ + queryByGroup: { + [groupByValue]: { + page, + }, + }, + }) + }, + [refineListData, groupByValue], + ) + + const handlePerPageChange: IListQueryContext['handlePerPageChange'] = useCallback( + async (limit) => { + await refineListData({ + queryByGroup: { + [groupByValue]: { + limit, + page: 1, + }, + }, + }) + }, + [refineListData, groupByValue], + ) + + return ( + + ) +} diff --git a/packages/ui/src/elements/PageControls/index.scss b/packages/ui/src/elements/PageControls/index.scss new file mode 100644 index 0000000000..70be0db966 --- /dev/null +++ b/packages/ui/src/elements/PageControls/index.scss @@ -0,0 +1,40 @@ +@import '../../scss/styles.scss'; + +@layer payload-default { + .page-controls { + width: 100%; + display: flex; + align-items: center; + + &__page-info { + [dir='ltr'] & { + margin-right: base(1); + margin-left: auto; + } + + [dir='rtl'] & { + margin-left: base(1); + margin-right: auto; + } + } + + @include small-break { + flex-wrap: wrap; + + &__page-info { + [dir='ltr'] & { + margin-left: base(0.5); + } + + [dir='rtl'] & { + margin-right: 0; + } + } + + .paginator { + width: 100%; + margin-bottom: base(0.5); + } + } + } +} diff --git a/packages/ui/src/elements/PageControls/index.tsx b/packages/ui/src/elements/PageControls/index.tsx new file mode 100644 index 0000000000..a0ea41745c --- /dev/null +++ b/packages/ui/src/elements/PageControls/index.tsx @@ -0,0 +1,94 @@ +import type { ClientCollectionConfig, PaginatedDocs } from 'payload' + +import { isNumber } from 'payload/shared' +import React, { Fragment } from 'react' + +import type { IListQueryContext } from '../../providers/ListQuery/types.js' + +import { Pagination } from '../../elements/Pagination/index.js' +import { PerPage } from '../../elements/PerPage/index.js' +import { useListQuery } from '../../providers/ListQuery/context.js' +import { useTranslation } from '../../providers/Translation/index.js' +import './index.scss' + +const baseClass = 'page-controls' + +export const PageControlsComponent: React.FC<{ + AfterPageControls?: React.ReactNode + collectionConfig: ClientCollectionConfig + data: PaginatedDocs + handlePageChange?: IListQueryContext['handlePageChange'] + handlePerPageChange?: IListQueryContext['handlePerPageChange'] + limit?: number +}> = ({ + AfterPageControls, + collectionConfig, + data, + handlePageChange, + handlePerPageChange, + limit, +}) => { + const { i18n } = useTranslation() + + return ( +
    + + {data.totalDocs > 0 && ( + +
    + {data.page * data.limit - (data.limit - 1)}- + {data.totalPages > 1 && data.totalPages !== data.page + ? data.limit * data.page + : data.totalDocs}{' '} + {i18n.t('general:of')} {data.totalDocs} +
    + + {AfterPageControls} +
    + )} +
    + ) +} + +/* + * These page controls are controlled by the global ListQuery state. + * To override thi behavior, build your own wrapper around PageControlsComponent. + */ +export const PageControls: React.FC<{ + AfterPageControls?: React.ReactNode + collectionConfig: ClientCollectionConfig +}> = ({ AfterPageControls, collectionConfig }) => { + const { + data, + defaultLimit: initialLimit, + handlePageChange, + handlePerPageChange, + query, + } = useListQuery() + + return ( + + ) +} diff --git a/packages/ui/src/elements/Pagination/ClickableArrow/index.scss b/packages/ui/src/elements/Pagination/ClickableArrow/index.scss index 4cb8c6812e..e8c103d791 100644 --- a/packages/ui/src/elements/Pagination/ClickableArrow/index.scss +++ b/packages/ui/src/elements/Pagination/ClickableArrow/index.scss @@ -4,14 +4,14 @@ .clickable-arrow { cursor: pointer; @extend %btn-reset; - width: base(2); - height: base(2); + width: base(1.5); + height: base(1.5); display: flex; justify-content: center; align-content: center; align-items: center; outline: 0; - padding: base(0.5); + padding: base(0.25); color: var(--theme-elevation-800); line-height: base(1); diff --git a/packages/ui/src/elements/Pagination/index.scss b/packages/ui/src/elements/Pagination/index.scss index e7cb22ceb0..1bdac9dea2 100644 --- a/packages/ui/src/elements/Pagination/index.scss +++ b/packages/ui/src/elements/Pagination/index.scss @@ -3,7 +3,6 @@ @layer payload-default { .paginator { display: flex; - margin-bottom: $baseline; &__page { cursor: pointer; @@ -25,15 +24,16 @@ &__page { @extend %btn-reset; - width: base(2); - height: base(2); + width: base(1.5); + height: base(1.5); display: flex; justify-content: center; align-content: center; outline: 0; + border-radius: var(--style-radius-s); padding: base(0.5); color: var(--theme-elevation-800); - line-height: base(1); + line-height: 0.9; &:focus-visible { outline: var(--accessibility-outline); diff --git a/packages/ui/src/elements/Pagination/index.tsx b/packages/ui/src/elements/Pagination/index.tsx index fb6ef55a2e..e2e7ba3010 100644 --- a/packages/ui/src/elements/Pagination/index.tsx +++ b/packages/ui/src/elements/Pagination/index.tsx @@ -52,7 +52,7 @@ export const Pagination: React.FC = (props) => { totalPages = null, } = props - if (!hasNextPage && !hasPrevPage) { + if (!hasPrevPage && !hasNextPage) { return null } diff --git a/packages/ui/src/elements/PermanentlyDeleteButton/index.tsx b/packages/ui/src/elements/PermanentlyDeleteButton/index.tsx new file mode 100644 index 0000000000..00e3b0ec13 --- /dev/null +++ b/packages/ui/src/elements/PermanentlyDeleteButton/index.tsx @@ -0,0 +1,172 @@ +'use client' + +import type { SanitizedCollectionConfig } from 'payload' + +import { useModal } from '@faceless-ui/modal' +import { getTranslation } from '@payloadcms/translations' +import { useRouter } from 'next/navigation.js' +import { formatAdminURL } from 'payload/shared' +import * as qs from 'qs-esm' +import React, { Fragment, useCallback } from 'react' +import { toast } from 'sonner' + +import type { DocumentDrawerContextType } from '../DocumentDrawer/Provider.js' + +import { useConfig } from '../../providers/Config/index.js' +import { useDocumentTitle } from '../../providers/DocumentTitle/index.js' +import { useRouteTransition } from '../../providers/RouteTransition/index.js' +import { useTranslation } from '../../providers/Translation/index.js' +import { requests } from '../../utilities/api.js' +import { Button } from '../Button/index.js' +import { ConfirmationModal } from '../ConfirmationModal/index.js' +import { Translation } from '../Translation/index.js' + +export type Props = { + readonly buttonId?: string + readonly collectionSlug: SanitizedCollectionConfig['slug'] + readonly id?: string + readonly onDelete?: DocumentDrawerContextType['onDelete'] + readonly redirectAfterDelete?: boolean + readonly singularLabel: SanitizedCollectionConfig['labels']['singular'] + readonly title?: string +} + +export const PermanentlyDeleteButton: React.FC = (props) => { + const { + id, + buttonId, + collectionSlug, + onDelete, + redirectAfterDelete = true, + singularLabel, + title: titleFromProps, + } = props + + const { + config: { + routes: { admin: adminRoute, api }, + serverURL, + }, + getEntityConfig, + } = useConfig() + + const collectionConfig = getEntityConfig({ collectionSlug }) + const router = useRouter() + const { i18n, t } = useTranslation() + const { title } = useDocumentTitle() + const { startRouteTransition } = useRouteTransition() + const { openModal } = useModal() + + const modalSlug = `perma-delete-${id}` + + const addDefaultError = useCallback(() => { + toast.error(t('error:deletingTitle', { title })) + }, [t, title]) + + const handleDelete = useCallback(async () => { + try { + const url = `${serverURL}${api}/${collectionSlug}?${qs.stringify({ + trash: true, + where: { + and: [{ id: { equals: id } }, { deletedAt: { exists: true } }], + }, + })}` + + const res = await requests.delete(url, { + headers: { + 'Accept-Language': i18n.language, + 'Content-Type': 'application/json', + }, + }) + + const json = await res.json() + + if (res.status < 400) { + toast.success( + t('general:titleDeleted', { + label: getTranslation(singularLabel, i18n), + title, + }) || json.message, + ) + + if (redirectAfterDelete) { + return startRouteTransition(() => + router.push( + formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/trash`, + }), + ), + ) + } + + if (typeof onDelete === 'function') { + await onDelete({ id, collectionConfig }) + } + + return + } + + if (json.errors) { + json.errors.forEach((error) => toast.error(error.message)) + } else { + addDefaultError() + } + } catch (_err) { + addDefaultError() + } + }, [ + serverURL, + api, + collectionSlug, + id, + t, + singularLabel, + addDefaultError, + i18n, + title, + router, + adminRoute, + redirectAfterDelete, + onDelete, + collectionConfig, + startRouteTransition, + ]) + + if (id) { + return ( + + + {children}, + }} + i18nKey="general:aboutToPermanentlyDelete" + t={t} + variables={{ + label: getTranslation(singularLabel, i18n), + title: titleFromProps || title || id, + }} + /> + } + confirmingLabel={t('general:deleting')} + heading={t('general:confirmDeletion')} + modalSlug={modalSlug} + onConfirm={handleDelete} + /> + + ) + } + + return null +} diff --git a/packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx b/packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx index 9fae1591a9..7f140b0b9c 100644 --- a/packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx +++ b/packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx @@ -6,6 +6,7 @@ import type { Column, SchedulePublish, Where } from 'payload' import { TZDateMini as TZDate } from '@date-fns/tz/date/mini' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' +import { endOfToday, isToday, startOfDay } from 'date-fns' import { transpose } from 'date-fns/transpose' import * as qs from 'qs-esm' import React, { useCallback, useMemo } from 'react' @@ -28,8 +29,8 @@ import { DatePickerField } from '../../DatePicker/index.js' import { Drawer } from '../../Drawer/index.js' import { Gutter } from '../../Gutter/index.js' import { ReactSelect } from '../../ReactSelect/index.js' -import { ShimmerEffect } from '../../ShimmerEffect/index.js' import './index.scss' +import { ShimmerEffect } from '../../ShimmerEffect/index.js' import { Table } from '../../Table/index.js' import { TimezonePicker } from '../../TimezonePicker/index.js' import { buildUpcomingColumns } from './buildUpcomingColumns.js' @@ -290,6 +291,14 @@ export const ScheduleDrawer: React.FC = ({ slug, defaultType, schedulePub } }, [upcoming, fetchUpcoming]) + const minTime = useMemo(() => { + if (date && isToday(date)) { + return new Date() + } + + return startOfDay(new Date()) + }, [date]) + return ( = ({ slug, defaultType, schedulePub onChangeDate(e)} pickerAppearance="dayAndTime" readOnly={processing} diff --git a/packages/ui/src/elements/PublishMany/DrawerContent.tsx b/packages/ui/src/elements/PublishMany/DrawerContent.tsx index 7d81e3dbdf..6e73afc8b8 100644 --- a/packages/ui/src/elements/PublishMany/DrawerContent.tsx +++ b/packages/ui/src/elements/PublishMany/DrawerContent.tsx @@ -22,7 +22,9 @@ type PublishManyDrawerContentProps = { ids: (number | string)[] onSuccess?: () => void selectAll: boolean + where?: Where } & PublishManyProps + export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) { const { collection, @@ -31,15 +33,18 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) { ids, onSuccess, selectAll, + where, } = props const { clearRouteCache } = useRouteCache() + const { config: { routes: { api }, serverURL, }, } = useConfig() + const { code: locale } = useLocale() const router = useRouter() @@ -59,6 +64,10 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) { }, ] + if (where) { + whereConstraints.push(where) + } + const queryWithSearch = mergeListSearchAndWhere({ collectionConfig: collection, search: searchParams.get('search'), @@ -73,7 +82,7 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) { whereConstraints.push( (parseSearchParams(searchParams)?.where as Where) || { id: { - exists: true, + not_equals: '', }, }, ) @@ -93,7 +102,7 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) { }, { addQueryPrefix: true }, ) - }, [collection, searchParams, selectAll, ids, locale]) + }, [collection, searchParams, selectAll, ids, locale, where]) const handlePublish = useCallback(async () => { await requests diff --git a/packages/ui/src/elements/PublishMany/index.tsx b/packages/ui/src/elements/PublishMany/index.tsx index 0df29b1856..33dca8d276 100644 --- a/packages/ui/src/elements/PublishMany/index.tsx +++ b/packages/ui/src/elements/PublishMany/index.tsx @@ -1,5 +1,5 @@ 'use client' -import type { ClientCollectionConfig } from 'payload' +import type { ClientCollectionConfig, Where } from 'payload' import { useModal } from '@faceless-ui/modal' import React from 'react' @@ -15,14 +15,14 @@ export type PublishManyProps = { } export const PublishMany: React.FC = (props) => { - const { count, selectAll, selected, toggleAll } = useSelection() + const { count, selectAll, selectedIDs, toggleAll } = useSelection() return ( toggleAll(false)} + ids={selectedIDs} + onSuccess={() => toggleAll()} selectAll={selectAll === SelectAllStatus.AllAvailable} /> ) @@ -31,17 +31,25 @@ export const PublishMany: React.FC = (props) => { type PublishMany_v4Props = { count: number ids: (number | string)[] + /** + * When multiple PublishMany components are rendered on the page, this will differentiate them. + */ + modalPrefix?: string onSuccess?: () => void selectAll: boolean + where?: Where } & PublishManyProps + export const PublishMany_v4: React.FC = (props) => { const { collection, collection: { slug, versions } = {}, count, ids, + modalPrefix, onSuccess, selectAll, + where, } = props const { permissions } = useAuth() @@ -52,7 +60,7 @@ export const PublishMany_v4: React.FC = (props) => { const collectionPermissions = permissions?.collections?.[slug] const hasPermission = collectionPermissions?.update - const drawerSlug = `publish-${slug}` + const drawerSlug = `${modalPrefix ? `${modalPrefix}-` : ''}publish-${slug}` if (!versions?.drafts || count === 0 || !hasPermission) { return null @@ -74,6 +82,7 @@ export const PublishMany_v4: React.FC = (props) => { ids={ids} onSuccess={onSuccess} selectAll={selectAll} + where={where} /> ) diff --git a/packages/ui/src/elements/ReactSelect/types.ts b/packages/ui/src/elements/ReactSelect/types.ts index a2a2e7ca9d..72dd352b18 100644 --- a/packages/ui/src/elements/ReactSelect/types.ts +++ b/packages/ui/src/elements/ReactSelect/types.ts @@ -84,6 +84,7 @@ export type ReactSelectAdapterProps = { boolean, GroupBase