Fix #4785 Sample data for Messaging Services/Topics (#4823)

This commit is contained in:
Sachin Chaurasiya 2022-05-10 14:01:55 +05:30 committed by GitHub
parent a31620af3d
commit 72ba2a7a5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 295 additions and 24 deletions

View File

@ -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 (

View File

@ -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(
<SampleDataTopic sampleData={mockSampleData} />,
{
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(<SampleDataTopic />, {
wrapper: MemoryRouter,
});
const noDataPlaceHolder = getByTestId('no-data');
expect(noDataPlaceHolder).toBeInTheDocument();
});
});

View File

@ -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<HTMLDivElement> {
sampleData?: TopicSampleData;
}
const MessageCard = ({ message }: { message: string }) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div
className="tw-bg-white tw-shadow tw-rounded tw-p-2 tw-mb-6 tw-border tw-border-main"
data-testid="message-card"
onClick={() => setIsExpanded((pre) => !pre)}>
<div className="tw-flex">
<div className="tw-mr-3 tw-cursor-pointer">
<FontAwesomeIcon
className="tw-text-xs"
icon={isExpanded ? 'chevron-up' : 'chevron-down'}
/>
</div>
{isExpanded ? (
<div>
<button
className="tw-gh-tabs active tw--mt-4"
data-testid="value"
id="sampleData-value">
Value
</button>
<SchemaEditor
className="tw-mt-2"
editorClass="topic-sample-data"
options={{
styleActiveLine: false,
}}
value={message.replace(/'/g, '"')}
/>
</div>
) : (
<p>
<p
className="tw-my-1 topic-sample-data-message"
style={{ color: '#450de2' }}>
{message}
</p>
</p>
)}
</div>
</div>
);
};
const SampleDataTopic: FC<SampleDataTopicProp> = ({ sampleData }) => {
if (!isUndefined(sampleData)) {
return (
<div className="tw-p-4 tw-flex tw-flex-col">
{sampleData.messages?.map((message, i) => (
<MessageCard key={i} message={message} />
))}
</div>
);
} else {
return (
<div
className="tw-flex tw-justify-center tw-font-medium tw-items-center tw-border tw-border-main tw-rounded-md tw-p-8"
data-testid="no-data">
No sample data available
</div>
);
}
};
export default withLoader<SampleDataTopicProp>(SampleDataTopic);

View File

@ -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<TopicDetailsProps> = ({
deletePostHandler,
paging,
fetchFeedHandler,
isSampleDataLoading,
sampleData,
}: TopicDetailsProps) => {
const { isAuthDisabled } = useAuthContext();
const [isEdit, setIsEdit] = useState(false);
@ -168,6 +171,17 @@ const TopicDetails: React.FC<TopicDetailsProps> = ({
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<TopicDetailsProps> = ({
selectedName: 'icon-configcolor',
},
isProtected: false,
position: 3,
position: 4,
},
{
name: 'Manage',
@ -190,7 +204,7 @@ const TopicDetails: React.FC<TopicDetailsProps> = ({
isProtected: true,
isHidden: deleted,
protectedState: !owner || hasEditAccess(),
position: 4,
position: 5,
},
];
const extraInfo = [
@ -400,12 +414,20 @@ const TopicDetails: React.FC<TopicDetailsProps> = ({
/>
</div>
</div>
{getInfoBadge([{ key: 'Schema', value: schemaType }])}
<div
className="tw-my-4 tw-border tw-border-main tw-rounded-md tw-py-4"
data-testid="schema">
<SchemaEditor value={schemaText} />
</div>
{schemaText ? (
<Fragment>
{getInfoBadge([{ key: 'Schema', value: schemaType }])}
<div
className="tw-my-4 tw-border tw-border-main tw-rounded-md tw-py-4"
data-testid="schema">
<SchemaEditor value={schemaText} />
</div>
</Fragment>
) : (
<div className="tw-flex tw-justify-center tw-font-medium tw-items-center tw-border tw-border-main tw-rounded-md tw-p-8">
No schema data available
</div>
)}
</>
)}
{activeTab === 2 && (
@ -426,11 +448,19 @@ const TopicDetails: React.FC<TopicDetailsProps> = ({
</div>
)}
{activeTab === 3 && (
<div data-testid="sample-data">
<SampleDataTopic
isLoading={isSampleDataLoading}
sampleData={sampleData}
/>
</div>
)}
{activeTab === 4 && (
<div data-testid="config">
<SchemaEditor value={JSON.stringify(getConfigObject())} />
</div>
)}
{activeTab === 4 && !deleted && (
{activeTab === 5 && !deleted && (
<div>
<ManageTabComponent
allowDelete

View File

@ -13,7 +13,7 @@
import { EntityFieldThreadCount, EntityTags, EntityThread } from 'Models';
import { CreateThread } from '../../generated/api/feed/createThread';
import { Topic } from '../../generated/entity/data/topic';
import { Topic, TopicSampleData } from '../../generated/entity/data/topic';
import { User } from '../../generated/entity/teams/user';
import { EntityReference } from '../../generated/type/entityReference';
import { Paging } from '../../generated/type/paging';
@ -46,6 +46,8 @@ export interface TopicDetailsProps {
feedCount: number;
entityFieldThreadCount: EntityFieldThreadCount[];
paging: Paging;
isSampleDataLoading?: boolean;
sampleData?: TopicSampleData;
fetchFeedHandler: (after?: string) => void;
createThread: (data: CreateThread) => void;
setActiveTabHandler: (value: number) => void;

View File

@ -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(
<TopicDetails {...TopicDetailsProps} activeTab={3} />,
{
wrapper: MemoryRouter,
}
);
const sampleData = await findByTestId(container, 'sample-data');
expect(sampleData).toBeInTheDocument();
});
it('Check if active tab is config', async () => {
const { container } = render(
<TopicDetails {...TopicDetailsProps} activeTab={4} />,
{
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(
<TopicDetails {...TopicDetailsProps} activeTab={4} />,
<TopicDetails {...TopicDetailsProps} activeTab={5} />,
{
wrapper: MemoryRouter,
}

View File

@ -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.
*

View File

@ -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<Paging>({} as Paging);
const [sampleData, setSampleData] = useState<TopicSampleData>();
const [isSampleDataLoading, setIsSampleDataLoading] =
useState<boolean>(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<AxiosResponse> => {
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}

View File

@ -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;
}

View File

@ -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(),

View File

@ -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':