Skip to content

Commit

Permalink
feat: Duplicate (#3396)
Browse files Browse the repository at this point in the history
* feat: Duplicate

* improving UX

* improving UX

* PR improvements
  • Loading branch information
xoscar committed Nov 23, 2023
1 parent c54cda9 commit c5c43de
Show file tree
Hide file tree
Showing 21 changed files with 224 additions and 49 deletions.
3 changes: 3 additions & 0 deletions web/src/components/ConfirmationModal/ConfirmationModal.tsx
@@ -1,4 +1,5 @@
import {Modal} from 'antd';
import useKeyEvent, {Keys} from 'hooks/useKeyEvent';

interface IProps {
isOpen: boolean;
Expand All @@ -19,6 +20,8 @@ const ConfirmationModal = ({
okText = 'Delete',
cancelText = 'Cancel',
}: IProps) => {
useKeyEvent([Keys.Enter], onConfirm);

return (
<Modal
cancelText={cancelText}
Expand Down
37 changes: 19 additions & 18 deletions web/src/components/ResourceCard/ResourceCardActions.tsx
Expand Up @@ -9,33 +9,34 @@ interface IProps {
shouldEdit: boolean;
onDelete(): void;
onEdit(): void;
onDuplicate(): void;
}

const ResourceCardActions = ({id, shouldEdit = true, onDelete, onEdit}: IProps) => {
const ResourceCardActions = ({id, shouldEdit = true, onDelete, onEdit, onDuplicate}: IProps) => {
const {getIsAllowed} = useCustomization();
const canEdit = getIsAllowed(Operation.Edit);

const onDeleteClick = useCallback(
({domEvent}) => {
domEvent?.stopPropagation();
onDelete();
},
[onDelete]
);

const onEditClick = useCallback(
({domEvent}) => {
domEvent?.stopPropagation();
onEdit();
},
[onEdit]
const onAction = useCallback(
action =>
({domEvent}: {domEvent: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>}) => {
domEvent?.stopPropagation();
action();
},
[]
);

const menuItems = useMemo(() => {
const defaultItems = [
{
key: 'duplicate',
label: <span data-cy="test-card-duplicate">Duplicate</span>,
onClick: onAction(onDuplicate),
disabled: !canEdit,
},
{
key: 'delete',
label: <span data-cy="test-card-delete">Delete</span>,
onClick: onDeleteClick,
onClick: onAction(onDelete),
disabled: !getIsAllowed(Operation.Edit),
},
];
Expand All @@ -45,13 +46,13 @@ const ResourceCardActions = ({id, shouldEdit = true, onDelete, onEdit}: IProps)
{
key: 'edit',
label: <span data-cy="test-card-edit">Edit</span>,
onClick: onEditClick,
onClick: onAction(onEdit),
disabled: !getIsAllowed(Operation.Edit),
},
...defaultItems,
]
: defaultItems;
}, [getIsAllowed, onDeleteClick, onEditClick, shouldEdit]);
}, [canEdit, getIsAllowed, onAction, onDelete, onDuplicate, onEdit, shouldEdit]);

return (
<Dropdown overlay={<Menu items={menuItems} />} placement="bottomLeft" trigger={['click']}>
Expand Down
4 changes: 3 additions & 1 deletion web/src/components/ResourceCard/TestCard.tsx
Expand Up @@ -18,11 +18,12 @@ interface IProps {
onEdit(id: string, lastRunId: number, type: ResourceType): void;
onDelete(id: string, name: string, type: ResourceType): void;
onRun(test: Test, type: ResourceType): void;
onDuplicate(test: Test, type: ResourceType): void;
onViewAll(id: string, type: ResourceType): void;
test: Test;
}

const TestCard = ({onEdit, onDelete, onRun, onViewAll, test}: IProps) => {
const TestCard = ({onEdit, onDelete, onDuplicate, onRun, onViewAll, test}: IProps) => {
const queryParams = useMemo(() => ({take: 5, testId: test.id}), [test.id]);
const {isCollapsed, isLoading, list, onClick} = useRuns<TestRun, {testId: string}>(
useLazyGetRunListQuery,
Expand Down Expand Up @@ -65,6 +66,7 @@ const TestCard = ({onEdit, onDelete, onRun, onViewAll, test}: IProps) => {
shouldEdit={shouldEdit}
onDelete={() => onDelete(test.id, test.name, ResourceType.Test)}
onEdit={() => onEdit(test.id, lastRunId, ResourceType.Test)}
onDuplicate={() => onDuplicate(test, ResourceType.Test)}
/>
</S.Row>
</S.TestContainer>
Expand Down
3 changes: 3 additions & 0 deletions web/src/components/ResourceCard/TestSuiteCard.tsx
Expand Up @@ -19,6 +19,7 @@ interface IProps {
onEdit(id: string, lastRunId: number, type: ResourceType): void;
onDelete(id: string, name: string, type: ResourceType): void;
onRun(testSuite: TestSuite, type: ResourceType): void;
onDuplicate(testSuite: TestSuite): void;
onViewAll(id: string, type: ResourceType): void;
testSuite: TestSuite;
}
Expand All @@ -27,6 +28,7 @@ const TestSuiteCard = ({
onEdit,
onDelete,
onRun,
onDuplicate,
onViewAll,
testSuite: {id: testSuiteId, summary, name, description},
testSuite,
Expand Down Expand Up @@ -71,6 +73,7 @@ const TestSuiteCard = ({
shouldEdit={shouldEdit}
onDelete={() => onDelete(testSuiteId, name, ResourceType.TestSuite)}
onEdit={() => onEdit(testSuiteId, lastRunId, ResourceType.TestSuite)}
onDuplicate={() => onDuplicate(testSuite)}
/>
</S.Row>
</S.TestContainer>
Expand Down
Expand Up @@ -8,6 +8,14 @@ test('ResourceCardActions', async () => {
const onDelete = jest.fn();
const testId = faker.datatype.uuid();

const {getByTestId} = render(<ResourceCardActions shouldEdit={shouldEdit} onEdit={onEdit} onDelete={onDelete} id={testId} />);
const {getByTestId} = render(
<ResourceCardActions
shouldEdit={shouldEdit}
onEdit={onEdit}
onDelete={onDelete}
id={testId}
onDuplicate={jest.fn()}
/>
);
await waitFor(() => getByTestId(`test-actions-button-${testId}`));
});
9 changes: 8 additions & 1 deletion web/src/components/ResourceCard/__tests__/TestCard.test.tsx
Expand Up @@ -10,7 +10,14 @@ test('TestCard', async () => {
const test = TestMock.model();

const {getByTestId, getByText} = render(
<TestCard onEdit={onEdit} onDelete={onDelete} onRun={onRunTest} onViewAll={onClick} test={test} />
<TestCard
onEdit={onEdit}
onDelete={onDelete}
onRun={onRunTest}
onViewAll={onClick}
test={test}
onDuplicate={jest.fn()}
/>
);
const mouseEvent = new MouseEvent('click', {bubbles: true});
fireEvent(getByTestId(`test-actions-button-${test.id}`), mouseEvent);
Expand Down
11 changes: 9 additions & 2 deletions web/src/components/TestHeader/TestHeader.tsx
Expand Up @@ -9,11 +9,12 @@ interface IProps {
shouldEdit: boolean;
onEdit(): void;
onDelete(): void;
onDuplicate(): void;
title: string;
runButton: React.ReactElement;
}

const TestHeader = ({description, id, shouldEdit, onEdit, onDelete, title, runButton}: IProps) => {
const TestHeader = ({description, id, shouldEdit, onEdit, onDelete, onDuplicate, title, runButton}: IProps) => {
const {navigate} = useDashboard();

return (
Expand All @@ -30,7 +31,13 @@ const TestHeader = ({description, id, shouldEdit, onEdit, onDelete, title, runBu
<S.Section>
<VariableSetSelector />
{runButton}
<ResourceCardActions id={id} onDelete={onDelete} onEdit={onEdit} shouldEdit={shouldEdit} />
<ResourceCardActions
id={id}
onDuplicate={onDuplicate}
onDelete={onDelete}
onEdit={onEdit}
shouldEdit={shouldEdit}
/>
</S.Section>
</S.Container>
);
Expand Down
11 changes: 10 additions & 1 deletion web/src/components/TestHeader/__tests__/TestHeader.test.tsx
Expand Up @@ -10,7 +10,16 @@ test('SpanAttributesTable', () => {
const shouldEdit = true;

const {getByTestId} = render(
<TestHeader description={test.description} id={test.id} shouldEdit={shouldEdit} onEdit={onEdit} onDelete={onDelete} title={test.name} runButton={<div />} />
<TestHeader
description={test.description}
id={test.id}
shouldEdit={shouldEdit}
onEdit={onEdit}
onDelete={onDelete}
title={test.name}
runButton={<div />}
onDuplicate={jest.fn()}
/>
);
expect(getByTestId('test-details-name')).toBeInTheDocument();
});
34 changes: 34 additions & 0 deletions web/src/hooks/useKeyEvent.ts
@@ -0,0 +1,34 @@
import {useCallback, useEffect} from 'react';

type TEvent = 'keydown' | 'keypress' | 'keyup';

export enum Keys {
Enter = 'Enter',
Escape = 'Escape',
}

const useKeyEvent = (targetKey: string[], callback: () => void, events: TEvent[] = ['keydown']) => {
const onEvent = useCallback(
(e: KeyboardEvent) => {
if (targetKey.includes(e.key)) {
e.stopPropagation();
callback();
}
},
[callback, targetKey]
);

useEffect(() => {
events.forEach(event => {
window.addEventListener(event, onEvent);
});

return () => {
events.forEach(event => {
window.removeEventListener(event, onEvent);
});
};
}, [events, onEvent]);
};

export default useKeyEvent;
4 changes: 2 additions & 2 deletions web/src/models/TestOutput.model.ts
Expand Up @@ -14,13 +14,13 @@ type TestOutput = {
error: string;
};

function TestOutput({name = '', selectorParsed = {}, value = ''}: TRawTestOutput, id = -1): TestOutput {
function TestOutput({name = '', selectorParsed = {}, selector = '', value = ''}: TRawTestOutput, id = -1): TestOutput {
return {
id,
isDeleted: false,
isDraft: false,
name,
selector: selectorParsed.query || '',
selector: selectorParsed.query || selector || '',
value,
valueRun: '',
valueRunDraft: '',
Expand Down
17 changes: 16 additions & 1 deletion web/src/pages/Home/TestsList.tsx
Expand Up @@ -14,6 +14,7 @@ import {useDashboard} from 'providers/Dashboard/Dashboard.provider';
import VariableSetSelector from 'components/VariableSetSelector/VariableSetSelector';
import Test from 'models/Test.model';
import ImportModal from 'components/ImportModal/ImportModal';
import {useConfirmationModal} from 'providers/ConfirmationModal/ConfirmationModal.provider';
import * as S from './Home.styled';
import HomeFilters from './HomeFilters';
import Loading from './Loading';
Expand All @@ -29,12 +30,13 @@ const Tests = () => {
const [isCreateTestOpen, setIsCreateTestOpen] = useState(false);
const [isImportTestOpen, setIsImportTestOpen] = useState(false);
const [parameters, setParameters] = useState<TParameters>(defaultSort);
const {onOpen} = useConfirmationModal();

const pagination = usePagination<Test, TParameters>(useGetTestListQuery, {
...parameters,
});
const onDelete = useDeleteResource();
const {runTest} = useTestCrud();
const {runTest, duplicate} = useTestCrud();
const {navigate} = useDashboard();

const handleOnRun = useCallback(
Expand All @@ -44,6 +46,18 @@ const Tests = () => {
[runTest]
);

const handleOnDuplicate = useCallback(
(test: Test) => {
onOpen({
heading: `Duplicate Test`,
title: `Create a duplicated version of Test: ${test.name}`,
okText: 'Duplicate',
onConfirm: () => duplicate(test),
});
},
[duplicate, onOpen]
);

const handleOnViewAll = useCallback((id: string) => {
onTestClick(id);
}, []);
Expand Down Expand Up @@ -106,6 +120,7 @@ const Tests = () => {
onDelete={onDelete}
onRun={handleOnRun}
onViewAll={handleOnViewAll}
onDuplicate={handleOnDuplicate}
test={test}
/>
))}
Expand Down
16 changes: 14 additions & 2 deletions web/src/pages/Test/Content.tsx
@@ -1,4 +1,4 @@
import {useMemo} from 'react';
import {useCallback, useMemo} from 'react';
import CreateButton from 'components/CreateButton';
import PaginatedList from 'components/PaginatedList';
import TestRunCard from 'components/RunCard/TestRunCard';
Expand All @@ -10,6 +10,7 @@ import {useDashboard} from 'providers/Dashboard/Dashboard.provider';
import {useTest} from 'providers/Test/Test.provider';
import useTestCrud from 'providers/Test/hooks/useTestCrud';
import TracetestAPI from 'redux/apis/Tracetest';
import {useConfirmationModal} from 'providers/ConfirmationModal/ConfirmationModal.provider';
import {ResourceType} from 'types/Resource.type';
import * as S from './Test.styled';

Expand All @@ -18,7 +19,8 @@ const {useGetRunListQuery} = TracetestAPI.instance;
const Content = () => {
const {test} = useTest();
const onDeleteResource = useDeleteResource();
const {runTest, isLoadingRunTest} = useTestCrud();
const {runTest, isLoadingRunTest, duplicate} = useTestCrud();
const {onOpen} = useConfirmationModal();
const params = useMemo(() => ({testId: test.id}), [test.id]);
useDocumentTitle(`${test.name}`);

Expand All @@ -27,6 +29,15 @@ const Content = () => {
const shouldEdit = test.summary.hasRuns;
const onEdit = () => navigate(`/test/${test.id}/run/${test.summary.runs}`);

const handleOnDuplicate = useCallback(() => {
onOpen({
heading: `Duplicate Test`,
title: `Create a duplicated version of Test: ${test.name}`,
okText: 'Duplicate',
onConfirm: () => duplicate(test),
});
}, [duplicate, onOpen, test]);

return (
<S.Container $isWhite>
<TestHeader
Expand All @@ -36,6 +47,7 @@ const Content = () => {
id={test.id}
onDelete={() => onDeleteResource(test.id, test.name, ResourceType.Test)}
onEdit={onEdit}
onDuplicate={handleOnDuplicate}
shouldEdit={shouldEdit}
title={`${test.name} (v${test.version})`}
runButton={
Expand Down
14 changes: 13 additions & 1 deletion web/src/pages/TestSuite/Content.tsx
Expand Up @@ -8,14 +8,16 @@ import TestSuiteRun from 'models/TestSuiteRun.model';
import {useDashboard} from 'providers/Dashboard/Dashboard.provider';
import {useTestSuite} from 'providers/TestSuite/TestSuite.provider';
import {useTestSuiteCrud} from 'providers/TestSuite';
import {useConfirmationModal} from 'providers/ConfirmationModal/ConfirmationModal.provider';
import TracetestAPI from 'redux/apis/Tracetest';
import * as S from './TestSuite.styled';

const {useGetTestSuiteRunsQuery} = TracetestAPI.instance;

const Content = () => {
const {onDelete, testSuite} = useTestSuite();
const {runTestSuite, isEditLoading} = useTestSuiteCrud();
const {runTestSuite, duplicate, isEditLoading} = useTestSuiteCrud();
const {onOpen} = useConfirmationModal();
const params = useMemo(() => ({testSuiteId: testSuite.id}), [testSuite.id]);

useDocumentTitle(`${testSuite.name}`);
Expand All @@ -29,13 +31,23 @@ const Content = () => {
const shouldEdit = testSuite.summary.hasRuns;
const onEdit = () => navigate(`/testsuite/${testSuite.id}/run/${testSuite.summary.runs}`);

const handleOnDuplicate = useCallback(() => {
onOpen({
heading: `Duplicate Test Suite`,
title: `Create a duplicated version of Test Suite: ${suite.name}`,
okText: 'Duplicate',
onConfirm: () => duplicate(testSuite),
});
}, [duplicate, onOpen, testSuite]);

return (
<S.Container $isWhite>
<TestHeader
description={testSuite.description}
id={testSuite.id}
onDelete={() => onDelete(testSuite.id, testSuite.name)}
onEdit={onEdit}
onDuplicate={handleOnDuplicate}
shouldEdit={shouldEdit}
title={`${testSuite.name} (v${testSuite.version})`}
runButton={
Expand Down

0 comments on commit c5c43de

Please sign in to comment.