Referenced documents can still be unpublished
Opened this issue · 5 comments
Describe the Bug
If one document is being referenced in a relationship field, we can prevent unpublished documents from being added to that field, by using a filter:
return {
_status: {
equals: 'published',
},
};
However, AFTER selecting the published document in the relationship field, that same document can still be unpublished. This causes a lot of issues, since published pages will remain pointing to unpublished documents. I see a few possible solutions, but I'm not sure which one would be the most viable:
- Exposing a flag (i.e.
weak: trueorweak:false) that we could set when creating a relationship field that would throw an error by default if we tried to unpublish a document that is being referenced somewhere. IMHO, this is the ideal solution. - Using a hook to compare the previous
_statuswith the new_status. However, this won't work if we havedraftsandautosaveenabled, since we are not able to determine if it's changing frompublishedto_statusbecause the user is unpublishing the document or just because they updated any content (sincedraftsandautosaveare enabled). - Exposing an
unpublishoption underadmin.components.editthat would allow us to implement this check before effectively unpublishing the document.
Link to the code that reproduces this issue
https://github.com/lcnogueira/payload-draft-published-issue
Reproduction Steps
- Install the packages:
pnpm install. - Ensure you have the proper env variables under your
.envfile:
DATABASE_URI=mongodb://127.0.0.1/drafts-published-bug
PAYLOAD_SECRET=any-string-you-want
- Use docker to start the database:
docker-compose up. - Start the project:
pnpm run dev. - Create an entry in the
Placescollection and publish it. - Create an entry in the
Postscollection and select the place you just published in theReference to Place Collectionfield:
- Open the published
Placesentry again and ensure you can unpublish it, even though it's being referenced by thePostentry.
Which area(s) are affected? (Select all that apply)
area: core
Environment Info
`
Binaries:
Node: 23.11.1
npm: 10.9.2
Yarn: 1.22.19
pnpm: 10.14.0
Relevant Packages:
payload: 3.56.0
next: 15.4.4
@payloadcms/db-mongodb: 3.56.0
@payloadcms/email-nodemailer: 3.56.0
@payloadcms/graphql: 3.56.0
@payloadcms/next/utilities: 3.56.0
@payloadcms/payload-cloud: 3.56.0
@payloadcms/richtext-lexical: 3.56.0
@payloadcms/translations: 3.56.0
@payloadcms/ui/shared: 3.56.0
react: 19.1.0
react-dom: 19.1.0
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 24.4.0: Fri Apr 11 18:33:39 PDT 2025; root:xnu-11417.101.15~117/RELEASE_ARM64_T6020
Available memory (MB): 16384
Available CPU cores: 10
@lcnogueira I ran into a similar issue a while back, so hopefully this helps.
In our case, we had a Global that referenced a collection used to showcase top news. The problem was that sometimes articles were unpublished, but their references in the Global weren’t automatically removed.
To fix this, we added an afterChange hook in the related collection. The hook checks if an article’s status changes from published to draft. If it does, it looks for any references to that article in the Global and removes them.
Here’s an example:
import type { CollectionAfterChangeHook } from 'payload'
import type { Article } from 'payload/generated-types'
export const removeTopNewsRelation: CollectionAfterChangeHook<Article> = async ({
doc,
previousDoc,
req,
}) => {
if (!previousDoc) return doc
const wasPublished = previousDoc._status === 'published'
const isNowDraft = doc._status === 'draft'
if (!(wasPublished && isNowDraft)) return doc
const articleID = doc.id
const global = await req.payload.findGlobal({ slug: 'topNews' })
const originalNews = global.news || []
const updatedNewsArray =
originalNews.filter(item => {
const relatedID = typeof item.article === 'object' ? item.article.id : item.article
return relatedID !== articleID
}) || []
const wesReferenced = originalNews.length !== updatedNewsArray.length
if (!wasReferenced) return doc
await req.payload.updateGlobal({
slug: 'topNews',
data: { news: updatedNewsArray },
})
return doc
}Thanks so much for sharing, @marcmaceira!
Did your Global have drafts and autosave enabled? I'm asking because I think it's not possible to use that check with previousDoc._status and doc._status if you have those options enabled: let's say you have a published document and that collection has drafts and autosave enabled. As soon as the user types anything in the content, doc._status will become draft and previousDoc._status will be published. This means that it will match your check even though the document is not being unpublished.
That makes sense @lcnogueira. In our Articles collection we have a self-relation (one-to-many) to other Articles. We didn’t add an afterChange hook for this case. Instead, we use an afterRead hook to strip out any related articles that aren’t published when reading a published article. This ensures clients never receive unpublished items in the response.
We haven’t benchmarked this at large scale as our use case is small (typically 1–5 related articles). Here’s the collection's afterRead hook:
import type { CollectionAfterReadHook } from 'payload'
import type { Article } from 'payload/generated-types'
export const filterUnpublishedRelatedArticles: CollectionAfterReadHook<Article> = ({ doc }) => {
// Check if the document and the relationship field exist and are populated - Here you would be checking for your document relationship field
if (doc && doc.relatedArticles && Array.isArray(doc.relatedArticles)) {
// Filter the relatedArticles array
doc.relatedArticles = doc.relatedArticles.filter(related => {
// If the relationship wasn't populated (depth = 0), 'related' will be an ID (string). Keep it.
if (typeof related === 'string' || typeof related === 'number') {
return false
}
// If populated (depth >= 1), 'related' is an object. Check its status.
if (typeof related === 'object' && related !== null && related._status) {
return related._status === 'published'
}
// Otherwise, filter it out
return false
})
}
// Return the modified document
return doc
}Thanks so much @marcmaceira ! Just confirming here for anyone else reading this that I tried this solution and it works, in the sense that we can remove the not published content once we read the data.
For our particular case, though, the solution won't be enough for the client. We'll need to find a solution to ensure the content won't be unpublished. @akhrarovsaid shared a solution in the payload discord channel that might work. I didn't have the time to focus on that yet, but I plan to try that next week and then share the results.
I am sharing here for visibility that the solution I mentioned in my previous comment, which was shared in the Discord payload, works. The solution would be replacing the publish button by using the admin.components.edit.PublishButton optoin to return both the regular PublishButton (exposed by Payload) AND the unpublish button, which is not exposed by payload, but can be copied from the project easily. Then, hiding the default unpublish button with CSS. By doing that, we can do any checks once the user clicks the unpublish button.