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:
@@ -1,12 +1,11 @@
|
|||||||
import type { LabelFunction } from 'payload'
|
import type { LabelFunction } from 'payload'
|
||||||
import type { CommonProps, GroupBase, Props as ReactSelectStateManagerProps } from 'react-select'
|
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 = {
|
type CustomSelectProps = {
|
||||||
disableKeyDown?: boolean
|
disableKeyDown?: boolean
|
||||||
disableMouseDown?: boolean
|
disableMouseDown?: boolean
|
||||||
DocumentDrawerToggler?: ReturnType<UseDocumentDrawer>[1]
|
|
||||||
draggableProps?: any
|
draggableProps?: any
|
||||||
droppableRef?: React.RefObject<HTMLDivElement | null>
|
droppableRef?: React.RefObject<HTMLDivElement | null>
|
||||||
editableProps?: (
|
editableProps?: (
|
||||||
@@ -15,10 +14,11 @@ type CustomSelectProps = {
|
|||||||
selectProps: ReactSelectStateManagerProps,
|
selectProps: ReactSelectStateManagerProps,
|
||||||
) => any
|
) => any
|
||||||
onDelete?: DocumentDrawerProps['onDelete']
|
onDelete?: DocumentDrawerProps['onDelete']
|
||||||
onDocumentDrawerOpen?: (args: {
|
onDocumentOpen?: (args: {
|
||||||
collectionSlug: string
|
collectionSlug: string
|
||||||
hasReadPermission: boolean
|
hasReadPermission: boolean
|
||||||
id: number | string
|
id: number | string
|
||||||
|
openInNewTab?: boolean
|
||||||
}) => void
|
}) => void
|
||||||
onDuplicate?: DocumentDrawerProps['onSave']
|
onDuplicate?: DocumentDrawerProps['onSave']
|
||||||
onSave?: DocumentDrawerProps['onSave']
|
onSave?: DocumentDrawerProps['onSave']
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
} from 'payload'
|
} from 'payload'
|
||||||
|
|
||||||
import { dequal } from 'dequal/lite'
|
import { dequal } from 'dequal/lite'
|
||||||
import { wordBoundariesRegex } from 'payload/shared'
|
import { formatAdminURL, wordBoundariesRegex } from 'payload/shared'
|
||||||
import * as qs from 'qs-esm'
|
import * as qs from 'qs-esm'
|
||||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
|
|||||||
const hasMultipleRelations = Array.isArray(relationTo)
|
const hasMultipleRelations = Array.isArray(relationTo)
|
||||||
|
|
||||||
const [currentlyOpenRelationship, setCurrentlyOpenRelationship] = useState<
|
const [currentlyOpenRelationship, setCurrentlyOpenRelationship] = useState<
|
||||||
Parameters<ReactSelectAdapterProps['customProps']['onDocumentDrawerOpen']>[0]
|
Parameters<ReactSelectAdapterProps['customProps']['onDocumentOpen']>[0]
|
||||||
>({
|
>({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
collectionSlug: undefined,
|
collectionSlug: undefined,
|
||||||
@@ -631,16 +631,29 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
|
|||||||
return r.test(labelString.slice(-breakApartThreshold))
|
return r.test(labelString.slice(-breakApartThreshold))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onDocumentDrawerOpen = useCallback<
|
const onDocumentOpen = useCallback<ReactSelectAdapterProps['customProps']['onDocumentOpen']>(
|
||||||
ReactSelectAdapterProps['customProps']['onDocumentDrawerOpen']
|
({ id, collectionSlug, hasReadPermission, openInNewTab }) => {
|
||||||
>(({ id, collectionSlug, hasReadPermission }) => {
|
if (openInNewTab) {
|
||||||
openDrawerWhenRelationChanges.current = true
|
if (hasReadPermission && id && collectionSlug) {
|
||||||
setCurrentlyOpenRelationship({
|
const docUrl = formatAdminURL({
|
||||||
id,
|
adminRoute: config.routes.admin,
|
||||||
collectionSlug,
|
path: `/collections/${collectionSlug}/${id}`,
|
||||||
hasReadPermission,
|
})
|
||||||
})
|
|
||||||
}, [])
|
window.open(docUrl, '_blank')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
openDrawerWhenRelationChanges.current = true
|
||||||
|
|
||||||
|
setCurrentlyOpenRelationship({
|
||||||
|
id,
|
||||||
|
collectionSlug,
|
||||||
|
hasReadPermission,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setCurrentlyOpenRelationship, config.routes.admin],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openDrawerWhenRelationChanges.current) {
|
if (openDrawerWhenRelationChanges.current) {
|
||||||
@@ -697,7 +710,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
|
|||||||
customProps={{
|
customProps={{
|
||||||
disableKeyDown: isDrawerOpen || isListDrawerOpen,
|
disableKeyDown: isDrawerOpen || isListDrawerOpen,
|
||||||
disableMouseDown: isDrawerOpen || isListDrawerOpen,
|
disableMouseDown: isDrawerOpen || isListDrawerOpen,
|
||||||
onDocumentDrawerOpen,
|
onDocumentOpen,
|
||||||
onSave,
|
onSave,
|
||||||
}}
|
}}
|
||||||
disabled={readOnly || disabled || isDrawerOpen || isListDrawerOpen}
|
disabled={readOnly || disabled || isDrawerOpen || isListDrawerOpen}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const MultiValueLabel: React.FC<
|
|||||||
> = (props) => {
|
> = (props) => {
|
||||||
const {
|
const {
|
||||||
data: { allowEdit, label, relationTo, value },
|
data: { allowEdit, label, relationTo, value },
|
||||||
selectProps: { customProps: { draggableProps, onDocumentDrawerOpen } = {} } = {},
|
selectProps: { customProps: { draggableProps, onDocumentOpen } = {} } = {},
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const { permissions } = useAuth()
|
const { permissions } = useAuth()
|
||||||
@@ -49,12 +49,13 @@ export const MultiValueLabel: React.FC<
|
|||||||
<button
|
<button
|
||||||
aria-label={`Edit ${label}`}
|
aria-label={`Edit ${label}`}
|
||||||
className={`${baseClass}__drawer-toggler`}
|
className={`${baseClass}__drawer-toggler`}
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
setShowTooltip(false)
|
setShowTooltip(false)
|
||||||
onDocumentDrawerOpen({
|
onDocumentOpen({
|
||||||
id: value,
|
id: value,
|
||||||
collectionSlug: relationTo,
|
collectionSlug: relationTo,
|
||||||
hasReadPermission,
|
hasReadPermission,
|
||||||
|
openInNewTab: event.metaKey || event.ctrlKey,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const SingleValue: React.FC<
|
|||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
data: { allowEdit, label, relationTo, value },
|
data: { allowEdit, label, relationTo, value },
|
||||||
selectProps: { customProps: { onDocumentDrawerOpen } = {} } = {},
|
selectProps: { customProps: { onDocumentOpen } = {} } = {},
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const [showTooltip, setShowTooltip] = useState(false)
|
const [showTooltip, setShowTooltip] = useState(false)
|
||||||
@@ -44,12 +44,13 @@ export const SingleValue: React.FC<
|
|||||||
<button
|
<button
|
||||||
aria-label={t('general:editLabel', { label })}
|
aria-label={t('general:editLabel', { label })}
|
||||||
className={`${baseClass}__drawer-toggler`}
|
className={`${baseClass}__drawer-toggler`}
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
setShowTooltip(false)
|
setShowTooltip(false)
|
||||||
onDocumentDrawerOpen({
|
onDocumentOpen({
|
||||||
id: value,
|
id: value,
|
||||||
collectionSlug: relationTo,
|
collectionSlug: relationTo,
|
||||||
hasReadPermission,
|
hasReadPermission,
|
||||||
|
openInNewTab: event.metaKey || event.ctrlKey,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|||||||
@@ -358,6 +358,46 @@ describe('relationship', () => {
|
|||||||
).toHaveText(`${value}123456`)
|
).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
|
// 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
|
// 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.
|
// opened through the edit button can be saved using the hotkey.
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ import { wait } from 'payload/shared'
|
|||||||
export async function openDocDrawer({
|
export async function openDocDrawer({
|
||||||
page,
|
page,
|
||||||
selector,
|
selector,
|
||||||
|
withMetaKey = false,
|
||||||
}: {
|
}: {
|
||||||
page: Page
|
page: Page
|
||||||
selector: string
|
selector: string
|
||||||
|
withMetaKey?: boolean
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
let clickProperties = {}
|
||||||
|
if (withMetaKey) {
|
||||||
|
clickProperties = { modifiers: ['ControlOrMeta'] }
|
||||||
|
}
|
||||||
await wait(500) // wait for parent form state to initialize
|
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
|
await wait(500) // wait for drawer form state to initialize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@payload-config": ["./test/_community/config.ts"],
|
"@payload-config": ["./test/fields/config.ts"],
|
||||||
"@payloadcms/admin-bar": ["./packages/admin-bar/src"],
|
"@payloadcms/admin-bar": ["./packages/admin-bar/src"],
|
||||||
"@payloadcms/live-preview": ["./packages/live-preview/src"],
|
"@payloadcms/live-preview": ["./packages/live-preview/src"],
|
||||||
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
|
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
|
||||||
|
|||||||
Reference in New Issue
Block a user