Conditional check before deleting media if it's in use #558
Replies: 3 comments 5 replies
-
Hey @AkashReddy-playy — this would be a great idea for a plugin. It could be applied to any relationship or upload field. It could dynamically build up a list of relationship / upload fields per collection, and then search for documents that have relationships to the document being deleted. This would all happen in a If you decide to build this, keep us posted—and we'll do the same! |
Beta Was this translation helpful? Give feedback.
-
I have code for a similar example, but it may be of limited use if you have a lot of media in hard to query places on many relationships. Instead of checking on delete, this is a way to show a count and link to the list with filters set where a relationship is used in another collection. In this example there are // post category field
{
name: 'category',
type: 'relationship',
relationTo: 'categories',
hasMany: true,
}, On the categories we have a UI field with a custom component that queries posts that have the relationship. The UI field is: // post summary field:
{
name: 'summary',
type: 'ui',
admin: {
position: 'sidebar',
components: {
Field: CategorySummary,
}
}
} Now the CategorySummary component: import React, { useEffect, useState } from 'react';
import qs from 'qs';
import { useDocumentInfo } from 'payload/dist/admin/components/utilities/DocumentInfo';
const get = (url: string, params: unknown = {}): Promise<Response> => {
const query = qs.stringify(params, { addQueryPrefix: true });
return fetch(`${url}${query}`);
};
/**
* A custom UI component for the category to display count of posts and add links
* @constructor
*/
const CategorySummary: React.FC = () => {
// access the id of a saved document from payload
const { id } = useDocumentInfo();
// getters and setters for component state variables
const [isLoading, setIsLoading] = useState(true);
const [postCount, setPostCount] = useState(null);
const [error, setError] = useState(false);
// useEffect adds a hook so that when the id is set the function is called
useEffect(() => {
if (id) {
const queryRelations = async () => {
const request = await get('/api/posts', {
where: {
// the 'in' operator is used when relations can be more than one
// on relationships that are not a `hasMany` you would use 'equals' instead of 'in'
category: { in: id },
// to add more query constraints use 'or', 'and' operator objects
},
depth: 0,
limit: 0,
});
const result = await request.json();
if (result?.docs) {
setPostCount(result?.totalDocs);
}
if (result.status >= 400) {
setError(true);
}
setIsLoading(false);
};
const ignoreResult = queryRelations();
}
}, [id]);
if (!id) {
return null;
}
if (error) {
return (<span>There was a problem fetching data</span>);
}
return (
<div>
<h4>
Summary
</h4>
<p>
{isLoading ? (
loading...
) : (
{/*
link to /posts is formatted so that it will populate the filters inputs on the listing
on relationships that are not a `hasMany` you would use [equals] instead of [in][0]
*/}
<a href={`/admin/collections/posts?where[or][0][and][0][category][in][0]=${id}`} >
{postCount}
{' '}
Posts
</a>
)}
</p>
</div>
);
};
export default CategorySummary; For your use, this might be more difficult if relationships are more complicated, such as:
|
Beta Was this translation helpful? Give feedback.
-
I think a good way to achieve this is to leverage the search plugin. One can use the Now one can simply search the search-index for documents referencing the to-be-deleted document in the Additionally, situations may arise, when an editor or admin wants a list of all docs referencing another doc and thus can simply search for the id. EDIT: Here is my solution using the searchPlugin:
{
name: 'relationships',
label: 'Referenced documents',
type: 'relationship',
hasMany: true,
admin: {
position: 'sidebar',
readOnly: true,
},
localized: true,
relationTo: ['pages', 'posts'], // add all relevant collections here
},
import { getDocumentRelationships } from '@/utilities/getDocumentRelationships'
import { BeforeSync, DocToSync } from '@payloadcms/plugin-search/types'
import { CollectionSlug } from 'payload'
export const beforeSyncWithSearch: BeforeSync = async ({ req, originalDoc, searchDoc }) => {
const {
doc: { relationTo: collection },
} = searchDoc
const modifiedDoc: DocToSync = {
...searchDoc,
relationships: getDocumentRelationships(
originalDoc,
req.payload.collections[collection as CollectionSlug].config.flattenedFields,
),
}
return modifiedDoc
} Here is the import type {
BlockSlug,
CollectionSlug,
Document,
FlattenedField,
RelationshipField,
UploadField,
} from 'payload'
import { getBlockFieldConfigs } from './getBlockFieldConfigs'
type DocumentRelationship = {
relationTo: CollectionSlug
value: string
}
type GetDocumentRelationships = (
document: Document,
fieldConfigs: FlattenedField[],
) => Promise<DocumentRelationship[]>
export const getDocumentRelationships: GetDocumentRelationships = async (
document,
fieldConfigs,
) => {
const documentRelationships: DocumentRelationship[] = []
/**
* Adds a relationship to the `documentRelationships` array if it does not already exist.
*
* @param relationTo - The collection slug to relate to.
* @param value - The value representing the related document.
*
* @remarks
* This function prevents duplicate relationships by checking if a relationship
* with the same `relationTo` and `value` already exists before adding a new one.
*/
function addRelationship(relationTo: CollectionSlug, value: string): void {
// Avoid duplicates.
if (
!documentRelationships.some(
(existing) => existing.relationTo === relationTo && existing.value === value,
)
) {
documentRelationships.push({ relationTo, value })
}
}
/**
* Processes a root-level document, block or array-item.
*
* @param rootNode - The root-level document, block or array-item, whose fields should be processed.
* @param fieldsConfig - An array of flattened field configurations for this document, block or array-field.
*/
async function processRootNode(
rootNode: Document,
fieldsConfig: FlattenedField[],
): Promise<void> {
for (const key of Object.keys(rootNode)) {
const fieldConfig = fieldsConfig.find((field) => 'name' in field && field.name === key)
if (fieldConfig) {
await processNode(rootNode[key], fieldConfig)
}
}
}
/**
* Gathers relationships from a rich-text node, recursively processing its children.
*
* @param node - The rich-text node to gather text from.
*/
function processRichTextContent(node: Record<string, unknown>): void {
if (typeof node === 'object' && node !== null) {
if (Array.isArray(node)) {
for (const item of node) {
processRichTextContent(item)
}
return
}
if ('relationTo' in node && 'value' in node) {
processRelation(node.value as Record<string, unknown>, node.relationTo as CollectionSlug)
}
for (const key of Object.keys(node)) {
processRichTextContent(node[key] as unknown as Record<string, unknown>)
}
}
}
/**
* Processes a relationship value and adds it to the relationships list.
*
* Handles both unresolved (string ID) and resolved (object with `id` property) relationships.
*
* @param value - The relationship value, either a string ID or an object with an `id` property.
* @param relationTo - The collection slug indicating which collection the relationship refers to.
*/
function processRelation(
value: string | Record<string, unknown>,
relationTo: CollectionSlug,
): void {
// Unresolved relationships.
if (typeof value === 'string' && value.length > 0) {
addRelationship(relationTo, value)
}
// Resolved relationships.
else if (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof value.id === 'string' &&
value.id.length > 0
) {
addRelationship(relationTo, value.id)
}
}
/**
* Processes a relationship node by determining whether it targets a single collection or multiple collections,
* and then delegates processing to the appropriate handler.
*
* @param node - The document node representing the relationship to process.
* @param fieldConfig - The configuration for the relationship field, which specifies the target collection(s).
*/
function processRelationshipNode(
node: Document,
fieldConfig: RelationshipField | UploadField,
): void {
// Relationships with only one target collection.
if (typeof fieldConfig.relationTo === 'string') {
processRelation(node, fieldConfig.relationTo)
}
// Relationships with multiple target collections.
else if (
Array.isArray(fieldConfig.relationTo) &&
typeof node === 'object' &&
'relationTo' in node
) {
processRelation(node.value, node.relationTo)
}
}
/**
* Determines whether a given node should be processed based on its field configuration.
*
* @param node - The document node to evaluate.
* @param fieldConfig - The configuration object for the field, including its name and type.
* @returns `true` if the node should be processed; otherwise, `false`.
*/
function shouldProcessNode(node: Document, fieldConfig: FlattenedField): boolean {
// Skip processing for breadcrumbs field
if (fieldConfig.name === 'breadcrumbs') {
return false
}
// Skip processing for referencingRecords field
if (fieldConfig.name === 'referencingRecords') {
return false
}
// Skip processing for folder field
if ('relationTo' in fieldConfig && fieldConfig.relationTo === 'payload-folders') {
return false
}
return true
}
/**
* Processes a given document node based on its field configuration.
*
* @param node - The document node to process. Its structure depends on the field type.
* @param fieldConfig - The configuration describing the field, including its type and any nested fields.
*/
async function processNode(node: Document, fieldConfig: FlattenedField): Promise<void> {
if (!shouldProcessNode(node, fieldConfig)) {
return
}
switch (fieldConfig.type) {
case 'upload':
case 'relationship':
if (fieldConfig.hasMany && Array.isArray(node) && node.length > 0) {
for (const relationshipItem of node as Document[]) {
processRelationshipNode(relationshipItem, fieldConfig)
}
} else {
processRelationshipNode(node, fieldConfig)
}
break
case 'richText':
if (node && typeof node === 'object' && 'root' in node) {
processRichTextContent(node.root)
}
break
case 'blocks':
for (const blockNode of Object.values(node as Record<string, Record<string, unknown>>)) {
const blockFieldConfigs = await getBlockFieldConfigs(
fieldConfig,
blockNode.blockType as BlockSlug,
)
if (blockFieldConfigs) {
await processRootNode(blockNode, blockFieldConfigs)
}
}
break
case 'array':
for (const arrayNode of Object.values(node as Record<string, Record<string, unknown>>)) {
await processRootNode(arrayNode, fieldConfig.flattenedFields)
}
break
case 'tab':
case 'group':
await processRootNode(node, fieldConfig.flattenedFields)
break
}
}
// Process the root document node
await processRootNode(document, fieldConfigs)
return documentRelationships
} Here is the import { allBlockConfigs } from '@/blocks/_sets/allBlockConfigs'
import { BlockSlug, flattenAllFields, FlattenedBlocksField, FlattenedField } from 'payload'
/**
* Retrieves the flattened field configuration for a specific block within a blocks field.
*
* If the provided `fieldConfig` uses block references, the function fetches the block configuration
* from all available block configs and returns its flattened fields. Otherwise, it searches for the block
* within the `fieldConfig` and returns its `flattenedFields` property.
*
* @param fieldConfig - The configuration object for the blocks field, which may contain block references or direct block definitions.
* @param blockSlug - The unique identifier (slug) of the block whose field configuration is to be retrieved.
* @returns A promise that resolves to an array of flattened fields for the specified block, or `undefined` if not found.
*/
export async function getBlockFieldConfigs(
fieldConfig: FlattenedBlocksField,
blockSlug: BlockSlug,
): Promise<FlattenedField[] | undefined> {
const blockConfigs = await allBlockConfigs()
// If blockReferences are used, the field-config is not directly available,
// so we need to get it directly and add the 'flattenedFields' field.
if ('blockReferences' in fieldConfig) {
const blockConfig = blockConfigs.find((block) => block.slug === blockSlug)
if (blockConfig) {
return flattenAllFields({ fields: blockConfig?.fields })
}
}
const blockConfig = fieldConfig.blocks.find((block) => block.slug === blockSlug)
return blockConfig?.flattenedFields || undefined
} The
import { authenticated } from '@/access/authenticated'
import { allCollectionSlugs } from '@/collections/_sets/allCollectionSlugs'
import { getDocumentRelationships } from '@/utilities/getDocumentRelationships'
import { getTranslation } from '@payloadcms/translations'
import type { GroupField } from 'payload'
type ReferencingRecordsField = () => GroupField
export const referencingRecordsField: ReferencingRecordsField = () => {
const field: GroupField = {
name: 'referencingRecords',
label: 'Referencing records',
type: 'group',
interfaceName: 'ReferencingRecordsField',
virtual: true,
access: {
read: authenticated,
},
admin: {
readOnly: true,
description: 'Records that reference this document. The document can only be deleted once all references are removed.',
},
position: 'sidebar',
},
fields: [
{
name: 'documents',
label: 'Documents',
type: 'relationship',
hasMany: true,
virtual: true,
access: {
read: authenticated,
},
admin: {
readOnly: true,
placeholder: '',
},
hooks: {
afterRead: [
async ({ collection, originalDoc, req, context }) => {
// Skip, if user is not authenticated
if (!Boolean(req.user)) {
return []
}
// Skip, if this read is already part of a referencing documents hook
// to prevent infinite recursion.
if (context?.isReferencingDocumentsHook) {
return []
}
// Find all documents in the 'search' collection that reference the original document.
const foundDocuments = await req.payload.find({
where: {
relationships: {
equals: { relationTo: collection?.slug, value: originalDoc?.id },
},
},
collection: 'search',
locale: req.locale,
depth: 1,
fallbackLocale: false,
context: {
isReferencingDocumentsHook: true,
},
select: {
doc: true,
},
})
return foundDocuments.docs.map((doc) => doc.doc)
},
],
},
localized: true,
relationTo: allCollectionSlugs,
},
{
name: 'globals',
label: 'Settings',
type: 'text',
hasMany: true,
virtual: true,
access: {
read: authenticated,
},
admin: {
readOnly: true,
placeholder: '',
},
hooks: {
afterRead: [
async ({ originalDoc, req, context }) => {
// Skip, if user is not authenticated
if (!Boolean(req.user)) {
return []
}
// Skip, if this read is already part of a referencing documents hook
// to prevent infinite recursion.
if (context?.isReferencingDocumentsHook) {
return []
}
// Find any globals that reference this document.
const referencingGlobals: string[] = []
for (const globalConfig of req.payload.globals.config) {
const globalDoc = await req.payload.findGlobal({
slug: globalConfig.slug,
locale: req.locale,
depth: 0,
fallbackLocale: false,
})
const globalRelationships = await getDocumentRelationships(
globalDoc,
globalConfig.flattenedFields,
)
if (
globalRelationships.some((relationship) => relationship.value === originalDoc?.id)
) {
referencingGlobals.push(
getTranslation(globalConfig.label, req.i18n) || globalConfig.slug,
)
}
}
return referencingGlobals
},
],
},
localized: true,
},
],
}
return field
} The import Use the field in your collections like this: import { referencingRecordsField } from '@/fields/referencingRecordsField'
import type { CollectionConfig } from 'payload'
export const MyCollection: CollectionConfig = {
...
fields: [
...
referencingRecordsField(),
],
}
import { getDocumentRelationships } from '@/utilities/getDocumentRelationships'
import { getTranslation } from '@payloadcms/translations'
import {
APIError,
CollectionBeforeChangeHook,
PayloadRequest,
RequestContext,
type CollectionBeforeDeleteHook,
type TypedLocale,
} from 'payload'
type CheckForRelationsBeforeDeleteHook = () => CollectionBeforeDeleteHook
type CheckForRelationsBeforeTrashHook = () => CollectionBeforeChangeHook
const checkForRelations = async (
id: string | number,
req: PayloadRequest,
context: RequestContext,
) => {
// Skip if the hook is called with `forceDelete` context.
if (context?.forceDelete === true) {
return
}
// Get the current locale and all configured locales
const currentLocale: TypedLocale = req.locale as TypedLocale
const appLocales: TypedLocale[] =
req.payload.config.localization !== false
? (req.payload.config.localization.localeCodes as TypedLocale[])
: [currentLocale]
// Initialize an array to store found relations
const foundRelations: string[] = []
/**
* Adds a relation to the `foundRelations` array if it does not already exist.
*
* @param relation - The name of the relation to add.
*/
function addFoundRelation(relation: string) {
if (!foundRelations.includes(relation)) {
foundRelations.push(relation)
}
}
// Search for relations in all locales.
for (const locale of appLocales) {
// Search for documents in the 'search' collection that have a relationship to the document being deleted.
const foundDocuments = await req.payload.find({
where: {
or: Object.keys(req.payload.collections).map((collectionSlug) => ({
relationships: {
equals: { relationTo: collectionSlug, value: id },
},
})),
},
collection: 'search',
locale: locale,
depth: 1,
fallbackLocale: false,
})
if (foundDocuments.docs.length > 0) {
for (const foundDocument of foundDocuments.docs) {
addFoundRelation(foundDocument.title || foundDocument.id)
}
}
// Find any globals that reference this document.
for (const globalConfig of req.payload.globals.config) {
const globalDoc = await req.payload.findGlobal({
slug: globalConfig.slug,
locale: req.locale,
depth: 0,
fallbackLocale: false,
})
const globalRelationships = await getDocumentRelationships(
globalDoc,
globalConfig.flattenedFields,
)
if (globalRelationships.some((relationship) => relationship.value === id)) {
addFoundRelation(getTranslation(globalConfig.label, req.i18n) || globalConfig.slug)
}
}
}
// If any relations were found, throw an error.
if (foundRelations.length > 0) {
throw new APIError(
req.t('custom:trigger:checkForRelationsBeforeDelete:error', {
foundRelations: foundRelations.join(', '),
}),
429,
)
}
}
export const checkForRelationsBeforeDeleteHook: CheckForRelationsBeforeDeleteHook = () => {
const hook: CollectionBeforeDeleteHook = async ({ id, req, context }) => {
await checkForRelations(id, req, context)
}
return hook
}
export const checkForRelationsBeforeTrashHook: CheckForRelationsBeforeTrashHook = () => {
const hook: CollectionBeforeChangeHook = async ({
data,
req,
context,
originalDoc,
operation,
}) => {
if (operation === 'update') {
const previousDeletedState =
'deletedAt' in originalDoc &&
typeof originalDoc.deletedAt === 'string' &&
originalDoc.deletedAt.length > 0
const newDeletedState =
'deletedAt' in data && typeof data.deletedAt === 'string' && data.deletedAt.length > 0
if (newDeletedState === true && previousDeletedState === false) {
await checkForRelations(originalDoc.id, req, context)
}
}
}
return hook
} In addition to looking for collection documents with relationships to the to-be-deleted document, this also checks all globals for the same. This way one should have a fairly complete deletion protection for all documents of any collection one wishes. Add this hook to any collection you want to be protected like this: import { checkForRelationsBeforeDeleteHook } from '@/hooks/checkForRelationsBeforeDeleteHook'
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
...
hooks: {
beforeDelete: [checkForRelationsBeforeDeleteHook()],
},
...
} NOTE: Searching the |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Hello,
Currently is it possible to check if media is in use in any of the collections?
This will be a great feature to have with an indicator to show relative links where media is used.
Any solutions or approaches to be considered would be great.
Ak
Beta Was this translation helpful? Give feedback.
All reactions