Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,20 @@ jest.mock('@Pimcore/components/modal/form-modal/hooks/use-form-modal', () => ({
useFormModal: () => ({ confirm: jest.fn() })
}))

// Records the props so the upload guard can be driven through the real `onSuccess`.
let mockUploadButtonProps: Record<string, any> | null = null

jest.mock('@Pimcore/components/modal-upload/components/modal-upload-button/modal-upload-button', () => ({
ModalUploadButton: () => null
ModalUploadButton: (props: Record<string, any>) => {
mockUploadButtonProps = props
return null
}
}))

const mockAlertWarn = jest.fn()

jest.mock('@Pimcore/components/modal/alert-modal/hooks/use-alert-modal', () => ({
useAlertModal: () => ({ warn: mockAlertWarn })
}))

jest.mock('@Pimcore/modules/element/element-selector/components/triggers/button/element-selector-button', () => ({
Expand Down Expand Up @@ -63,15 +75,22 @@ const appliedFilter = {
locale: null
}

const renderToolbar = (initialValues?: Record<string, unknown>): void => {
const renderToolbar = (
initialValues?: Record<string, unknown>,
overrides: Partial<ManyToManyRelationToolbarProps> = {}
): void => {
mockUploadButtonProps = null
mockAlertWarn.mockClear()

const props: ManyToManyRelationToolbarProps = {
addAssets: async () => {},
addItems: () => {},
allowClear: false,
empty: () => {},
enableUpload: false,
onSearch: () => {},
disabled: true
disabled: true,
...overrides
}

render(
Expand Down Expand Up @@ -105,3 +124,51 @@ describe('ManyToManyRelationToolbar clear filters button', () => {
expect(screen.queryByLabelText(CLEAR_FILTERS_LABEL)).not.toBeInTheDocument()
})
})

describe('ManyToManyRelationToolbar inline upload guard', () => {
const image = { id: 1, type: 'image', fullPath: '/examples/image.jpg' }
const pdf = { id: 2, type: 'document', fullPath: '/examples/spec.pdf' }

const renderUploadToolbar = (
overrides: Partial<ManyToManyRelationToolbarProps> = {}
): jest.Mock => {
const addAssets = jest.fn(async () => {})

renderToolbar(undefined, {
addAssets,
disabled: false,
enableUpload: true,
assetsAllowed: true,
...overrides
})

return addAssets
}

it('assigns every uploaded asset when the relation restricts no subtype', async () => {
const addAssets = renderUploadToolbar()

await mockUploadButtonProps?.onSuccess([image, pdf])

expect(addAssets).toHaveBeenCalledWith([image, pdf])
expect(mockAlertWarn).not.toHaveBeenCalled()
})

it('drops the uploaded assets whose type the relation disallows', async () => {
const addAssets = renderUploadToolbar({ allowedAssetTypes: ['image'] })

await mockUploadButtonProps?.onSuccess([image, pdf])

expect(addAssets).toHaveBeenCalledWith([image])
expect(mockAlertWarn).toHaveBeenCalledWith({ content: 'relations.upload.subtype-not-allowed' })
})

it('does not touch the value when every uploaded asset is disallowed', async () => {
const addAssets = renderUploadToolbar({ allowedAssetTypes: ['image'] })

await mockUploadButtonProps?.onSuccess([pdf])

expect(addAssets).not.toHaveBeenCalled()
expect(mockAlertWarn).toHaveBeenCalledWith({ content: 'relations.upload.subtype-not-allowed' })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import { type Asset } from '@Pimcore/modules/asset/asset-api-slice.gen'
import { ButtonGroup } from '@Pimcore/components/button-group/button-group'
import { ElementSelectorButton } from '@Pimcore/modules/element/element-selector/components/triggers/button/element-selector-button'
import { SelectionType } from '@Pimcore/modules/element/element-selector/provider/element-selector/element-selector-provider'
import { createElementSelectorAreas, createElementSelectorConfig, type IRelationAllowedTypesDataComponent } from '@Pimcore/modules/element/dynamic-types/definitions/objects/data-related/helpers/relations/allowed-types'
import { createElementSelectorAreas, createElementSelectorConfig, isAllowedSubType, type IRelationAllowedTypesDataComponent } from '@Pimcore/modules/element/dynamic-types/definitions/objects/data-related/helpers/relations/allowed-types'
import { useAlertModal } from '@Pimcore/components/modal/alert-modal/hooks/use-alert-modal'
import { mapToLegacyElementType } from '@Pimcore/modules/element/utils/element-type'
import { type ManyToManyRelationValueItem } from '../../hooks/use-value'
import { type SelectedItem } from '@sdk/modules/element'
Expand All @@ -44,6 +45,26 @@ export interface ManyToManyRelationToolbarProps extends IRelationAllowedTypesDat
export const ManyToManyRelationToolbar = (props: ManyToManyRelationToolbarProps): React.JSX.Element => {
const { confirm } = useFormModal()
const { t } = useTranslation()
const alertModal = useAlertModal()

/**
* The assets already exist at this point - the server created them and decided their
* type. Assigning them anyway would put values into the relation that neither the
* element selector nor drag and drop would have accepted.
*/
const handleUploadSuccess = async (assets: Asset[]): Promise<void> => {
const allowedAssets = assets.filter((asset) => isAllowedSubType('asset', asset.type ?? '', props))

if (allowedAssets.length < assets.length) {
alertModal.warn({ content: t('relations.upload.subtype-not-allowed') })
}

if (allowedAssets.length === 0) {
return
}

await props.addAssets(allowedAssets)
}

const filtersStore = useRelationFiltersOptional()

Expand Down Expand Up @@ -89,7 +110,7 @@ export const ManyToManyRelationToolbar = (props: ManyToManyRelationToolbarProps)
buttons.push(
<ModalUploadButton
maxItems={ props.uploadMaxItems }
onSuccess={ props.addAssets }
onSuccess={ handleUploadSuccess }
showMaxItemsError={ props.uploadShowMaxItemsError }
targetFolderPath={ props.assetUploadPath ?? undefined }
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const config: Meta<typeof ManyToOneRelation> = {
- **Element Selector**: Built-in element selector for browsing and selecting elements
- **Path Text Input**: Optional mode for entering paths manually as text
- **Asset Downloads**: Inline download functionality for asset elements
- **Inline Upload**: Upload button that creates a new asset and assigns it as the relation (opt-out via \`disableInlineUpload\`)
- **Type Validation**: Configurable allowed types for each element category
- **Width Customization**: Flexible width configuration with field width provider integration

Expand Down Expand Up @@ -125,13 +126,15 @@ const ConfigurationsExample = (): React.JSX.Element => {
objectsOnly: ManyToOneRelationValueType
withTextInput: ManyToOneRelationValueType
withDownload: ManyToOneRelationValueType
withUpload: ManyToOneRelationValueType
readOnly: ManyToOneRelationValueType
}>({
assetsOnly: null,
documentsOnly: null,
objectsOnly: null,
withTextInput: null,
withDownload: null,
withUpload: null,
readOnly: {
type: 'document',
id: 123,
Expand Down Expand Up @@ -249,6 +252,22 @@ const ConfigurationsExample = (): React.JSX.Element => {
</p>
</div>

{/* With Inline Upload */}
<div style={ { padding: '16px', border: '1px solid #e1e1e1', borderRadius: '8px' } }>
<h4 style={ { margin: '0 0 12px 0' } }>With Inline Upload</h4>
<ManyToOneRelation
allowToClearRelation
allowedAssetTypes={ ['image', 'document', 'video'] }
assetUploadPath="/examples"
assetsAllowed
onChange={ (value) => { handleChange('withUpload', value) } }
value={ values.withUpload }
/>
<p style={ { fontSize: '12px', color: '#666', margin: '8px 0 0 0' } }>
Uploads a new asset into <code>/examples</code> and assigns it as the relation
</p>
</div>

{/* Read Only */}
<div style={ { padding: '16px', border: '1px solid #e1e1e1', borderRadius: '8px' } }>
<h4 style={ { margin: '0 0 12px 0' } }>Read Only</h4>
Expand Down Expand Up @@ -294,7 +313,7 @@ export const Configurations: Story = {
layout: 'fullscreen',
docs: {
description: {
story: 'Showcase of different ManyToOneRelation configurations: assets-only, documents-only, objects-only, with path text input, with asset download functionality, and read-only mode. Each configuration demonstrates specific use cases and feature combinations.'
story: 'Showcase of different ManyToOneRelation configurations: assets-only, documents-only, objects-only, with path text input, with asset download functionality, with inline asset upload, and read-only mode. Each configuration demonstrates specific use cases and feature combinations.'
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ describe('ManyToOneRelation upload button', () => {
expect(screen.queryByTestId('modal-upload-button')).toBeNull()
})

it('does not render an upload button when inline upload is disabled', () => {
render(
<ManyToOneRelation
assetsAllowed
disableInlineUpload
onChange={ jest.fn() }
/>
)

expect(screen.queryByTestId('modal-upload-button')).toBeNull()
})

it('sets the field value to the uploaded asset on successful upload', async () => {
const onChange = jest.fn()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface PathTextInputValue {

export interface ManyToOneRelationClassDefinitionProps {
assetInlineDownloadAllowed?: boolean
/** Folder the inline upload puts the new asset into. Defaults to the asset root. */
assetUploadPath?: string | null
allowToClearRelation?: boolean
allowPathTextInput?: boolean
Expand All @@ -70,6 +71,8 @@ export interface ManyToOneRelationProps extends IRelationAllowedTypesDataCompone
onChange?: (value: ManyToOneRelationValueType) => void
onOpenElement?: () => void
className?: string
/** Hides the upload button that is otherwise shown whenever assets are allowed. */
disableInlineUpload?: boolean
combinedFieldName?: string
pathFormatterClass?: string
additionalButtons?: (value: ManyToOneRelationValueType) => React.ReactNode
Expand Down Expand Up @@ -214,7 +217,7 @@ export const ManyToOneRelation = (props: ManyToOneRelationProps): React.JSX.Elem
/>
)}

{isEnabled && props.assetsAllowed === true && (
{isEnabled && props.assetsAllowed === true && props.disableInlineUpload !== true && (
<ModalUploadButton
maxItems={ 1 }
multiple={ false }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type RelationEditableDefinition = Omit<AbstractDocumentEditableDefinition
reload?: boolean
width?: number
uploadPath?: string
disableInlineUpload?: boolean
class?: string
}
}
Expand All @@ -49,9 +50,11 @@ export class DynamicTypeDocumentEditableRelation extends DynamicTypeDocumentEdit
allowedClasses={ props.config?.classes }
allowedDataObjectTypes={ props.config?.subtypes?.object }
allowedDocumentTypes={ props.config?.subtypes?.document }
assetUploadPath={ props.config?.uploadPath ?? undefined }
assetsAllowed={ isTypeAllowed(props.config?.types, 'asset') }
className={ props.config?.class }
dataObjectsAllowed={ isTypeAllowed(props.config?.types, 'object') }
disableInlineUpload={ props.config?.disableInlineUpload ?? undefined }
documentsAllowed={ isTypeAllowed(props.config?.types, 'document') }
inherited={ props.inherited }
width={ props.config?.width }
Expand Down
Binary file not shown.
2 changes: 2 additions & 0 deletions translations/studio.de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1302,8 +1302,10 @@ relations.subtype: Untertyp
relations.classname: Klasse
relations.fullpath: Vollständiger Pfad
relations.remove-all.confirm: Möchten Sie wirklich alle Relationen löschen?
relations.upload.subtype-not-allowed: Hochgeladene Assets, deren Typ diese Relation nicht zulässt, wurden nicht zugewiesen.
relation: Relation
items-limit-reached: Die maximale Anzahl von Einträgen ({{maxItems}}) wurde erreicht.
asset-upload-type-not-allowed: Der Typ des hochgeladenen Assets ist für dieses Feld nicht zulässig.
actions: Aktionen
asset: Asset
data-object: Data Object
Expand Down
1 change: 1 addition & 0 deletions translations/studio.en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,7 @@ relations.subtype: Subtype
relations.classname: Class
relations.fullpath: Full Path
relations.remove-all.confirm: Do you really want to delete all relations?
relations.upload.subtype-not-allowed: Uploaded assets whose type this relation does not allow were not assigned.
relation: Relation
items-limit-reached: The maximum number of items ({{maxItems}}) has been reached.
asset-upload-type-not-allowed: The uploaded asset's type is not allowed for this field.
Expand Down
2 changes: 2 additions & 0 deletions translations/studio.es.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1302,8 +1302,10 @@ relations.subtype: Subtipo
relations.classname: Clase
relations.fullpath: Ruta completa
relations.remove-all.confirm: ¿Realmente desea eliminar todas las relaciones?
relations.upload.subtype-not-allowed: Los assets subidos cuyo tipo no está permitido en esta relación no se asignaron.
relation: relación
items-limit-reached: Se ha alcanzado el número máximo de elementos ({{maxItems}}).
asset-upload-type-not-allowed: El tipo del asset subido no está permitido para este campo.
actions: Acciones
asset: Asset
data-object: Data Object
Expand Down
2 changes: 2 additions & 0 deletions translations/studio.fr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1302,8 +1302,10 @@ relations.subtype: Sous-type
relations.classname: Classe
relations.fullpath: Chemin complet
relations.remove-all.confirm: Voulez-vous vraiment supprimer toutes les relations ?
relations.upload.subtype-not-allowed: Les assets téléversés dont le type n'est pas autorisé par cette relation n'ont pas été affectés.
relation: relation
items-limit-reached: Le nombre maximum d'éléments ({{maxItems}}) a été atteint.
asset-upload-type-not-allowed: Le type de l'asset téléversé n'est pas autorisé pour ce champ.
actions: Actions
asset: Asset
data-object: Data Object
Expand Down
2 changes: 2 additions & 0 deletions translations/studio.it.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1302,8 +1302,10 @@ relations.subtype: Sottotipo
relations.classname: Classe
relations.fullpath: Percorso completo
relations.remove-all.confirm: Eliminare tutte le relazioni?
relations.upload.subtype-not-allowed: Gli asset caricati il cui tipo non è consentito da questa relazione non sono stati assegnati.
relation: relazione
items-limit-reached: Il numero massimo di elementi ({{maxItems}}) è stato raggiunto.
asset-upload-type-not-allowed: Il tipo dell'asset caricato non è consentito per questo campo.
actions: Azioni
asset: Asset
data-object: Data Object
Expand Down
2 changes: 2 additions & 0 deletions translations/studio.no.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1302,8 +1302,10 @@ relations.subtype: Undertype
relations.classname: Klasse
relations.fullpath: Full sti
relations.remove-all.confirm: Vil du virkelig slette alle relasjoner?
relations.upload.subtype-not-allowed: Opplastede assets med en type som denne relasjonen ikke tillater, ble ikke tilordnet.
relation: relasjon
items-limit-reached: Maksimalt antall elementer ({{maxItems}}) er nådd.
asset-upload-type-not-allowed: Typen til det opplastede assetet er ikke tillatt for dette feltet.
actions: Handlinger
asset: Ressurs
data-object: Data Object
Expand Down
2 changes: 2 additions & 0 deletions translations/studio.sv.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1302,8 +1302,10 @@ relations.subtype: Undertyp
relations.classname: Klass
relations.fullpath: Fullständig sökväg
relations.remove-all.confirm: Vill du verkligen ta bort alla relationer?
relations.upload.subtype-not-allowed: Uppladdade assets vars typ den här relationen inte tillåter tilldelades inte.
relation: relation
items-limit-reached: Maximalt antal objekt ({{maxItems}}) har nåtts.
asset-upload-type-not-allowed: Typen på det uppladdade assetet är inte tillåten för det här fältet.
actions: Åtgärder
asset: Asset
data-object: Data Object
Expand Down
Loading