feat(ui): add option to open related documents in a new tab (#11939)

### What?
Selected documents in a relationship field can be opened in a new tab.

### Why?
Related documents can be edited using the edit icon which opens the
document in a drawer. Sometimes users would like to open the document in
a new tab instead to e.g. modify the related document at a later point
in time. This currently requires users to find the related document via
the list view and open it there. There is no easy way to find and open a
related document.

### How?
Adds custom handling to the relationship edit button to support opening
it in a new tab via middle-click, Ctrl+click, or right-click → 'Open in
new tab'.

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
This commit is contained in:
Tobias Odendahl
2025-05-02 19:03:51 +02:00
committed by GitHub
parent 055a263af3
commit 1ef1c5564d
7 changed files with 85 additions and 24 deletions

View File

@@ -1,12 +1,11 @@
import type { LabelFunction } from 'payload'
import type { CommonProps, GroupBase, Props as ReactSelectStateManagerProps } from 'react-select'
import type { DocumentDrawerProps, UseDocumentDrawer } from '../DocumentDrawer/types.js'
import type { DocumentDrawerProps } from '../DocumentDrawer/types.js'
type CustomSelectProps = {
disableKeyDown?: boolean
disableMouseDown?: boolean
DocumentDrawerToggler?: ReturnType<UseDocumentDrawer>[1]
draggableProps?: any
droppableRef?: React.RefObject<HTMLDivElement | null>
editableProps?: (
@@ -15,10 +14,11 @@ type CustomSelectProps = {
selectProps: ReactSelectStateManagerProps,
) => any
onDelete?: DocumentDrawerProps['onDelete']
onDocumentDrawerOpen?: (args: {
onDocumentOpen?: (args: {
collectionSlug: string
hasReadPermission: boolean
id: number | string
openInNewTab?: boolean
}) => void
onDuplicate?: DocumentDrawerProps['onSave']
onSave?: DocumentDrawerProps['onSave']

View File

@@ -7,7 +7,7 @@ import type {
} from 'payload'
import { dequal } from 'dequal/lite'
import { wordBoundariesRegex } from 'payload/shared'
import { formatAdminURL, wordBoundariesRegex } from 'payload/shared'
import * as qs from 'qs-esm'
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
@@ -83,7 +83,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
const hasMultipleRelations = Array.isArray(relationTo)
const [currentlyOpenRelationship, setCurrentlyOpenRelationship] = useState<
Parameters<ReactSelectAdapterProps['customProps']['onDocumentDrawerOpen']>[0]
Parameters<ReactSelectAdapterProps['customProps']['onDocumentOpen']>[0]
>({
id: undefined,
collectionSlug: undefined,
@@ -631,16 +631,29 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
return r.test(labelString.slice(-breakApartThreshold))
}, [])
const onDocumentDrawerOpen = useCallback<
ReactSelectAdapterProps['customProps']['onDocumentDrawerOpen']
>(({ id, collectionSlug, hasReadPermission }) => {
const onDocumentOpen = useCallback<ReactSelectAdapterProps['customProps']['onDocumentOpen']>(
({ id, collectionSlug, hasReadPermission, openInNewTab }) => {
if (openInNewTab) {
if (hasReadPermission && id && collectionSlug) {
const docUrl = formatAdminURL({
adminRoute: config.routes.admin,
path: `/collections/${collectionSlug}/${id}`,
})
window.open(docUrl, '_blank')
}
} else {
openDrawerWhenRelationChanges.current = true
setCurrentlyOpenRelationship({
id,
collectionSlug,
hasReadPermission,
})
}, [])
}
},
[setCurrentlyOpenRelationship, config.routes.admin],
)
useEffect(() => {
if (openDrawerWhenRelationChanges.current) {
@@ -697,7 +710,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
customProps={{
disableKeyDown: isDrawerOpen || isListDrawerOpen,
disableMouseDown: isDrawerOpen || isListDrawerOpen,
onDocumentDrawerOpen,
onDocumentOpen,
onSave,
}}
disabled={readOnly || disabled || isDrawerOpen || isListDrawerOpen}

View File

@@ -25,7 +25,7 @@ export const MultiValueLabel: React.FC<
> = (props) => {
const {
data: { allowEdit, label, relationTo, value },
selectProps: { customProps: { draggableProps, onDocumentDrawerOpen } = {} } = {},
selectProps: { customProps: { draggableProps, onDocumentOpen } = {} } = {},
} = props
const { permissions } = useAuth()
@@ -49,12 +49,13 @@ export const MultiValueLabel: React.FC<
<button
aria-label={`Edit ${label}`}
className={`${baseClass}__drawer-toggler`}
onClick={() => {
onClick={(event) => {
setShowTooltip(false)
onDocumentDrawerOpen({
onDocumentOpen({
id: value,
collectionSlug: relationTo,
hasReadPermission,
openInNewTab: event.metaKey || event.ctrlKey,
})
}}
onKeyDown={(e) => {

View File

@@ -26,7 +26,7 @@ export const SingleValue: React.FC<
const {
children,
data: { allowEdit, label, relationTo, value },
selectProps: { customProps: { onDocumentDrawerOpen } = {} } = {},
selectProps: { customProps: { onDocumentOpen } = {} } = {},
} = props
const [showTooltip, setShowTooltip] = useState(false)
@@ -44,12 +44,13 @@ export const SingleValue: React.FC<
<button
aria-label={t('general:editLabel', { label })}
className={`${baseClass}__drawer-toggler`}
onClick={() => {
onClick={(event) => {
setShowTooltip(false)
onDocumentDrawerOpen({
onDocumentOpen({
id: value,
collectionSlug: relationTo,
hasReadPermission,
openInNewTab: event.metaKey || event.ctrlKey,
})
}}
onKeyDown={(e) => {

View File

@@ -358,6 +358,46 @@ describe('relationship', () => {
).toHaveText(`${value}123456`)
})
test('should open related document in a new tab when meta key is applied', async () => {
await page.goto(url.create)
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
await openDocDrawer({
page,
selector:
'#field-relationWithAllowCreateToFalse .relationship--single-value__drawer-toggler',
withMetaKey: true,
}),
])
// Wait for navigation to complete in the new tab and ensure the edit view is open
await expect(newPage.locator('.collection-edit')).toBeVisible()
})
test('multi value relationship should open document in a new tab', async () => {
await page.goto(url.create)
// Select "Seeded text document" relationship
await page.locator('#field-relationshipHasMany .rs__control').click()
await page.locator('.rs__option:has-text("Seeded text document")').click()
await expect(
page.locator('#field-relationshipHasMany .relationship--multi-value-label__drawer-toggler'),
).toBeVisible()
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
await openDocDrawer({
page,
selector: '#field-relationshipHasMany .relationship--multi-value-label__drawer-toggler',
withMetaKey: true,
}),
])
// Wait for navigation to complete in the new tab and ensure the edit view is open
await expect(newPage.locator('.collection-edit')).toBeVisible()
})
// Drawers opened through the edit button are prone to issues due to the use of stopPropagation for certain
// events - specifically for drawers opened through the edit button. This test is to ensure that drawers
// opened through the edit button can be saved using the hotkey.

View File

@@ -6,12 +6,18 @@ import { wait } from 'payload/shared'
export async function openDocDrawer({
page,
selector,
withMetaKey = false,
}: {
page: Page
selector: string
withMetaKey?: boolean
}): Promise<void> {
let clickProperties = {}
if (withMetaKey) {
clickProperties = { modifiers: ['ControlOrMeta'] }
}
await wait(500) // wait for parent form state to initialize
await page.locator(selector).click()
await page.locator(selector).click(clickProperties)
await wait(500) // wait for drawer form state to initialize
}

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/_community/config.ts"],
"@payload-config": ["./test/fields/config.ts"],
"@payloadcms/admin-bar": ["./packages/admin-bar/src"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],