Compare commits

...

2 Commits

Author SHA1 Message Date
Paul Popus
3861e4820b fix: remove 'id' from the generated types for globals 2024-09-27 09:35:44 -06:00
Germán Jabloñski
17e0547db3 feat(payload, ui): add admin.allowEdit relationship field (#8398)
This PR adds a new property `allowEdit` to the admin of the relationship
field. It is very similar to the existing `allowCreate`, only in this
case it hides the edit icon:

<img width="796" alt="image"
src="https://github.com/user-attachments/assets/bbe79bb2-db06-4ec4-b023-2f1c53330fcb">
2024-09-27 09:22:03 -04:00
12 changed files with 119 additions and 37 deletions

View File

@@ -90,6 +90,7 @@ The Relationship Field inherits all of the default options from the base [Field
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **`isSortable`** | Set to `true` if you'd like this field to be sortable within the Admin UI using drag and drop (only works when `hasMany` is set to `true`). |
| **`allowCreate`** | Set to `false` if you'd like to disable the ability to create new documents from within the relationship field. |
| **`allowEdit`** | Set to `false` if you'd like to disable the ability to edit documents from within the relationship field. |
| **`sortOptions`** | Define a default sorting order for the options within a Relationship field's dropdown. [More](#sortOptions) |
### Sort Options

View File

@@ -1125,6 +1125,7 @@ type SharedRelationshipPropertiesClient = FieldBaseClient &
type RelationshipAdmin = {
allowCreate?: boolean
allowEdit?: boolean
components?: {
Error?: CustomComponent<
RelationshipFieldErrorClientComponent | RelationshipFieldErrorServerComponent
@@ -1142,7 +1143,7 @@ type RelationshipAdminClient = {
Label?: MappedComponent
} & AdminClient['components']
} & AdminClient &
Pick<RelationshipAdmin, 'allowCreate' | 'isSortable'>
Pick<RelationshipAdmin, 'allowCreate' | 'allowEdit' | 'isSortable'>
export type PolymorphicRelationshipField = {
admin?: {

View File

@@ -580,26 +580,39 @@ export function fieldsToJSONSchema(
}
// This function is part of the public API and is exported through payload/utilities
export function entityToJSONSchema(
config: SanitizedConfig,
incomingEntity: SanitizedCollectionConfig | SanitizedGlobalConfig,
interfaceNameDefinitions: Map<string, JSONSchema4>,
defaultIDType: 'number' | 'text',
): JSONSchema4 {
export function entityToJSONSchema({
config,
defaultIDType,
entityType,
incomingEntity,
interfaceNameDefinitions,
}: {
config: SanitizedConfig
defaultIDType: 'number' | 'text'
entityType: 'collection' | 'global'
incomingEntity: SanitizedCollectionConfig | SanitizedGlobalConfig
interfaceNameDefinitions: Map<string, JSONSchema4>
}): JSONSchema4 {
const entity: SanitizedCollectionConfig | SanitizedGlobalConfig = deepCopyObject(incomingEntity)
const title = entity.typescript?.interface
? entity.typescript.interface
: singular(toWords(entity.slug, true))
const idField: FieldAffectingData = { name: 'id', type: defaultIDType as 'text', required: true }
const customIdField = entity.fields.find(
(field) => fieldAffectsData(field) && field.name === 'id',
) as FieldAffectingData
if (entityType === 'collection') {
const idField: FieldAffectingData = {
name: 'id',
type: defaultIDType as 'text',
required: true,
}
const customIdField = entity.fields.find(
(field) => fieldAffectsData(field) && field.name === 'id',
) as FieldAffectingData
if (customIdField && customIdField.type !== 'group' && customIdField.type !== 'tab') {
customIdField.required = true
} else {
entity.fields.unshift(idField)
if (customIdField && customIdField.type !== 'group' && customIdField.type !== 'tab') {
customIdField.required = true
} else {
entity.fields.unshift(idField)
}
}
// mark timestamp fields required
@@ -803,13 +816,33 @@ export function configToJSONSchema(
// Collections and Globals have to be moved to the top-level definitions as well. Reason: The top-level type will be the `Config` type - we don't want all collection and global
// types to be inlined inside the `Config` type
const entityDefinitions: { [k: string]: JSONSchema4 } = [
...config.globals,
...config.collections,
].reduce((acc, entity) => {
acc[entity.slug] = entityToJSONSchema(config, entity, interfaceNameDefinitions, defaultIDType)
return acc
}, {})
const globalsEntityDefinitions: { [k: string]: JSONSchema4 } = [...config.globals].reduce(
(acc, entity) => {
acc[entity.slug] = entityToJSONSchema({
config,
defaultIDType,
entityType: 'global',
incomingEntity: entity,
interfaceNameDefinitions,
})
return acc
},
{},
)
const collectionsEntityDefinitions: { [k: string]: JSONSchema4 } = [...config.collections].reduce(
(acc, entity) => {
acc[entity.slug] = entityToJSONSchema({
config,
defaultIDType,
entityType: 'collection',
incomingEntity: entity,
interfaceNameDefinitions,
})
return acc
},
{},
)
const authOperationDefinitions = [...config.collections]
.filter(({ auth }) => Boolean(auth))
@@ -824,7 +857,8 @@ export function configToJSONSchema(
let jsonSchema: JSONSchema4 = {
additionalProperties: false,
definitions: {
...entityDefinitions,
...globalsEntityDefinitions,
...collectionsEntityDefinitions,
...Object.fromEntries(interfaceNameDefinitions),
...authOperationDefinitions,
},

View File

@@ -63,7 +63,12 @@ export type ReactSelectAdapterProps = {
disabled?: boolean
filterOption?:
| ((
{ data, label, value }: { data: Option; label: string; value: string },
{
allowEdit,
data,
label,
value,
}: { allowEdit: boolean; data: Option; label: string; value: string },
search: string,
) => boolean)
| undefined

View File

@@ -3,11 +3,12 @@ import type { Option } from '../../elements/ReactSelect/types.js'
import type { OptionGroup, Value } from './types.js'
type Args = {
allowEdit: boolean
options: OptionGroup[]
value: Value | Value[]
}
export const findOptionsByValue = ({ options, value }: Args): Option | Option[] => {
export const findOptionsByValue = ({ allowEdit, options, value }: Args): Option | Option[] => {
if (value || typeof value === 'number') {
if (Array.isArray(value)) {
return value.map((val) => {
@@ -25,7 +26,7 @@ export const findOptionsByValue = ({ options, value }: Args): Option | Option[]
}
})
return matchedOption
return matchedOption ? { allowEdit, ...matchedOption } : undefined
})
}
@@ -42,7 +43,7 @@ export const findOptionsByValue = ({ options, value }: Args): Option | Option[]
}
})
return matchedOption
return matchedOption ? { allowEdit, ...matchedOption } : undefined
}
return undefined

View File

@@ -46,6 +46,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
_path: pathFromProps,
admin: {
allowCreate = true,
allowEdit = true,
className,
description,
isSortable = true,
@@ -576,7 +577,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
}
}, [openDrawer, currentlyOpenRelationship])
const valueToRender = findOptionsByValue({ options, value })
const valueToRender = findOptionsByValue({ allowEdit, options, value })
if (!Array.isArray(valueToRender) && valueToRender?.value === 'null') {
valueToRender.value = null

View File

@@ -24,7 +24,7 @@ export const MultiValueLabel: React.FC<
} & MultiValueProps<Option>
> = (props) => {
const {
data: { label, relationTo, value },
data: { allowEdit, label, relationTo, value },
selectProps: { customProps: { draggableProps, onDocumentDrawerOpen } = {} } = {},
} = props
@@ -44,7 +44,7 @@ export const MultiValueLabel: React.FC<
}}
/>
</div>
{relationTo && hasReadPermission && (
{relationTo && hasReadPermission && allowEdit !== false && (
<Fragment>
<button
aria-label={`Edit ${label}`}

View File

@@ -25,7 +25,7 @@ export const SingleValue: React.FC<
> = (props) => {
const {
children,
data: { label, relationTo, value },
data: { allowEdit, label, relationTo, value },
selectProps: { customProps: { onDocumentDrawerOpen } = {} } = {},
} = props
@@ -39,7 +39,7 @@ export const SingleValue: React.FC<
<div className={`${baseClass}__label`}>
<div className={`${baseClass}__label-text`}>
<div className={`${baseClass}__text`}>{children}</div>
{relationTo && hasReadPermission && (
{relationTo && hasReadPermission && allowEdit !== false && (
<Fragment>
<button
aria-label={t('general:editLabel', { label })}

View File

@@ -2,6 +2,7 @@ import type { I18nClient } from '@payloadcms/translations'
import type { ClientCollectionConfig, ClientConfig, FilterOptionsResult } from 'payload'
export type Option = {
allowEdit: boolean
label: string
options?: Option[]
relationTo?: string

View File

@@ -159,11 +159,32 @@ describe('relationship', () => {
test('should hide relationship add new button', async () => {
await page.goto(url.create)
await page.waitForURL(url.create)
const locator1 = page.locator(
'#relationWithAllowEditToFalse-add-new .relationship-add-new__add-button',
)
await expect(locator1).toHaveCount(1)
// expect the button to not exist in the field
const count = await page
.locator('#relationToSelfSelectOnly-add-new .relationship-add-new__add-button')
.count()
expect(count).toEqual(0)
const locator2 = page.locator(
'#relationWithAllowCreateToFalse-add-new .relationship-add-new__add-button',
)
await expect(locator2).toHaveCount(0)
})
test('should hide relationship edit button', async () => {
await page.goto(url.create)
await page.waitForURL(url.create)
const locator1 = page
.locator('#field-relationWithAllowEditToFalse')
.getByLabel('Edit dev@payloadcms.com')
await expect(locator1).toHaveCount(0)
const locator2 = page
.locator('#field-relationWithAllowCreateToFalse')
.getByLabel('Edit dev@payloadcms.com')
await expect(locator2).toHaveCount(1)
// The reason why I check for locator 1 again is that I've noticed that sometimes
// the default value does not appear after the first locator is tested. IDK why.
await expect(locator1).toHaveCount(0)
})
// TODO: Flaky test in CI - fix this. https://github.com/payloadcms/payload/actions/runs/8910825395/job/24470963991

View File

@@ -38,10 +38,25 @@ const RelationshipFields: CollectionConfig = {
},
{
name: 'relationToSelfSelectOnly',
relationTo: relationshipFieldsSlug,
type: 'relationship',
},
{
name: 'relationWithAllowCreateToFalse',
admin: {
allowCreate: false,
},
relationTo: relationshipFieldsSlug,
defaultValue: ({ user }) => user?.id,
relationTo: 'users',
type: 'relationship',
},
{
name: 'relationWithAllowEditToFalse',
admin: {
allowEdit: false,
},
defaultValue: ({ user }) => user?.id,
relationTo: 'users',
type: 'relationship',
},
{

View File

@@ -1140,6 +1140,8 @@ export interface RelationshipField {
| null;
relationToSelf?: (string | null) | RelationshipField;
relationToSelfSelectOnly?: (string | null) | RelationshipField;
relationWithAllowCreateToFalse?: (string | null) | User;
relationWithAllowEditToFalse?: (string | null) | User;
relationWithDynamicDefault?: (string | null) | User;
relationHasManyWithDynamicDefault?: {
relationTo: 'users';