diff --git a/webapp/channels/src/components/admin_console/blockable_link/blockable_link.test.tsx b/webapp/channels/src/components/admin_console/blockable_link/blockable_link.test.tsx
new file mode 100644
index 0000000000..b426704f2b
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/blockable_link/blockable_link.test.tsx
@@ -0,0 +1,99 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {fireEvent, render, screen} from '@testing-library/react';
+import React from 'react';
+import {MemoryRouter} from 'react-router-dom';
+
+import BlockableLink from './blockable_link';
+
+jest.mock('utils/browser_history', () => ({
+ getHistory: jest.fn().mockReturnValue({
+ push: jest.fn(),
+ }),
+}));
+
+describe('components/admin_console/blockable_link/BlockableLink', () => {
+ const defaultProps = {
+ to: '/admin_console/test',
+ blocked: false,
+ actions: {
+ deferNavigation: jest.fn(),
+ },
+ children: 'Link Text',
+ };
+
+ test('should render properly', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('Link Text')).toBeInTheDocument();
+ expect(screen.getByRole('link')).toHaveAttribute('href', '/admin_console/test');
+ });
+
+ test('should navigate directly when not blocked', () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByText('Link Text'));
+ expect(defaultProps.actions.deferNavigation).not.toHaveBeenCalled();
+ });
+
+ test('should defer navigation when blocked', () => {
+ const blockedProps = {
+ ...defaultProps,
+ blocked: true,
+ };
+
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByText('Link Text'));
+ expect(blockedProps.actions.deferNavigation).toHaveBeenCalled();
+ });
+
+ test('should call custom onClick handler if provided', () => {
+ const onClickProps = {
+ ...defaultProps,
+ onClick: jest.fn(),
+ };
+
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByText('Link Text'));
+ expect(onClickProps.onClick).toHaveBeenCalled();
+ });
+
+ test('should apply additional props correctly', () => {
+ const customProps = {
+ ...defaultProps,
+ className: 'custom-class',
+ id: 'custom-id',
+ 'data-testid': 'custom-test-id',
+ };
+
+ render(
+
+
+ ,
+ );
+
+ const link = screen.getByRole('link');
+ expect(link).toHaveClass('custom-class');
+ expect(link).toHaveAttribute('id', 'custom-id');
+ expect(link).toHaveAttribute('data-testid', 'custom-test-id');
+ });
+});
diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx
index ddd90366b2..bc1464f0a6 100644
--- a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx
+++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx
@@ -3,6 +3,7 @@
import {fireEvent, screen, waitFor} from '@testing-library/react';
import React from 'react';
+import type {ComponentProps} from 'react';
import type {UserPropertyField} from '@mattermost/types/properties';
@@ -30,19 +31,23 @@ describe('UserPropertyDotMenu', () => {
const updateField = jest.fn();
const deleteField = jest.fn();
+ const createField = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
- const renderComponent = (field: UserPropertyField = baseField) => {
+ const renderComponent = (field: UserPropertyField = baseField, dotMenuProps?: Partial>) => {
return renderWithContext(
(
@@ -105,6 +110,51 @@ describe('UserPropertyDotMenu', () => {
});
});
+ it('displays LDAP and SAML link menu options', async () => {
+ renderComponent();
+
+ // Open the menu
+ const menuButton = screen.getByTestId(`user-property-field_dotmenu-${baseField.id}`);
+ fireEvent.click(menuButton);
+
+ // Verify both link options are shown
+ expect(screen.getByText('Link property to AD/LDAP')).toBeInTheDocument();
+ expect(screen.getByText('Link property to SAML')).toBeInTheDocument();
+
+ // TODO mock history and verify the link actions
+ });
+
+ it('handles field duplication', async () => {
+ renderComponent();
+
+ // Open the menu
+ const menuButton = screen.getByTestId(`user-property-field_dotmenu-${baseField.id}`);
+ fireEvent.click(menuButton);
+
+ // Click the duplicate option
+ fireEvent.click(screen.getByText(/Duplicate property/));
+
+ // Wait for createField to be called
+ await waitFor(() => {
+ // Verify createField was called with the correct parameters
+ expect(createField).toHaveBeenCalledWith(expect.objectContaining({
+ id: baseField.id,
+ name: 'Test Field (copy)',
+ }));
+ });
+ });
+
+ it('hides field duplication when at field limit', async () => {
+ renderComponent(undefined, {canCreate: false});
+
+ // Open the menu
+ const menuButton = screen.getByTestId(`user-property-field_dotmenu-${baseField.id}`);
+ fireEvent.click(menuButton);
+
+ // Verify duplicate option is not shown
+ expect(screen.queryByText(/Duplicate property/)).not.toBeInTheDocument();
+ });
+
it('handles field deletion with confirmation when field exists in DB', async () => {
renderComponent();
diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx
index 098cadf8b2..19ea4aa34b 100644
--- a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx
+++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx
@@ -2,9 +2,9 @@
// See LICENSE.txt for license information.
import React from 'react';
-import {FormattedMessage} from 'react-intl';
+import {FormattedMessage, useIntl} from 'react-intl';
-import {CheckIcon, ChevronRightIcon, DotsHorizontalIcon, EyeOutlineIcon, TrashCanOutlineIcon} from '@mattermost/compass-icons/components';
+import {CheckIcon, ChevronRightIcon, DotsHorizontalIcon, EyeOutlineIcon, SyncIcon, TrashCanOutlineIcon, ContentCopyIcon} from '@mattermost/compass-icons/components';
import type {FieldVisibility, UserPropertyField} from '@mattermost/types/properties';
import * as Menu from 'components/menu';
@@ -12,9 +12,10 @@ import * as Menu from 'components/menu';
import './user_properties_dot_menu.scss';
import {useUserPropertyFieldDelete} from './user_properties_delete_modal';
import {isCreatePending} from './user_properties_utils';
-
type Props = {
field: UserPropertyField;
+ canCreate: boolean;
+ createField: (field: UserPropertyField) => void;
updateField: (field: UserPropertyField) => void;
deleteField: (id: string) => void;
}
@@ -23,11 +24,23 @@ const menuId = 'user-property-field_dotmenu';
const DotMenu = ({
field,
+ canCreate,
+ createField,
updateField,
deleteField,
}: Props) => {
+ const {formatMessage} = useIntl();
const {promptDelete} = useUserPropertyFieldDelete();
+ const handleDuplicate = () => {
+ const name = formatMessage({
+ id: 'admin.system_properties.user_properties.dotmenu.duplicate.name_copy',
+ defaultMessage: '{fieldName} (copy)',
+ }, {fieldName: field.name});
+
+ createField({...field, attrs: {...field.attrs}, name});
+ };
+
const handleDelete = () => {
if (isCreatePending(field)) {
// skip prompt when field is pending creation
@@ -161,18 +174,53 @@ const DotMenu = ({
)}
/>
+