diff --git a/openmetadata-ui/src/main/resources/ui/src/App.tsx b/openmetadata-ui/src/main/resources/ui/src/App.tsx index a8d02d28b52..a52fc73dff7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/App.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/App.tsx @@ -18,6 +18,7 @@ import { faCheckSquare, faChevronDown, faChevronRight, + faChevronUp, faPlus, faSearch, faTimes, @@ -39,7 +40,8 @@ const App: FunctionComponent = () => { faCheckSquare, faCheckCircle, faChevronDown, - faChevronRight + faChevronRight, + faChevronUp ); return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SampleDataTopic/SampleDataTopic.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SampleDataTopic/SampleDataTopic.test.tsx new file mode 100644 index 00000000000..377ba2e0847 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/SampleDataTopic/SampleDataTopic.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import SampleDataTopic from './SampleDataTopic'; + +const mockSampleData = { + messages: ['{"email":"data","name":"job"}'], +}; + +describe('Test SampleData Component', () => { + it('Should render message cards', () => { + const { getAllByTestId } = render( + , + { + wrapper: MemoryRouter, + } + ); + + const messageCards = getAllByTestId('message-card'); + + expect(messageCards).toHaveLength(mockSampleData.messages.length); + }); + + it('Should render no data placeholder if no data available', () => { + const { getByTestId } = render(, { + wrapper: MemoryRouter, + }); + + const noDataPlaceHolder = getByTestId('no-data'); + + expect(noDataPlaceHolder).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SampleDataTopic/SampleDataTopic.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SampleDataTopic/SampleDataTopic.tsx new file mode 100644 index 00000000000..0eb3f4a5943 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/SampleDataTopic/SampleDataTopic.tsx @@ -0,0 +1,91 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { isUndefined } from 'lodash'; +import React, { FC, HTMLAttributes, useState } from 'react'; +import { TopicSampleData } from '../../generated/entity/data/topic'; +import { withLoader } from '../../hoc/withLoader'; +import SchemaEditor from '../schema-editor/SchemaEditor'; + +interface SampleDataTopicProp extends HTMLAttributes { + sampleData?: TopicSampleData; +} + +const MessageCard = ({ message }: { message: string }) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
setIsExpanded((pre) => !pre)}> +
+
+ +
+ {isExpanded ? ( +
+ + +
+ ) : ( +

+

+ {message} +

+

+ )} +
+
+ ); +}; + +const SampleDataTopic: FC = ({ sampleData }) => { + if (!isUndefined(sampleData)) { + return ( +
+ {sampleData.messages?.map((message, i) => ( + + ))} +
+ ); + } else { + return ( +
+ No sample data available +
+ ); + } +}; + +export default withLoader(SampleDataTopic); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/TopicDetails/TopicDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/TopicDetails/TopicDetails.component.tsx index f024293c2ab..13ea415ae80 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/TopicDetails/TopicDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/TopicDetails/TopicDetails.component.tsx @@ -12,7 +12,7 @@ */ import { EntityTags } from 'Models'; -import React, { RefObject, useEffect, useState } from 'react'; +import React, { Fragment, RefObject, useEffect, useState } from 'react'; import { useAuthContext } from '../../authentication/auth-provider/AuthProvider'; import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants'; import { getTeamAndUserDetailsPath } from '../../constants/constants'; @@ -43,6 +43,7 @@ import PageContainer from '../containers/PageContainer'; import Loader from '../Loader/Loader'; import ManageTabComponent from '../ManageTab/ManageTab.component'; import RequestDescriptionModal from '../Modals/RequestDescriptionModal/RequestDescriptionModal'; +import SampleDataTopic from '../SampleDataTopic/SampleDataTopic'; import SchemaEditor from '../schema-editor/SchemaEditor'; import { TopicDetailsProps } from './TopicDetails.interface'; @@ -83,6 +84,8 @@ const TopicDetails: React.FC = ({ deletePostHandler, paging, fetchFeedHandler, + isSampleDataLoading, + sampleData, }: TopicDetailsProps) => { const { isAuthDisabled } = useAuthContext(); const [isEdit, setIsEdit] = useState(false); @@ -168,6 +171,17 @@ const TopicDetails: React.FC = ({ position: 2, count: feedCount, }, + { + name: 'Sample Data', + icon: { + alt: 'sample_data', + name: 'sample-data', + title: 'Sample Data', + selectedName: 'sample-data-color', + }, + isProtected: false, + position: 3, + }, { name: 'Config', icon: { @@ -177,7 +191,7 @@ const TopicDetails: React.FC = ({ selectedName: 'icon-configcolor', }, isProtected: false, - position: 3, + position: 4, }, { name: 'Manage', @@ -190,7 +204,7 @@ const TopicDetails: React.FC = ({ isProtected: true, isHidden: deleted, protectedState: !owner || hasEditAccess(), - position: 4, + position: 5, }, ]; const extraInfo = [ @@ -400,12 +414,20 @@ const TopicDetails: React.FC = ({ /> - {getInfoBadge([{ key: 'Schema', value: schemaType }])} -
- -
+ {schemaText ? ( + + {getInfoBadge([{ key: 'Schema', value: schemaType }])} +
+ +
+
+ ) : ( +
+ No schema data available +
+ )} )} {activeTab === 2 && ( @@ -426,11 +448,19 @@ const TopicDetails: React.FC = ({ )} {activeTab === 3 && ( +
+ +
+ )} + {activeTab === 4 && (
)} - {activeTab === 4 && !deleted && ( + {activeTab === 5 && !deleted && (
void; createThread: (data: CreateThread) => void; setActiveTabHandler: (value: number) => void; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/TopicDetails/TopicDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/TopicDetails/TopicDetails.test.tsx index 38ebe74f58a..1ffed0aca29 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/TopicDetails/TopicDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/TopicDetails/TopicDetails.test.tsx @@ -56,7 +56,7 @@ const TopicDetailsProps = { maximumMessageSize: 0, replicationFactor: 0, retentionSize: 0, - schemaText: '', + schemaText: 'schema text', schemaType: 'Avro', serviceType: '', users: [], @@ -192,13 +192,25 @@ describe('Test TopicDetails component', () => { expect(activityFeedList).toBeInTheDocument(); }); - it('Check if active tab is config', async () => { + it('Check if active tab is sample data', async () => { const { container } = render( , { wrapper: MemoryRouter, } ); + const sampleData = await findByTestId(container, 'sample-data'); + + expect(sampleData).toBeInTheDocument(); + }); + + it('Check if active tab is config', async () => { + const { container } = render( + , + { + wrapper: MemoryRouter, + } + ); const config = await findByTestId(container, 'config'); expect(config).toBeInTheDocument(); @@ -206,7 +218,7 @@ describe('Test TopicDetails component', () => { it('Check if active tab is manage', async () => { const { container } = render( - , + , { wrapper: MemoryRouter, } diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/topic.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/topic.ts index 009219c17c3..b009c132785 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/topic.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/topic.ts @@ -88,6 +88,10 @@ export interface Topic { * Retention time in milliseconds. For Kafka - `retention.ms` configuration. */ retentionTime?: number; + /** + * Sample data for a topic. + */ + sampleData?: TopicSampleData; /** * Schema used for message serialization. Optional as some topics may not have associated * schemas. @@ -227,6 +231,18 @@ export interface EntityReference { type: string; } +/** + * Sample data for a topic. + * + * This schema defines the type to capture sample data for a topic. + */ +export interface TopicSampleData { + /** + * List of local sample messages for a topic. + */ + messages?: string[]; +} + /** * Schema used for message serialization. * diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.component.tsx index c46ed925d1b..8e2e3ca698a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.component.tsx @@ -13,7 +13,7 @@ import { AxiosError, AxiosResponse } from 'axios'; import { compare } from 'fast-json-patch'; -import { startCase } from 'lodash'; +import { isUndefined, startCase } from 'lodash'; import { observer } from 'mobx-react'; import { EntityFieldThreadCount, EntityTags, EntityThread } from 'Models'; import React, { FunctionComponent, useEffect, useState } from 'react'; @@ -43,7 +43,7 @@ import { import { EntityType, TabSpecificField } from '../../enums/entity.enum'; import { ServiceCategory } from '../../enums/service.enum'; import { CreateThread } from '../../generated/api/feed/createThread'; -import { Topic } from '../../generated/entity/data/topic'; +import { Topic, TopicSampleData } from '../../generated/entity/data/topic'; import { EntityReference } from '../../generated/type/entityReference'; import { Paging } from '../../generated/type/paging'; import { TagLabel } from '../../generated/type/tagLabel'; @@ -103,6 +103,10 @@ const TopicDetailsPage: FunctionComponent = () => { >([]); const [paging, setPaging] = useState({} as Paging); + const [sampleData, setSampleData] = useState(); + const [isSampleDataLoading, setIsSampleDataLoading] = + useState(false); + const activeTabHandler = (tabValue: number) => { const currentTabIndex = tabValue - 1; if (topicDetailsTabs[currentTabIndex].path !== tab) { @@ -159,6 +163,47 @@ const TopicDetailsPage: FunctionComponent = () => { .finally(() => setIsentityThreadLoading(false)); }; + const fetchTabSpecificData = (tabField = '') => { + switch (tabField) { + case TabSpecificField.ACTIVITY_FEED: { + fetchActivityFeed(); + + break; + } + + case TabSpecificField.SAMPLE_DATA: { + if (!isUndefined(sampleData)) { + break; + } else { + setIsSampleDataLoading(true); + getTopicByFqn(topicFQN, tabField) + .then((res: AxiosResponse) => { + if (res.data) { + const { sampleData } = res.data; + setSampleData(sampleData); + } else { + showErrorToast( + jsonData['api-error-messages']['fetch-sample-data-error'] + ); + } + }) + .catch((err: AxiosError) => { + showErrorToast( + err, + jsonData['api-error-messages']['fetch-sample-data-error'] + ); + }) + .finally(() => setIsSampleDataLoading(false)); + + break; + } + } + + default: + break; + } + }; + const saveUpdatedTopicData = (updatedData: Topic): Promise => { const jsonPatch = compare(topicDetails, updatedData); @@ -478,13 +523,13 @@ const TopicDetailsPage: FunctionComponent = () => { if (topicDetailsTabs[activeTab - 1].path !== tab) { setActiveTab(getCurrentTopicTab(tab)); } - if (TabSpecificField.ACTIVITY_FEED === tab) { - fetchActivityFeed(); - } else { - setEntityThread([]); - } + setEntityThread([]); }, [tab]); + useEffect(() => { + fetchTabSpecificData(topicDetailsTabs[activeTab - 1].field); + }, [activeTab]); + useEffect(() => { fetchTopicDetail(topicFQN); getEntityFeedCount(); @@ -514,6 +559,7 @@ const TopicDetailsPage: FunctionComponent = () => { fetchFeedHandler={fetchActivityFeed} followTopicHandler={followTopic} followers={followers} + isSampleDataLoading={isSampleDataLoading} isentityThreadLoading={isentityThreadLoading} maximumMessageSize={maximumMessageSize} owner={owner as EntityReference} @@ -522,6 +568,7 @@ const TopicDetailsPage: FunctionComponent = () => { postFeedHandler={postFeedHandler} replicationFactor={replicationFactor} retentionSize={retentionSize} + sampleData={sampleData} schemaText={schemaText} schemaType={schemaType} setActiveTabHandler={activeTabHandler} diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/x-master.css b/openmetadata-ui/src/main/resources/ui/src/styles/x-master.css index 5b5faf9ce52..7ce8b9dff1a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/x-master.css +++ b/openmetadata-ui/src/main/resources/ui/src/styles/x-master.css @@ -931,3 +931,17 @@ code { .page-not-found-container::before { background-color: #fff; } + +.topic-sample-data > .CodeMirror { + height: auto !important; +} + +.topic-sample-data-message { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + line-height: 18px; + max-height: 54px; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/StringsUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/StringsUtils.ts index 6b3fe47e0e3..2d5fc7cfd24 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/StringsUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/StringsUtils.ts @@ -83,6 +83,8 @@ export const bytesToSize = (bytes: number) => { const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; if (bytes === 0) { return `${bytes} ${sizes[0]}`; + } else if (bytes < 0) { + return `N/A`; } else { const i = parseInt( Math.floor(Math.log(bytes) / Math.log(1024)).toString(), diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TopicDetailsUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TopicDetailsUtils.ts index a6ce00dc70b..e6bf3f44362 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TopicDetailsUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TopicDetailsUtils.ts @@ -23,6 +23,11 @@ export const topicDetailsTabs = [ path: 'activity_feed', field: TabSpecificField.ACTIVITY_FEED, }, + { + name: 'Sample Data', + path: 'sample_data', + field: TabSpecificField.SAMPLE_DATA, + }, { name: 'Config', path: 'config', @@ -40,13 +45,17 @@ export const getCurrentTopicTab = (tab: string) => { currentTab = 2; break; - case 'config': + case 'sample_data': currentTab = 3; break; - case 'manage': + case 'config': currentTab = 4; + break; + case 'manage': + currentTab = 5; + break; case 'schema':