feat(ui): add option for rendering the relationship field as list drawer (#11553)

### What?

This PR adds the ability to use the ListDrawer component for selecting
related collections for the relationship field instead of the default
drop down interface. This exposes the advanced filtering options that
the list view provides and provides a good interface for searching for
the correct relationship when the workflows may be more complicated.
I've added an additional "selectionType" prop to the relationship field
admin config that defaults to "dropdown" for compatability with the
existing implementation but "drawer" can be passed in as well which
enables using the ListDrawer for selecting related components.

### Why?

Adding the ability to search through the list view enables advanced
workflows or handles edge cases when just using the useAsTitle may not
be informative enough to find the related record that the user wants.
For example, if we have a collection of oscars nominations and are
trying to relate the nomination to the person who recieved the
nomination there may be multiple actors with the same name (Michelle
Williams, for example:
[https://www.imdb.com/name/nm0931329/](https://www.imdb.com/name/nm0931329/),
[https://www.imdb.com/name/nm0931332/](https://www.imdb.com/name/nm0931332/)).
It would be hard to search through the current dropdown ui to choose the
correct person, but in the list view the user could use other fields to
identify the correct person such as an imdb id, description, or anything
else they have in the collection for that person. Other advanced
workflows could be if there are multiple versions of a record in a
collection and the user wants to select the most recent one or just
anything where the user needs to see more details about the record that
they are setting up the relationship to.

### How?

This implementation just re-uses the useListDrawer hook and the
ListDrawer component so the code changes are pretty minimal. The main
change is a new onListSelect handler that gets passed into the
ListDrawer and handles updating the value in the field when a record is
selected in the ListDrawer.

There were also a two things that I didn't implement as they would
require broader code changes 1) Bulk select from the ListDrawer when a
relationship is hasMany - when using bulkSelect in the list drawer the
relatedCollection doesn't get returned so this doesn't work for
polymorphic relationships. Updating this would involve changing the
useListDrawer hook 2) Hide values that are already selected from the
ListDrawer - potentially possible by modifying the filterOptions and
passing in an additional filter but honestly it may not be desired
behaviour to hide values from the ListDrawer as this could be confusing
for the user if they don't see records that they are expected to see
(maybe if anything make them unselectable and indicate that they are
disabled). Currently if an already selected value gets selected the
selected value gets replaced by the new value



https://github.com/user-attachments/assets/fee164da-4270-4612-9304-73ccf34ccf69

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
This commit is contained in:
Sam Wheeler
2025-04-14 11:37:09 -07:00
committed by GitHub
parent 5b554e5256
commit 55d00e2b1d
7 changed files with 347 additions and 21 deletions

View File

@@ -650,6 +650,163 @@ describe('relationship', () => {
await expect(page.locator(tableRowLocator)).toHaveCount(1)
})
test('should be able to select relationship with drawer appearance', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-relationshipDrawer')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const firstRow = listDrawerContent.locator('table tbody tr').first()
const button = firstRow.locator('button')
await button.click()
await expect(listDrawerContent).toBeHidden()
const selectedValue = relationshipField.locator('.relationship--single-value__text')
await expect(selectedValue).toBeVisible()
// Fill required field
await page.locator('#field-relationship').click()
await page.locator('.rs__option:has-text("Seeded text document")').click()
await saveDocAndAssert(page)
})
test('should be able to search within relationship list drawer', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-relationshipDrawer')
await relationshipField.click()
const searchField = page.locator('.list-drawer .search-filter')
await expect(searchField).toBeVisible()
const searchInput = searchField.locator('input')
await searchInput.fill('seeded')
const rows = page.locator('.list-drawer table tbody tr')
await expect(rows).toHaveCount(1)
const closeButton = page.locator('.list-drawer__header-close')
await closeButton.click()
await expect(page.locator('.list-drawer')).toBeHidden()
})
test('should handle read-only relationship field when `appearance: "drawer"`', async () => {
await page.goto(url.create)
const readOnlyField = page.locator(
'#field-relationshipDrawerReadOnly .rs__control--is-disabled',
)
await expect(readOnlyField).toBeVisible()
})
test('should handle polymorphic relationship when `appearance: "drawer"`', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-polymorphicRelationshipDrawer')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const relationToSelector = page.locator('.list-header__select-collection')
await expect(relationToSelector).toBeVisible()
await relationToSelector.locator('.rs__control').click()
const option = relationToSelector.locator('.rs__option').nth(1)
await option.click()
const firstRow = listDrawerContent.locator('table tbody tr').first()
const button = firstRow.locator('button')
await button.click()
await expect(listDrawerContent).toBeHidden()
const selectedValue = relationshipField.locator('.relationship--single-value__text')
await expect(selectedValue).toBeVisible()
// Fill required field
await page.locator('#field-relationship').click()
await page.locator('.rs__option:has-text("Seeded text document")').click()
await saveDocAndAssert(page)
})
test('should handle `hasMany` relationship when `appearance: "drawer"`', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-relationshipDrawerHasMany')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const firstRow = listDrawerContent.locator('table tbody tr').first()
const button = firstRow.locator('button')
await button.click()
await expect(listDrawerContent).toBeHidden()
const selectedValue = relationshipField.locator('.relationship--multi-value-label__text')
await expect(selectedValue).toBeVisible()
})
test('should handle `hasMany` polymorphic relationship when `appearance: "drawer"`', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-relationshipDrawerHasManyPolymorphic')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const firstRow = listDrawerContent.locator('table tbody tr').first()
const button = firstRow.locator('button')
await button.click()
await expect(listDrawerContent).toBeHidden()
const selectedValue = relationshipField.locator('.relationship--multi-value-label__text')
await expect(selectedValue).toBeVisible()
})
test('should not be allowed to create in relationship list drawer when `allowCreate` is `false`', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-relationshipDrawerWithAllowCreateFalse')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const createNewButton = listDrawerContent.locator('list-drawer__create-new-button')
await expect(createNewButton).toBeHidden()
})
test('should respect `filterOptions` in the relationship list drawer for filtered relationship', async () => {
// Create test documents
await createTextFieldDoc({ text: 'list drawer test' })
await createTextFieldDoc({ text: 'not test' })
await page.goto(url.create)
const relationshipField = page.locator('#field-relationshipDrawerWithFilterOptions')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const rows = page.locator('.list-drawer table tbody tr')
await expect(rows).toHaveCount(1)
})
test('should filter out existing values from relationship list drawer', async () => {
await page.goto(url.create)
await page.locator('#field-relationshipDrawer').click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const rows = listDrawerContent.locator('table tbody tr')
await expect(rows).toHaveCount(2)
await listDrawerContent.getByText('Seeded text document', { exact: true }).click()
const selectedValue = page.locator(
'#field-relationshipDrawer .relationship--single-value__text',
)
await expect(selectedValue).toHaveText('Seeded text document')
await page.locator('#field-relationshipDrawer').click()
const newRows = listDrawerContent.locator('table tbody tr')
await expect(newRows).toHaveCount(1)
await expect(listDrawerContent.getByText('Seeded text document')).toHaveCount(0)
})
})
async function createTextFieldDoc(overrides?: Partial<TextField>): Promise<TextField> {

View File

@@ -126,6 +126,71 @@ const RelationshipFields: CollectionConfig = {
type: 'relationship',
hasMany: true,
},
{
name: 'relationshipDrawer',
relationTo: 'text-fields',
admin: { appearance: 'drawer' },
type: 'relationship',
},
{
name: 'relationshipDrawerReadOnly',
relationTo: 'text-fields',
admin: {
readOnly: true,
appearance: 'drawer',
},
type: 'relationship',
},
{
name: 'polymorphicRelationshipDrawer',
admin: { appearance: 'drawer' },
type: 'relationship',
relationTo: ['text-fields', 'array-fields'],
},
{
name: 'relationshipDrawerHasMany',
relationTo: 'text-fields',
admin: {
appearance: 'drawer',
},
hasMany: true,
type: 'relationship',
},
{
name: 'relationshipDrawerHasManyPolymorphic',
relationTo: ['text-fields'],
admin: {
appearance: 'drawer',
},
hasMany: true,
type: 'relationship',
},
{
name: 'relationshipDrawerWithAllowCreateFalse',
admin: {
allowCreate: false,
appearance: 'drawer',
},
type: 'relationship',
relationTo: 'text-fields',
},
{
name: 'relationshipDrawerWithFilterOptions',
admin: { appearance: 'drawer' },
type: 'relationship',
relationTo: ['text-fields'],
filterOptions: ({ relationTo }) => {
if (relationTo === 'text-fields') {
return {
text: {
equals: 'list drawer test',
},
}
} else {
return true
}
},
},
],
slug: relationshipFieldsSlug,
}

