Disable linking a new external source while an attribute still applies to a resource

This commit is contained in:
Julien Tant
2026-08-26 10:45:54 -07:00
parent e1c6d188ac
commit c8b85585a6
5 changed files with 87 additions and 36 deletions
@@ -1309,6 +1309,22 @@ describe('AttributeDetails', () => {
expect(screen.queryByTestId('attributeTypeLockWrap')).not.toBeInTheDocument();
});
it('locks adding an external source while a pending Applies-to resource is on the form, so linking cannot silently change the type', async () => {
mockLoadedField(makeTemplate({type: 'select', attrs: {display_name: 'Department', options: [{id: 'opt-1', name: 'Engineering'}]}}), [makeLinked('user', 'user-field')]);
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributeExternalSourceTrigger')).toBeDisabled();
expect(screen.getByTestId('attributeExternalSourceTriggerLockWrap')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-remove'));
await waitFor(() => expect(screen.queryByTestId('attributeAppliesToRow-user')).not.toBeInTheDocument());
expect(screen.getByTestId('attributeExternalSourceTrigger')).not.toBeDisabled();
expect(screen.queryByTestId('attributeExternalSourceTriggerLockWrap')).not.toBeInTheDocument();
});
it('locks Name editing while the attribute is currently applied to a resource', async () => {
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field')]);
renderEdit();
@@ -1129,6 +1129,7 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
fieldType={fieldType}
onLink={handleLink}
disabled={saving || disabled}
disableAdding={typeLockedByAppliesTo}
/>
</div>
</div>
@@ -43,6 +43,15 @@ describe('AttributeExternalSource', () => {
expect(screen.getByRole('menuitem', {name: /^SAML/})).toBeInTheDocument();
});
it('disables the add-source trigger when disableAdding is set, without disabling an existing chip\'s edit/remove actions', () => {
renderComponent({ldapAttr: 'department', disableAdding: true});
expect(screen.getByTestId('attributeExternalSourceTrigger')).toBeDisabled();
expect(screen.getByTestId('attributeExternalSourceTriggerLockWrap')).toBeInTheDocument();
expect(screen.getByTestId('attributeExternalSourceChip-ldap-edit')).not.toBeDisabled();
expect(screen.getByTestId('attributeExternalSourceChip-ldap-remove')).not.toBeDisabled();
});
it('renders a chip for a linked source prefixed by Synced with, and offers only the remaining source', async () => {
renderComponent({ldapAttr: 'department'});
@@ -9,6 +9,7 @@ import {components} from 'react-select';
import {PencilOutlineIcon, RefreshIcon, SyncIcon} from '@mattermost/compass-icons/components';
import {buttonClassNames} from '@mattermost/shared/components/button';
import {WithTooltip} from '@mattermost/shared/components/tooltip';
import {openModal} from 'actions/views/modals';
@@ -34,13 +35,21 @@ type Props = {
fieldType: AttributeFieldType;
onLink: (source: ExternalSource, value: string) => void;
disabled?: boolean;
// Linking a new source forces fieldType to 'text' (see attribute_details.tsx's
// handleLink) -- while this attribute is applied to a resource, that would
// change its type out from under the server's type_change_with_dependents
// guard the same way the Type menu itself is locked for. Only gates the
// "add" trigger below: editing or removing an already-linked source never
// touches fieldType, so those stay enabled.
disableAdding?: boolean;
};
function sourceValue(source: ExternalSource, ldapAttr: string, samlAttr: string): string {
return source === 'ldap' ? ldapAttr : samlAttr;
}
function AttributeExternalSource({ldapAttr, samlAttr, fieldType, onLink, disabled = false}: Props): JSX.Element {
function AttributeExternalSource({ldapAttr, samlAttr, fieldType, onLink, disabled = false, disableAdding = false}: Props): JSX.Element {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
@@ -156,41 +165,52 @@ function AttributeExternalSource({ldapAttr, samlAttr, fieldType, onLink, disable
</div>
)}
{unlinkedSources.length > 0 && (
<Menu.Container
menuButton={{
id: TRIGGER_ID,
class: classNames(buttonClassNames({emphasis: 'quaternary'}), 'AttributeExternalSource__trigger'),
disabled,
onMouseDown: handleTriggerMouseDown,
children: (
<>
<RefreshIcon size={16}/>
<FormattedMessage {...messages.triggerLabel}/>
<i className='icon icon-chevron-down'/>
</>
),
dataTestId: 'attributeExternalSourceTrigger',
}}
menu={{
id: 'attribute-external-source-menu',
'aria-label': formatMessage(messages.triggerLabel),
}}
>
{unlinkedSources.map((source) => (
<Menu.Item
id={`attribute-external-source-${source}`}
key={source}
leadingElement={<SyncIcon size={18}/>}
onClick={() => openLinkModal(source)}
labels={(
<>
<FormattedMessage {...sourceMessages[source].title}/>
<FormattedMessage {...sourceMessages[source].subtitle}/>
</>
)}
/>
))}
</Menu.Container>
(() => {
const trigger = (
<Menu.Container
menuButton={{
id: TRIGGER_ID,
class: classNames(buttonClassNames({emphasis: 'quaternary'}), 'AttributeExternalSource__trigger'),
disabled: disabled || disableAdding,
onMouseDown: handleTriggerMouseDown,
children: (
<>
<RefreshIcon size={16}/>
<FormattedMessage {...messages.triggerLabel}/>
<i className='icon icon-chevron-down'/>
</>
),
dataTestId: 'attributeExternalSourceTrigger',
}}
menu={{
id: 'attribute-external-source-menu',
'aria-label': formatMessage(messages.triggerLabel),
}}
>
{unlinkedSources.map((source) => (
<Menu.Item
id={`attribute-external-source-${source}`}
key={source}
leadingElement={<SyncIcon size={18}/>}
onClick={() => openLinkModal(source)}
labels={(
<>
<FormattedMessage {...sourceMessages[source].title}/>
<FormattedMessage {...sourceMessages[source].subtitle}/>
</>
)}
/>
))}
</Menu.Container>
);
return disableAdding ? (
<WithTooltip title={formatMessage(messages.disabledWhileAppliesToTooltip)}>
<span data-testid='attributeExternalSourceTriggerLockWrap'>
{trigger}
</span>
</WithTooltip>
) : trigger;
})()
)}
<span
role='status'
@@ -263,6 +283,10 @@ const messages = defineMessages({
id: 'admin.global_attributes.attribute_details.external_source.links_removed',
defaultMessage: '{count, plural, one {External source link removed} other {External source links removed}}',
},
disabledWhileAppliesToTooltip: {
id: 'admin.global_attributes.attribute_details.external_source.disabled_applies_to_tooltip',
defaultMessage: 'Cannot link an external source while this attribute applies to a resource.',
},
});
const sourceMessages = {
+1
View File
@@ -1468,6 +1468,7 @@
"admin.global_attributes.attribute_details.display_name.placeholder": "Add a display name",
"admin.global_attributes.attribute_details.edit_title": "Edit attribute",
"admin.global_attributes.attribute_details.external_source.chip_label": "{source}: {value}",
"admin.global_attributes.attribute_details.external_source.disabled_applies_to_tooltip": "Cannot link an external source while this attribute applies to a resource.",
"admin.global_attributes.attribute_details.external_source.edit_link": "Edit {source} link",
"admin.global_attributes.attribute_details.external_source.ldap.help_text": "The attribute in your AD/LDAP directory to sync this value from.",
"admin.global_attributes.attribute_details.external_source.ldap.modal_title": "Link to AD/LDAP",