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/.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/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 f431c925f7..b9336c53d1 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -60,32 +60,33 @@ export const Posts: CollectionConfig = { The following options are available: -| Option | Description | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). | -| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). | -| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). | -| `custom` | Extension point for adding custom data (e.g. for plugins) | -| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. | -| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. | -| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. | -| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). | -| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). | -| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) | -| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). | -| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. | -| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | -| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). | -| `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. | -| `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). | -| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). | -| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. [More details](../database/indexes#compound-indexes). | -| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks | -| `disableBulkEdit` | Disable the bulk edit operation for the collection in the admin panel and the REST API | +| Option | Description | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). | +| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). | +| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). | +| `custom` | Extension point for adding custom data (e.g. for plugins) | +| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. | +| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. | +| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. | +| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). | +| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). | +| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) | +| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). | +| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. | +| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | +| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). | +| `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). | +| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). | +| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. | +| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks | +| `disableBulkEdit` | Disable the bulk edit operation for the collection in the admin panel and the REST API | _\* An asterisk denotes that a property is required._ @@ -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. | diff --git a/docs/database/mongodb.mdx b/docs/database/mongodb.mdx index 16958cd1c6..26a139bae3 100644 --- a/docs/database/mongodb.mdx +++ b/docs/database/mongodb.mdx @@ -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/fields/join.mdx b/docs/fields/join.mdx index d88dff2685..67c753183c 100644 --- a/docs/fields/join.mdx +++ b/docs/fields/join.mdx @@ -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/overview.mdx b/docs/fields/overview.mdx index 7ecc88d5f2..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 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/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/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..32dcfb86c6 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,22 @@ 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 baseListFilter * * @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 +92,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 +105,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 +146,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 +164,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 +174,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 +189,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 +198,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 baseListFilter to filter + * tenants by selected tenant */ useTenantsListFilter?: boolean /** - * Opt out including the baseListFilter to filter users by selected tenant + * Opt out including the baseListFilter to filter + * users by selected tenant */ useUsersTenantFilter?: boolean } @@ -212,15 +229,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 +248,7 @@ const config = buildConfig({ name: 'domain', type: 'text', required: true, - } + }, ], }, ], @@ -241,7 +258,7 @@ const config = buildConfig({ pages: {}, navigation: { isGlobal: true, - } + }, }, }), ], @@ -327,14 +344,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/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/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 index cdf1efcb18..bffa4c0b2e 100644 --- a/docs/rich-text/official-features.mdx +++ b/docs/rich-text/official-features.mdx @@ -124,12 +124,15 @@ HeadingFeature({ ```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. + * 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. + * 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 */ @@ -180,7 +183,8 @@ type LinkFeatureServerProps = { */ disableAutoLinks?: 'creationOnly' | true /** - * A function or array defining additional fields for the link feature. + * A function or array defining additional + * fields for the link feature. * These will be displayed in the link editor drawer. */ fields?: @@ -235,7 +239,9 @@ LinkFeature({ ```ts type RelationshipFeatureProps = { /** - * Sets a maximum population depth for this relationship, regardless of the remaining depth when the respective field is reached. + * Sets a maximum population depth for this relationship, + * regardless of the remaining depth when the respective + * field is reached. */ maxDepth?: number } & ExclusiveRelationshipFeatureProps @@ -274,7 +280,10 @@ type UploadFeatureProps = { } } /** - * 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. + * 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 } 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/upload/overview.mdx b/docs/upload/overview.mdx index 2c8218eba6..6b0b058c38 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -90,33 +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. | -| **`modifyResponseHeaders`** | Accepts an object with existing `headers` and allows you to manipulate the response headers for media files. [More](#modifying-response-headers) | +| 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 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 d00e17bd8d..ea8f105e67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload-monorepo", - "version": "3.47.0", + "version": "3.49.1", "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 57b1c7a870..c0be6a6d57 100644 --- a/packages/admin-bar/package.json +++ b/packages/admin-bar/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/admin-bar", - "version": "3.47.0", + "version": "3.49.1", "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 5b091b3e62..4a68bff1bf 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.47.0", + "version": "3.49.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/db-mongodb/package.json b/packages/db-mongodb/package.json index 4fd777f07a..0c25f906d1 100644 --- a/packages/db-mongodb/package.json +++ b/packages/db-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-mongodb", - "version": "3.47.0", + "version": "3.49.1", "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 889dae507a..62c6f91146 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/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 7318c29cee..35a271877c 100644 --- a/packages/db-mongodb/src/utilities/transform.ts +++ b/packages/db-mongodb/src/utilities/transform.ts @@ -406,6 +406,10 @@ export const transform = ({ parentIsLocalized = false, validateRelationships = true, }: Args) => { + if (!data) { + return null + } + if (Array.isArray(data)) { for (const item of data) { transform({ $inc, adapter, data: item, fields, globalSlug, operation, validateRelationships }) @@ -426,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, diff --git a/packages/db-postgres/package.json b/packages/db-postgres/package.json index 6030f85c7f..376de824ed 100644 --- a/packages/db-postgres/package.json +++ b/packages/db-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-postgres", - "version": "3.47.0", + "version": "3.49.1", "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 05c2e49058..e7a0571708 100644 --- a/packages/db-sqlite/package.json +++ b/packages/db-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-sqlite", - "version": "3.47.0", + "version": "3.49.1", "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 71b13d98c4..65f1fed2e9 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.47.0", + "version": "3.49.1", "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/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/updateOne.ts b/packages/drizzle/src/updateOne.ts index 3bd37e4682..8fddd9378f 100644 --- a/packages/drizzle/src/updateOne.ts +++ b/packages/drizzle/src/updateOne.ts @@ -1,67 +1,15 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql' -import type { FlattenedField, UpdateOne } from 'payload' +import type { UpdateOne } from 'payload' -import { eq } from 'drizzle-orm' import toSnakeCase from 'to-snake-case' import type { DrizzleAdapter } from './types.js' -import { buildFindManyArgs } from './find/buildFindManyArgs.js' import { buildQuery } from './queries/buildQuery.js' import { selectDistinct } from './queries/selectDistinct.js' -import { transform } from './transform/read/index.js' -import { transformForWrite } from './transform/write/index.js' import { upsertRow } from './upsertRow/index.js' import { getTransaction } from './utilities/getTransaction.js' -/** - * 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. - */ -const shouldUseUpsertRow = ({ - 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 true - } - - if ( - (field.type === 'group' || field.type === 'tab') && - value && - typeof value === 'object' && - shouldUseUpsertRow({ data: value as Record, fields: field.flattenedFields }) - ) { - return true - } - } - - return false -} - export const updateOne: UpdateOne = async function updateOne( this: DrizzleAdapter, { @@ -126,72 +74,23 @@ export const updateOne: UpdateOne = async function updateOne( return null } - if (!idToUpdate || shouldUseUpsertRow({ data, fields: collection.flattenedFields })) { - const result = await upsertRow({ - id: idToUpdate, - adapter: this, - data, - db, - fields: collection.flattenedFields, - ignoreResult: returning === false, - joinQuery, - operation: 'update', - req, - select, - tableName, - }) - - if (returning === false) { - return null - } - - return result - } - - const { row } = transformForWrite({ + const result = await upsertRow({ + id: idToUpdate, adapter: this, data, - enableAtomicWrites: true, + db, fields: collection.flattenedFields, + ignoreResult: returning === false, + joinQuery, + operation: 'update', + req, + select, tableName, }) - const drizzle = db as LibSQLDatabase - await drizzle - .update(this.tables[tableName]) - .set(row) - // TODO: we can skip fetching idToUpdate here with using the incoming where - .where(eq(this.tables[tableName].id, idToUpdate)) - if (returning === false) { return null } - const findManyArgs = buildFindManyArgs({ - adapter: this, - depth: 0, - fields: collection.flattenedFields, - joinQuery: false, - select, - tableName, - }) - - findManyArgs.where = eq(this.tables[tableName].id, idToUpdate) - - const doc = await db.query[tableName].findFirst(findManyArgs) - - // ////////////////////////////////// - // TRANSFORM DATA - // ////////////////////////////////// - - const result = transform({ - adapter: this, - config: this.payload.config, - data: doc, - fields: collection.flattenedFields, - joinQuery: false, - tableName, - }) - return result } diff --git a/packages/drizzle/src/upsertRow/index.ts b/packages/drizzle/src/upsertRow/index.ts index ad10c5fd14..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,6 +42,87 @@ 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({ @@ -51,8 +135,6 @@ export const upsertRow = async | TypeWithID>( }) // First, we insert the main row - let insertedRow: Record - try { if (operation === 'update') { const target = upsertTarget || adapter.tables[tableName].id @@ -276,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 fed7571620..1c21e0bbe7 100644 --- a/packages/email-nodemailer/package.json +++ b/packages/email-nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-nodemailer", - "version": "3.47.0", + "version": "3.49.1", "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 bc9d5f61ed..07f3a46c3e 100644 --- a/packages/email-resend/package.json +++ b/packages/email-resend/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-resend", - "version": "3.47.0", + "version": "3.49.1", "description": "Payload Resend Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 5e50af1df0..478e2332ed 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/graphql", - "version": "3.47.0", + "version": "3.49.1", "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 d8a5915db2..d66dd4c0ff 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.47.0", + "version": "3.49.1", "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 5f826d54f0..2d7faccaa8 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.47.0", + "version": "3.49.1", "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 d7b9fe1e82..ab5651cf32 100644 --- a/packages/live-preview/package.json +++ b/packages/live-preview/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview", - "version": "3.47.0", + "version": "3.49.1", "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 f833f30232..c7067454e8 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/next", - "version": "3.47.0", + "version": "3.49.1", "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/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/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 6995374069..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,16 +110,18 @@ 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) { // If it's a collection document that doesn't exist, redirect to collection list @@ -134,6 +138,8 @@ export const renderDocument = async ({ } } + const isTrashedDoc = typeof doc?.deletedAt === 'string' + const [ docPreferences, { docPermissions, hasPublishPermission, hasSavePermission }, @@ -202,6 +208,7 @@ export const renderDocument = async ({ globalSlug, locale: locale?.code, operation, + readOnly: isTrashedDoc, renderAllFields: true, req, schemaPath: collectionSlug || globalSlug, @@ -389,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} > @@ -408,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..243e432506 --- /dev/null +++ b/packages/next/src/views/List/handleGroupBy.ts @@ -0,0 +1,208 @@ +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 || req.i18n.t('general:noValue') + + if ( + groupByField?.type === 'relationship' && + potentiallyPopulatedRelationship && + typeof potentiallyPopulatedRelationship === 'object' + ) { + heading = + potentiallyPopulatedRelationship[relationshipConfig.admin.useAsTitle || 'id'] || + valueOrRelationshipID + } + + if (groupByField.type === 'date') { + heading = formatDate({ + date: String(heading), + i18n: req.i18n, + pattern: clientConfig.admin.dateFormat, + }) + } + + 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, + 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 a2af698ea1..599f369392 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,39 @@ 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, - }) + let baseListFilter = undefined if (typeof collectionConfig.admin?.baseListFilter === 'function') { - const baseListFilter = await collectionConfig.admin.baseListFilter({ - limit, - page, + baseListFilter = await collectionConfig.admin.baseListFilter({ + limit: query.limit, + page: query.page, req, - sort, + sort: query.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, baseListFilter]), + }) + + if (trash === true) { + whereWithMergedSearch = { + and: [ + whereWithMergedSearch, + { + deletedAt: { + exists: true, + }, + }, + ], + } + } + if (collectionPreferences?.preset) { try { queryPreset = (await payload.findByID({ @@ -173,38 +194,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,6 +289,7 @@ 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 @@ -232,7 +298,7 @@ export const renderListView = async ( collectionConfig, data, i18n, - limit, + limit: query.limit, listPreferences: collectionPreferences, listSearchableFields: collectionConfig.admin.listSearchableFields, locale: fullLocale, @@ -247,6 +313,7 @@ export const renderListView = async ( clientProps: { collectionSlug, hasCreatePermission, + hasDeletePermission, newDocumentURL, }, collectionConfig, @@ -258,19 +325,20 @@ export const renderListView = async ( 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: { @@ -282,6 +350,7 @@ export const renderListView = async ( disableQueryPresets, enableRowSelections, hasCreatePermission, + hasDeletePermission, listPreferences: collectionPreferences, newDocumentURL, queryPreset, @@ -289,6 +358,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/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/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/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/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 65e185efbf..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), @@ -222,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, }) } @@ -235,40 +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, - }) - - user.collection = collectionConfig.slug - user._strategy = 'local-jwt' - - 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/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 b1b973d0ef..6bd604a373 100755 --- a/packages/payload/src/bin/index.ts +++ b/packages/payload/src/bin/index.ts @@ -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/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..f32596bb28 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 @@ -366,6 +368,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 +561,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 +688,7 @@ export type AuthCollection = { } export type TypeWithID = { + deletedAt?: string docId?: any id: number | string } @@ -675,6 +696,7 @@ export type TypeWithID = { export type TypeWithTimestamps = { [key: string]: unknown createdAt: string + deletedAt?: 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 6c0bd40562..0f34dec563 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, @@ -286,7 +292,9 @@ export const createOperation = async < collection: collectionConfig, docWithLocales: result, locale, + 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 9c46773d49..7a75180046 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -315,6 +315,7 @@ export const updateDocument = async < docWithLocales: result, draft: shouldSaveDraft, locale, + 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 { - 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 a3da00bbeb..df55f94798 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 } @@ -483,6 +487,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/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/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/formatFolderOrDocumentItem.ts b/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts index 825dbb9545..4f13d17083 100644 --- a/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts +++ b/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts @@ -23,6 +23,7 @@ export function formatFolderOrDocumentItem({ _folderOrDocumentTitle: String((useAsTitle && value?.[useAsTitle]) || value['id']), createdAt: value?.createdAt, folderID: value?.[folderFieldName], + folderType: value?.folderType || [], updatedAt: value?.updatedAt, } 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 d72d24da0f..6eed6db4c1 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -283,6 +283,7 @@ export const updateOperation = async < draft: shouldSaveDraft, global: globalConfig, locale, + operation: 'update', payload, publishSpecificLocale, req, diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 0c2ea26805..542f18c71d 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' @@ -464,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> => { @@ -847,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) }), ) } @@ -913,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( @@ -1158,6 +1193,7 @@ export type { 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' @@ -1172,9 +1208,7 @@ export { findVersionsOperation } from './collections/operations/findVersions.js' export { restoreVersionOperation } from './collections/operations/restoreVersion.js' export { updateOperation } from './collections/operations/update.js' export { updateByIDOperation } from './collections/operations/updateByID.js' - export { buildConfig } from './config/build.js' - export { type ClientConfig, createClientConfig, @@ -1183,6 +1217,7 @@ export { 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 +1272,7 @@ export type { Destroy, Find, FindArgs, + FindDistinct, FindGlobal, FindGlobalArgs, FindGlobalVersions, @@ -1250,6 +1286,7 @@ export type { Migration, MigrationData, MigrationTemplateArgs, + PaginatedDistinctDocs, PaginatedDocs, QueryDrafts, QueryDraftsArgs, @@ -1301,6 +1338,7 @@ export { export type { ValidationFieldError } from './errors/index.js' export { baseBlockFields } from './fields/baseFields/baseBlockFields.js' + export { baseIDField } from './fields/baseFields/baseIDField.js' export { @@ -1424,6 +1462,7 @@ export type { 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' @@ -1431,6 +1470,7 @@ export { traverseFields as beforeValidateTraverseFields } from './fields/hooks/b export { sortableFieldTypes } from './fields/sortableFieldTypes.js' export { validations } from './fields/validations.js' + export type { ArrayFieldValidation, BlocksFieldValidation, @@ -1485,6 +1525,7 @@ export type { 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,8 +1546,7 @@ 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, @@ -1521,6 +1561,7 @@ export type { TaskOutput, TaskType, } from './queues/config/types/taskTypes.js' + export type { BaseJob, JobLog, @@ -1531,8 +1572,14 @@ 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' 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 6bf730f44f..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. */ @@ -135,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/uploads/checkFileRestrictions.ts b/packages/payload/src/uploads/checkFileRestrictions.ts index c3088e7b10..c24401cd01 100644 --- a/packages/payload/src/uploads/checkFileRestrictions.ts +++ b/packages/payload/src/uploads/checkFileRestrictions.ts @@ -4,6 +4,7 @@ import type { checkFileRestrictionsParams, FileAllowList } from './types.js' import { ValidationError } from '../errors/index.js' import { validateMimeType } from '../utilities/validateMimeType.js' +import { detectSvgFromXml } from './detectSvgFromXml.js' /** * Restricted file types and their extensions. @@ -69,7 +70,19 @@ export const checkFileRestrictions = async ({ // Secondary mimetype check to assess file type from buffer if (configMimeTypes.length > 0) { - const detected = await fileTypeFromBuffer(file.data) + let detected = await fileTypeFromBuffer(file.data) + + // 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) { 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 e7b39c0796..8a316840fc 100644 --- a/packages/payload/src/uploads/endpoints/getFile.ts +++ b/packages/payload/src/uploads/endpoints/getFile.ts @@ -93,9 +93,14 @@ 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 }) || headers 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/types.ts b/packages/payload/src/uploads/types.ts index ff4963833a..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 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/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 f3981c6811..9e7fb8b1d0 100644 --- a/packages/payload/src/utilities/traverseFields.ts +++ b/packages/payload/src/utilities/traverseFields.ts @@ -28,7 +28,7 @@ const traverseArrayOrBlocksField = ({ fillEmpty: boolean leavesFirst: boolean parentIsLocalized: boolean - parentPath?: string + parentPath: string parentRef?: unknown }) => { if (fillEmpty) { @@ -403,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 } @@ -423,6 +434,7 @@ export const traverseFields = ({ fillEmpty, leavesFirst, parentIsLocalized: true, + parentPath, parentRef: currentParentRef, }) } @@ -436,6 +448,7 @@ export const traverseFields = ({ fillEmpty, leavesFirst, parentIsLocalized: parentIsLocalized!, + parentPath, parentRef: currentParentRef, }) } 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 974572e315..c19fc6ebb6 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -19,6 +19,7 @@ type Args = { global?: SanitizedGlobalConfig id?: number | string locale?: null | string + operation?: 'create' | 'restoreVersion' | 'update' payload: Payload publishSpecificLocale?: string req?: PayloadRequest @@ -34,6 +35,7 @@ export const saveVersion = async ({ draft, global, locale, + operation, payload, publishSpecificLocale, req, @@ -172,7 +174,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, localeStatus, parent: collection ? id : undefined, diff --git a/packages/plugin-cloud-storage/package.json b/packages/plugin-cloud-storage/package.json index 98b0ea98bd..fc5c0d8a4d 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.47.0", + "version": "3.49.1", "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-form-builder/package.json b/packages/plugin-form-builder/package.json index fdaa894696..4c3d5ebf4e 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.47.0", + "version": "3.49.1", "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 bf893f2ec4..0efff5625e 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.47.0", + "version": "3.49.1", "description": "Import-Export plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx b/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx index 37c2a47f48..a20568b436 100644 --- a/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx +++ b/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx @@ -114,7 +114,11 @@ export const reduceFields = ({ const val = createNestedClientFieldPath(path, field) // If the field is disabled, skip it - if (disabledFields.includes(val)) { + if ( + disabledFields.some( + (disabledField) => val === disabledField || val.startsWith(`${disabledField}.`), + ) + ) { return fieldsToUse } diff --git a/packages/plugin-import-export/src/components/Preview/index.tsx b/packages/plugin-import-export/src/components/Preview/index.tsx index 046b04c4a7..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,12 +47,11 @@ export const Preview = () => { (collection) => collection.slug === collectionSlug, ) - const disabledFieldsUnderscored = React.useMemo(() => { - return ( - collectionConfig?.admin?.custom?.['plugin-import-export']?.disabledFields?.map((f: string) => - f.replace(/\./g, '_'), - ) ?? [] - ) + const disabledFieldRegexes: RegExp[] = React.useMemo(() => { + const disabledFieldPaths = + collectionConfig?.admin?.custom?.['plugin-import-export']?.disabledFields ?? [] + + return disabledFieldPaths.map(buildDisabledFieldRegex) }, [collectionConfig]) const isCSV = format === 'csv' @@ -68,6 +68,7 @@ export const Preview = () => { collectionSlug, draft, fields, + format, limit, locale, sort, @@ -101,17 +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) && !disabledFieldsUnderscored.includes(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) => ({ @@ -150,9 +161,10 @@ export const Preview = () => { }, [ collectionConfig, collectionSlug, - disabledFieldsUnderscored, + disabledFieldRegexes, draft, fields, + format, i18n, limit, locale, diff --git a/packages/plugin-import-export/src/export/createExport.ts b/packages/plugin-import-export/src/export/createExport.ts index 9868e0a965..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,53 +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, }) - const disabledFieldsDot = + const disabledFields = collectionConfig.admin?.custom?.['plugin-import-export']?.disabledFields ?? [] - const disabledFields = disabledFieldsDot.map((f: string) => f.replace(/\./g, '_')) - const filterDisabled = (row: Record): Record => { - for (const key of disabledFields) { - delete row[key] + 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 row + + 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 = filterDisabled(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() @@ -159,38 +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) => - filterDisabled(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 } @@ -207,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[] = [] @@ -224,14 +282,14 @@ 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) => - filterDisabled(flattenObject({ doc, fields, toCSVFunctions })), + filterDisabledCSV(flattenObject({ doc, fields, toCSVFunctions })), ) // Track discovered column keys @@ -246,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 @@ -273,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, @@ -288,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, @@ -304,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 022238aacf..0801a2e5ef 100644 --- a/packages/plugin-import-export/src/export/flattenObject.ts +++ b/packages/plugin-import-export/src/export/flattenObject.ts @@ -24,6 +24,10 @@ export const flattenObject = ({ if (Array.isArray(value)) { value.forEach((item, index) => { if (typeof item === 'object' && item !== null) { + 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 && @@ -31,12 +35,12 @@ export const flattenObject = ({ typeof item.value === 'object' && item.value !== null ) { - row[`${`${newKey}_${index}`}_relationTo`] = item.relationTo - row[`${`${newKey}_${index}`}_id`] = item.value.id + row[`${itemPrefix}_relationTo`] = item.relationTo + row[`${itemPrefix}_id`] = item.value.id return } - flatten(item, `${newKey}_${index}`) + flatten(item, itemPrefix) } else { if (toCSVFunctions?.[newKey]) { const columnName = `${newKey}_${index}` @@ -54,7 +58,9 @@ export const flattenObject = ({ } } catch (error) { throw new Error( - `Error in toCSVFunction for array item "${columnName}": ${JSON.stringify(item)}\n${(error as Error).message}`, + `Error in toCSVFunction for array item "${columnName}": ${JSON.stringify(item)}\n${ + (error as Error).message + }`, ) } } else { diff --git a/packages/plugin-import-export/src/index.ts b/packages/plugin-import-export/src/index.ts index 27dd52bd83..e3b4f99f96 100644 --- a/packages/plugin-import-export/src/index.ts +++ b/packages/plugin-import-export/src/index.ts @@ -1,6 +1,6 @@ import type { Config, FlattenedField } from 'payload' -import { addDataAndFileToRequest, deepMergeSimple, flattenTopLevelFields } from 'payload' +import { addDataAndFileToRequest, deepMergeSimple } from 'payload' import type { PluginDefaultTranslationsObject } from './translations/types.js' import type { ImportExportPluginConfig, ToCSVFunction } from './types.js' @@ -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) => @@ -59,15 +63,8 @@ export const importExportPlugin = path: '@payloadcms/plugin-import-export/rsc#ExportListMenuItem', }) - // Flatten top-level fields to expose nested fields for export config - const flattenedFields = flattenTopLevelFields(collection.fields, { - moveSubFieldsToTop: true, - }) - - // Find fields explicitly marked as disabled for import/export - const disabledFieldAccessors = flattenedFields - .filter((field) => field.custom?.['plugin-import-export']?.disabled) - .map((field) => field.accessor || field.name) + // // 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 = { @@ -97,6 +94,7 @@ export const importExportPlugin = collectionSlug: string draft?: 'no' | 'yes' fields?: string[] + format?: 'csv' | 'json' limit?: number locale?: string sort?: any @@ -126,29 +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[], - }) + 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, @@ -189,6 +216,9 @@ declare module 'payload' { * @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/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 5ba649c13e..db25206b8b 100644 --- a/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts +++ b/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts @@ -22,66 +22,63 @@ export const getFlattenedFieldKeys = (fields: FieldWithPresentational[], prefix 'plugin-import-export' in field.custom && field.custom['plugin-import-export']?.toCSV - if (!('name' in field) || typeof field.name !== 'string' || fieldHasToCSVFunction) { - return - } - - 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) { if (Array.isArray(field.relationTo)) { // hasMany polymorphic - keys.push(`${name}_0_relationTo`, `${name}_0_id`) + keys.push(`${fullKey}_0_relationTo`, `${fullKey}_0_id`) } else { // hasMany monomorphic - keys.push(`${name}_0`) + keys.push(`${fullKey}_0`) } } else { if (Array.isArray(field.relationTo)) { // hasOne polymorphic - keys.push(`${name}_relationTo`, `${name}_id`) + keys.push(`${fullKey}_relationTo`, `${fullKey}_id`) } else { // hasOne monomorphic - keys.push(name) + 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/package.json b/packages/plugin-multi-tenant/package.json index 6c8ecac5f1..465bbe5a16 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.47.0", + "version": "3.49.1", "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/index.ts b/packages/plugin-multi-tenant/src/index.ts index 4b47f24594..b9c6a54b39 100644 --- a/packages/plugin-multi-tenant/src/index.ts +++ b/packages/plugin-multi-tenant/src/index.ts @@ -7,6 +7,7 @@ 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 { addTenantCleanup } from './hooks/afterTenantDelete.js' @@ -248,6 +249,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) @@ -327,8 +339,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 +358,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/providers/TenantSelectionProvider/index.client.tsx b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx index bf66c2550d..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, @@ -89,7 +85,7 @@ export const TenantSelectionProviderClient = ({ 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, @@ -142,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', @@ -151,23 +147,18 @@ export const TenantSelectionProviderClient = ({ const result = await req.json() - if (result.docs && userID) { - setTenantOptions( - result.docs.map((doc: Record) => ({ - label: doc[useAsTitle], - value: doc.id, - })), - ) + if (result.tenantOptions && userID) { + setTenantOptions(result.tenantOptions) - if (result.totalDocs === 1) { - setSelectedTenantID(result.docs[0].id) - setCookie(String(result.docs[0].id)) + 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, setCookie, userID]) + }, [config.serverURL, config.routes.api, tenantsCollectionSlug, setCookie, userID]) const updateTenants = React.useCallback( ({ id, label }) => { diff --git a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx index 844c70ca34..73f902b867 100644 --- a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx +++ b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx @@ -1,45 +1,47 @@ -import type { OptionObject, Payload, TypedUser } from 'payload' +import type { Payload, TypedUser } from 'payload' import { cookies as getCookies } from 'next/headers.js' -import { findTenantOptions } from '../../queries/findTenantOptions.js' +import type { MultiTenantPluginConfig } from '../../types.js' + +import { getTenantOptions } from '../../utilities/getTenantOptions.js' import { TenantSelectionProviderClient } from './index.client.js' -type Args = { +type Args = { 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/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-nested-docs/package.json b/packages/plugin-nested-docs/package.json index 6e96917d68..647fa963a6 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.47.0", + "version": "3.49.1", "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 6ef9ceb11e..af67161e21 100644 --- a/packages/plugin-redirects/package.json +++ b/packages/plugin-redirects/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-redirects", - "version": "3.47.0", + "version": "3.49.1", "description": "Redirects plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-search/package.json b/packages/plugin-search/package.json index e4a5086360..143ec3dead 100644 --- a/packages/plugin-search/package.json +++ b/packages/plugin-search/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-search", - "version": "3.47.0", + "version": "3.49.1", "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 9cfae24d58..db866dd6ab 100644 --- a/packages/plugin-sentry/package.json +++ b/packages/plugin-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-sentry", - "version": "3.47.0", + "version": "3.49.1", "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 e970f1518a..8a77dc84c1 100644 --- a/packages/plugin-seo/package.json +++ b/packages/plugin-seo/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-seo", - "version": "3.47.0", + "version": "3.49.1", "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-stripe/package.json b/packages/plugin-stripe/package.json index d792db9a12..73fe7e5155 100644 --- a/packages/plugin-stripe/package.json +++ b/packages/plugin-stripe/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-stripe", - "version": "3.47.0", + "version": "3.49.1", "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 46f318440b..22c20a69c0 100644 --- a/packages/richtext-lexical/package.json +++ b/packages/richtext-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-lexical", - "version": "3.47.0", + "version": "3.49.1", "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/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 4bd5c023bf..fb77f47521 100644 --- a/packages/richtext-slate/package.json +++ b/packages/richtext-slate/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-slate", - "version": "3.47.0", + "version": "3.49.1", "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 dcd0d6d3ff..9b9933e9c6 100644 --- a/packages/storage-azure/package.json +++ b/packages/storage-azure/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-azure", - "version": "3.47.0", + "version": "3.49.1", "description": "Payload storage adapter for Azure Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-gcs/package.json b/packages/storage-gcs/package.json index 7535814e6b..c490433b8d 100644 --- a/packages/storage-gcs/package.json +++ b/packages/storage-gcs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-gcs", - "version": "3.47.0", + "version": "3.49.1", "description": "Payload storage adapter for Google Cloud Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-s3/package.json b/packages/storage-s3/package.json index e667db97e2..fa2090fd1a 100644 --- a/packages/storage-s3/package.json +++ b/packages/storage-s3/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-s3", - "version": "3.47.0", + "version": "3.49.1", "description": "Payload storage adapter for Amazon S3", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-uploadthing/package.json b/packages/storage-uploadthing/package.json index aeaeecd7fc..91b3b96e6b 100644 --- a/packages/storage-uploadthing/package.json +++ b/packages/storage-uploadthing/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-uploadthing", - "version": "3.47.0", + "version": "3.49.1", "description": "Payload storage adapter for uploadthing", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-vercel-blob/package.json b/packages/storage-vercel-blob/package.json index 652d56acc3..4e0b3b4596 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.47.0", + "version": "3.49.1", "description": "Payload storage adapter for Vercel Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/translations/package.json b/packages/translations/package.json index 8ebecc5dfe..283bcd9645 100644 --- a/packages/translations/package.json +++ b/packages/translations/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/translations", - "version": "3.47.0", + "version": "3.49.1", "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 0a9c986847..67c99862e4 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -91,6 +91,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'error:tokenNotProvided', 'error:unPublishingDocument', 'error:problemUploadingFile', + 'error:restoringTitle', 'fields:addLabel', 'fields:addLink', @@ -134,6 +135,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'folder:browseByFolder', 'folder:deleteFolder', 'folder:folders', + 'folder:folderTypeDescription', 'folder:folderName', 'folder:itemsMovedToFolder', 'folder:itemsMovedToRoot', @@ -154,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', @@ -183,7 +193,9 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:confirmReindexAll', 'general:confirmReindexDescription', 'general:confirmReindexDescriptionAll', + 'general:confirmRestoration', 'general:copied', + 'general:clear', 'general:clearAll', 'general:copy', 'general:copyField', @@ -202,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', @@ -209,6 +224,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:depth', 'general:deselectAllRows', 'general:document', + 'general:documentIsTrashed', 'general:documentLocked', 'general:documents', 'general:duplicate', @@ -222,6 +238,8 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:editedSince', 'general:email', 'general:emailAddress', + 'general:emptyTrash', + 'general:emptyTrashLabel', 'general:enterAValue', 'general:error', 'general:errors', @@ -231,6 +249,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:filterWhere', 'general:globals', 'general:goBack', + 'general:groupByLabel', 'general:isEditing', 'general:item', 'general:items', @@ -263,6 +282,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:noResults', 'general:notFound', 'general:nothingFound', + 'general:noTrashResults', 'general:noUpcomingEventsScheduled', 'general:noValue', 'general:of', @@ -276,6 +296,8 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:pasteField', 'general:pasteRow', 'general:payloadSettings', + 'general:permanentlyDelete', + 'general:permanentlyDeletedCountSuccessfully', 'general:perPage', 'general:previous', 'general:reindex', @@ -286,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', @@ -315,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', @@ -334,6 +364,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:updatedSuccessfully', 'general:updating', 'general:value', + 'general:viewing', 'general:viewReadOnly', 'general:uploading', 'general:uploadingBulk', @@ -441,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 6a33e7ecbe..e58e1589df 100644 --- a/packages/translations/src/languages/ar.ts +++ b/packages/translations/src/languages/ar.ts @@ -113,6 +113,7 @@ export const arTranslations: DefaultTranslationsObject = { noUser: 'لا يوجد مستخدم', previewing: 'حدث خطأ في اثناء معاينة هذا المستند.', problemUploadingFile: 'حدث خطأ اثناء رفع الملفّ.', + restoringTitle: 'حدث خطأ أثناء استعادة {{title}}. يرجى التحقق من اتصالك وحاول مرة أخرى.', tokenInvalidOrExpired: 'الرّمز إمّا غير صالح أو منتهي الصّلاحيّة.', tokenNotProvided: 'لم يتم تقديم الرمز.', unableToCopy: 'تعذر النسخ.', @@ -183,6 +184,7 @@ export const arTranslations: DefaultTranslationsObject = { deleteFolder: 'حذف المجلد', folderName: 'اسم المجلد', folders: 'مجلدات', + folderTypeDescription: 'حدد نوع المستندات التي يجب السماح بها في هذا المجلد من المجموعات.', itemHasBeenMoved: 'تم نقل {{title}} إلى {{folderName}}', itemHasBeenMovedToRoot: 'تم نقل {{title}} إلى المجلد الجذر', itemsMovedToFolder: '{{title}} تم نقله إلى {{folderName}}', @@ -208,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: 'شكل واجهة المستخدم', @@ -223,6 +234,7 @@ export const arTranslations: DefaultTranslationsObject = { backToDashboard: 'العودة للوحة التّحكّم', cancel: 'إلغاء', changesNotSaved: 'لم يتمّ حفظ التّغييرات. إن غادرت الآن ، ستفقد تغييراتك.', + clear: 'واضح', clearAll: 'امسح الكل', close: 'إغلاق', collapse: 'طيّ', @@ -240,6 +252,7 @@ export const arTranslations: DefaultTranslationsObject = { 'سيؤدي هذا إلى إزالة الفهارس الحالية وإعادة فهرسة المستندات في مجموعات {{collections}}.', confirmReindexDescriptionAll: 'سيؤدي هذا إلى إزالة الفهارس الحالية وإعادة فهرسة المستندات في جميع المجموعات.', + confirmRestoration: 'تأكيد الاستعادة', copied: 'تمّ النّسخ', copy: 'نسخ', copyField: 'نسخ الحقل', @@ -259,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: 'استنساخ', @@ -281,6 +298,8 @@ export const arTranslations: DefaultTranslationsObject = { editLabel: 'تعديل {{label}}', email: 'البريد الإلكتروني', emailAddress: 'عنوان البريد الإلكتروني', + emptyTrash: 'أفرغ القمامة', + emptyTrashLabel: 'أفرغ سلة المحذوفات {{label}}', enterAValue: 'أدخل قيمة', error: 'خطأ', errors: 'أخطاء', @@ -293,6 +312,7 @@ export const arTranslations: DefaultTranslationsObject = { filterWhere: 'تصفية {{label}} حيث', globals: 'عامة', goBack: 'العودة', + groupByLabel: 'التجميع حسب {{label}}', import: 'استيراد', isEditing: 'يحرر', item: 'عنصر', @@ -327,6 +347,7 @@ export const arTranslations: DefaultTranslationsObject = { 'لا يوجد {{label}}. إما أن لا {{label}} موجودة حتى الآن أو لا تتطابق مع عوامل التصفية التي حددتها أعلاه.', notFound: 'غير موجود', nothingFound: 'لم يتم العثور على شيء', + noTrashResults: 'لا {{label}} في المهملات.', noUpcomingEventsScheduled: 'لا يوجد أحداث مقبلة مجدولة.', noValue: 'لا يوجد قيمة', of: 'من', @@ -340,6 +361,8 @@ export const arTranslations: DefaultTranslationsObject = { pasteField: 'لصق الحقل', pasteRow: 'لصق الصف', payloadSettings: 'الإعدادات', + permanentlyDelete: 'حذف بشكل دائم', + permanentlyDeletedCountSuccessfully: 'تم حذف {{count}} {{label}} بشكل دائم بنجاح.', perPage: 'لكلّ صفحة: {{limit}}', previous: 'سابق', reindex: 'إعادة الفهرسة', @@ -351,6 +374,11 @@ export const arTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'سيؤدي ذلك إلى إعادة تعيين جميع تفضيلاتك إلى الإعدادات الافتراضية.', resettingPreferences: 'إعادة تعيين التفضيلات.', + restore: 'استعادة', + restoreAsPublished: 'استعادة كإصدار منشور', + restoredCountSuccessfully: 'تمت استعادة {{count}} {{label}} بنجاح.', + restoring: + 'احترم معنى النص الأصلي في سياق Payload. هنا قائمة بالمصطلحات الشائعة في Payload التي تحمل معانٍ محددة جدًا:\n - Collection: المجموعة هي مجموعة من الوثائق التي تتشارك في الهيكل والغرض المشترك. تُستخدم المجموعات لتنظيم وإدارة المحتوى في Payload.', row: 'سطر', rows: 'أسطُر', save: 'حفظ', @@ -381,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: 'لديك تغييرات غير محفوظة. قم بالحفظ أو التجاهل قبل المتابعة.', @@ -399,6 +431,7 @@ export const arTranslations: DefaultTranslationsObject = { username: 'اسم المستخدم', users: 'المستخدمين', value: 'القيمة', + viewing: 'عرض', viewReadOnly: 'عرض للقراءة فقط', welcome: 'مرحبًا', yes: 'نعم', @@ -519,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 59726751e4..08b1f07359 100644 --- a/packages/translations/src/languages/az.ts +++ b/packages/translations/src/languages/az.ts @@ -114,6 +114,8 @@ 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.', @@ -186,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ü', @@ -212,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', @@ -228,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', @@ -245,6 +263,7 @@ 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', @@ -265,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', @@ -287,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', @@ -299,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', @@ -334,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', @@ -347,6 +374,8 @@ export const azTranslations: DefaultTranslationsObject = { 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ə', @@ -357,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', @@ -387,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: @@ -407,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', @@ -531,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 b507d73d69..a92388bb4c 100644 --- a/packages/translations/src/languages/bg.ts +++ b/packages/translations/src/languages/bg.ts @@ -114,6 +114,8 @@ export const bgTranslations: DefaultTranslationsObject = { noUser: 'Липсващ потребител', previewing: 'Имаше проблем при предварителното разглеждане на документа.', problemUploadingFile: 'Имаше проблем при качването на файла.', + restoringTitle: + 'Възникна грешка при възстановяването на {{title}}. Моля, проверете връзката си и опитайте отново.', tokenInvalidOrExpired: 'Ключът е невалиден или изтекъл.', tokenNotProvided: 'Токенът не е предоставен.', unableToCopy: 'Неуспешно копиране.', @@ -186,6 +188,8 @@ export const bgTranslations: DefaultTranslationsObject = { deleteFolder: 'Изтрий папка', folderName: 'Име на папка', folders: 'Папки', + folderTypeDescription: + 'Изберете кой тип документи от колекциите трябва да се допускат в тази папка.', itemHasBeenMoved: '{{title}} е преместен в {{folderName}}', itemHasBeenMovedToRoot: '{{title}} беше преместено в основната папка', itemsMovedToFolder: '{{title}} беше преместен в {{folderName}}', @@ -212,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: 'Цветова тема', @@ -227,6 +242,7 @@ export const bgTranslations: DefaultTranslationsObject = { backToDashboard: 'Обратно към таблото', cancel: 'Отмени', changesNotSaved: 'Промените ти не са запазени. Ако напуснеш сега, ще ги загубиш.', + clear: 'Ясно', clearAll: 'Изчисти всичко', close: 'Затвори', collapse: 'Свий', @@ -244,6 +260,7 @@ export const bgTranslations: DefaultTranslationsObject = { 'Това ще премахне съществуващите индекси и ще преиндексира документите в колекциите {{collections}}.', confirmReindexDescriptionAll: 'Това ще премахне съществуващите индекси и ще преиндексира документите във всички колекции.', + confirmRestoration: 'Потвърдете възстановяването', copied: 'Копирано', copy: 'Копирай', copyField: 'Копирай поле', @@ -264,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: 'Дупликирай', @@ -286,6 +307,8 @@ export const bgTranslations: DefaultTranslationsObject = { editLabel: 'Редактирай {{label}}', email: 'Имейл', emailAddress: 'Имейл адрес', + emptyTrash: 'Изпразни кошчето', + emptyTrashLabel: 'Изпразнете кошчето за {{label}}', enterAValue: 'Въведи стойност', error: 'Грешка', errors: 'Грешки', @@ -298,6 +321,7 @@ export const bgTranslations: DefaultTranslationsObject = { filterWhere: 'Филтрирай {{label}} където', globals: 'Глобални', goBack: 'Върни се', + groupByLabel: 'Групирай по {{label}}', import: 'Внос', isEditing: 'редактира', item: 'артикул', @@ -333,6 +357,7 @@ export const bgTranslations: DefaultTranslationsObject = { '{{label}} не е открит. {{label}} не съществува или никой не отговаря на зададените филтри.', notFound: 'Няма открит', nothingFound: 'Нищо не беше открито', + noTrashResults: 'Няма {{label}} в кошчето.', noUpcomingEventsScheduled: 'Няма предстоящи събития.', noValue: 'Няма стойност', of: 'от', @@ -346,6 +371,8 @@ export const bgTranslations: DefaultTranslationsObject = { pasteField: 'Постави поле', pasteRow: 'Постави ред', payloadSettings: 'Настройки на Payload', + permanentlyDelete: 'Трайно изтриване', + permanentlyDeletedCountSuccessfully: 'Успешно изтрити завинаги {{count}} {{label}}.', perPage: 'На страница: {{limit}}', previous: 'Предишен', reindex: 'Преиндексиране', @@ -357,6 +384,10 @@ export const bgTranslations: DefaultTranslationsObject = { resetPreferencesDescription: 'Това ще нулира всички ваши предпочитания до техните настройки по подразбиране.', resettingPreferences: 'Нулиране на предпочитанията.', + restore: 'Възстановяване', + restoreAsPublished: 'Възстановете като публикувана версия', + restoredCountSuccessfully: 'Успешно възстановени {{count}} {{label}}.', + restoring: 'Възстановяване...', row: 'ред', rows: 'Редове', save: 'Запази', @@ -387,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: 'Имате незапазени промени. Запазете или отхвърлете преди да продължите.', @@ -405,6 +440,7 @@ export const bgTranslations: DefaultTranslationsObject = { username: 'Потребителско име', users: 'Потребители', value: 'Стойност', + viewing: 'Преглеждане', viewReadOnly: 'Преглед само за четене', welcome: 'Добре дошъл', yes: 'Да', @@ -530,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 50a3462030..01276ef13c 100644 --- a/packages/translations/src/languages/bnBd.ts +++ b/packages/translations/src/languages/bnBd.ts @@ -114,6 +114,8 @@ export const bnBdTranslations: DefaultTranslationsObject = { noUser: 'কোনো ব্যবহারকারী নেই', previewing: 'এই ডকুমেন্টটি প্রিভিউ করতে একটি সমস্যা হয়েছে।', problemUploadingFile: 'ফাইল আপলোড করতে একটি সমস্যা হয়েছে।', + restoringTitle: + '{{title}} পুনরুদ্ধার করার সময় একটি ত্রুটি ঘটেছে। দয়া করে আপনার সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।', tokenInvalidOrExpired: 'টোকেন অবৈধ বা মেয়াদ শেষ হয়ে গেছে।', tokenNotProvided: 'টোকেন প্রদান করা হয়নি।', unableToCopy: 'কপি করা সম্ভব নয়।', @@ -187,6 +189,8 @@ export const bnBdTranslations: DefaultTranslationsObject = { deleteFolder: 'ফোল্ডার মুছুন', folderName: 'ফোল্ডারের নাম', folders: 'ফোল্ডারগুলি', + folderTypeDescription: + 'এই ফোল্ডারে কোন ধরনের সংগ্রহ নথিপত্র অনুমোদিত হওয়া উচিত তা নির্বাচন করুন।', itemHasBeenMoved: '{{title}} কে {{folderName}} এ সরানো হয়েছে', itemHasBeenMovedToRoot: '{{title}} কে মূল ফোল্ডারে সরানো হয়েছে', itemsMovedToFolder: '{{title}} কে {{folderName}} এ সরানো হয়েছে', @@ -213,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: 'অ্যাডমিন থিম', @@ -229,6 +246,8 @@ export const bnBdTranslations: DefaultTranslationsObject = { cancel: 'বাতিল করুন', changesNotSaved: 'আপনার পরিবর্তনগুলি সংরক্ষণ করা হয়নি। আপনি যদি এখন চলে যান, তাহলে আপনার পরিবর্তনগুলি হারিয়ে যাবে।', + clear: + 'মূল পাঠের অর্থ সম্মান করুন পেলোড প্রসঙ্গে। এখানে পেলোড নির্দিষ্ট বিশেষ অর্থ বহন করে এরকম একটি সাধারণ টার্মের তালিকা:\n - সংগ্রহ', clearAll: 'সমস্ত সাফ করুন', close: 'বন্ধ করুন', collapse: 'সংকুচিত করুন', @@ -246,6 +265,7 @@ export const bnBdTranslations: DefaultTranslationsObject = { 'এটি বিদ্যমান সূচিগুলি সরিয়ে দেবে এবং {{collections}} সংগ্রহগুলির ডকুমেন্টগুলি পুনরায় সূচিবদ্ধ করবে।', confirmReindexDescriptionAll: 'এটি বিদ্যমান সূচিগুলি সরিয়ে দেবে এবং সমস্ত সংগ্রহগুলির ডকুমেন্টগুলি পুনরায় সূচিবদ্ধ করবে।', + confirmRestoration: 'পুনরুদ্ধার নিশ্চিত করুন', copied: 'কপি করা হয়েছে', copy: 'কপি করুন', copyField: 'ফিল্ড কপি করুন', @@ -266,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: 'ডুপ্লিকেট করুন', @@ -288,6 +312,8 @@ export const bnBdTranslations: DefaultTranslationsObject = { editLabel: '{{label}} সম্পাদনা করুন', email: 'ইমেইল', emailAddress: 'ইমেইল ঠিকানা', + emptyTrash: 'ট্র্যাশ খালি করুন', + emptyTrashLabel: '{{label}} ট্র্যাশ খালি করুন', enterAValue: 'একটি মান লিখুন', error: 'ত্রুটি', errors: 'ত্রুটিগুলি', @@ -300,6 +326,7 @@ export const bnBdTranslations: DefaultTranslationsObject = { filterWhere: '{{label}} যেখানে ফিল্টার করুন', globals: 'গ্লোবালগুলি', goBack: 'পিছনে যান', + groupByLabel: '{{label}} অনুযায়ী গ্রুপ করুন', import: 'ইম্পোর্ট করুন', isEditing: 'সম্পাদনা করছেন', item: 'আইটেম', @@ -335,6 +362,7 @@ export const bnBdTranslations: DefaultTranslationsObject = { 'কোনো {{label}} পাওয়া যায়নি। হয় এখনও কোনো {{label}} তৈরি করা হয়নি বা উপরে নির্দিষ্ট করা ফিল্টারগুলির সাথে কোনোটি মেলে না।', notFound: 'পাওয়া যায়নি', nothingFound: 'কিছুই পাওয়া যায়নি', + noTrashResults: 'ট্র্যাশে কোন {{label}} নেই।', noUpcomingEventsScheduled: 'কোনো আসন্ন ইভেন্ট নির্ধারিত নেই।', noValue: 'কোনো মান নেই', of: 'এর', @@ -348,6 +376,9 @@ export const bnBdTranslations: DefaultTranslationsObject = { pasteField: 'ফিল্ড পেস্ট করুন', pasteRow: 'সারি পেস্ট করুন', payloadSettings: 'পেলোড সেটিংস', + permanentlyDelete: 'চিরতরে মুছে ফেলুন', + permanentlyDeletedCountSuccessfully: + 'স্থায়ীভাবে {{count}} {{label}} সফলভাবে মুছে ফেলা হয়েছে।', perPage: 'প্রতি পৃষ্ঠায়: {{limit}}', previous: 'পূর্ববর্তী', reindex: 'পুনরায় সূচিবদ্ধ করুন', @@ -358,6 +389,11 @@ export const bnBdTranslations: DefaultTranslationsObject = { resetPreferences: 'পছন্দগুলি রিসেট করুন', resetPreferencesDescription: 'এটি আপনার সমস্ত পছন্দগুলি তাদের ডিফল্ট সেটিংসে রিসেট করবে।', resettingPreferences: 'পছন্দগুলি রিসেট করা হচ্ছে।', + restore: 'পুনরুদ্ধার করুন', + restoreAsPublished: 'প্রকাশিত সংস্করণ হিসাবে পুনরুদ্ধার করুন', + restoredCountSuccessfully: '{{count}} {{label}} সফলভাবে পুনরুদ্ধার করা হয়েছে।', + restoring: + 'Payload এর প্রসঙ্গে মূল পাঠের অর্থ সম্মান করুন। এখানে Payload পদ গুলির একটি তালিকা রয়েছে যা খুব নির্দিষ্ট অর্থ বহন করে:\n - সংগ্রহ: একটি সং', row: 'সারি', rows: 'সারিগুলি', save: 'সংরক্ষণ করুন', @@ -388,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: @@ -408,6 +448,7 @@ export const bnBdTranslations: DefaultTranslationsObject = { username: 'ব্যবহারকারীর নাম', users: 'ব্যবহারকারীরা', value: 'মান', + viewing: 'দেখা', viewReadOnly: 'শুধুমাত্র পড়ার জন্য দেখুন', welcome: 'স্বাগতম', yes: 'হ্যাঁ', @@ -531,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 97e1a90f76..3d0efea0ab 100644 --- a/packages/translations/src/languages/bnIn.ts +++ b/packages/translations/src/languages/bnIn.ts @@ -114,6 +114,8 @@ export const bnInTranslations: DefaultTranslationsObject = { noUser: 'কোনো ব্যবহারকারী নেই', previewing: 'এই ডকুমেন্টটি প্রিভিউ করতে একটি সমস্যা হয়েছে।', problemUploadingFile: 'ফাইল আপলোড করতে একটি সমস্যা হয়েছে।', + restoringTitle: + '{{title}} পুনরুদ্ধার করতে গিয়ে একটি ত্রুটি ঘটেছে। দয়া করে আপনার সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।', tokenInvalidOrExpired: 'টোকেন অবৈধ বা মেয়াদ শেষ হয়ে গেছে।', tokenNotProvided: 'টোকেন প্রদান করা হয়নি।', unableToCopy: 'কপি করতে অক্ষম।', @@ -187,6 +189,8 @@ export const bnInTranslations: DefaultTranslationsObject = { deleteFolder: 'ফোল্ডার মুছুন', folderName: 'ফোল্ডারের নাম', folders: 'ফোল্ডারগুলি', + folderTypeDescription: + 'এই ফোল্ডারে কোন ধরণের কালেকশন ডকুমেন্টস অনুমতি দেওয়া উচিত তা নির্বাচন করুন।', itemHasBeenMoved: '{{title}} কে {{folderName}} এ সরানো হয়েছে', itemHasBeenMovedToRoot: '{{title}} কে মূল ফোল্ডারে সরানো হয়েছে', itemsMovedToFolder: '{{title}} কে {{folderName}} এ সরানো হয়েছে', @@ -213,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: 'অ্যাডমিন থিম', @@ -229,6 +246,7 @@ export const bnInTranslations: DefaultTranslationsObject = { cancel: 'বাতিল করুন', changesNotSaved: 'আপনার পরিবর্তনগুলি সংরক্ষণ করা হয়নি। আপনি যদি এখন চলে যান, তাহলে আপনার পরিবর্তনগুলি হারিয়ে যাবে।', + clear: 'স্পষ্ট', clearAll: 'সমস্ত সাফ করুন', close: 'বন্ধ করুন', collapse: 'সংকুচিত করুন', @@ -246,6 +264,7 @@ export const bnInTranslations: DefaultTranslationsObject = { 'এটি বিদ্যমান সূচিগুলি সরিয়ে দেবে এবং {{collections}} সংগ্রহগুলির ডকুমেন্টগুলি পুনরায় সূচিবদ্ধ করবে।', confirmReindexDescriptionAll: 'এটি বিদ্যমান সূচিগুলি সরিয়ে দেবে এবং সমস্ত সংগ্রহগুলির ডকুমেন্টগুলি পুনরায় সূচিবদ্ধ করবে।', + confirmRestoration: 'পুনর্বাসন নিশ্চিত করুন', copied: 'কপি করা হয়েছে', copy: 'কপি করুন', copyField: 'ফিল্ড কপি করুন', @@ -266,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: 'ডুপ্লিকেট করুন', @@ -288,6 +311,8 @@ export const bnInTranslations: DefaultTranslationsObject = { editLabel: '{{label}} সম্পাদনা করুন', email: 'ইমেইল', emailAddress: 'ইমেইল ঠিকানা', + emptyTrash: 'ট্র্যাশ খালি করুন', + emptyTrashLabel: '{{label}} ফাঁকা করুন', enterAValue: 'একটি মান লিখুন', error: 'ত্রুটি', errors: 'ত্রুটিগুলি', @@ -300,6 +325,7 @@ export const bnInTranslations: DefaultTranslationsObject = { filterWhere: '{{label}} যেখানে ফিল্টার করুন', globals: 'গ্লোবালগুলি', goBack: 'পিছনে যান', + groupByLabel: '{{label}} দ্বারা গ্রুপ করুন', import: 'ইম্পোর্ট করুন', isEditing: 'সম্পাদনা করছেন', item: 'আইটেম', @@ -335,6 +361,7 @@ export const bnInTranslations: DefaultTranslationsObject = { 'কোনো {{label}} পাওয়া যায়নি। হয় এখনও কোনো {{label}} তৈরি করা হয়নি বা উপরে নির্দিষ্ট করা ফিল্টারগুলির সাথে কোনোটি মেলে না।', notFound: 'পাওয়া যায়নি', nothingFound: 'কিছুই পাওয়া যায়নি', + noTrashResults: 'ট্র্যাশে কোনো {{label}} নেই।', noUpcomingEventsScheduled: 'কোনো আসন্ন ইভেন্ট নির্ধারিত নেই।', noValue: 'কোনো মান নেই', of: 'এর', @@ -348,6 +375,9 @@ export const bnInTranslations: DefaultTranslationsObject = { pasteField: 'ফিল্ড পেস্ট করুন', pasteRow: 'সারি পেস্ট করুন', payloadSettings: 'পেলোড সেটিংস', + permanentlyDelete: 'স্থায়ীভাবে মুছে ফেলুন', + permanentlyDeletedCountSuccessfully: + 'স্থায়ীভাবে {{count}} টি {{label}} সফলভাবে মুছে ফেলা হয়েছে।', perPage: 'প্রতি পৃষ্ঠায়: {{limit}}', previous: 'পূর্ববর্তী', reindex: 'পুনরায় সূচিবদ্ধ করুন', @@ -358,6 +388,11 @@ export const bnInTranslations: DefaultTranslationsObject = { resetPreferences: 'পছন্দগুলি রিসেট করুন', resetPreferencesDescription: 'এটি আপনার সমস্ত পছন্দগুলি তাদের ডিফল্ট সেটিংসে রিসেট করবে।', resettingPreferences: 'পছন্দগুলি রিসেট করা হচ্ছে।', + restore: 'পুনরুদ্ধার করুন', + restoreAsPublished: 'প্রকাশিত সংস্করণ হিসাবে পুনরুদ্ধার করুন', + restoredCountSuccessfully: '{{count}} {{label}} সফলভাবে পুনরুদ্ধার করা হয়েছে।', + restoring: + 'প্রস্থাপনার অর্থকে সম্মান করুন। এখানে Payload এর সাথে সম্পর্কিত কিছু সাধারণ পদগুলির তালিকা রয়েছে যা খুব নির্দিষ্ট অর্থ বহন করে:\n - কালেক', row: 'সারি', rows: 'সারিগুলি', save: 'সংরক্ষণ করুন', @@ -388,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: @@ -408,6 +447,7 @@ export const bnInTranslations: DefaultTranslationsObject = { username: 'ব্যবহারকারীর নাম', users: 'ব্যবহারকারীরা', value: 'মান', + viewing: 'দর্শন', viewReadOnly: 'শুধুমাত্র পড়ার জন্য দেখুন', welcome: 'স্বাগতম', yes: 'হ্যাঁ', @@ -531,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 36a9a5823c..e340d98eec 100644 --- a/packages/translations/src/languages/ca.ts +++ b/packages/translations/src/languages/ca.ts @@ -115,6 +115,8 @@ 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.', @@ -187,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}}", @@ -213,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ó", @@ -228,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', @@ -245,6 +262,7 @@ 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', @@ -265,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', @@ -287,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', @@ -299,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', @@ -334,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', @@ -347,6 +373,9 @@ export const caTranslations: DefaultTranslationsObject = { 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', @@ -358,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', @@ -388,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?', @@ -406,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í', @@ -533,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 4808651d5e..d475579f31 100644 --- a/packages/translations/src/languages/cs.ts +++ b/packages/translations/src/languages/cs.ts @@ -114,6 +114,8 @@ 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.', @@ -186,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}}', @@ -212,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í', @@ -227,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', @@ -244,6 +260,7 @@ 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', @@ -263,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', @@ -285,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', @@ -297,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', @@ -332,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', @@ -345,6 +370,8 @@ export const csTranslations: DefaultTranslationsObject = { 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', @@ -355,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', @@ -385,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.', @@ -403,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', @@ -527,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 0f449e1c2a..199e044f5a 100644 --- a/packages/translations/src/languages/da.ts +++ b/packages/translations/src/languages/da.ts @@ -113,6 +113,8 @@ 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.', @@ -185,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}}', @@ -210,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', @@ -226,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', @@ -243,6 +260,7 @@ 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', @@ -262,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', @@ -284,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', @@ -296,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', @@ -331,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', @@ -344,6 +370,8 @@ export const daTranslations: DefaultTranslationsObject = { 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', @@ -355,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', @@ -385,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.', @@ -403,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', @@ -528,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 9c2fd199d3..910d99f4b9 100644 --- a/packages/translations/src/languages/de.ts +++ b/packages/translations/src/languages/de.ts @@ -116,6 +116,8 @@ 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: 'Token nicht bereitgestellt.', unableToCopy: 'Kopieren nicht möglich.', @@ -191,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.', @@ -217,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', @@ -233,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', @@ -250,6 +270,7 @@ 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', @@ -270,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', @@ -292,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', @@ -304,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', @@ -339,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', @@ -352,6 +381,8 @@ export const deTranslations: DefaultTranslationsObject = { 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', @@ -362,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', @@ -393,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: @@ -413,6 +453,7 @@ export const deTranslations: DefaultTranslationsObject = { username: 'Benutzername', users: 'Benutzer', value: 'Wert', + viewing: 'Ansehen', viewReadOnly: 'Nur-Lese-Ansicht', welcome: 'Willkommen', yes: 'Ja', @@ -537,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 0b2d0f7694..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 = { @@ -115,6 +117,8 @@ 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.', @@ -186,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}}', @@ -212,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', @@ -228,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', @@ -245,6 +264,7 @@ 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', @@ -265,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', @@ -287,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', @@ -299,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', @@ -334,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', @@ -347,6 +375,8 @@ export const enTranslations = { 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', @@ -358,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', @@ -388,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.', @@ -406,6 +444,7 @@ export const enTranslations = { username: 'Username', users: 'Users', value: 'Value', + viewing: 'Viewing', viewReadOnly: 'View read-only', welcome: 'Welcome', yes: 'Yes', @@ -531,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 311848771a..8ca8c5b504 100644 --- a/packages/translations/src/languages/es.ts +++ b/packages/translations/src/languages/es.ts @@ -114,6 +114,8 @@ 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.', @@ -190,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}}', @@ -216,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', @@ -232,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', @@ -249,6 +266,7 @@ 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', @@ -269,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', @@ -291,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', @@ -303,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', @@ -338,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', @@ -351,6 +377,9 @@ export const esTranslations: DefaultTranslationsObject = { 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', @@ -362,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', @@ -392,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.', @@ -410,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í', @@ -535,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 1a72d3e422..0a7aaa3991 100644 --- a/packages/translations/src/languages/et.ts +++ b/packages/translations/src/languages/et.ts @@ -113,6 +113,8 @@ 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.', @@ -185,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}}', @@ -211,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', @@ -226,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', @@ -243,6 +259,7 @@ 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', @@ -262,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', @@ -284,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', @@ -296,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', @@ -330,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: '/', @@ -343,6 +368,8 @@ export const etTranslations: DefaultTranslationsObject = { 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', @@ -353,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', @@ -383,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.', @@ -401,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', @@ -523,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 b0c0012eef..12237ecff8 100644 --- a/packages/translations/src/languages/fa.ts +++ b/packages/translations/src/languages/fa.ts @@ -113,6 +113,8 @@ export const faTranslations: DefaultTranslationsObject = { noUser: 'بدون کاربر', previewing: 'مشکلی در پیش‌نمایش این رسانه رخ داد.', problemUploadingFile: 'هنگام بارگذاری سند خطایی رخ داد.', + restoringTitle: + 'هنگام بازیابی {{title}} خطایی رخ داد. لطفا اتصال خود را بررسی کرده و دوباره تلاش کنید.', tokenInvalidOrExpired: 'ژتون شما نامعتبر یا منقضی شده است.', tokenNotProvided: 'توکن ارائه نشده است.', unableToCopy: 'کپی امکان‌پذیر نیست.', @@ -184,6 +186,7 @@ export const faTranslations: DefaultTranslationsObject = { deleteFolder: 'حذف پوشه', folderName: 'نام پوشه', folders: 'پوشه‌ها', + folderTypeDescription: 'انتخاب کنید که کدام نوع اسناد مجموعه باید در این پوشه مجاز باشند.', itemHasBeenMoved: '{{title}} به {{folderName}} منتقل شده است.', itemHasBeenMovedToRoot: '{{title}} به پوشه اصلی انتقال یافته است.', itemsMovedToFolder: '{{title}} به {{folderName}} منتقل شد.', @@ -210,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: 'پوسته پیشخوان', @@ -226,6 +239,7 @@ export const faTranslations: DefaultTranslationsObject = { cancel: 'لغو', changesNotSaved: 'تغییرات شما ذخیره نشده، اگر این برگه را ترک کنید. تمام تغییرات از دست خواهد رفت.', + clear: 'روشن', clearAll: 'همه را پاک کنید', close: 'بستن', collapse: 'بستن', @@ -243,6 +257,7 @@ export const faTranslations: DefaultTranslationsObject = { 'این کار ایندکس‌های موجود را حذف کرده و اسناد را در مجموعه‌های {{collections}} بازایندکس می‌کند.', confirmReindexDescriptionAll: 'این کار ایندکس‌های موجود را حذف کرده و اسناد را در همه مجموعه‌ها بازایندکس می‌کند.', + confirmRestoration: 'تأیید بازیابی', copied: 'رونوشت شده', copy: 'رونوشت', copyField: 'کپی فیلد', @@ -263,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: 'تکراری', @@ -285,6 +304,8 @@ export const faTranslations: DefaultTranslationsObject = { editLabel: 'نگارش {{label}}', email: 'رایانامه', emailAddress: 'نشانی رایانامه', + emptyTrash: 'خالی کردن سطل زباله', + emptyTrashLabel: 'خالی کردن سطل زباله {{label}}', enterAValue: 'یک مقدار وارد کنید', error: 'خطا', errors: 'خطاها', @@ -297,6 +318,7 @@ export const faTranslations: DefaultTranslationsObject = { filterWhere: 'علامت گذاری کردن {{label}} جایی که', globals: 'سراسری', goBack: 'برگشت', + groupByLabel: 'گروه بندی بر اساس {{label}}', import: 'واردات', isEditing: 'در حال ویرایش است', item: 'مورد', @@ -332,6 +354,7 @@ export const faTranslations: DefaultTranslationsObject = { 'هیچ {{label}} یافت نشد. {{label}} یا هنوز وجود ندارد یا هیچ کدام با علامت‌گذاری‌هایی که در بالا مشخص کرده اید مطابقت ندارد.', notFound: 'یافت نشد', nothingFound: 'چیزی یافت نشد', + noTrashResults: 'به زباله‌دان {{label}} موجود نیست.', noUpcomingEventsScheduled: 'هیچ رویدادی در دست نیست.', noValue: 'بدون مقدار', of: 'از', @@ -345,6 +368,8 @@ export const faTranslations: DefaultTranslationsObject = { pasteField: 'چسباندن فیلد', pasteRow: 'چسباندن ردیف', payloadSettings: 'تنظیمات پی‌لود', + permanentlyDelete: 'حذف دائم', + permanentlyDeletedCountSuccessfully: '{{count}} {{label}} با موفقیت حذف همیشگی شد.', perPage: 'هر برگه: {{limit}}', previous: 'قبلی', reindex: 'بازنمایه‌سازی', @@ -355,6 +380,11 @@ export const faTranslations: DefaultTranslationsObject = { resetPreferences: 'بازنشانی تنظیمات', resetPreferencesDescription: 'این تمام تنظیمات شما را به تنظیمات پیش‌فرض بازنشانی خواهد کرد.', resettingPreferences: 'در حال بازنشانی تنظیمات.', + restore: 'بازیابی', + restoreAsPublished: 'بازگردانی به عنوان نسخه منتشر شده', + restoredCountSuccessfully: '{{count}} {{label}} با موفقیت بازیابی شد.', + restoring: + 'درک معنی متن اصلی در زمینه Payload. در اینجا لیستی از اصطلاحات متداول Payload که معانی خاص خاص خود را دارند:\n- مجموعه: مجموعه گروهی از اسناد است که ساختار و هدف مشترکی را به اشتراک می‌گذارند. مجموعه‌ها برای سازماندهی و مدیر', row: 'ردیف', rows: 'ردیف‌ها', save: 'ذخیره', @@ -385,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: 'تغییرات ذخیره نشده ای دارید. قبل از ادامه ذخیره کنید یا رد کنید.', @@ -403,6 +437,7 @@ export const faTranslations: DefaultTranslationsObject = { username: 'نام کاربری', users: 'کاربران', value: 'مقدار', + viewing: 'مشاهده', viewReadOnly: 'فقط برای خواندن مشاهده کنید', welcome: 'خوش‌آمدید', yes: 'بله', @@ -526,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 24ec5fd7b0..1c908c850a 100644 --- a/packages/translations/src/languages/fr.ts +++ b/packages/translations/src/languages/fr.ts @@ -117,6 +117,8 @@ 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.', @@ -192,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}}', @@ -219,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', @@ -235,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', @@ -252,6 +271,7 @@ 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', @@ -272,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', @@ -294,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', @@ -306,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', @@ -341,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', @@ -354,6 +382,8 @@ export const frTranslations: DefaultTranslationsObject = { 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', @@ -365,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', @@ -395,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: @@ -415,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', @@ -543,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 32d5ad7200..dc372ad11d 100644 --- a/packages/translations/src/languages/he.ts +++ b/packages/translations/src/languages/he.ts @@ -111,6 +111,7 @@ export const heTranslations: DefaultTranslationsObject = { noUser: 'אין משתמש', previewing: 'אירעה בעיה בתצוגה מקדימה של מסמך זה.', problemUploadingFile: 'אירעה בעיה בזמן העלאת הקובץ.', + restoringTitle: 'אירעה שגיאה בעת שחזור {{title}}. אנא בדוק את החיבור שלך ונסה שוב.', tokenInvalidOrExpired: 'הטוקן אינו תקין או שפג תוקפו.', tokenNotProvided: 'טוקן לא סופק.', unableToCopy: 'לא ניתן להעתיק.', @@ -181,6 +182,7 @@ export const heTranslations: DefaultTranslationsObject = { deleteFolder: 'מחק תיקייה', folderName: 'שם תיקייה', folders: 'תיקיות', + folderTypeDescription: 'בחר איזה סוג של מסמכים מהאוסף יותרו להיות בתיקייה זו.', itemHasBeenMoved: '"{{title}}" הועבר ל- "{{folderName}}"', itemHasBeenMovedToRoot: '"{{title}}" הועבר לתיקיית השורש', itemsMovedToFolder: '{{title}} הועבר אל {{folderName}}', @@ -206,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: 'ערכת נושא ממשק הניהול', @@ -221,6 +233,8 @@ export const heTranslations: DefaultTranslationsObject = { backToDashboard: 'חזרה ללוח המחוונים', cancel: 'ביטול', changesNotSaved: 'השינויים שלך לא נשמרו. אם תצא כעת, תאבד את השינויים שלך.', + clear: + 'בהתחשב במשמעות של הטקסט המקורי בהקשר של Payload. הנה רשימה של מונחים מקוריים של Payload שנושאים משמעויות מסוימות:\n- אוסף: אוסף הוא קבוצה של מסמכים ששותפים למבנה ולמטרה משות', clearAll: 'נקה הכל', close: 'סגור', collapse: 'כווץ', @@ -237,6 +251,7 @@ export const heTranslations: DefaultTranslationsObject = { confirmReindexDescription: 'זה יסיר את האינדקסים הקיימים ויחזיר אינדקס למסמכים באוספים {{collections}}.', confirmReindexDescriptionAll: 'זה יסיר את האינדקסים הקיימים ויחזיר אינדקס למסמכים בכל האוספים.', + confirmRestoration: 'אשר שחזור', copied: 'הועתק', copy: 'העתק', copyField: 'העתק שדה', @@ -257,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: 'שכפול', @@ -279,6 +298,8 @@ export const heTranslations: DefaultTranslationsObject = { editLabel: 'עריכת {{label}}', email: 'דוא"ל', emailAddress: 'כתובת דוא"ל', + emptyTrash: 'רוקן את הזבל', + emptyTrashLabel: 'רוקן את האשפה {{label}}', enterAValue: 'הזן ערך', error: 'שגיאה', errors: 'שגיאות', @@ -291,6 +312,7 @@ export const heTranslations: DefaultTranslationsObject = { filterWhere: 'סנן {{label}} בהם', globals: 'גלובלים', goBack: 'חזור', + groupByLabel: 'קבץ לפי {{label}}', import: 'יבוא', isEditing: 'עורך', item: 'פריט', @@ -324,6 +346,7 @@ export const heTranslations: DefaultTranslationsObject = { noResults: 'לא נמצאו {{label}}. אין עדיין {{label}}, או שאינם תואמים למסננים שנבחרו.', notFound: 'לא נמצא', nothingFound: 'לא נמצא כלום', + noTrashResults: 'אין {{label}} בפח.', noUpcomingEventsScheduled: 'אין אירועים מתוכנתים בהמשך.', noValue: 'אין ערך', of: 'מתוך', @@ -337,6 +360,8 @@ export const heTranslations: DefaultTranslationsObject = { pasteField: 'הדבק שדה', pasteRow: 'הדבק שורה', payloadSettings: 'הגדרות מערכת Payload', + permanentlyDelete: 'מחק לצמיתות', + permanentlyDeletedCountSuccessfully: 'נמחקו לצמיתות {{count}} {{label}} בהצלחה.', perPage: '{{limit}} בכל עמוד', previous: 'קודם', reindex: 'החזרת אינדקס', @@ -347,6 +372,11 @@ export const heTranslations: DefaultTranslationsObject = { resetPreferences: 'איפוס העדפות', resetPreferencesDescription: 'זאת תאפס את כל ההעדפות שלך להגדרות ברירת המחדל.', resettingPreferences: 'מאפס העדפות.', + restore: 'שחזור', + restoreAsPublished: 'שחזר כגרסה שפורסמה', + restoredCountSuccessfully: 'שוחזרו בהצלחה {{count}} {{label}}.', + restoring: + 'שמעו למשמעות של הטקסט המקורי בהקשר של Payload. הנה רשימה של מונחים נפוצים של Payload שנושאים משמעויות מאוד מסוימות:\n- אוסף: אוסף הוא קבוצה של מסמכים ששותפים למבנה ולמטרה מש', row: 'שורה', rows: 'שורות', save: 'שמירה', @@ -377,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: 'יש לך שינויים שלא נשמרו. שמור או מחק לפני שתמשיך.', @@ -395,6 +429,7 @@ export const heTranslations: DefaultTranslationsObject = { username: 'שם משתמש', users: 'משתמשים', value: 'ערך', + viewing: 'צפיה', viewReadOnly: 'הצג קריאה בלבד', welcome: 'ברוך הבא', yes: 'כן', @@ -513,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 7271e0ac06..0276b3c5fd 100644 --- a/packages/translations/src/languages/hr.ts +++ b/packages/translations/src/languages/hr.ts @@ -115,6 +115,8 @@ 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.', @@ -187,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}}', @@ -213,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', @@ -228,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', @@ -245,6 +261,7 @@ 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', @@ -265,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', @@ -287,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', @@ -299,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', @@ -334,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', @@ -347,6 +372,8 @@ export const hrTranslations: DefaultTranslationsObject = { 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', @@ -357,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', @@ -387,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.', @@ -405,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', @@ -527,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 eac7af8c04..a7d7d37d73 100644 --- a/packages/translations/src/languages/hu.ts +++ b/packages/translations/src/languages/hu.ts @@ -116,6 +116,8 @@ 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.', @@ -188,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', @@ -214,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', @@ -230,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', @@ -247,6 +265,7 @@ 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', @@ -267,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', @@ -289,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', @@ -301,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', @@ -335,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', @@ -348,6 +375,8 @@ export const huTranslations: DefaultTranslationsObject = { 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', @@ -359,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', @@ -389,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.', @@ -407,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', @@ -534,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 925181f244..4b3843940c 100644 --- a/packages/translations/src/languages/hy.ts +++ b/packages/translations/src/languages/hy.ts @@ -114,6 +114,8 @@ export const hyTranslations: DefaultTranslationsObject = { noUser: 'Օգտատեր չկա', previewing: 'Այս փաստաթուղթը նախադիտելու ժամանակ խնդիր է առաջացել։', problemUploadingFile: 'Ֆայլը վերբեռնելու ժամանակ խնդիր է առաջացել։', + restoringTitle: + 'Սխալ է տեղի ունեցել {{title}}-ի վերականգնելիս: Խնդրում ենք ստուգել ձեր կապը և կրկին փորձել:', tokenInvalidOrExpired: 'Թոքենն անվավեր է կամ ժամկետանց։', tokenNotProvided: 'Թոքենը տրամադրված չէ։', unableToCopy: 'Չհաջողվեց պատճենել։', @@ -186,6 +188,8 @@ export const hyTranslations: DefaultTranslationsObject = { deleteFolder: 'Ջնջել թղթապանակը', folderName: 'Տեսակավորման անվանում', folders: 'Պատուհաններ', + folderTypeDescription: + 'Ընտրեք, թե որն է հավաքածուի փաստաթղթերը, որոնք պետք է թույլատրվեն այս պանակում:', itemHasBeenMoved: '{{title}}-ը տեղափոխվել է {{folderName}}-ում', itemHasBeenMovedToRoot: '«{{title}}» տեղափոխվել է արմատային պանակ։', itemsMovedToFolder: '{{title}} տեղափոխվեց {{folderName}}', @@ -212,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: 'Կառավարման թեմա', @@ -228,6 +243,8 @@ export const hyTranslations: DefaultTranslationsObject = { cancel: 'Չեղարկել', changesNotSaved: 'Ձեր փոփոխությունները չեն պահպանվել։ Եթե հիմա հեռանաք, կկորցնեք չպահպանված փոփոխությունները։', + clear: + 'Հիմնական տեքստի իմաստը պետք է պահպանվի Payload կոնտեքստի մեջ: Այս այս այստեղ են հաճախակի', clearAll: 'Մաքրել բոլորը', close: 'Փակել', collapse: 'Փակել', @@ -245,6 +262,7 @@ export const hyTranslations: DefaultTranslationsObject = { 'Սա կհեռացնի գոյություն ունեցող ինդեքսները և կվերաինդեքսավորի փաստաթղթերը {{collections}} հավաքածուներում։', confirmReindexDescriptionAll: 'Սա կհեռացնի գոյություն ունեցող ինդեքսները և կվերաինդեքսավորի փաստաթղթերը բոլոր հավաքածուներում։', + confirmRestoration: 'Հաստատեք վերականգնումը', copied: 'Պատճենված', copy: 'Պատճենել', copyField: 'Պատճենել դաշտը', @@ -265,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: 'Կրկնօրինակել', @@ -287,6 +309,8 @@ export const hyTranslations: DefaultTranslationsObject = { editLabel: 'Խմբագրել {{label}}', email: 'Էլ. փոստ', emailAddress: 'Էլ. փոստի հասցե', + emptyTrash: 'Մաքրել աղբաղեցույցը', + emptyTrashLabel: 'Դատարկել {{label}} աղբուկը', enterAValue: 'Մուտքագրեք արժեք', error: 'Սխալ', errors: 'Սխալներ', @@ -299,6 +323,7 @@ export const hyTranslations: DefaultTranslationsObject = { filterWhere: 'Ֆիլտրել {{label}}-ը, որտեղ', globals: 'Համընդհանուրներ', goBack: 'Հետ գնալ', + groupByLabel: 'Խմբավորել {{label}}-ով', import: 'Ներմուծում', isEditing: 'խմբագրում է', item: 'տարր', @@ -334,6 +359,7 @@ export const hyTranslations: DefaultTranslationsObject = { '{{label}}-ը չի գտնվել։ Կա՛մ դեռևս {{label}} չկա, կա՛մ ոչ մեկը չի համապատասխանում վերևում նշված ֆիլտրերին։', notFound: 'Չի գտնվել', nothingFound: 'Ոչինչ չի գտնվել', + noTrashResults: 'Ոչ մի {{label}} աղբարկղում:', noUpcomingEventsScheduled: 'Իրադարձություններ նախատեսված չեն։', noValue: 'Արժեք չկա', of: 'ի', @@ -347,6 +373,8 @@ export const hyTranslations: DefaultTranslationsObject = { pasteField: 'Տեղադրել դաշտը', pasteRow: 'Տեղադրել տողը', payloadSettings: 'Payload-ի կարգավորումներ', + permanentlyDelete: 'Մշտականությամբ Ջնջել', + permanentlyDeletedCountSuccessfully: '{{count}} {{label}}-ը հաստատապես ջնջվել է հաջողակ:', perPage: 'Էջում՝ {{limit}}', previous: 'Նախորդ', reindex: 'Վերաինդեքսավորել', @@ -358,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: 'Պահպանել', @@ -388,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: @@ -408,6 +445,7 @@ export const hyTranslations: DefaultTranslationsObject = { username: 'Օգտանուն', users: 'Օգտատերեր', value: 'Արժեք', + viewing: 'Դիտում', viewReadOnly: '«Միայն կարդալու» ռեժիմ', welcome: 'Բարի գալուստ', yes: 'Այո', @@ -537,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 55bfdbcd8e..af0c399628 100644 --- a/packages/translations/src/languages/it.ts +++ b/packages/translations/src/languages/it.ts @@ -116,6 +116,8 @@ 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.', @@ -190,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}}', @@ -216,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', @@ -232,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', @@ -249,6 +265,7 @@ 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', @@ -268,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', @@ -290,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', @@ -302,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', @@ -336,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', @@ -349,6 +374,9 @@ export const itTranslations: DefaultTranslationsObject = { 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', @@ -360,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', @@ -390,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.', @@ -408,6 +445,7 @@ export const itTranslations: DefaultTranslationsObject = { username: 'Nome utente', users: 'Utenti', value: 'Valore', + viewing: 'Visualizzazione', viewReadOnly: 'Visualizza solo lettura', welcome: 'Benvenuto', yes: 'Sì', @@ -534,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 51d9284b96..d4cab9756f 100644 --- a/packages/translations/src/languages/ja.ts +++ b/packages/translations/src/languages/ja.ts @@ -115,6 +115,8 @@ export const jaTranslations: DefaultTranslationsObject = { noUser: 'ユーザーなし', previewing: 'このデータをプレビューする際に問題が発生しました。', problemUploadingFile: 'ファイルのアップロード中に問題が発生しました。', + restoringTitle: + '{{title}}の復元中にエラーが発生しました。接続を確認して、もう一度お試しください。', tokenInvalidOrExpired: 'トークンが無効、または、有効期限が切れています。', tokenNotProvided: 'トークンが提供されていません。', unableToCopy: 'コピーできません。', @@ -187,6 +189,8 @@ export const jaTranslations: DefaultTranslationsObject = { deleteFolder: 'フォルダを削除する', folderName: 'フォルダ名', folders: 'フォルダー', + folderTypeDescription: + 'このフォルダーに許可されるコレクションドキュメントのタイプを選択してください。', itemHasBeenMoved: '{{title}}は{{folderName}}に移動されました', itemHasBeenMovedToRoot: '{{title}}はルートフォルダに移動されました', itemsMovedToFolder: '{{title}}は{{folderName}}に移動されました', @@ -213,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: '管理画面のテーマ', @@ -228,6 +245,7 @@ export const jaTranslations: DefaultTranslationsObject = { backToDashboard: 'ダッシュボードに戻る', cancel: 'キャンセル', changesNotSaved: '未保存の変更があります。このまま画面を離れると内容が失われます。', + clear: 'クリア', clearAll: 'すべてクリア', close: '閉じる', collapse: '閉じる', @@ -245,6 +263,7 @@ export const jaTranslations: DefaultTranslationsObject = { 'これにより既存のインデックスが削除され、{{collections}}コレクション内のドキュメントが再インデックスされます。', confirmReindexDescriptionAll: 'これにより既存のインデックスが削除され、すべてのコレクション内のドキュメントが再インデックスされます。', + confirmRestoration: '復元を確認してください', copied: 'コピーしました', copy: 'コピー', copyField: 'フィールドをコピー', @@ -265,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: '複製', @@ -287,6 +310,8 @@ export const jaTranslations: DefaultTranslationsObject = { editLabel: '{{label}} を編集', email: 'メールアドレス', emailAddress: 'メールアドレス', + emptyTrash: 'ゴミ箱を空にする', + emptyTrashLabel: '{{label}}のゴミ箱を空にする', enterAValue: '値を入力', error: 'エラー', errors: 'エラー', @@ -299,6 +324,7 @@ export const jaTranslations: DefaultTranslationsObject = { filterWhere: '{{label}} の絞り込み', globals: 'グローバル', goBack: '戻る', + groupByLabel: '{{label}}でグループ化する', import: '輸入', isEditing: '編集中', item: 'アイテム', @@ -334,6 +360,7 @@ export const jaTranslations: DefaultTranslationsObject = { '{{label}} データが見つかりませんでした。データが存在しない、または、絞り込みに一致するものがありません。', notFound: 'Not Found', nothingFound: 'Nothing found', + noTrashResults: 'ゴミ箱に{{label}}はありません。', noUpcomingEventsScheduled: '予定されているイベントはありません。', noValue: '未設定', of: '/', @@ -347,6 +374,8 @@ export const jaTranslations: DefaultTranslationsObject = { pasteField: 'フィールドを貼り付け', pasteRow: '行を貼り付け', payloadSettings: 'Payload 設定', + permanentlyDelete: '永久に削除する', + permanentlyDeletedCountSuccessfully: '{{count}} {{label}}を正常に完全に削除しました。', perPage: '表示件数: {{limit}}', previous: '前の', reindex: '再インデックス', @@ -357,6 +386,11 @@ export const jaTranslations: DefaultTranslationsObject = { resetPreferences: '設定をリセット', resetPreferencesDescription: 'これにより、すべての設定がデフォルト設定にリセットされます。', resettingPreferences: '設定をリセットしています。', + restore: '復元', + restoreAsPublished: '公開バージョンとして復元する', + restoredCountSuccessfully: '{{count}} {{label}} の復元に成功しました。', + restoring: + '以下はPayloadの文脈での原文の意味を尊重してください。以下に、特定の意味を持つ一般的なPayload用語のリストを示します。\n - コレクション: コレクションは、共通の構造と目的を共有する文書のグループです。コレクションは、Payload内のコンテンツを整理および管理するために使用されます。\n - フィールド: フィールドは、コレクション内の文', row: '列', rows: '列', save: '保存', @@ -387,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: '保存されていない変更があります。続行する前に保存または破棄してください。', @@ -405,6 +443,7 @@ export const jaTranslations: DefaultTranslationsObject = { username: 'ユーザーネーム', users: 'ユーザー', value: '値', + viewing: '閲覧', viewReadOnly: '読み取り専用で表示', welcome: 'ようこそ', yes: 'はい', @@ -528,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 e053968388..0823560246 100644 --- a/packages/translations/src/languages/ko.ts +++ b/packages/translations/src/languages/ko.ts @@ -114,6 +114,8 @@ export const koTranslations: DefaultTranslationsObject = { noUser: '사용자가 없습니다.', previewing: '이 문서를 미리보는 중에 문제가 발생했습니다.', problemUploadingFile: '파일 업로드 중에 문제가 발생했습니다.', + restoringTitle: + '{{title}} 복원 중 오류가 발생했습니다. 연결 상태를 확인하고 다시 시도해 주세요.', tokenInvalidOrExpired: '토큰이 유효하지 않거나 만료되었습니다.', tokenNotProvided: '토큰이 제공되지 않았습니다.', unableToCopy: '복사할 수 없습니다.', @@ -186,6 +188,7 @@ export const koTranslations: DefaultTranslationsObject = { deleteFolder: '폴더 삭제', folderName: '폴더 이름', folders: '폴더들', + folderTypeDescription: '이 폴더에서 어떤 유형의 컬렉션 문서가 허용되어야 하는지 선택하세요.', itemHasBeenMoved: '{{title}}는 {{folderName}}로 이동되었습니다.', itemHasBeenMovedToRoot: '{{title}}이(가) 루트 폴더로 이동되었습니다.', itemsMovedToFolder: '{{title}}이(가) {{folderName}}로 이동되었습니다.', @@ -211,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: '관리자 테마', @@ -226,6 +240,8 @@ export const koTranslations: DefaultTranslationsObject = { backToDashboard: '대시보드로 돌아가기', cancel: '취소', changesNotSaved: '변경 사항이 저장되지 않았습니다. 지금 떠나면 변경 사항을 잃게 됩니다.', + clear: + '페이로드의 맥락 내에서 원문의 의미를 존중하십시오. 다음은 페이로드에서 사용되는 특정 의미를 내포하는 일반적인 페이로드 용어 목록입니다: \n- Collection: 컬렉션은 공통의 구조와 목적을 공유하는 문서의 그룹입니다. 컬렉션은 페이로드에서 콘텐츠를 정리하고 관리하는 데 사용됩니다.\n- Field: 필드는 컬렉', clearAll: '모두 지우기', close: '닫기', collapse: '접기', @@ -243,6 +259,7 @@ export const koTranslations: DefaultTranslationsObject = { '이 작업은 기존 인덱스를 삭제하고 {{collections}} 컬렉션 내의 문서를 다시 인덱싱합니다.', confirmReindexDescriptionAll: '이 작업은 기존 인덱스를 삭제하고 모든 컬렉션 내의 문서를 다시 인덱싱합니다.', + confirmRestoration: '복구를 확인하십시오', copied: '복사됨', copy: '복사', copyField: '필드 복사', @@ -262,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: '복제', @@ -284,6 +305,8 @@ export const koTranslations: DefaultTranslationsObject = { editLabel: '{{label}} 수정', email: '이메일', emailAddress: '이메일 주소', + emptyTrash: '휴지통 비우기', + emptyTrashLabel: '{{label}} 휴지통 비우기', enterAValue: '값을 입력하세요', error: '오류', errors: '오류', @@ -296,6 +319,7 @@ export const koTranslations: DefaultTranslationsObject = { filterWhere: '{{label}} 필터링 조건', globals: '글로벌', goBack: '돌아가기', + groupByLabel: '{{label}}로 그룹화', import: '수입', isEditing: '편집 중', item: '항목', @@ -331,6 +355,7 @@ export const koTranslations: DefaultTranslationsObject = { '{{label}}를 찾을 수 없습니다. 아직 {{label}}이 없거나 설정한 필터와 일치하는 것이 없습니다.', notFound: '찾을 수 없음', nothingFound: '찾을 수 없습니다', + noTrashResults: '휴지통에 {{label}}이 없습니다.', noUpcomingEventsScheduled: '예정된 행사가 없습니다.', noValue: '값 없음', of: '의', @@ -344,6 +369,9 @@ export const koTranslations: DefaultTranslationsObject = { pasteField: '필드 붙여넣기', pasteRow: '행 붙여넣기', payloadSettings: 'Payload 설정', + permanentlyDelete: '영구적으로 삭제', + permanentlyDeletedCountSuccessfully: + '영구적으로 {{count}} {{label}}가 성공적으로 삭제되었습니다.', perPage: '페이지당 개수: {{limit}}', previous: '이전', reindex: '재인덱싱', @@ -354,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: '저장', @@ -384,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: '저장되지 않은 변경 사항이 있습니다. 계속하기 전에 저장하거나 무시하십시오.', @@ -402,6 +439,7 @@ export const koTranslations: DefaultTranslationsObject = { username: '사용자 이름', users: '사용자', value: '값', + viewing: '열람', viewReadOnly: '읽기 전용으로 보기', welcome: '환영합니다', yes: '네', @@ -522,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 0a9b605a10..fc2e26bb13 100644 --- a/packages/translations/src/languages/lt.ts +++ b/packages/translations/src/languages/lt.ts @@ -115,6 +115,8 @@ 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.', @@ -188,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}}', @@ -214,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', @@ -230,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', @@ -247,6 +264,7 @@ 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ą', @@ -267,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', @@ -289,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', @@ -301,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', @@ -336,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', @@ -349,6 +375,8 @@ export const ltTranslations: DefaultTranslationsObject = { 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', @@ -359,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', @@ -389,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.', @@ -407,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', @@ -534,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 e7fb84bb10..44a903d0ed 100644 --- a/packages/translations/src/languages/lv.ts +++ b/packages/translations/src/languages/lv.ts @@ -114,6 +114,8 @@ 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.', @@ -186,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}}', @@ -212,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', @@ -227,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', @@ -244,6 +262,7 @@ 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', @@ -264,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', @@ -286,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', @@ -298,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', @@ -333,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', @@ -346,6 +373,8 @@ export const lvTranslations: DefaultTranslationsObject = { 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', @@ -356,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', @@ -386,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.', @@ -404,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ā', @@ -529,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 c05bf18710..4374f169c0 100644 --- a/packages/translations/src/languages/my.ts +++ b/packages/translations/src/languages/my.ts @@ -114,6 +114,8 @@ export const myTranslations: DefaultTranslationsObject = { noUser: 'အသုံးပြုသူ မရှိပါ။', previewing: 'ဖိုင်ကို အစမ်းကြည့်ရန် ပြဿနာရှိနေသည်။', problemUploadingFile: 'ဖိုင်ကို အပ်လုဒ်တင်ရာတွင် ပြဿနာရှိနေသည်။', + restoringTitle: + 'Terdapat ralat semasa memulihkan {{title}}. Sila semak sambungan anda dan cuba lagi.', tokenInvalidOrExpired: 'တိုကင်သည် မမှန်ကန်ပါ သို့မဟုတ် သက်တမ်းကုန်သွားပါပြီ။', tokenNotProvided: 'Token မပေးထားပါ။', unableToCopy: 'ကူးရန်မဖြစ်နိုင်ပါ။', @@ -187,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}} သို့ ရွှေ့လိုက်သွားပါပယ်', @@ -214,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: 'အက်ပ်ဒိုင်များစပ်စွာ', @@ -230,6 +245,7 @@ export const myTranslations: DefaultTranslationsObject = { cancel: 'မလုပ်တော့ပါ။', changesNotSaved: 'သင်၏ပြောင်းလဲမှုများကို မသိမ်းဆည်းရသေးပါ။ ယခု စာမျက်နှာက ထွက်လိုက်ပါက သင်၏ပြောင်းလဲမှုများ အကုန် ဆုံးရှုံးသွားပါမည်။ အကုန်နော်။', + clear: 'Jelas', clearAll: 'အားလုံးကိုရှင်းလင်းပါ', close: 'ပိတ်', collapse: 'ခေါက်သိမ်းပါ။', @@ -247,6 +263,7 @@ export const myTranslations: DefaultTranslationsObject = { 'ဤသည်သည် ရှိပြီးသား အညွှန်းများကို ဖျက်ပစ်ပြီး {{collections}} ကော်လက်ရှင်းများတွင် စာရွက်များကို ထပ်လိပ်ပါလိမ့်မည်။', confirmReindexDescriptionAll: 'ဤသည်သည် ရှိပြီးသား အညွှန်းများကို ဖျက်ပစ်ပြီး အားလုံးသော ကော်လက်ရှင်းများတွင် စာရွက်များကို ထပ်လိပ်ပါလိမ့်မည်။', + confirmRestoration: 'Sahkan pemulihan', copied: 'ကူးယူပြီးပြီ။', copy: 'ကူးယူမည်။', copyField: 'ကွက်လပ်ကိုကူးပါ', @@ -267,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: 'ပုံတူပွားမည်။', @@ -289,6 +310,8 @@ export const myTranslations: DefaultTranslationsObject = { editLabel: '{{label}} ပြင်ဆင်မည်။', email: 'အီးမေးလ်', emailAddress: 'အီးမေးလ် လိပ်စာ', + emptyTrash: 'Kosongkan tong sampah', + emptyTrashLabel: 'Kosongkan {{label}} sampah', enterAValue: 'တန်ဖိုးတစ်ခုထည့်ပါ။', error: 'အမှား', errors: 'အမှားများ', @@ -301,6 +324,7 @@ export const myTranslations: DefaultTranslationsObject = { filterWhere: 'နေရာတွင် စစ်ထုတ်ပါ။', globals: 'Globals', goBack: 'နောက်သို့', + groupByLabel: 'Berkumpulkan mengikut {{label}}', import: 'သွင်းကုန်', isEditing: 'ပြင်ဆင်နေသည်', item: 'barang', @@ -336,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: '၏', @@ -349,6 +374,9 @@ export const myTranslations: DefaultTranslationsObject = { pasteField: 'ကွက်လပ်ကိုတင်ပါ', pasteRow: 'တန်းကိုတင်ပါ', payloadSettings: 'ရွေးချယ်စရာများ', + permanentlyDelete: 'Padam Selamanya', + permanentlyDeletedCountSuccessfully: + '{{count}} {{label}} telah berjaya dipadamkan secara kekal.', perPage: 'စာမျက်နှာ အလိုက်: {{limit}}', previous: 'ယခင်', reindex: 'ပြန်လည်အညွှန်းပြုလုပ်ပါ', @@ -360,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: 'သိမ်းဆည်းမည်။', @@ -390,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: @@ -410,6 +447,7 @@ export const myTranslations: DefaultTranslationsObject = { username: 'Nama pengguna', users: 'အသုံးပြုသူများ', value: 'တန်ဖိုး', + viewing: 'Melihat', viewReadOnly: 'ဖတ်ရှုရန်သာကြည့်ပါ', welcome: 'ကြိုဆိုပါတယ်။', yes: 'ဟုတ်ကဲ့', @@ -538,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 90c796312c..4c2ef03204 100644 --- a/packages/translations/src/languages/nb.ts +++ b/packages/translations/src/languages/nb.ts @@ -114,6 +114,8 @@ 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.', @@ -186,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}}', @@ -212,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', @@ -228,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', @@ -245,6 +260,7 @@ 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', @@ -265,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', @@ -287,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', @@ -299,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', @@ -334,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', @@ -347,6 +371,8 @@ export const nbTranslations: DefaultTranslationsObject = { 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', @@ -358,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', @@ -388,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.', @@ -406,6 +441,7 @@ export const nbTranslations: DefaultTranslationsObject = { username: 'Brukernavn', users: 'Brukere', value: 'Verdi', + viewing: 'Visning', viewReadOnly: 'Vis skrivebeskyttet', welcome: 'Velkommen', yes: 'Ja', @@ -531,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 316cc69508..424d6dba4f 100644 --- a/packages/translations/src/languages/nl.ts +++ b/packages/translations/src/languages/nl.ts @@ -115,6 +115,8 @@ 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.', @@ -188,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}}', @@ -215,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', @@ -231,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', @@ -248,6 +268,7 @@ 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', @@ -268,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', @@ -290,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', @@ -302,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', @@ -337,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', @@ -350,6 +379,8 @@ export const nlTranslations: DefaultTranslationsObject = { 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', @@ -361,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', @@ -391,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.', @@ -409,6 +449,7 @@ export const nlTranslations: DefaultTranslationsObject = { username: 'Gebruikersnaam', users: 'Gebruikers', value: 'Waarde', + viewing: 'Bekijken', viewReadOnly: 'Alleen-lezen weergave', welcome: 'Welkom', yes: 'Ja', @@ -535,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 2c12ba691b..ee59807154 100644 --- a/packages/translations/src/languages/pl.ts +++ b/packages/translations/src/languages/pl.ts @@ -113,6 +113,8 @@ 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ć.', @@ -185,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}}', @@ -211,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', @@ -227,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ń', @@ -244,6 +260,7 @@ 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', @@ -264,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', @@ -286,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', @@ -298,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', @@ -333,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', @@ -346,6 +371,8 @@ export const plTranslations: DefaultTranslationsObject = { 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', @@ -356,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', @@ -386,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.', @@ -404,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', @@ -529,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 8b8f95fa9a..ca01657d8d 100644 --- a/packages/translations/src/languages/pt.ts +++ b/packages/translations/src/languages/pt.ts @@ -115,6 +115,8 @@ 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.', @@ -186,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}}', @@ -212,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', @@ -228,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', @@ -245,6 +262,7 @@ 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', @@ -265,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', @@ -287,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', @@ -299,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', @@ -334,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', @@ -347,6 +373,8 @@ export const ptTranslations: DefaultTranslationsObject = { 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', @@ -358,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', @@ -388,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.', @@ -406,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', @@ -531,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 38825ec119..9b559551bf 100644 --- a/packages/translations/src/languages/ro.ts +++ b/packages/translations/src/languages/ro.ts @@ -116,6 +116,8 @@ 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.', @@ -190,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}}', @@ -216,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', @@ -232,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', @@ -249,6 +266,7 @@ 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', @@ -269,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', @@ -291,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', @@ -303,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', @@ -338,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', @@ -351,6 +377,8 @@ export const roTranslations: DefaultTranslationsObject = { 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', @@ -361,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ă', @@ -391,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.', @@ -409,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', @@ -538,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 78803c8e58..449cf51b8c 100644 --- a/packages/translations/src/languages/rs.ts +++ b/packages/translations/src/languages/rs.ts @@ -115,6 +115,8 @@ 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: 'Није могуће копирати.', @@ -187,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}}', @@ -213,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: 'Администраторска тема', @@ -228,6 +244,7 @@ export const rsTranslations: DefaultTranslationsObject = { backToDashboard: 'Назад на контролни панел', cancel: 'Откажи', changesNotSaved: 'Ваше промене нису сачуване. Ако изађете сада, изгубићете промене.', + clear: 'Jasno', clearAll: 'Obriši sve', close: 'Затвори', collapse: 'Скупи', @@ -245,6 +262,7 @@ 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: 'Копирај поље', @@ -265,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: 'Дупликат', @@ -287,6 +309,8 @@ export const rsTranslations: DefaultTranslationsObject = { editLabel: 'Уреди {{label}}', email: 'Е-пошта', emailAddress: 'Адреса е-поште', + emptyTrash: 'Isprazni korpu', + emptyTrashLabel: 'Isprazni {{label}} korpu za smeće', enterAValue: 'Унеси вредност', error: 'Грешка', errors: 'Грешке', @@ -299,6 +323,7 @@ export const rsTranslations: DefaultTranslationsObject = { filterWhere: 'Филтер {{label}} где', globals: 'Глобали', goBack: 'Врати се', + groupByLabel: 'Grupiši po {{label}}', import: 'Uvoz', isEditing: 'уређује', item: 'artikal', @@ -334,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: 'Од', @@ -347,6 +373,8 @@ export const rsTranslations: DefaultTranslationsObject = { pasteField: 'Залепи поље', pasteRow: 'Залепи ред', payloadSettings: 'Payload поставке', + permanentlyDelete: 'Trajno Izbriši', + permanentlyDeletedCountSuccessfully: 'Trajno obrisano {{count}} {{label}} uspešno.', perPage: 'По страници: {{limit}}', previous: 'Prethodni', reindex: 'Реиндексирај', @@ -357,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: 'Сачувај', @@ -387,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.', @@ -405,6 +441,7 @@ export const rsTranslations: DefaultTranslationsObject = { username: 'Korisničko ime', users: 'Корисници', value: 'Вредност', + viewing: 'Pregled', viewReadOnly: 'Прегледај само за читање', welcome: 'Добродошли', yes: 'Да', @@ -526,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 31c321059b..4d0b9ca22a 100644 --- a/packages/translations/src/languages/rsLatin.ts +++ b/packages/translations/src/languages/rsLatin.ts @@ -115,6 +115,8 @@ 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.', @@ -187,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}}', @@ -213,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', @@ -228,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', @@ -245,6 +262,7 @@ 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', @@ -265,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', @@ -287,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', @@ -299,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', @@ -334,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', @@ -347,6 +373,8 @@ export const rsLatinTranslations: DefaultTranslationsObject = { 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', @@ -358,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', @@ -388,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.', @@ -406,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', @@ -521,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 e881a1c1d4..a50df7f591 100644 --- a/packages/translations/src/languages/ru.ts +++ b/packages/translations/src/languages/ru.ts @@ -115,6 +115,8 @@ export const ruTranslations: DefaultTranslationsObject = { noUser: 'Нет Пользователя', previewing: 'При предварительном просмотре этого документа возникла проблема.', problemUploadingFile: 'Возникла проблема при загрузке файла.', + restoringTitle: + 'Произошла ошибка при восстановлении {{title}}. Пожалуйста, проверьте свое соединение и попробуйте снова.', tokenInvalidOrExpired: 'Токен либо недействителен, либо срок его действия истек.', tokenNotProvided: 'Токен не предоставлен.', unableToCopy: 'Не удалось скопировать.', @@ -188,6 +190,8 @@ export const ruTranslations: DefaultTranslationsObject = { deleteFolder: 'Удалить папку', folderName: 'Название папки', folders: 'Папки', + folderTypeDescription: + 'Выберите, какие типы документов коллекции должны быть разрешены в этой папке.', itemHasBeenMoved: '{{title}} был перемещен в {{folderName}}', itemHasBeenMovedToRoot: '{{title}} был перемещен в корневую папку', itemsMovedToFolder: '{{title}} перемещен в {{folderName}}', @@ -214,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: 'Тема Панели', @@ -230,6 +245,7 @@ export const ruTranslations: DefaultTranslationsObject = { cancel: 'Отмена', changesNotSaved: 'Ваши изменения не были сохранены. Если вы сейчас уйдете, то потеряете свои изменения.', + clear: 'Четкий', clearAll: 'Очистить все', close: 'Закрыть', collapse: 'Свернуть', @@ -247,6 +263,7 @@ export const ruTranslations: DefaultTranslationsObject = { 'Это удалит существующие индексы и переиндексирует документы в коллекциях {{collections}}.', confirmReindexDescriptionAll: 'Это удалит существующие индексы и переиндексирует документы во всех коллекциях.', + confirmRestoration: 'Подтвердите восстановление', copied: 'Скопировано', copy: 'Скопировать', copyField: 'Копировать поле', @@ -267,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: 'Дублировать', @@ -289,6 +310,8 @@ export const ruTranslations: DefaultTranslationsObject = { editLabel: 'Редактировать {{label}}', email: 'Email', emailAddress: 'Email', + emptyTrash: 'Очистить корзину', + emptyTrashLabel: 'Очистить корзину для {{label}}', enterAValue: 'Введите значение', error: 'Ошибка', errors: 'Ошибки', @@ -301,6 +324,7 @@ export const ruTranslations: DefaultTranslationsObject = { filterWhere: 'Где фильтровать', globals: 'Глобальные', goBack: 'Назад', + groupByLabel: 'Группировать по {{label}}', import: 'Импорт', isEditing: 'редактирует', item: 'предмет', @@ -336,6 +360,7 @@ export const ruTranslations: DefaultTranslationsObject = { 'Ничего не найдено. Возможно, {{label}} еще не существует или не соответствует указанным фильтрам.', notFound: 'Не найдено', nothingFound: 'Ничего не найдено', + noTrashResults: 'Нет {{label}} в корзине.', noUpcomingEventsScheduled: 'Нет запланированных предстоящих событий.', noValue: 'Нет значения', of: 'из', @@ -349,6 +374,8 @@ export const ruTranslations: DefaultTranslationsObject = { pasteField: 'Вставить поле', pasteRow: 'Вставить строку', payloadSettings: 'Настройки Payload', + permanentlyDelete: 'Удалить Навсегда', + permanentlyDeletedCountSuccessfully: 'Успешно удалено {{count}} {{label}} навсегда.', perPage: 'На странице: {{limit}}', previous: 'Предыдущий', reindex: 'Переиндексировать', @@ -359,6 +386,10 @@ export const ruTranslations: DefaultTranslationsObject = { resetPreferences: 'Сбросить настройки', resetPreferencesDescription: 'Это сбросит все ваши настройки до значений по умолчанию.', resettingPreferences: 'Сброс настроек.', + restore: 'Восстановить', + restoreAsPublished: 'Восстановить как опубликованную версию', + restoredCountSuccessfully: 'Восстановлено успешно {{count}} {{label}}.', + restoring: 'Восстановление...', row: 'Строка', rows: 'Строки', save: 'Сохранить', @@ -389,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: @@ -409,6 +444,7 @@ export const ruTranslations: DefaultTranslationsObject = { username: 'Имя пользователя', users: 'пользователи', value: 'Значение', + viewing: 'Просмотр', viewReadOnly: 'Просмотр только для чтения', welcome: 'Добро пожаловать', yes: 'Да', @@ -533,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 44713ae7fe..43fbe8b04f 100644 --- a/packages/translations/src/languages/sk.ts +++ b/packages/translations/src/languages/sk.ts @@ -115,6 +115,8 @@ 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é.', @@ -189,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}}', @@ -215,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', @@ -230,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ť', @@ -247,6 +263,7 @@ 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', @@ -266,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ť', @@ -288,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', @@ -300,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', @@ -334,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', @@ -347,6 +372,8 @@ export const skTranslations: DefaultTranslationsObject = { 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ť', @@ -357,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ť', @@ -387,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.', @@ -405,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', @@ -529,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 45954b2d29..5ce531d84a 100644 --- a/packages/translations/src/languages/sl.ts +++ b/packages/translations/src/languages/sl.ts @@ -114,6 +114,8 @@ 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.', @@ -186,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}}', @@ -212,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', @@ -228,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', @@ -245,6 +261,7 @@ 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', @@ -264,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', @@ -286,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', @@ -298,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', @@ -333,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', @@ -346,6 +371,8 @@ export const slTranslations: DefaultTranslationsObject = { 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', @@ -356,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', @@ -386,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', @@ -404,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', @@ -527,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 3a9b1e12d2..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: @@ -114,6 +114,8 @@ 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.', @@ -186,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}}', @@ -212,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.', @@ -228,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', @@ -245,6 +261,7 @@ 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', @@ -265,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', @@ -287,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', @@ -299,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', @@ -308,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', @@ -334,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', @@ -341,12 +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', pasteField: 'Klistra in fält', pasteRow: 'Klistra in rad', - payloadSettings: 'Programinställningar', + payloadSettings: 'Systeminställningar', + permanentlyDelete: 'Radera Permanent', + permanentlyDeletedCountSuccessfully: '{{count}} {{label}} har raderats permanent.', perPage: 'Per Sida: {{limit}}', previous: 'Föregående', reindex: 'Omindexera', @@ -358,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', @@ -371,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', @@ -388,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.', @@ -406,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', }, @@ -530,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 8d53deae75..da336bc15c 100644 --- a/packages/translations/src/languages/th.ts +++ b/packages/translations/src/languages/th.ts @@ -112,6 +112,8 @@ export const thTranslations: DefaultTranslationsObject = { noUser: 'ไม่พบผู้ใช้', previewing: 'เกิดปัญหาระหว่างการแสดงตัวอย่างเอกสาร', problemUploadingFile: 'เกิดปัญหาระหว่างการอัปโหลดไฟล์', + restoringTitle: + 'เกิดข้อผิดพลาดขณะกำลังคืนค่า {{title}} กรุณาตรวจสอบการเชื่อมต่อของคุณและลองอีกครั้ง', tokenInvalidOrExpired: 'Token ไม่ถูกต้องหรือหมดอายุ', tokenNotProvided: 'ไม่ได้รับโทเค็น', unableToCopy: 'ไม่สามารถคัดลอกได้', @@ -183,6 +185,7 @@ export const thTranslations: DefaultTranslationsObject = { deleteFolder: 'ลบโฟลเดอร์', folderName: 'ชื่อโฟลเดอร์', folders: 'โฟลเดอร์', + folderTypeDescription: 'เลือกประเภทของเอกสารคอลเลกชันที่ควรอนุญาตในโฟลเดอร์นี้', itemHasBeenMoved: '{{title}} ได้ถูกย้ายไปที่ {{folderName}}', itemHasBeenMovedToRoot: '"{{title}}" ได้ถูกย้ายไปยังโฟลเดอร์ราก', itemsMovedToFolder: '{{title}} ถูกย้ายไปยัง {{folderName}}', @@ -208,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: 'ธีมผู้ดูแลระบบ', @@ -223,6 +235,8 @@ export const thTranslations: DefaultTranslationsObject = { backToDashboard: 'กลับไปหน้าแดชบอร์ด', cancel: 'ยกเลิก', changesNotSaved: 'การเปลี่ยนแปลงยังไม่ได้ถูกบันทึก ถ้าคุณออกตอนนี้ สิ่งที่แก้ไขไว้จะหายไป', + clear: + 'ให้เคารพความหมายของข้อความต้นฉบับภายในบริบทของ Payload นี่คือรายการของคำที่มักใช้ใน Payload ที่มีความหมายที่เฉพาะเจาะจงมาก:\n - Collection: Collection คือกลุ่มของเอกสารที่มีโครงสร้างและจุดประสงค์ท', clearAll: 'ล้างทั้งหมด', close: 'ปิด', collapse: 'ยุบ', @@ -240,6 +254,7 @@ export const thTranslations: DefaultTranslationsObject = { 'การดำเนินการนี้จะลบดัชนีที่มีอยู่และทำการจัดทำดัชนีใหม่ในเอกสารของคอลเลกชัน {{collections}}.', confirmReindexDescriptionAll: 'การดำเนินการนี้จะลบดัชนีที่มีอยู่และทำการจัดทำดัชนีใหม่ในเอกสารของทุกคอลเลกชัน.', + confirmRestoration: 'ยืนยันการคืนค่าให้ครบถ้วน', copied: 'คัดลอกแล้ว', copy: 'คัดลอก', copyField: 'คัดลอกฟิลด์', @@ -260,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: 'สำเนา', @@ -282,6 +301,8 @@ export const thTranslations: DefaultTranslationsObject = { editLabel: 'แก้ไข {{label}}', email: 'อีเมล', emailAddress: 'อีเมล', + emptyTrash: 'ลบถังขยะ', + emptyTrashLabel: 'ลบ {{label}} ที่อยู่ในถังขยะ', enterAValue: 'ระบุค่า', error: 'ข้อผิดพลาด', errors: 'ข้อผิดพลาด', @@ -294,6 +315,7 @@ export const thTranslations: DefaultTranslationsObject = { filterWhere: 'กรอง {{label}} เฉพาะ', globals: 'Globals', goBack: 'กลับไป', + groupByLabel: 'จัดกลุ่มตาม {{label}}', import: 'นำเข้า', isEditing: 'กำลังแก้ไข', item: 'รายการ', @@ -328,6 +350,7 @@ export const thTranslations: DefaultTranslationsObject = { 'ไม่พบ {{label}} เนื่องจากยังไม่มี {{label}} หรือไม่มี {{label}} ใดตรงกับการกรองด้านบน', notFound: 'ไม่พบ', nothingFound: 'ไม่พบสิ่งใด', + noTrashResults: 'ไม่มี {{label}} ในถังขยะ.', noUpcomingEventsScheduled: 'ไม่มีกิจกรรมที่จะมาถึงถูกกำหนดไว้', noValue: 'ไม่มีค่า', of: 'จาก', @@ -341,6 +364,8 @@ export const thTranslations: DefaultTranslationsObject = { pasteField: 'วางฟิลด์', pasteRow: 'วางแถว', payloadSettings: 'การตั้งค่า Payload', + permanentlyDelete: 'ลบถาวร', + permanentlyDeletedCountSuccessfully: 'ลบ {{label}} {{count}} รายการอย่างถาวรสำเร็จแล้ว', perPage: 'จำนวนต่อหน้า: {{limit}}', previous: 'ก่อนหน้านี้', reindex: 'จัดทำดัชนีใหม่', @@ -351,6 +376,11 @@ export const thTranslations: DefaultTranslationsObject = { resetPreferences: 'รีเซ็ตการตั้งค่า', resetPreferencesDescription: 'การกระทำนี้จะรีเซ็ตการตั้งค่าทั้งหมดของคุณเป็นค่าเริ่มต้น', resettingPreferences: 'กำลังรีเซ็ตการตั้งค่า', + restore: 'กู้คืน', + restoreAsPublished: 'เรียกคืนเป็นเวอร์ชันที่เผยแพร่', + restoredCountSuccessfully: 'ได้ทำการกู้คืน {{count}} {{label}} สำเร็จแล้ว', + restoring: + 'สนับสนุนความหมายของข้อความต้นฉบับในบริบทของ Payload นี่คือรายการของคำที่เกี่ยวข้องกับ Payload ที่มีความหมายเฉพาะเจาะจง:\n - Collection: Collection เป็นกลุ่มของเอกสารที่มีโครงสร้างและจุดประสงค์ที่เหมือน', row: 'แถว', rows: 'แถว', save: 'บันทึก', @@ -381,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: 'คุณมีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก บันทึกหรือทิ้งก่อนที่จะดำเนินการต่อ', @@ -399,6 +433,7 @@ export const thTranslations: DefaultTranslationsObject = { username: 'ชื่อผู้ใช้', users: 'ผู้ใช้', value: 'ค่า', + viewing: 'การดู', viewReadOnly: 'ดูในโหมดอ่านอย่างเดียว', welcome: 'ยินดีต้อนรับ', yes: 'ใช่', @@ -519,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 1630721cf9..b3bb2e6923 100644 --- a/packages/translations/src/languages/tr.ts +++ b/packages/translations/src/languages/tr.ts @@ -115,6 +115,8 @@ 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.', @@ -188,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ı.", @@ -215,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ü', @@ -231,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', @@ -248,6 +264,7 @@ 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', @@ -268,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', @@ -290,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', @@ -302,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', @@ -337,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', @@ -350,6 +375,8 @@ export const trTranslations: DefaultTranslationsObject = { 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', @@ -361,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', @@ -391,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.', @@ -410,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', @@ -532,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 e76e29e6be..677f085495 100644 --- a/packages/translations/src/languages/uk.ts +++ b/packages/translations/src/languages/uk.ts @@ -115,6 +115,8 @@ export const ukTranslations: DefaultTranslationsObject = { noUser: 'Немає користувача', previewing: 'Виникла помилка під час попереднього перегляду цього документа.', problemUploadingFile: 'Виникла помилка під час завантаження файлу.', + restoringTitle: + "Виникла помилка при відновленні {{title}}. Будь ласка, перевірте своє з'єднання і спробуйте ще раз.", tokenInvalidOrExpired: 'Токен недійсний, або його строк дії закінчився.', tokenNotProvided: 'Токен не надано.', unableToCopy: 'Неможливо скопіювати.', @@ -187,6 +189,8 @@ export const ukTranslations: DefaultTranslationsObject = { deleteFolder: 'Видалити папку', folderName: 'Назва папки', folders: 'Папки', + folderTypeDescription: + 'Виберіть, який тип документів колекції повинен бути дозволений у цій папці.', itemHasBeenMoved: '{{title}} було переміщено до {{folderName}}', itemHasBeenMovedToRoot: '{{title}} був переміщений до кореневої папки', itemsMovedToFolder: '{{title}} перенесено до {{folderName}}', @@ -213,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: 'Тема адмін панелі', @@ -228,6 +243,7 @@ export const ukTranslations: DefaultTranslationsObject = { backToDashboard: 'Повернутись до головної сторінки', cancel: 'Скасувати', changesNotSaved: 'Ваши зміни не були збережені. Якщо ви вийдете зараз, то втратите свої зміни.', + clear: 'Чітко', clearAll: 'Очистити все', close: 'Закрити', collapse: 'Згорнути', @@ -245,6 +261,7 @@ export const ukTranslations: DefaultTranslationsObject = { 'Це видалить наявні індекси та перебудує індекси документів у колекціях {{collections}}.', confirmReindexDescriptionAll: 'Це видалить наявні індекси та перебудує індекси документів у всіх колекціях.', + confirmRestoration: 'Підтвердіть відновлення', copied: 'Скопійовано', copy: 'Скопіювати', copyField: 'Копіювати поле', @@ -264,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: 'Дублювати', @@ -286,6 +307,8 @@ export const ukTranslations: DefaultTranslationsObject = { editLabel: 'Редагувати {{label}}', email: 'Електронна пошта', emailAddress: 'Адреса електронної пошти', + emptyTrash: 'Спорожнити кошик', + emptyTrashLabel: 'Спорожнити кошик для {{label}}', enterAValue: 'Введіть значення', error: 'Помилка', errors: 'Помилки', @@ -298,6 +321,7 @@ export const ukTranslations: DefaultTranslationsObject = { filterWhere: 'Де фільтрувати {{label}}', globals: 'Глобальні', goBack: 'Повернутися', + groupByLabel: 'Групувати за {{label}}', import: 'Імпорт', isEditing: 'редагує', item: 'предмет', @@ -333,6 +357,7 @@ export const ukTranslations: DefaultTranslationsObject = { 'Жодного {{label}} не знайдено. Або {{label}} ще не існує, або жодна з них не відповідає фільтрам, що ви задали више.', notFound: 'Не знайдено', nothingFound: 'Нічого не знайдено', + noTrashResults: 'Немає {{label}} у смітнику.', noUpcomingEventsScheduled: 'Не заплановано жодних майбутніх подій.', noValue: 'Немає значення', of: 'з', @@ -346,6 +371,8 @@ export const ukTranslations: DefaultTranslationsObject = { pasteField: 'Вставити поле', pasteRow: 'Вставити рядок', payloadSettings: 'Налаштування Payload', + permanentlyDelete: 'Назавжди видалити', + permanentlyDeletedCountSuccessfully: 'Успішно видалено назавжди {{count}} {{label}}.', perPage: 'На сторінці: {{limit}}', previous: 'Попередній', reindex: 'Повторне індексування', @@ -356,6 +383,11 @@ export const ukTranslations: DefaultTranslationsObject = { resetPreferences: 'Скинути налаштування', resetPreferencesDescription: 'Це скине всі ваші налаштування до значень за замовчуванням.', resettingPreferences: 'Скидання налаштувань.', + restore: 'Відновити', + restoreAsPublished: 'Відновити як опубліковану версію', + restoredCountSuccessfully: 'Відновлено {{count}} {{label}} успішно.', + restoring: + 'Поважайте сенс оригінального тексту в контексті Payload. Ось список поширених термінів Payload, які мають дуже специфічні значення:\n - Колекція: Колекцією є група документів, які мають спільну структуру та сенс. Колекції використовуються для організації й керування контент', row: 'Рядок', rows: 'Рядки', save: 'Зберегти', @@ -386,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: 'У вас є незбережені зміни. Збережіть або скасуйте перед продовженням.', @@ -404,6 +440,7 @@ export const ukTranslations: DefaultTranslationsObject = { username: "Ім'я користувача", users: 'Користувачі', value: 'Значення', + viewing: 'Перегляд', viewReadOnly: 'Перегляд тільки для читання', welcome: 'Вітаю', yes: 'Так', @@ -528,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 ae280b4fc0..c0842cf49b 100644 --- a/packages/translations/src/languages/vi.ts +++ b/packages/translations/src/languages/vi.ts @@ -114,6 +114,8 @@ 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.', @@ -186,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}}', @@ -212,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', @@ -227,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', @@ -244,6 +260,7 @@ 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', @@ -264,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', @@ -286,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', @@ -298,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', @@ -333,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ố', @@ -346,6 +371,8 @@ export const viTranslations: DefaultTranslationsObject = { 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', @@ -356,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', @@ -386,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.', @@ -404,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', @@ -525,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 296612d0e1..2ea4fe9e3d 100644 --- a/packages/translations/src/languages/zh.ts +++ b/packages/translations/src/languages/zh.ts @@ -109,6 +109,7 @@ export const zhTranslations: DefaultTranslationsObject = { noUser: '没有该用户', previewing: '预览文档时出现了问题。', problemUploadingFile: '上传文件时出现了问题。', + restoringTitle: '恢复{{title}}时出现错误。请检查您的连接并再试一次。', tokenInvalidOrExpired: '令牌无效或已过期。', tokenNotProvided: '未提供令牌。', unableToCopy: '无法复制。', @@ -179,6 +180,7 @@ export const zhTranslations: DefaultTranslationsObject = { deleteFolder: '删除文件夹', folderName: '文件夹名称', folders: '文件夹', + folderTypeDescription: '在此文件夹中选择应允许哪种类型的集合文档。', itemHasBeenMoved: '{{title}}已被移至{{folderName}}', itemHasBeenMovedToRoot: '{{title}}已被移至根文件夹', itemsMovedToFolder: '{{title}}已移至{{folderName}}', @@ -202,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: '管理页面主题', @@ -217,6 +228,7 @@ export const zhTranslations: DefaultTranslationsObject = { backToDashboard: '返回到仪表板', cancel: '取消', changesNotSaved: '您的更改尚未保存。您确定要离开吗?', + clear: '清晰', clearAll: '清除全部', close: '关闭', collapse: '折叠', @@ -232,6 +244,7 @@ export const zhTranslations: DefaultTranslationsObject = { confirmReindexAll: '重新索引所有集合?', confirmReindexDescription: '此操作将删除现有索引,并重新索引{{collections}}集合中的文档。', confirmReindexDescriptionAll: '此操作将删除现有索引,并重新索引所有集合中的文档。', + confirmRestoration: '确认恢复', copied: '已复制', copy: '复制', copyField: '复制字段', @@ -251,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: '复制', @@ -273,6 +290,8 @@ export const zhTranslations: DefaultTranslationsObject = { editLabel: '编辑{{label}}', email: '电子邮件', emailAddress: '电子邮件地址', + emptyTrash: '清空垃圾桶', + emptyTrashLabel: '清空 {{label}} 垃圾箱', enterAValue: '输入一个值', error: '错误', errors: '错误', @@ -285,6 +304,7 @@ export const zhTranslations: DefaultTranslationsObject = { filterWhere: '过滤{{label}}', globals: '全局', goBack: '返回', + groupByLabel: '按{{label}}分组', import: '导入', isEditing: '正在编辑', item: '条目', @@ -318,6 +338,7 @@ export const zhTranslations: DefaultTranslationsObject = { noResults: '没有找到{{label}}。{{label}}并不存在或没有符合您上面所指定的过滤条件。', notFound: '未找到', nothingFound: '没有找到任何东西', + noTrashResults: '回收站中没有 {{label}}。', noUpcomingEventsScheduled: '没有即将进行的活动计划。', noValue: '没有值', of: '共', @@ -331,6 +352,8 @@ export const zhTranslations: DefaultTranslationsObject = { pasteField: '粘贴字段', pasteRow: '粘贴行', payloadSettings: 'Payload设置', + permanentlyDelete: '永久删除', + permanentlyDeletedCountSuccessfully: '已成功永久删除 {{count}} {{label}}。', perPage: '每一页: {{limit}}', previous: '前一个', reindex: '重新索引', @@ -341,6 +364,10 @@ export const zhTranslations: DefaultTranslationsObject = { resetPreferences: '重置偏好设置', resetPreferencesDescription: '这将把您的所有偏好设置恢复为默认值。', resettingPreferences: '正在重置偏好设置。', + restore: '恢复', + restoreAsPublished: '恢复为已发布版本', + restoredCountSuccessfully: '成功恢复了{{count}} {{label}}。', + restoring: '恢复中...', row: '行', rows: '行', save: '保存', @@ -371,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: '您有未保存的更改。请在继续之前保存或放弃。', @@ -389,6 +420,7 @@ export const zhTranslations: DefaultTranslationsObject = { username: '用户名', users: '用户', value: '值', + viewing: '查看', viewReadOnly: '只读查看', welcome: '欢迎', yes: '是的', @@ -506,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 6cf18d9773..6a47698752 100644 --- a/packages/translations/src/languages/zhTw.ts +++ b/packages/translations/src/languages/zhTw.ts @@ -108,6 +108,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { noUser: '沒有該使用者', previewing: '預覽文件時出現了問題。', problemUploadingFile: '上傳文件時出現了問題。', + restoringTitle: '在恢復 {{title}} 時發生了錯誤。請檢查您的連接並再試一次。', tokenInvalidOrExpired: '令牌無效或已過期。', tokenNotProvided: '未提供令牌。', unableToCopy: '無法複製。', @@ -178,6 +179,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { deleteFolder: '刪除資料夾', folderName: '資料夾名稱', folders: '資料夾', + folderTypeDescription: '在此文件夾中選擇應允許的集合文件類型。', itemHasBeenMoved: '{{title}}已被移至{{folderName}}', itemHasBeenMovedToRoot: '{{title}}已被移至根文件夾', itemsMovedToFolder: '{{title}} 已移至 {{folderName}}', @@ -201,6 +203,15 @@ export const zhTwTranslations: 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: '管理頁面主題', @@ -216,6 +227,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { backToDashboard: '返回到控制面板', cancel: '取消', changesNotSaved: '您還有尚未儲存的變更。您確定要離開嗎?', + clear: '清晰', clearAll: '清除全部', close: '關閉', collapse: '折疊', @@ -231,6 +243,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { confirmReindexAll: '重新索引所有集合?', confirmReindexDescription: '此操作將刪除現有索引並重新索引{{collections}}集合中的文件。', confirmReindexDescriptionAll: '此操作將刪除現有索引並重新索引所有集合中的文件。', + confirmRestoration: '確認恢復', copied: '已複製', copy: '複製', copyField: '複製欄位', @@ -250,13 +263,17 @@ export const zhTwTranslations: DefaultTranslationsObject = { dark: '深色', dashboard: '控制面板', delete: '刪除', + deleted: '已刪除', + deletedAt: '刪除於', deletedCountSuccessfully: '已成功刪除 {{count}} 個 {{label}}。', deletedSuccessfully: '已成功刪除。', + deletePermanently: '跳過垃圾桶並永久刪除', deleting: '刪除中...', depth: '深度', descending: '降冪', deselectAllRows: '取消選擇全部', document: '文件', + documentIsTrashed: '此 {{label}} 已被丟入垃圾桶且只能讀取。', documentLocked: '文件已鎖定', documents: '文件', duplicate: '複製', @@ -272,6 +289,8 @@ export const zhTwTranslations: DefaultTranslationsObject = { editLabel: '編輯{{label}}', email: '電子郵件', emailAddress: '電子郵件地址', + emptyTrash: '清空垃圾箱', + emptyTrashLabel: '清空 {{label}} 垃圾桶', enterAValue: '輸入一個值', error: '錯誤', errors: '錯誤', @@ -284,6 +303,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { filterWhere: '過濾{{label}}', globals: '全域', goBack: '返回', + groupByLabel: '按照 {{label}} 分類', import: '進口', isEditing: '正在編輯', item: '物品', @@ -317,6 +337,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { noResults: '沒有找到{{label}}。{{label}}並不存在或沒有符合您上面所指定的過濾器。', notFound: '未找到', nothingFound: '沒有找到任何東西', + noTrashResults: '垃圾桶中無{{label}}。', noUpcomingEventsScheduled: '沒有即將到來的活動。', noValue: '沒有數值', of: '的', @@ -330,6 +351,8 @@ export const zhTwTranslations: DefaultTranslationsObject = { pasteField: '貼上欄位', pasteRow: '貼上列', payloadSettings: 'Payload設定', + permanentlyDelete: '永久刪除', + permanentlyDeletedCountSuccessfully: '成功永久刪除 {{count}} {{label}}。', perPage: '每一頁: {{limit}} 個', previous: '先前的', reindex: '重新索引', @@ -340,6 +363,10 @@ export const zhTwTranslations: DefaultTranslationsObject = { resetPreferences: '重設偏好設定', resetPreferencesDescription: '這將把您的所有偏好設定恢復為預設值。', resettingPreferences: '正在重設偏好設定。', + restore: '恢复', + restoreAsPublished: '恢复为已发布版本', + restoredCountSuccessfully: '成功恢復了 {{count}} {{label}}。', + restoring: '恢复中...', row: '行', rows: '行', save: '儲存', @@ -370,6 +397,10 @@ export const zhTwTranslations: DefaultTranslationsObject = { time: '時間', timezone: '時區', titleDeleted: '{{label}} "{{title}}"已被成功刪除。', + titleRestored: '"{{label}}" "{{title}}" 成功恢复。', + titleTrashed: '"{{label}}" "{{title}}" 已移至垃圾桶。', + trash: '垃圾', + trashedCountSuccessfully: '{{count}} {{label}} 已移至垃圾桶。', true: '真實', unauthorized: '未經授權', unsavedChanges: '您有未保存的更改。繼續前請保存或放棄。', @@ -388,6 +419,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { username: '使用者名稱', users: '使用者', value: '值', + viewing: '查看', viewReadOnly: '僅檢視', welcome: '歡迎', yes: '是的', @@ -505,6 +537,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { noRowsFound: '沒有發現{{label}}', noRowsSelected: '未選擇 {{label}}', preview: '預覽', + previouslyDraft: '先前為草案', previouslyPublished: '先前出版過的', previousVersion: '先前版本', problemRestoringVersion: '回復這個版本時發生了問題', 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 72537a0bb2..88f15b6782 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/ui", - "version": "3.47.0", + "version": "3.49.1", "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/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/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/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