View File

@@ -1312,6 +1312,29 @@ export interface RelationshipField {
| null;
relationToRow?: (string | null) | RowField;
relationToRowMany?: (string | RowField)[] | null;
relationshipDrawer?: (string | null) | TextField;
relationshipDrawerReadOnly?: (string | null) | TextField;
polymorphicRelationshipDrawer?:
| ({
relationTo: 'text-fields';
value: string | TextField;
} | null)
| ({
relationTo: 'array-fields';
value: string | ArrayField;
} | null);
relationshipDrawerHasMany?: (string | TextField)[] | null;
relationshipDrawerHasManyPolymorphic?:
| {
relationTo: 'text-fields';
value: string | TextField;
}[]
| null;
relationshipDrawerWithAllowCreateFalse?: (string | null) | TextField;
relationshipDrawerWithFilterOptions?: {
relationTo: 'text-fields';
value: string | TextField;
} | null;
updatedAt: string;
createdAt: string;
}
@@ -2786,6 +2809,13 @@ export interface RelationshipFieldsSelect<T extends boolean = true> {
relationshipWithMinRows?: T;
relationToRow?: T;
relationToRowMany?: T;
relationshipDrawer?: T;
relationshipDrawerReadOnly?: T;
polymorphicRelationshipDrawer?: T;
relationshipDrawerHasMany?: T;
relationshipDrawerHasManyPolymorphic?: T;
relationshipDrawerWithAllowCreateFalse?: T;
relationshipDrawerWithFilterOptions?: T;
updatedAt?: T;
createdAt?: T;
}