Skip to content

Commit

Permalink
[Workspace]Add WorkspaceCollaboratorTypesService and AddCollaborators…
Browse files Browse the repository at this point in the history
…Modal (#8486)
  • Loading branch information
wanglam authored Oct 5, 2024
1 parent 5203139 commit d3ddf91
Show file tree
Hide file tree
Showing 18 changed files with 1,102 additions and 5 deletions.
2 changes: 2 additions & 0 deletions changelogs/fragments/8486.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- [Workspace]Add WorkspaceCollaboratorTypesService and AddCollaboratorsModal ([#8486](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8486))
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { AddCollaboratorsModal } from './add_collaborators_modal';

describe('AddCollaboratorsModal', () => {
const defaultProps = {
title: 'Add Collaborators',
inputLabel: 'Collaborator ID',
addAnotherButtonLabel: 'Add Another',
permissionType: 'readOnly',
onClose: jest.fn(),
onAddCollaborators: jest.fn(),
};

afterEach(() => {
jest.clearAllMocks();
});

it('renders the modal with the correct title', () => {
render(<AddCollaboratorsModal {...defaultProps} />);
expect(screen.getByText(defaultProps.title)).toBeInTheDocument();
});

it('renders the collaborator input field with the correct label', () => {
render(<AddCollaboratorsModal {...defaultProps} />);
expect(screen.getByLabelText(defaultProps.inputLabel)).toBeInTheDocument();
});

it('renders the "Add Another" button with the correct label', () => {
render(<AddCollaboratorsModal {...defaultProps} />);
expect(
screen.getByRole('button', { name: defaultProps.addAnotherButtonLabel })
).toBeInTheDocument();
});

it('calls onAddCollaborators with valid collaborators when clicking the "Add collaborators" button', async () => {
render(<AddCollaboratorsModal {...defaultProps} />);
const collaboratorInput = screen.getByLabelText(defaultProps.inputLabel);
fireEvent.change(collaboratorInput, { target: { value: 'user1' } });
const addCollaboratorsButton = screen.getByRole('button', { name: 'Add collaborators' });
fireEvent.click(addCollaboratorsButton);
await waitFor(() => {
expect(defaultProps.onAddCollaborators).toHaveBeenCalledWith([
{ collaboratorId: 'user1', accessLevel: 'readOnly', permissionType: 'readOnly' },
]);
});
});

it('calls onClose when clicking the "Cancel" button', () => {
render(<AddCollaboratorsModal {...defaultProps} />);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
fireEvent.click(cancelButton);
expect(defaultProps.onClose).toHaveBeenCalled();
});

it('renders the description if provided', () => {
const props = { ...defaultProps, description: 'Add collaborators to your workspace' };
render(<AddCollaboratorsModal {...props} />);
expect(screen.getByText(props.description)).toBeInTheDocument();
});

it('renders the instruction if provided', () => {
const instruction = {
title: 'Instructions',
detail: 'Follow these instructions to add collaborators',
};
const props = { ...defaultProps, instruction };
render(<AddCollaboratorsModal {...props} />);
expect(screen.getByText(instruction.title)).toBeInTheDocument();
expect(screen.getByText(instruction.detail)).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import {
EuiAccordion,
EuiHorizontalRule,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSmallButton,
EuiSmallButtonEmpty,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import React, { useState } from 'react';
import { i18n } from '@osd/i18n';

import { WorkspaceCollaboratorPermissionType, WorkspaceCollaborator } from '../../types';
import {
WorkspaceCollaboratorsPanel,
WorkspaceCollaboratorInner,
} from './workspace_collaborators_panel';

export interface AddCollaboratorsModalProps {
title: string;
description?: string;
inputLabel: string;
addAnotherButtonLabel: string;
inputDescription?: string;
inputPlaceholder?: string;
instruction?: {
title: string;
detail: string;
link?: string;
};
permissionType: WorkspaceCollaboratorPermissionType;
onClose: () => void;
onAddCollaborators: (collaborators: WorkspaceCollaborator[]) => Promise<void>;
}

export const AddCollaboratorsModal = ({
title,
inputLabel,
instruction,
description,
permissionType,
inputDescription,
inputPlaceholder,
addAnotherButtonLabel,
onClose,
onAddCollaborators,
}: AddCollaboratorsModalProps) => {
const [collaborators, setCollaborators] = useState<WorkspaceCollaboratorInner[]>([
{ id: 0, accessLevel: 'readOnly', collaboratorId: '' },
]);
const validCollaborators = collaborators.flatMap(({ collaboratorId, accessLevel }) => {
if (!collaboratorId) {
return [];
}
return { collaboratorId, accessLevel, permissionType };
});

const handleAddCollaborators = () => {
onAddCollaborators(validCollaborators);
};

return (
<EuiModal style={{ minWidth: 748 }} onClose={onClose}>
<EuiModalHeader>
<EuiModalHeaderTitle>
<h2>{title}</h2>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
{description && (
<>
<EuiText size="xs">{description}</EuiText>
<EuiSpacer size="m" />
</>
)}
{instruction && (
<>
<EuiAccordion
id="workspace-details-add-collaborator-modal-instruction"
buttonContent={<EuiText size="s">{instruction.title}</EuiText>}
>
<EuiSpacer size="xs" />
<EuiSpacer size="s" />
<EuiText size="xs">{instruction.detail}</EuiText>
</EuiAccordion>
<EuiHorizontalRule margin="xs" />
<EuiSpacer size="s" />
</>
)}
<WorkspaceCollaboratorsPanel
collaborators={collaborators}
onChange={setCollaborators}
label={inputLabel}
description={inputDescription}
collaboratorIdInputPlaceholder={inputPlaceholder}
addAnotherButtonLabel={addAnotherButtonLabel}
/>
</EuiModalBody>

<EuiModalFooter>
<EuiSmallButtonEmpty iconType="cross" onClick={onClose}>
{i18n.translate('workspace.addCollaboratorsModal.cancelButton', {
defaultMessage: 'Cancel',
})}
</EuiSmallButtonEmpty>
<EuiSmallButton
disabled={validCollaborators.length === 0}
type="submit"
onClick={handleAddCollaborators}
fill
>
{i18n.translate('workspace.addCollaboratorsModal.addCollaboratorsButton', {
defaultMessage: 'Add collaborators',
})}
</EuiSmallButton>
</EuiModalFooter>
</EuiModal>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

export { AddCollaboratorsModal, AddCollaboratorsModalProps } from './add_collaborators_modal';
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { WorkspaceCollaboratorInput } from './workspace_collaborator_input';

describe('WorkspaceCollaboratorInput', () => {
const defaultProps = {
index: 0,
collaboratorId: '',
accessLevel: 'readOnly' as const,
onCollaboratorIdChange: jest.fn(),
onAccessLevelChange: jest.fn(),
onDelete: jest.fn(),
};

afterEach(() => {
jest.clearAllMocks();
});

it('calls onCollaboratorIdChange when input value changes', () => {
render(<WorkspaceCollaboratorInput {...defaultProps} />);
const input = screen.getByTestId('workspaceCollaboratorIdInput-0');
fireEvent.change(input, { target: { value: 'test' } });
expect(defaultProps.onCollaboratorIdChange).toHaveBeenCalledWith('test', 0);
});

it('calls onAccessLevelChange when access level changes', () => {
render(<WorkspaceCollaboratorInput {...defaultProps} />);
const readButton = screen.getByText('Admin');
fireEvent.click(readButton);
expect(defaultProps.onAccessLevelChange).toHaveBeenCalledWith('admin', 0);
});

it('calls onDelete when delete button is clicked', () => {
render(<WorkspaceCollaboratorInput {...defaultProps} />);
const deleteButton = screen.getByRole('button', { name: 'Delete collaborator 0' });
fireEvent.click(deleteButton);
expect(defaultProps.onDelete).toHaveBeenCalledWith(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useCallback } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiFieldText,
EuiButtonGroup,
EuiText,
} from '@elastic/eui';
import { i18n } from '@osd/i18n';
import { WorkspaceCollaboratorAccessLevel } from '../../types';
import { WORKSPACE_ACCESS_LEVEL_NAMES } from '../../constants';

export const COLLABORATOR_ID_INPUT_LABEL_ID = 'collaborator_id_input_label';

export interface WorkspaceCollaboratorInputProps {
index: number;
collaboratorId?: string;
accessLevel: WorkspaceCollaboratorAccessLevel;
collaboratorIdInputPlaceholder?: string;
onCollaboratorIdChange: (id: string, index: number) => void;
onAccessLevelChange: (accessLevel: WorkspaceCollaboratorAccessLevel, index: number) => void;
onDelete: (index: number) => void;
}

const accessLevelKeys = Object.keys(
WORKSPACE_ACCESS_LEVEL_NAMES
) as WorkspaceCollaboratorAccessLevel[];

const accessLevelButtonGroupOptions = accessLevelKeys.map((id) => ({
id,
label: <EuiText size="xs">{WORKSPACE_ACCESS_LEVEL_NAMES[id]}</EuiText>,
}));

const isAccessLevelKey = (test: string): test is WorkspaceCollaboratorAccessLevel =>
(accessLevelKeys as string[]).includes(test);

export const WorkspaceCollaboratorInput = ({
index,
accessLevel,
collaboratorId,
onDelete,
onAccessLevelChange,
onCollaboratorIdChange,
collaboratorIdInputPlaceholder,
}: WorkspaceCollaboratorInputProps) => {
const handleCollaboratorIdChange = useCallback(
(e) => {
onCollaboratorIdChange(e.target.value, index);
},
[index, onCollaboratorIdChange]
);

const handlePermissionModeOptionChange = useCallback(
(newAccessLevel: string) => {
if (isAccessLevelKey(newAccessLevel)) {
onAccessLevelChange(newAccessLevel, index);
}
},
[index, onAccessLevelChange]
);

const handleDelete = useCallback(() => {
onDelete(index);
}, [index, onDelete]);

return (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem>
<EuiFieldText
compressed={true}
onChange={handleCollaboratorIdChange}
value={collaboratorId}
data-test-subj={`workspaceCollaboratorIdInput-${index}`}
placeholder={collaboratorIdInputPlaceholder}
aria-labelledby={COLLABORATOR_ID_INPUT_LABEL_ID}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonGroup
options={accessLevelButtonGroupOptions}
legend={i18n.translate('workspace.form.permissionSettingInput.accessLevelLegend', {
defaultMessage: 'This is a access level button group',
})}
buttonSize="compressed"
type="single"
idSelected={accessLevel}
onChange={handlePermissionModeOptionChange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="danger"
aria-label={`Delete collaborator ${index}`}
iconType="trash"
display="empty"
size="xs"
onClick={handleDelete}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
Loading

0 comments on commit d3ddf91

Please sign in to comment.