diff --git a/frontend/src/components/Topics/Topic/Messages/Message.tsx b/frontend/src/components/Topics/Topic/Messages/Message.tsx index 611385d83..c632446aa 100644 --- a/frontend/src/components/Topics/Topic/Messages/Message.tsx +++ b/frontend/src/components/Topics/Topic/Messages/Message.tsx @@ -1,15 +1,19 @@ import React from 'react'; import useDataSaver from 'lib/hooks/useDataSaver'; -import { TopicMessage } from 'generated-sources'; +import { Action, ResourceType, TopicMessage } from 'generated-sources'; import MessageToggleIcon from 'components/common/Icons/MessageToggleIcon'; import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper'; import { Dropdown, DropdownItem } from 'components/common/Dropdown'; +import { ActionDropdownItem } from 'components/common/ActionComponent'; import { formatTimestamp } from 'lib/dateTimeHelpers'; import { JSONPath } from 'jsonpath-plus'; import Ellipsis from 'components/common/Ellipsis/Ellipsis'; import WarningRedIcon from 'components/common/Icons/WarningRedIcon'; import Tooltip from 'components/common/Tooltip/Tooltip'; import { useTimezone } from 'lib/hooks/useTimezones'; +import useAppParams from 'lib/hooks/useAppParams'; +import { RouteParamsClusterTopic } from 'lib/paths'; +import { useTopicActions } from 'components/contexts/TopicActionsContext'; import MessageContent from './MessageContent/MessageContent'; import * as S from './MessageContent/MessageContent.styled'; @@ -43,7 +47,24 @@ const Message: React.FC = ({ contentFilters, }) => { const { currentTimezone } = useTimezone(); + const { topicName } = useAppParams(); + const { openSidebarWithMessage } = useTopicActions(); const [isOpen, setIsOpen] = React.useState(false); + + const message = { + timestamp, + timestampType, + offset, + key, + keySize, + partition, + value, + valueSize, + headers, + valueSerde, + keySerde, + }; + const savedMessageJson = { Value: value, Offset: offset, @@ -142,10 +163,28 @@ const Message: React.FC = ({ {vEllipsisOpen && ( - + Copy to clipboard - Save as a file + + Save as a file + + { + openSidebarWithMessage(message); + }} + permission={{ + resource: ResourceType.TOPIC, + action: Action.MESSAGES_PRODUCE, + value: topicName, + }} + > + Reproduce message + )} diff --git a/frontend/src/components/Topics/Topic/Messages/__test__/Message.fixtures.ts b/frontend/src/components/Topics/Topic/Messages/__test__/Message.fixtures.ts new file mode 100644 index 000000000..97d54637d --- /dev/null +++ b/frontend/src/components/Topics/Topic/Messages/__test__/Message.fixtures.ts @@ -0,0 +1,57 @@ +import { + Action, + ResourceType, + TopicMessage, + TopicMessageTimestampTypeEnum, +} from 'generated-sources'; +import { PreviewFilter } from 'components/Topics/Topic/Messages/Message'; +import { RolesType } from 'lib/permissions'; + +export const mockMessageContentText = 'messageContentText'; + +export const mockRoles: Map> = new Map([ + [ + 'test-cluster', + new Map([ + [ + ResourceType.TOPIC, + [ + { + resource: ResourceType.TOPIC, + actions: [Action.MESSAGES_PRODUCE, Action.VIEW], + value: '.*', + clusters: ['test-cluster'], + }, + ], + ], + ]), + ], +]); + +export const mockNoRoles: Map> = new Map([ + ['test-cluster', new Map([])], +]); + +export const mockMessageKey = '{"payload":{"subreddit":"learnprogramming"}}'; + +export const mockMessageValue = + '{"payload":{"author":"DwaywelayTOP","archived":false,"name":"t3_11jshwd","id":"11jshwd"}}'; +export const mockMessage: TopicMessage = { + timestamp: new Date(), + timestampType: TopicMessageTimestampTypeEnum.CREATE_TIME, + offset: 0, + key: 'test-key', + partition: 6, + value: '{"data": "test"}', + headers: { header: 'test' }, +}; + +export const mockKeyFilters: PreviewFilter = { + field: 'sub', + path: '$.payload.subreddit', +}; + +export const mockContentFilters: PreviewFilter = { + field: 'author', + path: '$.payload.author', +}; diff --git a/frontend/src/components/Topics/Topic/Messages/__test__/Message.spec.tsx b/frontend/src/components/Topics/Topic/Messages/__test__/Message.spec.tsx index a82ccfbc8..c515ec6b3 100644 --- a/frontend/src/components/Topics/Topic/Messages/__test__/Message.spec.tsx +++ b/frontend/src/components/Topics/Topic/Messages/__test__/Message.spec.tsx @@ -1,64 +1,77 @@ import React from 'react'; -import { TopicMessage, TopicMessageTimestampTypeEnum } from 'generated-sources'; -import Message, { - PreviewFilter, - Props, -} from 'components/Topics/Topic/Messages/Message'; -import { screen } from '@testing-library/react'; +import { ResourceType } from 'generated-sources'; +import Message, { Props } from 'components/Topics/Topic/Messages/Message'; +import { act, screen } from '@testing-library/react'; import { render } from 'lib/testHelpers'; import userEvent from '@testing-library/user-event'; +import useAppParams from 'lib/hooks/useAppParams'; +import { TopicActionsProvider } from 'components/contexts/TopicActionsContext'; import { formatTimestamp } from 'lib/dateTimeHelpers'; +import { getDefaultActionMessage } from 'components/common/ActionComponent/ActionComponent'; +import { UserInfoRolesAccessContext } from 'components/contexts/UserInfoRolesAccessContext'; +import { RolesType } from 'lib/permissions'; -const messageContentText = 'messageContentText'; +import { + mockMessageValue, + mockNoRoles, + mockMessageKey, + mockMessageContentText, + mockContentFilters, + mockKeyFilters, + mockMessage, + mockRoles, +} from './Message.fixtures'; -const keyTest = '{"payload":{"subreddit":"learnprogramming"}}'; -const contentTest = - '{"payload":{"author":"DwaywelayTOP","archived":false,"name":"t3_11jshwd","id":"11jshwd"}}'; jest.mock( 'components/Topics/Topic/Messages/MessageContent/MessageContent', () => () => ( - {messageContentText} + {mockMessageContentText} ) ); +jest.mock('lib/hooks/useAppParams'); + +const mockUseAppParams = jest.mocked(useAppParams); +const mockOpenSidebarWithMessage = jest.fn(); +const renderComponent = ( + props: Partial = { + message: mockMessage, + keyFilters: [], + contentFilters: [], + }, + roles: Map> = mockNoRoles +) => + render( + + + + + + +
+
+
+ ); describe('Message component', () => { - const mockMessage: TopicMessage = { - timestamp: new Date(), - timestampType: TopicMessageTimestampTypeEnum.CREATE_TIME, - offset: 0, - key: 'test-key', - partition: 6, - value: '{"data": "test"}', - headers: { header: 'test' }, - }; - const mockKeyFilters: PreviewFilter = { - field: 'sub', - path: '$.payload.subreddit', - }; - const mockContentFilters: PreviewFilter = { - field: 'author', - path: '$.payload.author', - }; - const renderComponent = ( - props: Partial = { - message: mockMessage, - keyFilters: [], - contentFilters: [], - } - ) => - render( - - - - -
- ); + beforeEach(() => { + jest.clearAllMocks(); + mockUseAppParams.mockReturnValue({ + clusterName: 'test-cluster', + topicName: 'testTopic', + }); + }); it('shows the data in the table row', () => { renderComponent(); @@ -82,30 +95,43 @@ describe('Message component', () => { ).not.toBeInTheDocument(); }); - it('should check the dropdown being visible during hover', async () => { + it('should toggle action dropdown button visibility on hover', async () => { renderComponent(); - const text = 'Save as a file'; const trElement = screen.getByRole('row'); - expect(screen.queryByText(text)).not.toBeInTheDocument(); - await userEvent.hover(trElement); - expect(screen.getByText(text)).toBeInTheDocument(); + expect( + screen.queryByRole('button', { + name: 'Dropdown Toggle', + }) + ).not.toBeInTheDocument(); + + await act(() => userEvent.hover(trElement)); + + expect( + screen.getByRole('button', { + name: 'Dropdown Toggle', + }) + ).toBeVisible(); - await userEvent.unhover(trElement); - expect(screen.queryByText(text)).not.toBeInTheDocument(); + await act(() => userEvent.unhover(trElement)); + expect( + screen.queryByRole('button', { + name: 'Dropdown Toggle', + }) + ).not.toBeInTheDocument(); }); it('should check open Message Content functionality', async () => { renderComponent(); const messageToggleIcon = screen.getByRole('button', { hidden: true }); - expect(screen.queryByText(messageContentText)).not.toBeInTheDocument(); - await userEvent.click(messageToggleIcon); - expect(screen.getByText(messageContentText)).toBeInTheDocument(); + expect(screen.queryByText(mockMessageContentText)).not.toBeInTheDocument(); + await act(() => userEvent.click(messageToggleIcon)); + expect(screen.getByText(mockMessageContentText)).toBeInTheDocument(); }); it('should check if Preview filter showing for key', () => { const props = { - message: { ...mockMessage, key: keyTest as string }, + message: { ...mockMessage, key: mockMessageKey as string }, keyFilters: [mockKeyFilters], }; renderComponent(props); @@ -115,11 +141,95 @@ describe('Message component', () => { it('should check if Preview filter showing for Value', () => { const props = { - message: { ...mockMessage, value: contentTest as string }, + message: { ...mockMessage, value: mockMessageValue as string }, contentFilters: [mockContentFilters], }; renderComponent(props); const keyFiltered = screen.getByText('author: "DwaywelayTOP"'); expect(keyFiltered).toBeInTheDocument(); }); + + it('shows action options in dropdown on click', async () => { + renderComponent(); + const trElement = screen.getByRole('row'); + + await act(() => userEvent.hover(trElement)); + + const dropdownToggle = screen.getByRole('button', { + name: 'Dropdown Toggle', + }); + await act(() => userEvent.click(dropdownToggle)); + + expect( + await screen.findByRole('menuitem', { name: 'Copy to clipboard' }) + ).toBeVisible(); + expect( + await screen.findByRole('menuitem', { name: 'Save as a file' }) + ).toBeVisible(); + expect( + await screen.findByRole('menuitem', { name: 'Reproduce message' }) + ).toBeVisible(); + }); + + it('calls openSidebarWithMessage when "Produce Message" is clicked', async () => { + renderComponent(undefined, mockRoles); + const trElement = screen.getByRole('row'); + await act(() => userEvent.hover(trElement)); + + const dropdownToggle = screen.getByRole('button', { + name: 'Dropdown Toggle', + }); + await act(() => userEvent.click(dropdownToggle)); + + const produceMessageButton = screen.getByRole('menuitem', { + name: 'Reproduce message', + }); + await act(() => userEvent.click(produceMessageButton)); + + expect(mockOpenSidebarWithMessage).toHaveBeenCalledTimes(1); + expect(mockOpenSidebarWithMessage).toHaveBeenCalledWith({ + timestamp: mockMessage.timestamp, + timestampType: mockMessage.timestampType, + offset: mockMessage.offset, + key: mockMessage.key, + partition: mockMessage.partition, + value: mockMessage.value, + headers: mockMessage.headers, + valueSerde: mockMessage.valueSerde, + keySerde: mockMessage.keySerde, + }); + }); + + test.each([ + ['has produce message roles', true], + ['lacks produce message roles', false], + ])( + 'when user %s for topic, "Reproduce message" button should be enabled: %s', + async (_scenario, hasRoles) => { + renderComponent(undefined, hasRoles ? mockRoles : mockNoRoles); + + const trElement = screen.getByRole('row'); + await act(() => userEvent.hover(trElement)); + + const dropdownToggle = screen.getByRole('button', { + name: 'Dropdown Toggle', + }); + await act(() => userEvent.click(dropdownToggle)); + + const produceMessageButton = screen.getByRole('menuitem', { + name: 'Reproduce message', + }); + await act(() => userEvent.hover(produceMessageButton)); + + if (hasRoles) { + expect(produceMessageButton).not.toHaveAttribute( + 'aria-disabled', + 'true' + ); + } else { + expect(produceMessageButton).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByText(getDefaultActionMessage())).toBeVisible(); + } + } + ); }); diff --git a/frontend/src/components/Topics/Topic/Messages/__test__/MessagesTable.spec.tsx b/frontend/src/components/Topics/Topic/Messages/__test__/MessagesTable.spec.tsx index 7c91be5bd..bd4d09215 100644 --- a/frontend/src/components/Topics/Topic/Messages/__test__/MessagesTable.spec.tsx +++ b/frontend/src/components/Topics/Topic/Messages/__test__/MessagesTable.spec.tsx @@ -9,6 +9,7 @@ import { TopicMessage, TopicMessageTimestampTypeEnum } from 'generated-sources'; import { useIsLiveMode } from 'lib/hooks/useMessagesFilters'; import useAppParams from 'lib/hooks/useAppParams'; import { LOCAL_STORAGE_KEY_PREFIX } from 'lib/constants'; +import { TopicActionsProvider } from 'components/contexts/TopicActionsContext'; export const topicMessagePayload: TopicMessage = { partition: 29, @@ -46,7 +47,9 @@ describe('MessagesTable', () => { topicName: 'testTopic', })); return render( - + + + ); }; diff --git a/frontend/src/components/Topics/Topic/SendMessage/SendMessage.tsx b/frontend/src/components/Topics/Topic/SendMessage/SendMessage.tsx index b820ebed3..20241efc6 100644 --- a/frontend/src/components/Topics/Topic/SendMessage/SendMessage.tsx +++ b/frontend/src/components/Topics/Topic/SendMessage/SendMessage.tsx @@ -11,6 +11,7 @@ import { useSendMessage, useTopicDetails } from 'lib/hooks/api/topics'; import { InputLabel } from 'components/common/Input/InputLabel.styled'; import { useSerdes } from 'lib/hooks/api/topicMessages'; import { SerdeUsage } from 'generated-sources'; +import { MessageFormData } from 'lib/interfaces/message'; import * as S from './SendMessage.styled'; import { @@ -20,18 +21,14 @@ import { validateBySchema, } from './utils'; -interface FormType { - key: string; - content: string; - headers: string; - partition: number; - keySerde: string; - valueSerde: string; - keepContents: boolean; +interface SendMessageProps { + closeSidebar: () => void; + messageData?: Partial | null; } -const SendMessage: React.FC<{ closeSidebar: () => void }> = ({ +const SendMessage: React.FC = ({ closeSidebar, + messageData = null, }) => { const { clusterName, topicName } = useAppParams(); const { data: topic } = useTopicDetails({ clusterName, topicName }); @@ -41,24 +38,30 @@ const SendMessage: React.FC<{ closeSidebar: () => void }> = ({ use: SerdeUsage.SERIALIZE, }); const sendMessage = useSendMessage({ clusterName, topicName }); - const defaultValues = React.useMemo(() => getDefaultValues(serdes), [serdes]); const partitionOptions = React.useMemo( () => getPartitionOptions(topic?.partitions || []), [topic] ); + + const formDefaults = React.useMemo( + () => ({ + ...defaultValues, + partition: Number(partitionOptions[0]?.value || 0), + keepContents: false, + ...messageData, + }), + [defaultValues, partitionOptions, messageData] + ); + const { handleSubmit, formState: { isSubmitting }, control, setValue, - } = useForm({ + } = useForm({ mode: 'onChange', - defaultValues: { - ...defaultValues, - partition: Number(partitionOptions[0].value), - keepContents: false, - }, + defaultValues: formDefaults, }); const submit = async ({ @@ -69,7 +72,7 @@ const SendMessage: React.FC<{ closeSidebar: () => void }> = ({ headers, partition, keepContents, - }: FormType) => { + }: MessageFormData) => { let errors: string[] = []; if (keySerde) { @@ -132,14 +135,14 @@ const SendMessage: React.FC<{ closeSidebar: () => void }> = ({
- Partition + Partition ( void }> = ({ /> - Value Serde + Value Serde (