revamp announcement card (#16016)

* revamp announcement card

* cypress fix and minor improvement

* fix sonar issue

* refactor ProfilePicture view and support cypress and fix sonar lint issue

* added and fix some unit test

* fix and added unit test

* changes made as per comments

* fix sonar issue

* skip cypress failure due to flakiness
This commit is contained in:
Ashish Gupta 2024-04-30 15:19:57 +05:30 committed by GitHub
parent 5954ecf060
commit 89b083b6f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1988 additions and 462 deletions

View File

@ -17,7 +17,8 @@ import { EntityType, ENTITY_PATH } from '../../constants/Entity.interface';
import {
createAnnouncement as createAnnouncementUtil,
createInactiveAnnouncement as createInactiveAnnouncementUtil,
deleteAnnoucement,
deleteAnnouncement,
replyAnnouncementUtil,
} from '../Utils/Annoucement';
import {
createCustomPropertyForEntity,
@ -449,26 +450,30 @@ class EntityClass {
createAnnouncement() {
createAnnouncementUtil({
title: 'Cypress annocement',
description: 'Cypress annocement description',
title: 'Cypress announcement',
description: 'Cypress announcement description',
});
}
replyAnnouncement() {
replyAnnouncementUtil();
}
removeAnnouncement() {
deleteAnnoucement();
deleteAnnouncement();
}
// Inactive Announcement
createInactiveAnnouncement() {
createInactiveAnnouncementUtil({
title: 'Inactive Cypress annocement',
description: 'Inactive Cypress annocement description',
title: 'Inactive Cypress announcement',
description: 'Inactive Cypress announcement description',
});
}
removeInactiveAnnouncement() {
deleteAnnoucement();
deleteAnnouncement();
}
followUnfollowEntity() {

View File

@ -21,7 +21,7 @@ import {
verifyResponseStatusCode,
} from '../common';
const annoucementForm = ({ title, description, startDate, endDate }) => {
const announcementForm = ({ title, description, startDate, endDate }) => {
cy.get('#title').type(title);
cy.get('#startTime').click().type(`${startDate}{enter}`);
@ -60,7 +60,7 @@ export const createAnnouncement = (announcement) => {
cy.get('[data-testid="add-announcement"]').click();
cy.get('.ant-modal-header').should('contain', 'Make an announcement');
annoucementForm({ ...announcement, startDate, endDate });
announcementForm({ ...announcement, startDate, endDate });
// wait time for success toast message
verifyResponseStatusCode('@announcementFeed', 200);
@ -76,7 +76,7 @@ export const createAnnouncement = (announcement) => {
);
};
export const deleteAnnoucement = () => {
export const deleteAnnouncement = () => {
interceptURL(
'GET',
'/api/v1/feed?entityLink=*type=Announcement',
@ -103,6 +103,62 @@ export const deleteAnnoucement = () => {
});
};
export const replyAnnouncementUtil = () => {
interceptURL(
'GET',
'/api/v1/feed?entityLink=*type=Announcement',
'announcementFeed'
);
interceptURL('GET', '/api/v1/feed/*', 'allAnnouncementFeed');
interceptURL('POST', '/api/v1/feed/*/posts', 'addAnnouncementReply');
cy.get('[data-testid="announcement-card"]').click();
cy.get(
'[data-testid="announcement-card"] [data-testid="main-message"]'
).trigger('mouseover');
cy.get('[data-testid="add-reply"]').should('be.visible').click();
cy.get('[data-testid="send-button"]').should('be.disabled');
verifyResponseStatusCode('@allAnnouncementFeed', 200);
cy.get('[data-testid="editor-wrapper"] .ql-editor').type('Reply message');
cy.get('[data-testid="send-button"]').should('not.disabled').click();
verifyResponseStatusCode('@addAnnouncementReply', 201);
verifyResponseStatusCode('@announcementFeed', 200);
verifyResponseStatusCode('@allAnnouncementFeed', 200);
cy.get('[data-testid="replies"] [data-testid="viewer-container"]').should(
'contain',
'Reply message'
);
cy.get('[data-testid="show-reply-thread"]').should('contain', '1 replies');
// Edit the reply message
cy.get('[data-testid="replies"] > [data-testid="main-message"]').trigger(
'mouseover'
);
cy.get('[data-testid="edit-message"]').should('be.visible').click();
cy.get('.feed-message [data-testid="editor-wrapper"] .ql-editor')
.clear()
.type('Reply message edited');
cy.get('[data-testid="save-button"]').click();
cy.get('[data-testid="replies"] [data-testid="viewer-container"]').should(
'contain',
'Reply message edited'
);
cy.reload();
};
export const createInactiveAnnouncement = (announcement) => {
// Create InActive Announcement
interceptURL(
@ -126,7 +182,7 @@ export const createInactiveAnnouncement = (announcement) => {
'yyyy-MM-dd'
);
annoucementForm({
announcementForm({
...announcement,
startDate: InActiveStartDate,
endDate: InActiveEndDate,

View File

@ -101,12 +101,16 @@ describe('Database hierarchy details page', { tags: 'DataAssets' }, () => {
entity.renameEntity();
});
it(`Annoucement create & delete`, () => {
it(`Announcement create & delete`, () => {
entity.createAnnouncement();
/**
* Todo: Fix the flakiness issue with the Activity feed changes and enable this test
*/
// entity.replyAnnouncement();
entity.removeAnnouncement();
});
it(`Inactive annoucement create & delete`, () => {
it(`Inactive announcement create & delete`, () => {
entity.createInactiveAnnouncement();
entity.removeInactiveAnnouncement();
});

View File

@ -105,12 +105,16 @@ describe('Entity detail page', { tags: 'DataAssets' }, () => {
entity.removeGlossary();
});
it(`Annoucement create & delete`, () => {
it(`Announcement create & delete`, () => {
entity.createAnnouncement();
/**
* Todo: Fix the flakiness issue with the Activity feed changes and enable this test
*/
// entity.replyAnnouncement();
entity.removeAnnouncement();
});
it(`Inactive annoucement create & delete`, () => {
it(`Inactive Announcement create & delete`, () => {
entity.createInactiveAnnouncement();
entity.removeInactiveAnnouncement();
});

View File

@ -106,12 +106,13 @@ describe('Services detail page', { tags: 'Integration' }, () => {
entity.renameEntity();
});
it(`Annoucement create & delete`, () => {
it(`Announcement create & delete`, () => {
entity.createAnnouncement();
entity.replyAnnouncement();
entity.removeAnnouncement();
});
it(`Inactive annoucement create & delete`, () => {
it(`Inactive Announcement create & delete`, () => {
entity.createInactiveAnnouncement();
entity.removeInactiveAnnouncement();
});

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 442" fill="none"><path fill="currentColor" d="M481.508 175.336 68.414 3.926C51.011-3.296 31.35-.119 17.105 12.213 2.86 24.547-3.098 43.551 1.558 61.808L38.328 206h180.025c8.284 0 15.001 6.716 15.001 15.001 0 8.284-6.716 15.001-15.001 15.001H38.327L1.558 380.193c-4.656 18.258 1.301 37.262 15.547 49.595 14.274 12.357 33.937 15.495 51.31 8.287L481.51 266.666C500.317 258.862 512 241.364 512 221.001s-11.683-37.862-30.492-45.665Z"/></svg>

After

Width:  |  Height:  |  Size: 493 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 442" fill="none"><path fill="#0968da" d="M481.508 175.336 68.414 3.926C51.011-3.296 31.35-.119 17.105 12.213 2.86 24.547-3.098 43.551 1.558 61.808L38.328 206h180.025c8.284 0 15.001 6.716 15.001 15.001 0 8.284-6.716 15.001-15.001 15.001H38.327L1.558 380.193c-4.656 18.258 1.301 37.262 15.547 49.595 14.274 12.357 33.937 15.495 51.31 8.287L481.51 266.666C500.317 258.862 512 241.364 512 221.001s-11.683-37.862-30.492-45.665Z"/></svg>

Before

Width:  |  Height:  |  Size: 488 B

View File

@ -43,6 +43,7 @@ const FeedCardFooter: FC<FeedFooterProp> = ({
onClick={() => onThreadSelect?.(threadId as string)}>
{repliedUsers?.map((u, i) => (
<ProfilePicture
avatarType="outlined"
className="m-r-xss"
data-testid="replied-user"
key={i}

View File

@ -11,10 +11,12 @@
* limitations under the License.
*/
import Icon from '@ant-design/icons/lib/components/Icon';
import { Button } from 'antd';
import classNames from 'classnames';
import React, { FC } from 'react';
import { ReactComponent as IconPaperPlanePrimary } from '../../../assets/svg/paper-plane-primary.svg';
import { ReactComponent as IconPaperPlanePrimary } from '../../../assets/svg/paper-plane-fill.svg';
import './send-button.less';
interface SendButtonProp {
editorValue: string;
@ -28,12 +30,10 @@ export const SendButton: FC<SendButtonProp> = ({
onSaveHandler,
}) => (
<Button
className={classNames('absolute', className)}
className={classNames('send-button', className)}
data-testid="send-button"
disabled={editorValue.length === 0}
icon={<IconPaperPlanePrimary height={18} width={18} />}
size="small"
style={{ bottom: '2px', right: '5px' }}
icon={<Icon component={IconPaperPlanePrimary} />}
type="text"
onClick={onSaveHandler}
/>

View File

@ -0,0 +1,47 @@
/*
* Copyright 2024 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 (reference) url('../../../styles/variables.less');
// Define a mixin for repeated styles
.buttonStyle() {
background: @primary-color;
color: @white;
}
.ant-btn.send-button {
position: absolute;
bottom: 8px;
right: 8px;
width: 34px;
height: 34px;
padding: 0;
border-radius: 50%;
// Apply the mixin to the base and hover, disabled states
.buttonStyle();
&:hover {
.buttonStyle();
opacity: 0.9;
}
&:disabled {
.buttonStyle();
opacity: 0.8;
&:hover {
.buttonStyle();
opacity: 0.8;
}
}
}

View File

@ -44,7 +44,6 @@ export interface ActivityThreadPanelBodyProp
| 'createThread'
| 'deletePostHandler'
> {
editAnnouncementPermission?: boolean;
threadType: ThreadType;
showHeader?: boolean;
}

View File

@ -42,11 +42,9 @@ import FeedPanelHeader from '../ActivityFeedPanel/FeedPanelHeader';
import ActivityThread from './ActivityThread';
import ActivityThreadList from './ActivityThreadList';
import { ActivityThreadPanelBodyProp } from './ActivityThreadPanel.interface';
import AnnouncementThreads from './AnnouncementThreads';
const ActivityThreadPanelBody: FC<ActivityThreadPanelBodyProp> = ({
threadLink,
editAnnouncementPermission,
onCancel,
postFeedHandler,
createThread,
@ -84,8 +82,6 @@ const ActivityThreadPanelBody: FC<ActivityThreadPanelBodyProp> = ({
const isTaskClosed = isEqual(taskStatus, ThreadTaskStatus.Closed);
const isAnnouncementType = threadType === ThreadType.Announcement;
const getThreads = (after?: string) => {
const status = isTaskType ? taskStatus : undefined;
setIsThreadLoading(true);
@ -295,49 +291,29 @@ const ActivityThreadPanelBody: FC<ActivityThreadPanelBodyProp> = ({
/>
</Space>
)}
{(isAnnouncementType || isTaskType) && !isThreadLoading && (
{isTaskType && !isThreadLoading && (
<ErrorPlaceHolder
className="mt-24"
type={ERROR_PLACEHOLDER_TYPE.CUSTOM}>
{isTaskType ? (
<Typography.Paragraph>
{isTaskClosed
? t('message.no-closed-task')
: t('message.no-open-task')}
</Typography.Paragraph>
) : (
<Typography.Paragraph data-testid="announcement-error">
{t('message.no-announcement-message')}
</Typography.Paragraph>
)}
<Typography.Paragraph>
{isTaskClosed
? t('message.no-closed-task')
: t('message.no-open-task')}
</Typography.Paragraph>
</ErrorPlaceHolder>
)}
</>
) : null}
{isAnnouncementType ? (
<AnnouncementThreads
className={classNames(className)}
editAnnouncementPermission={editAnnouncementPermission}
postFeed={postFeed}
selectedThreadId={selectedThreadId}
threads={threads}
updateThreadHandler={onUpdateThread}
onConfirmation={onConfirmation}
onThreadIdSelect={onThreadIdSelect}
onThreadSelect={onThreadSelect}
/>
) : (
<ActivityThreadList
className={classNames(className)}
postFeed={postFeed}
selectedThreadId={selectedThreadId}
threads={threads}
updateThreadHandler={onUpdateThread}
onConfirmation={onConfirmation}
onThreadIdSelect={onThreadIdSelect}
onThreadSelect={onThreadSelect}
/>
)}
<ActivityThreadList
className={classNames(className)}
postFeed={postFeed}
selectedThreadId={selectedThreadId}
threads={threads}
updateThreadHandler={onUpdateThread}
onConfirmation={onConfirmation}
onThreadIdSelect={onThreadIdSelect}
onThreadSelect={onThreadSelect}
/>
<div
data-testid="observer-element"
id="observer-element"

View File

@ -1,175 +0,0 @@
/*
* Copyright 2022 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 { Card, Divider, Typography } from 'antd';
import React, { FC, Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import {
Post,
Thread,
ThreadType,
} from '../../../generated/entity/feed/thread';
import { isActiveAnnouncement } from '../../../utils/AnnouncementsUtils';
import { getFeedListWithRelativeDays } from '../../../utils/FeedUtils';
import ActivityFeedCard from '../ActivityFeedCard/ActivityFeedCard';
import FeedCardFooter from '../ActivityFeedCard/FeedCardFooter/FeedCardFooter';
import ActivityFeedEditor from '../ActivityFeedEditor/ActivityFeedEditor';
import AnnouncementBadge from '../Shared/AnnouncementBadge';
import { ActivityThreadListProp } from './ActivityThreadPanel.interface';
import './announcement.less';
const AnnouncementThreads: FC<ActivityThreadListProp> = ({
threads,
className,
selectedThreadId,
onThreadIdSelect,
onThreadSelect,
onConfirmation,
postFeed,
updateThreadHandler,
editAnnouncementPermission,
}) => {
const { t } = useTranslation();
const { updatedFeedList: updatedThreads } =
getFeedListWithRelativeDays(threads);
const toggleReplyEditor = (id: string) => {
onThreadIdSelect(selectedThreadId === id ? '' : id);
};
const activeAnnouncements = updatedThreads.filter(
(thread) =>
thread.announcement &&
isActiveAnnouncement(
thread.announcement?.startTime,
thread.announcement?.endTime
)
);
const inActiveAnnouncements = updatedThreads.filter(
(thread) =>
!(
thread.announcement &&
isActiveAnnouncement(
thread.announcement?.startTime,
thread.announcement?.endTime
)
)
);
const getAnnouncements = (announcements: Thread[]) => {
return announcements.map((thread, index) => {
const mainFeed = {
message: thread.message,
postTs: thread.threadTs,
from: thread.createdBy,
id: thread.id,
reactions: thread.reactions,
} as Post;
const postLength = thread?.posts?.length || 0;
const replies = thread.postsCount ? thread.postsCount - 1 : 0;
const repliedUsers = [
...new Set((thread?.posts || []).map((f) => f.from)),
];
const repliedUniqueUsersList = repliedUsers.slice(
0,
postLength >= 3 ? 2 : 1
);
const lastPost = thread?.posts?.[postLength - 1];
return (
<Fragment key={index}>
<Card
className="ant-card-feed announcement-thread-card"
data-testid="announcement-card"
key={`${index} - card`}>
<AnnouncementBadge />
<div data-testid="main-message">
<ActivityFeedCard
isEntityFeed
isThread
announcementDetails={thread.announcement}
editAnnouncementPermission={editAnnouncementPermission}
entityLink={thread.about}
feed={mainFeed}
feedType={thread.type || ThreadType.Conversation}
task={thread}
threadId={thread.id}
updateThreadHandler={updateThreadHandler}
onConfirmation={onConfirmation}
onReply={() => onThreadSelect(thread.id)}
/>
</div>
{postLength > 0 ? (
<div data-testid="replies-container">
{postLength > 1 ? (
<div>
{Boolean(lastPost) && <div />}
<div className="d-flex ">
<FeedCardFooter
isFooterVisible
lastReplyTimeStamp={lastPost?.postTs}
repliedUsers={repliedUniqueUsersList}
replies={replies}
threadId={thread.id}
onThreadSelect={() => onThreadSelect(thread.id)}
/>
</div>
</div>
) : null}
<div data-testid="latest-reply">
<ActivityFeedCard
isEntityFeed
feed={lastPost as Post}
feedType={thread.type || ThreadType.Conversation}
task={thread}
threadId={thread.id}
updateThreadHandler={updateThreadHandler}
onConfirmation={onConfirmation}
onReply={() => toggleReplyEditor(thread.id)}
/>
</div>
</div>
) : null}
{selectedThreadId === thread.id ? (
<div data-testid="quick-reply-editor">
<ActivityFeedEditor onSave={postFeed} />
</div>
) : null}
</Card>
</Fragment>
);
});
};
return (
<div className={className}>
{getAnnouncements(activeAnnouncements)}
{Boolean(inActiveAnnouncements.length) && (
<>
<Typography.Text
className="d-block m-t-lg"
data-testid="inActive-announcements">
{t('label.inactive-announcement-plural')}
</Typography.Text>
<Divider className="m-t-xs m-b-xlg" />
</>
)}
{getAnnouncements(inActiveAnnouncements)}
</div>
);
};
export default AnnouncementThreads;

View File

@ -1,22 +0,0 @@
/*
* Copyright 2023 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 url('../../../styles/variables.less');
.announcement-thread-card {
margin-top: 20px;
padding-top: 8px;
border-radius: 8px;
border: 1px solid @announcement-border;
background: @announcement-background;
}

View File

@ -11,6 +11,7 @@
* limitations under the License.
*/
import Icon from '@ant-design/icons/lib/components/Icon';
import { Typography } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -22,11 +23,11 @@ const AnnouncementBadge = () => {
return (
<div className="announcement-badge-container">
<AnnouncementIcon className="announcement-badge" />
<Icon className="announcement-badge" component={AnnouncementIcon} />
<Typography.Paragraph className="text-xs m-l-xss m-b-0 text-primary">
<Typography.Text className="announcement-text">
{t('label.announcement')}
</Typography.Paragraph>
</Typography.Text>
</div>
);
};

View File

@ -20,16 +20,23 @@
background: @announcement-background-dark;
border-radius: 4px;
border: 1px solid @announcement-border;
padding: 0 8px;
padding: 3px 8px;
display: flex;
align-items: center;
justify-content: center;
}
.announcement-badge {
width: 14px;
height: 14px;
color: @primary-color;
.announcement-badge {
width: 14px;
height: 14px;
color: @announcement-border;
}
.announcement-text {
margin-left: 4px;
color: @announcement-border;
font-size: 12px;
font-weight: 700;
}
}
.task-badge {

View File

@ -0,0 +1,91 @@
/*
* Copyright 2024 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 { Operation } from 'fast-json-patch';
import { HTMLAttributes } from 'react';
import {
AnnouncementDetails,
CreateThread,
ThreadType,
} from '../../generated/api/feed/createThread';
import { Post, Thread } from '../../generated/entity/feed/thread';
import { ThreadUpdatedFunc } from '../../interface/feed.interface';
import { ConfirmState } from '../ActivityFeed/ActivityFeedCard/ActivityFeedCard.interface';
export type ThreadUpdatedFunction = (
threadId: string,
postId: string,
isThread: boolean,
data: Operation[]
) => Promise<void>;
export interface AnnouncementThreadProp extends HTMLAttributes<HTMLDivElement> {
threadLink: string;
threadType?: ThreadType;
open?: boolean;
postFeedHandler: (value: string, id: string) => Promise<void>;
createThread: (data: CreateThread) => Promise<void>;
updateThreadHandler: ThreadUpdatedFunction;
onCancel?: () => void;
deletePostHandler?: (
threadId: string,
postId: string,
isThread: boolean
) => Promise<void>;
}
export interface AnnouncementThreadBodyProp
extends HTMLAttributes<HTMLDivElement>,
Pick<
AnnouncementThreadProp,
| 'threadLink'
| 'updateThreadHandler'
| 'postFeedHandler'
| 'deletePostHandler'
> {
refetchThread: boolean;
editPermission: boolean;
}
export interface AnnouncementThreadListProp
extends HTMLAttributes<HTMLDivElement>,
Pick<AnnouncementThreadProp, 'updateThreadHandler'> {
editPermission: boolean;
threads: Thread[];
postFeed: (value: string, id: string) => Promise<void>;
onConfirmation: (data: ConfirmState) => void;
}
export interface AnnouncementFeedCardProp {
feed: Post;
task: Thread;
editPermission: boolean;
onConfirmation: (data: ConfirmState) => void;
updateThreadHandler: ThreadUpdatedFunction;
postFeed: (value: string, id: string) => Promise<void>;
}
export interface AnnouncementFeedCardBodyProp
extends HTMLAttributes<HTMLDivElement> {
feed: Post;
editPermission: boolean;
entityLink?: string;
isThread?: boolean;
task: Thread;
announcementDetails?: AnnouncementDetails;
showRepliesButton?: boolean;
isReplyThreadOpen?: boolean;
onReply?: () => void;
onConfirmation: (data: ConfirmState) => void;
showReplyThread?: () => void;
updateThreadHandler: ThreadUpdatedFunc;
}

View File

@ -0,0 +1,168 @@
/*
* Copyright 2024 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 { Card, Col, Row } from 'antd';
import { AxiosError } from 'axios';
import { Operation } from 'fast-json-patch';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Post } from '../../generated/entity/feed/thread';
import { getFeedById } from '../../rest/feedsAPI';
import { showErrorToast } from '../../utils/ToastUtils';
import ActivityFeedEditor from '../ActivityFeed/ActivityFeedEditor/ActivityFeedEditor';
import AnnouncementBadge from '../ActivityFeed/Shared/AnnouncementBadge';
import ProfilePicture from '../common/ProfilePicture/ProfilePicture';
import { AnnouncementFeedCardProp } from './Announcement.interface';
import './announcement.less';
import AnnouncementFeedCardBody from './AnnouncementFeedCardBody.component';
const AnnouncementFeedCard = ({
feed,
task,
editPermission,
postFeed,
onConfirmation,
updateThreadHandler,
}: AnnouncementFeedCardProp) => {
const { t } = useTranslation();
const [isReplyThreadVisible, setIsReplyThreadVisible] =
useState<boolean>(false);
const [postFeedData, setPostFeedData] = useState<Post[]>([]);
const fetchAnnouncementThreadData = async () => {
try {
const res = await getFeedById(task.id);
setPostFeedData(res.data.posts ?? []);
} catch (err) {
showErrorToast(
err as AxiosError,
t('message.entity-fetch-error', {
entity: t('label.message-lowercase-plural'),
})
);
}
};
const handleUpdateThreadHandler = async (
threadId: string,
postId: string,
isThread: boolean,
data: Operation[]
) => {
await updateThreadHandler(threadId, postId, isThread, data);
if (isReplyThreadVisible) {
fetchAnnouncementThreadData();
}
};
const handleSaveReply = async (value: string) => {
await postFeed(value, task.id);
if (isReplyThreadVisible) {
fetchAnnouncementThreadData();
}
};
const handleOpenReplyThread = () => {
fetchAnnouncementThreadData();
setIsReplyThreadVisible((prev) => !prev);
};
const postFeedReplies = useMemo(
() =>
postFeedData.map((reply) => (
<AnnouncementFeedCardBody
editPermission={editPermission}
feed={reply}
key={reply.id}
showRepliesButton={false}
task={task}
updateThreadHandler={handleUpdateThreadHandler}
onConfirmation={onConfirmation}
/>
)),
[
task,
postFeedData,
editPermission,
onConfirmation,
handleUpdateThreadHandler,
]
);
// fetch announcement thread after delete action
useEffect(() => {
if (postFeedData.length !== task.postsCount) {
if (isReplyThreadVisible) {
fetchAnnouncementThreadData();
}
}
}, [task.postsCount]);
return (
<>
<Card
className="ant-card-feed announcement-thread-card"
data-testid="announcement-card">
<AnnouncementBadge />
<AnnouncementFeedCardBody
isThread
announcementDetails={task.announcement}
editPermission={editPermission}
entityLink={task.about}
feed={feed}
isReplyThreadOpen={isReplyThreadVisible}
showReplyThread={handleOpenReplyThread}
task={task}
updateThreadHandler={handleUpdateThreadHandler}
onConfirmation={onConfirmation}
onReply={handleOpenReplyThread}
/>
</Card>
{isReplyThreadVisible && (
<Row className="m-t-lg" gutter={[0, 10]}>
<Col span={24}>
<Row gutter={[10, 0]} wrap={false}>
<Col className="d-flex justify-center" flex="70px">
<div className="feed-line" />
</Col>
<Col flex="auto">
<div className="w-full m-l-xs" data-testid="replies">
{postFeedReplies}
</div>
</Col>
</Row>
</Col>
<Col span={24}>
<Row gutter={[10, 0]} wrap={false}>
<Col className="d-flex justify-center" flex="70px">
<ProfilePicture
avatarType="outlined"
className="m-l-xs"
name={feed.from}
width="24"
/>
</Col>
<Col flex="auto">
<ActivityFeedEditor onSave={handleSaveReply} />
</Col>
</Row>
</Col>
</Row>
)}
</>
);
};
export default AnnouncementFeedCard;

View File

@ -0,0 +1,165 @@
/*
* Copyright 2024 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 { act, fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import {
MOCK_ANNOUNCEMENT_DATA,
MOCK_ANNOUNCEMENT_FEED_DATA,
} from '../../mocks/Announcement.mock';
import { getFeedById } from '../../rest/feedsAPI';
import AnnouncementFeedCard from './AnnouncementFeedCard.component';
jest.mock('../../rest/feedsAPI', () => ({
getFeedById: jest.fn().mockImplementation(() => Promise.resolve()),
}));
jest.mock('./AnnouncementFeedCardBody.component', () =>
jest
.fn()
.mockImplementation(
({ showReplyThread, updateThreadHandler, onConfirmation, onReply }) => (
<>
<p>AnnouncementFeedCardBody</p>
<button onClick={showReplyThread}>ShowReplyThreadButton</button>
<button
onClick={() =>
updateThreadHandler('threadId', 'postId', true, 'data')
}>
UpdateThreadHandlerButton
</button>
<button onClick={onConfirmation}>ConfirmationButton</button>
<button onClick={onReply}>ReplyButton</button>
</>
)
)
);
jest.mock('../ActivityFeed/ActivityFeedEditor/ActivityFeedEditor', () => {
return jest.fn().mockImplementation(({ onSave }) => (
<>
<p>ActivityFeedEditor</p>
<button onClick={() => onSave('changesValue')}>onSaveReply</button>
</>
));
});
jest.mock('../ActivityFeed/Shared/AnnouncementBadge', () => {
return jest.fn().mockReturnValue(<p>AnnouncementBadge</p>);
});
jest.mock('../common/ProfilePicture/ProfilePicture', () => {
return jest.fn().mockReturnValue(<p>ProfilePicture</p>);
});
jest.mock('../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
}));
const mockProps = {
feed: {
message: 'Cypress announcement',
postTs: 1714026576902,
from: 'admin',
id: '36ea94c9-7f12-489c-94df-56cbefe14b2f',
reactions: [],
},
task: MOCK_ANNOUNCEMENT_DATA.data[0],
editPermission: true,
postFeed: jest.fn(),
onConfirmation: jest.fn(),
updateThreadHandler: jest.fn(),
};
describe('Test AnnouncementFeedCard Component', () => {
it('should render AnnouncementFeedCard component', () => {
render(<AnnouncementFeedCard {...mockProps} />);
expect(screen.getByText('AnnouncementBadge')).toBeInTheDocument();
expect(screen.getByText('AnnouncementFeedCardBody')).toBeInTheDocument();
});
it('should trigger onConfirmation', () => {
render(<AnnouncementFeedCard {...mockProps} />);
fireEvent.click(screen.getByText('ConfirmationButton'));
expect(mockProps.onConfirmation).toHaveBeenCalled();
});
it('should trigger updateThreadHandler without fetchAnnouncementThreadData when replyThread is closed', () => {
render(<AnnouncementFeedCard {...mockProps} />);
fireEvent.click(screen.getByText('UpdateThreadHandlerButton'));
expect(mockProps.updateThreadHandler).toHaveBeenCalledWith(
'threadId',
'postId',
true,
'data'
);
expect(getFeedById).not.toHaveBeenCalled();
});
it('should trigger updateThreadHandler with fetchAnnouncementThreadData when replyThread is open', () => {
render(<AnnouncementFeedCard {...mockProps} />);
act(() => {
fireEvent.click(screen.getByText('ShowReplyThreadButton'));
});
expect(getFeedById).toHaveBeenCalledWith(MOCK_ANNOUNCEMENT_DATA.data[0].id);
fireEvent.click(screen.getByText('UpdateThreadHandlerButton'));
expect(mockProps.updateThreadHandler).toHaveBeenCalledWith(
'threadId',
'postId',
true,
'data'
);
expect(getFeedById).toHaveBeenCalledWith(MOCK_ANNOUNCEMENT_DATA.data[0].id);
});
it('should trigger onReply with fetchAnnouncementThreadData', async () => {
(getFeedById as jest.Mock).mockImplementationOnce(() =>
Promise.resolve({ data: MOCK_ANNOUNCEMENT_FEED_DATA })
);
await act(async () => {
render(<AnnouncementFeedCard {...mockProps} />);
});
await act(async () => {
fireEvent.click(screen.getByText('ReplyButton'));
});
expect(getFeedById).toHaveBeenCalledWith(MOCK_ANNOUNCEMENT_DATA.data[0].id);
expect(screen.getByTestId('replies')).toBeInTheDocument();
expect(screen.getAllByText('AnnouncementFeedCardBody')).toHaveLength(5);
expect(screen.getByText('ProfilePicture')).toBeInTheDocument();
expect(screen.getByText('ActivityFeedEditor')).toBeInTheDocument();
fireEvent.click(screen.getByText('onSaveReply'));
expect(mockProps.postFeed).toHaveBeenCalledWith(
'changesValue',
'36ea94c9-7f12-489c-94df-56cbefe14b2f'
);
expect(getFeedById).toHaveBeenCalledWith(MOCK_ANNOUNCEMENT_DATA.data[0].id);
});
});

View File

@ -0,0 +1,282 @@
/*
* Copyright 2024 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 Icon from '@ant-design/icons/lib/components/Icon';
import { Avatar, Button, Col, Popover, Row } from 'antd';
import classNames from 'classnames';
import { compare, Operation } from 'fast-json-patch';
import { isEmpty, isUndefined } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as ArrowBottom } from '../../assets/svg/ic-arrow-down.svg';
import { ReactionOperation } from '../../enums/reactions.enum';
import {
AnnouncementDetails,
ThreadType,
} from '../../generated/api/feed/createThread';
import { Post } from '../../generated/entity/feed/thread';
import { Reaction, ReactionType } from '../../generated/type/reaction';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import {
getEntityField,
getEntityFQN,
getEntityType,
} from '../../utils/FeedUtils';
import FeedCardBody from '../ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody';
import FeedCardHeader from '../ActivityFeed/ActivityFeedCard/FeedCardHeader/FeedCardHeader';
import PopoverContent from '../ActivityFeed/ActivityFeedCard/PopoverContent';
import UserPopOverCard from '../common/PopOverCard/UserPopOverCard';
import ProfilePicture from '../common/ProfilePicture/ProfilePicture';
import EditAnnouncementModal from '../Modals/AnnouncementModal/EditAnnouncementModal';
import { AnnouncementFeedCardBodyProp } from './Announcement.interface';
import './announcement.less';
const AnnouncementFeedCardBody = ({
feed,
entityLink,
isThread,
editPermission,
showRepliesButton = true,
showReplyThread,
onReply,
announcementDetails,
onConfirmation,
updateThreadHandler,
task,
isReplyThreadOpen,
}: AnnouncementFeedCardBodyProp) => {
const { t } = useTranslation();
const entityType = getEntityType(entityLink ?? '');
const entityFQN = getEntityFQN(entityLink ?? '');
const entityField = getEntityField(entityLink ?? '');
const { currentUser } = useApplicationStore();
const containerRef = useRef<HTMLDivElement>(null);
const [feedDetail, setFeedDetail] = useState<Post>(feed);
const [visible, setVisible] = useState<boolean>(false);
const [isEditAnnouncement, setIsEditAnnouncement] = useState<boolean>(false);
const [isEditPost, setIsEditPost] = useState<boolean>(false);
const isAuthor = feedDetail.from === currentUser?.name;
const { id: threadId, type: feedType, posts } = task;
const repliesPostAvatarGroup = useMemo(() => {
return (
<Avatar.Group>
{(posts ?? []).map((u) => (
<ProfilePicture
avatarType="outlined"
key={u.id}
name={u.from}
width="18"
/>
))}
</Avatar.Group>
);
}, [posts]);
const onFeedUpdate = (data: Operation[]) => {
updateThreadHandler(
threadId ?? feedDetail.id,
feedDetail.id,
Boolean(isThread),
data
);
};
const onReactionSelect = (
reactionType: ReactionType,
reactionOperation: ReactionOperation
) => {
let updatedReactions = feedDetail.reactions || [];
if (reactionOperation === ReactionOperation.ADD) {
const reactionObject = {
reactionType,
user: {
id: currentUser?.id as string,
},
};
updatedReactions = [...updatedReactions, reactionObject as Reaction];
} else {
updatedReactions = updatedReactions.filter(
(reaction) =>
!(
reaction.reactionType === reactionType &&
reaction.user.id === currentUser?.id
)
);
}
const patch = compare(
{ ...feedDetail, reactions: [...(feedDetail.reactions || [])] },
{
...feedDetail,
reactions: updatedReactions,
}
);
if (!isEmpty(patch)) {
onFeedUpdate(patch);
}
};
const handleAnnouncementUpdate = (
title: string,
announcement: AnnouncementDetails
) => {
const existingAnnouncement = {
...feedDetail,
announcement: announcementDetails,
};
const updatedAnnouncement = {
...feedDetail,
message: title,
announcement,
};
const patch = compare(existingAnnouncement, updatedAnnouncement);
if (!isEmpty(patch)) {
onFeedUpdate(patch);
}
setIsEditAnnouncement(false);
};
const handlePostUpdate = (message: string) => {
const updatedPost = { ...feedDetail, message };
const patch = compare(feedDetail, updatedPost);
if (!isEmpty(patch)) {
onFeedUpdate(patch);
}
setIsEditPost(false);
};
const handleThreadEdit = () => {
if (announcementDetails) {
setIsEditAnnouncement(true);
} else {
setIsEditPost(true);
}
};
const handleVisibleChange = (newVisible: boolean) => setVisible(newVisible);
const onHide = () => setVisible(false);
useEffect(() => {
setFeedDetail(feed);
}, [feed]);
return (
<div
className={classNames(
'bg-grey-1-hover m--x-sm w-full p-x-sm m--t-xss py-2 m-b-xss rounded-4',
{
'bg-grey-1-hover': visible,
}
)}
data-testid="main-message"
ref={containerRef}>
<Popover
align={{ targetOffset: [0, -16] }}
content={
<PopoverContent
editAnnouncementPermission={editPermission}
isAnnouncement={!isUndefined(announcementDetails)}
isAuthor={isAuthor}
isThread={isThread}
postId={feedDetail.id}
reactions={feedDetail.reactions || []}
threadId={threadId}
onConfirmation={onConfirmation}
onEdit={handleThreadEdit}
onPopoverHide={onHide}
onReactionSelect={onReactionSelect}
onReply={onReply}
/>
}
destroyTooltipOnHide={{ keepParent: false }}
getPopupContainer={() => containerRef.current || document.body}
key="reaction-options-popover"
open={visible && !isEditPost}
overlayClassName="ant-popover-feed"
placement="topRight"
trigger="hover"
onOpenChange={handleVisibleChange}>
<Row gutter={[10, 0]} wrap={false}>
<Col className="avatar-column d-flex flex-column items-center justify-between">
<UserPopOverCard userName={feedDetail.from} />
{showRepliesButton && repliesPostAvatarGroup}
</Col>
<Col flex="auto">
<div>
<FeedCardHeader
isEntityFeed
createdBy={feedDetail.from}
entityFQN={entityFQN}
entityField={entityField ?? ''}
entityType={entityType}
feedType={feedType ?? ThreadType.Conversation}
task={task}
timeStamp={feedDetail.postTs}
/>
<FeedCardBody
announcementDetails={announcementDetails}
isEditPost={isEditPost}
isThread={isThread}
message={feedDetail.message}
reactions={feedDetail.reactions || []}
onCancelPostUpdate={() => setIsEditPost(false)}
onPostUpdate={handlePostUpdate}
onReactionSelect={onReactionSelect}
/>
{!isEmpty(task.posts) && showRepliesButton ? (
<Button
className="p-0 h-auto line-height-16 text-announcement m-r-xs m-t-xs d-flex items-center"
data-testid="show-reply-thread"
type="text"
onClick={showReplyThread}>
{`${task.postsCount} ${t('label.reply-lowercase-plural')}`}
<Icon
className={classNames('arrow-icon', {
'rotate-180': isReplyThreadOpen,
})}
component={ArrowBottom}
/>
</Button>
) : null}
</div>
</Col>
</Row>
</Popover>
{isEditAnnouncement && announcementDetails && (
<EditAnnouncementModal
announcement={announcementDetails}
announcementTitle={feedDetail.message}
open={isEditAnnouncement}
onCancel={() => setIsEditAnnouncement(false)}
onConfirm={handleAnnouncementUpdate}
/>
)}
</div>
);
};
export default AnnouncementFeedCardBody;

View File

@ -0,0 +1,180 @@
/*
* Copyright 2024 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 { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { ReactionOperation } from '../../enums/reactions.enum';
import { Thread } from '../../generated/entity/feed/thread';
import { ReactionType } from '../../generated/type/reaction';
import { MOCK_ANNOUNCEMENT_DATA } from '../../mocks/Announcement.mock';
import { mockUserData } from '../../mocks/MyDataPage.mock';
import AnnouncementFeedCardBody from './AnnouncementFeedCardBody.component';
jest.mock('../../utils/FeedUtils', () => ({
getEntityField: jest.fn(),
getEntityFQN: jest.fn(),
getEntityType: jest.fn(),
}));
jest.mock('../../hooks/useApplicationStore', () => ({
useApplicationStore: jest.fn(() => ({
currentUser: mockUserData,
})),
}));
jest.mock('../ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody', () =>
jest.fn().mockImplementation(({ onPostUpdate, onReactionSelect }) => (
<>
<p>FeedCardBody</p>
<button onClick={() => onPostUpdate('message')}>PostUpdateButton</button>
<button
onClick={() =>
onReactionSelect(ReactionType.Confused, ReactionOperation.ADD)
}>
ReactionSelectButton
</button>
</>
))
);
jest.mock(
'../ActivityFeed/ActivityFeedCard/FeedCardHeader/FeedCardHeader',
() => {
return jest.fn().mockReturnValue(<p>FeedCardHeader</p>);
}
);
jest.mock('../common/PopOverCard/UserPopOverCard', () => {
return jest.fn().mockImplementation(() => <p>UserPopOverCard</p>);
});
jest.mock('../common/ProfilePicture/ProfilePicture', () => {
return jest.fn().mockImplementation(() => <p>ProfilePicture</p>);
});
jest.mock('../Modals/AnnouncementModal/EditAnnouncementModal', () => {
return jest.fn().mockImplementation(() => <p>EditAnnouncementModal</p>);
});
jest.mock('../ActivityFeed/ActivityFeedCard/PopoverContent', () => {
return jest.fn().mockImplementation(() => <p>PopoverContent</p>);
});
const mockFeedCardProps = {
feed: {
from: 'admin',
id: '36ea94c9-7f12-489c-94df-56cbefe14b2f',
message: 'Cypress announcement',
postTs: 1714026576902,
reactions: [],
},
task: MOCK_ANNOUNCEMENT_DATA.data[0],
entityLink:
'<#E::database::cy-database-service-373851.cypress-database-1714026557974>',
isThread: true,
editPermission: true,
isReplyThreadOpen: false,
updateThreadHandler: jest.fn(),
onReply: jest.fn(),
onConfirmation: jest.fn(),
showReplyThread: jest.fn(),
};
describe('Test AnnouncementFeedCardBody Component', () => {
it('Check if AnnouncementFeedCardBody component has all child components', async () => {
render(<AnnouncementFeedCardBody {...mockFeedCardProps} />);
const feedCardHeader = screen.getByText('FeedCardHeader');
const feedCardBody = screen.getByText('FeedCardBody');
const profilePictures = screen.getAllByText('ProfilePicture');
const userPopOverCard = screen.getByText('UserPopOverCard');
expect(feedCardHeader).toBeInTheDocument();
expect(feedCardBody).toBeInTheDocument();
expect(userPopOverCard).toBeInTheDocument();
expect(profilePictures).toHaveLength(4);
});
it('should trigger onPostUpdate from FeedCardBody', async () => {
render(<AnnouncementFeedCardBody {...mockFeedCardProps} />);
const postUpdateButton = screen.getByText('PostUpdateButton');
fireEvent.click(postUpdateButton);
expect(mockFeedCardProps.updateThreadHandler).toHaveBeenCalledWith(
MOCK_ANNOUNCEMENT_DATA.data[0].id,
MOCK_ANNOUNCEMENT_DATA.data[0].id,
true,
[{ op: 'replace', path: '/message', value: 'message' }]
);
});
it('should trigger ReactionSelectButton from FeedCardBody', async () => {
render(<AnnouncementFeedCardBody {...mockFeedCardProps} />);
const reactionSelectButton = screen.getByText('ReactionSelectButton');
fireEvent.click(reactionSelectButton);
expect(mockFeedCardProps.updateThreadHandler).toHaveBeenCalledWith(
MOCK_ANNOUNCEMENT_DATA.data[0].id,
MOCK_ANNOUNCEMENT_DATA.data[0].id,
true,
[
{
op: 'add',
path: '/reactions/0',
value: {
reactionType: 'confused',
user: {
id: '123',
},
},
},
]
);
});
it('should trigger postReplies button', async () => {
render(<AnnouncementFeedCardBody {...mockFeedCardProps} />);
const showReplyThread = screen.getByTestId('show-reply-thread');
fireEvent.click(showReplyThread);
expect(mockFeedCardProps.showReplyThread).toHaveBeenCalled();
});
it('should not render PostReplies Profile Picture if showRepliesButton is false', async () => {
render(
<AnnouncementFeedCardBody
{...mockFeedCardProps}
showRepliesButton={false}
/>
);
const profilePictures = screen.queryByText('ProfilePicture');
const showReplyThread = screen.queryByTestId('show-reply-thread');
expect(profilePictures).not.toBeInTheDocument();
expect(showReplyThread).not.toBeInTheDocument();
});
it('should not render PostReplies button if repliesPost is empty', async () => {
render(
<AnnouncementFeedCardBody {...mockFeedCardProps} task={{} as Thread} />
);
const showReplyThread = screen.queryByTestId('show-reply-thread');
expect(showReplyThread).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,152 @@
/*
* Copyright 2024 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 { Typography } from 'antd';
import { AxiosError } from 'axios';
import { Operation } from 'fast-json-patch';
import { isEmpty } from 'lodash';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { confirmStateInitialValue } from '../../constants/Feeds.constants';
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
import { FeedFilter } from '../../enums/mydata.enum';
import { Thread, ThreadType } from '../../generated/entity/feed/thread';
import { getAllFeeds } from '../../rest/feedsAPI';
import { showErrorToast } from '../../utils/ToastUtils';
import { ConfirmState } from '../ActivityFeed/ActivityFeedCard/ActivityFeedCard.interface';
import ErrorPlaceHolder from '../common/ErrorWithPlaceholder/ErrorPlaceHolder';
import ConfirmationModal from '../Modals/ConfirmationModal/ConfirmationModal';
import { AnnouncementThreadBodyProp } from './Announcement.interface';
import AnnouncementThreads from './AnnouncementThreads';
const AnnouncementThreadBody = ({
threadLink,
refetchThread,
editPermission,
postFeedHandler,
deletePostHandler,
updateThreadHandler,
}: AnnouncementThreadBodyProp) => {
const { t } = useTranslation();
const [threads, setThreads] = useState<Thread[]>([]);
const [confirmationState, setConfirmationState] = useState<ConfirmState>(
confirmStateInitialValue
);
const [isThreadLoading, setIsThreadLoading] = useState(true);
const getThreads = async (after?: string) => {
setIsThreadLoading(true);
try {
const res = await getAllFeeds(
threadLink,
after,
ThreadType.Announcement,
FeedFilter.ALL
);
setThreads(res.data);
} catch (error) {
showErrorToast(
error as AxiosError,
t('server.entity-fetch-error', {
entity: t('label.thread-plural-lowercase'),
})
);
} finally {
setIsThreadLoading(false);
}
};
const loadNewThreads = () => {
setTimeout(() => {
getThreads();
}, 500);
};
const onDiscard = () => {
setConfirmationState(confirmStateInitialValue);
};
const onPostDelete = async (): Promise<void> => {
if (confirmationState.postId && confirmationState.threadId) {
await deletePostHandler?.(
confirmationState.threadId,
confirmationState.postId,
confirmationState.isThread
);
}
onDiscard();
loadNewThreads();
};
const onConfirmation = (data: ConfirmState) => {
setConfirmationState(data);
};
const postFeed = async (value: string, id: string): Promise<void> => {
await postFeedHandler?.(value, id);
loadNewThreads();
};
const onUpdateThread = async (
threadId: string,
postId: string,
isThread: boolean,
data: Operation[]
): Promise<void> => {
await updateThreadHandler(threadId, postId, isThread, data);
loadNewThreads();
};
useEffect(() => {
getThreads();
}, [threadLink, refetchThread]);
if (isEmpty(threads) && !isThreadLoading) {
return (
<ErrorPlaceHolder
className="h-auto mt-24"
type={ERROR_PLACEHOLDER_TYPE.CUSTOM}>
<Typography.Paragraph data-testid="announcement-error">
{t('message.no-announcement-message')}
</Typography.Paragraph>
</ErrorPlaceHolder>
);
}
return (
<div
className="announcement-thread-body"
data-testid="announcement-thread-body">
<AnnouncementThreads
editPermission={editPermission}
postFeed={postFeed}
threads={threads}
updateThreadHandler={onUpdateThread}
onConfirmation={onConfirmation}
/>
<ConfirmationModal
bodyText={t('message.confirm-delete-message')}
cancelText={t('label.cancel')}
confirmText={t('label.delete')}
header={t('message.delete-message-question-mark')}
visible={confirmationState.state}
onCancel={onDiscard}
onConfirm={onPostDelete}
/>
</div>
);
};
export default AnnouncementThreadBody;

View File

@ -0,0 +1,276 @@
/*
* Copyright 2024 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 { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { act } from 'react-test-renderer';
import { MOCK_ANNOUNCEMENT_DATA } from '../../mocks/Announcement.mock';
import { getAllFeeds } from '../../rest/feedsAPI';
import AnnouncementThreadBody from './AnnouncementThreadBody.component';
jest.mock('../../rest/feedsAPI', () => ({
getAllFeeds: jest.fn().mockImplementation(() => Promise.resolve()),
}));
jest.mock('./AnnouncementThreads', () =>
jest
.fn()
.mockImplementation(({ postFeed, updateThreadHandler, onConfirmation }) => (
<>
<p>AnnouncementThreads</p>
<button onClick={() => postFeed('valueId', 'id')}>
PostFeedButton
</button>
<button
onClick={() =>
onConfirmation({
state: true,
threadId: 'threadId',
postId: 'postId',
isThread: false,
})
}>
ConfirmationButton
</button>
<button
onClick={() =>
updateThreadHandler('threadId', 'postId', true, {
op: 'replace',
path: '/announcement/description',
value: 'Cypress announcement description.',
})
}>
UpdateThreadButton
</button>
</>
))
);
jest.mock('../Modals/ConfirmationModal/ConfirmationModal', () =>
jest.fn().mockImplementation(({ visible, onConfirm, onCancel }) => (
<>
{visible ? 'Confirmation Modal is open' : 'Confirmation Modal is close'}
<button onClick={onConfirm}>Confirm Confirmation Modal</button>
<button onClick={onCancel}>Cancel Confirmation Modal</button>
</>
))
);
jest.mock('../common/ErrorWithPlaceholder/ErrorPlaceHolder', () => {
return jest.fn().mockReturnValue(<p>ErrorPlaceHolder</p>);
});
jest.mock('../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
}));
const mockProps = {
threadLink: 'threadLink',
refetchThread: false,
editPermission: true,
postFeedHandler: jest.fn(),
deletePostHandler: jest.fn(),
updateThreadHandler: jest.fn(),
};
describe('Test AnnouncementThreadBody Component', () => {
it('should call getAllFeeds when component is mount', async () => {
render(<AnnouncementThreadBody {...mockProps} />);
expect(getAllFeeds).toHaveBeenCalledWith(
'threadLink',
undefined,
'Announcement',
'ALL'
);
});
it('should render empty placeholder when data is not there', async () => {
await act(async () => {
render(<AnnouncementThreadBody {...mockProps} />);
});
const emptyPlaceholder = screen.getByText('ErrorPlaceHolder');
expect(emptyPlaceholder).toBeInTheDocument();
});
it('Check if all child elements rendered', async () => {
(getAllFeeds as jest.Mock).mockImplementationOnce(() =>
Promise.resolve(MOCK_ANNOUNCEMENT_DATA)
);
await act(async () => {
render(<AnnouncementThreadBody {...mockProps} />);
});
const component = screen.getByTestId('announcement-thread-body');
const announcementThreads = screen.getByText('AnnouncementThreads');
const confirmationModal = screen.getByText('Confirmation Modal is close');
expect(component).toBeInTheDocument();
expect(confirmationModal).toBeInTheDocument();
expect(announcementThreads).toBeInTheDocument();
});
// Confirmation Modal
it('should open delete confirmation modal', async () => {
(getAllFeeds as jest.Mock).mockImplementationOnce(() =>
Promise.resolve(MOCK_ANNOUNCEMENT_DATA)
);
await act(async () => {
render(<AnnouncementThreadBody {...mockProps} />);
});
const confirmationCloseModal = screen.getByText(
'Confirmation Modal is close'
);
expect(confirmationCloseModal).toBeInTheDocument();
const confirmationButton = screen.getByText('ConfirmationButton');
act(() => {
fireEvent.click(confirmationButton);
});
const confirmationOpenModal = screen.getByText(
'Confirmation Modal is open'
);
expect(confirmationOpenModal).toBeInTheDocument();
});
it('should trigger onConfirm in confirmation modal', async () => {
(getAllFeeds as jest.Mock).mockImplementationOnce(() =>
Promise.resolve(MOCK_ANNOUNCEMENT_DATA)
);
await act(async () => {
render(<AnnouncementThreadBody {...mockProps} />);
});
const confirmationButton = screen.getByText('ConfirmationButton');
act(() => {
fireEvent.click(confirmationButton);
});
expect(screen.getByText('Confirmation Modal is open')).toBeInTheDocument();
const confirmConfirmationButton = screen.getByText(
'Confirm Confirmation Modal'
);
act(() => {
fireEvent.click(confirmConfirmationButton);
});
expect(mockProps.deletePostHandler).toHaveBeenCalledWith(
'threadId',
'postId',
false
);
expect(getAllFeeds).toHaveBeenCalledWith(
'threadLink',
undefined,
'Announcement',
'ALL'
);
});
it('should trigger onCancel in confirmation modal', async () => {
(getAllFeeds as jest.Mock).mockImplementationOnce(() =>
Promise.resolve(MOCK_ANNOUNCEMENT_DATA)
);
await act(async () => {
render(<AnnouncementThreadBody {...mockProps} />);
});
const confirmationButton = screen.getByText('ConfirmationButton');
act(() => {
fireEvent.click(confirmationButton);
});
expect(screen.getByText('Confirmation Modal is open')).toBeInTheDocument();
const cancelConfirmationButton = screen.getByText(
'Cancel Confirmation Modal'
);
act(() => {
fireEvent.click(cancelConfirmationButton);
});
expect(screen.getByText('Confirmation Modal is close')).toBeInTheDocument();
});
// AnnouncementThreads Component
it('should trigger postFeedHandler', async () => {
(getAllFeeds as jest.Mock).mockImplementationOnce(() =>
Promise.resolve(MOCK_ANNOUNCEMENT_DATA)
);
await act(async () => {
render(<AnnouncementThreadBody {...mockProps} />);
});
const postFeedButton = screen.getByText('PostFeedButton');
act(() => {
fireEvent.click(postFeedButton);
});
expect(mockProps.postFeedHandler).toHaveBeenCalledWith('valueId', 'id');
expect(getAllFeeds).toHaveBeenCalledWith(
'threadLink',
undefined,
'Announcement',
'ALL'
);
});
it('should trigger updateThreadHandler', async () => {
(getAllFeeds as jest.Mock).mockImplementationOnce(() =>
Promise.resolve(MOCK_ANNOUNCEMENT_DATA)
);
await act(async () => {
render(<AnnouncementThreadBody {...mockProps} />);
});
const postFeedButton = screen.getByText('UpdateThreadButton');
act(() => {
fireEvent.click(postFeedButton);
});
expect(mockProps.updateThreadHandler).toHaveBeenCalledWith(
'threadId',
'postId',
true,
{
op: 'replace',
path: '/announcement/description',
value: 'Cypress announcement description.',
}
);
expect(getAllFeeds).toHaveBeenCalledWith(
'threadLink',
undefined,
'Announcement',
'ALL'
);
});
});

View File

@ -14,10 +14,10 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { mockThreadData } from './ActivityThread.mock';
import { mockThreadData } from '../ActivityFeed/ActivityThreadPanel/ActivityThread.mock';
import AnnouncementThreads from './AnnouncementThreads';
jest.mock('../../../utils/FeedUtils', () => ({
jest.mock('../../utils/FeedUtils', () => ({
getFeedListWithRelativeDays: jest.fn().mockReturnValue({
updatedFeedList: mockThreadData,
relativeDays: ['Today', 'Yesterday'],
@ -27,6 +27,7 @@ jest.mock('../../../utils/FeedUtils', () => ({
const mockAnnouncementThreadsProp = {
threads: mockThreadData,
selectedThreadId: '',
editPermission: true,
postFeed: jest.fn(),
onThreadIdSelect: jest.fn(),
onThreadSelect: jest.fn(),
@ -34,16 +35,8 @@ const mockAnnouncementThreadsProp = {
updateThreadHandler: jest.fn(),
};
jest.mock('../ActivityFeedCard/ActivityFeedCard', () => {
return jest.fn().mockReturnValue(<p>ActivityFeedCard</p>);
});
jest.mock('../ActivityFeedEditor/ActivityFeedEditor', () => {
return jest.fn().mockReturnValue(<p>ActivityFeedEditor</p>);
});
jest.mock('../ActivityFeedCard/FeedCardFooter/FeedCardFooter', () => {
return jest.fn().mockReturnValue(<p>FeedCardFooter</p>);
jest.mock('./AnnouncementFeedCard.component', () => {
return jest.fn().mockReturnValue(<p>AnnouncementFeedCard</p>);
});
describe('Test AnnouncementThreads Component', () => {
@ -52,7 +45,7 @@ describe('Test AnnouncementThreads Component', () => {
wrapper: MemoryRouter,
});
const threads = await screen.findAllByTestId('announcement-card');
const threads = await screen.findAllByText('AnnouncementFeedCard');
expect(threads).toHaveLength(2);
});

View File

@ -0,0 +1,112 @@
/*
* Copyright 2022 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 { Divider, Typography } from 'antd';
import React, { FC, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Post, Thread } from '../../generated/entity/feed/thread';
import { isActiveAnnouncement } from '../../utils/AnnouncementsUtils';
import { getFeedListWithRelativeDays } from '../../utils/FeedUtils';
import { AnnouncementThreadListProp } from './Announcement.interface';
import './announcement.less';
import AnnouncementFeedCard from './AnnouncementFeedCard.component';
const AnnouncementThreads: FC<AnnouncementThreadListProp> = ({
threads,
editPermission,
postFeed,
onConfirmation,
updateThreadHandler,
}) => {
const { t } = useTranslation();
const { updatedFeedList: updatedThreads } =
getFeedListWithRelativeDays(threads);
const { activeAnnouncements, inActiveAnnouncements } = useMemo(() => {
return updatedThreads.reduce(
(
acc: {
activeAnnouncements: Thread[];
inActiveAnnouncements: Thread[];
},
cv: Thread
) => {
if (
cv.announcement &&
isActiveAnnouncement(
cv.announcement?.startTime,
cv.announcement?.endTime
)
) {
acc.activeAnnouncements.push(cv);
} else {
acc.inActiveAnnouncements.push(cv);
}
return acc;
},
{
activeAnnouncements: [],
inActiveAnnouncements: [],
}
);
}, [updatedThreads]);
const getAnnouncements = useCallback(
(announcements: Thread[]) => {
return announcements.map((thread) => {
const mainFeed = {
message: thread.message,
postTs: thread.threadTs,
from: thread.createdBy,
id: thread.id,
reactions: thread.reactions,
} as Post;
return (
<AnnouncementFeedCard
editPermission={editPermission}
feed={mainFeed}
key={thread.id}
postFeed={postFeed}
task={thread}
updateThreadHandler={updateThreadHandler}
onConfirmation={onConfirmation}
/>
);
});
},
[editPermission, postFeed, updateThreadHandler, onConfirmation]
);
return (
<>
{getAnnouncements(activeAnnouncements)}
{Boolean(inActiveAnnouncements.length) && (
<div className="d-flex flex-column items-end m-y-xlg">
<Typography.Text
className="text-announcement"
data-testid="inActive-announcements">
<strong>{inActiveAnnouncements.length}</strong>{' '}
{t('label.inactive-announcement-plural')}
</Typography.Text>
<Divider className="m-t-xs m-b-0" />
</div>
)}
{getAnnouncements(inActiveAnnouncements)}
</>
);
};
export default AnnouncementThreads;

View File

@ -0,0 +1,69 @@
/*
* Copyright 2024 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 (reference) url('../../styles/variables.less');
.announcement-thread-body {
margin-top: 16px;
.text-announcement {
color: @announcement-border;
}
.ant-card.announcement-thread-card {
margin-top: 20px;
padding-top: 8px;
border-radius: 8px;
border: 1px solid @announcement-border;
background: @announcement-background;
.avatar-column {
position: relative;
&::after {
position: absolute;
content: '';
top: 0;
left: 50%;
transform: translateX(-50%);
width: 1px;
background: @announcement-border-light;
height: calc(100% - 10px);
z-index: 1;
}
.assignee-item {
margin: 0;
z-index: 10;
}
.ant-avatar-group {
z-index: 10;
}
}
.arrow-icon {
font-size: 10px;
}
.rotate-180 {
transform: rotate(180deg);
}
}
}
.feed-line {
margin-left: 8px;
background: @announcement-border-light;
width: 1px;
height: calc(100% - 10px);
}

View File

@ -147,7 +147,6 @@ const TableQueryRightPanel = ({
<ProfilePicture
displayName={getEntityName(user)}
name={user.name || ''}
textClass="text-xs"
width="20"
/>
<Link to={getUserPath(user.name ?? '')}>

View File

@ -63,7 +63,6 @@ function DomainExperts({
<ProfilePicture
displayName={getEntityName(expert)}
name={expert.name ?? ''}
textClass="text-xs"
width="20"
/>
<Link to={getUserPath(expert.name ?? '')}>

View File

@ -73,7 +73,6 @@ function GlossaryReviewers({
displayName={getEntityName(reviewer)}
isTeam={reviewer.type === UserTeam.Team}
name={reviewer.name ?? ''}
textClass="text-xs"
width="20"
/>
<Link to={getUserPath(reviewer.name ?? '')}>

View File

@ -42,12 +42,14 @@ jest.mock('react-router-dom', () => ({
useLocation: jest.fn().mockReturnValue({ pathname: 'pathname' }),
}));
const onCancel = jest.fn();
const onSave = jest.fn();
const mockProps = {
open: true,
entityType: '',
entityFQN: '',
onCancel,
onSave,
};
describe('Test Add Announcement modal', () => {

View File

@ -35,6 +35,7 @@ interface Props {
entityType: string;
entityFQN: string;
onCancel: () => void;
onSave: () => void;
}
export interface CreateAnnouncement {
@ -47,6 +48,7 @@ export interface CreateAnnouncement {
const AddAnnouncementModal: FC<Props> = ({
open,
onCancel,
onSave,
entityType,
entityFQN,
}) => {
@ -85,7 +87,7 @@ const AddAnnouncementModal: FC<Props> = ({
if (data) {
showSuccessToast(t('message.announcement-created-successfully'));
}
onCancel();
onSave();
} catch (error) {
showErrorToast(error as AxiosError);
} finally {

View File

@ -40,7 +40,7 @@ import {
getImageWithResolutionAndFallback,
ImageQuality,
} from '../../../../utils/ProfilerUtils';
import Avatar from '../../../common/AvatarComponent/Avatar';
import ProfilePicture from '../../../common/ProfilePicture/ProfilePicture';
import './user-profile-icon.less';
type ListMenuItemProps = {
@ -337,7 +337,7 @@ export const UserProfileIcon = () => {
onError={handleOnImageError}
/>
) : (
<Avatar name={userName} type="circle" width="36" />
<ProfilePicture name={userName} width="36" />
)}
<div className="d-flex flex-col">
<Tooltip title={getEntityName(currentUser)}>

View File

@ -40,8 +40,8 @@ jest.mock('../../../../utils/ProfilerUtils', () => ({
ImageQuality: jest.fn().mockReturnValue('6x'),
}));
jest.mock('../../../common/AvatarComponent/Avatar', () =>
jest.fn().mockReturnValue(<div>Avatar</div>)
jest.mock('../../../common/ProfilePicture/ProfilePicture', () =>
jest.fn().mockReturnValue(<div>ProfilePicture</div>)
);
jest.mock('react-router-dom', () => ({
@ -84,7 +84,7 @@ describe('UserProfileIcon', () => {
const { queryByTestId, getByText } = render(<UserProfileIcon />);
expect(queryByTestId('app-bar-user-profile-pic')).not.toBeInTheDocument();
expect(getByText('Avatar')).toBeInTheDocument();
expect(getByText('ProfilePicture')).toBeInTheDocument();
});
it('should display the user team', () => {

View File

@ -53,7 +53,6 @@ const UserProfileImage = ({ userData }: UserProfileImageProps) => {
displayName={userData?.displayName ?? userData.name}
height="54"
name={userData?.name ?? ''}
textClass="text-xl"
width="54"
/>
)}

View File

@ -13,7 +13,7 @@
@import url('../../../../styles/variables.less');
.announcement-card {
.ant-card.announcement-card {
width: 340px;
background: @announcement-background;
border: 1px solid @announcement-border;

View File

@ -28,12 +28,9 @@ jest.mock('../../../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
}));
jest.mock(
'../../../ActivityFeed/ActivityThreadPanel/ActivityThreadPanelBody',
() => {
return jest.fn().mockReturnValue(<div>ActivityThreadPanelBody</div>);
}
);
jest.mock('../../../Announcement/AnnouncementThreadBody.component', () => {
return jest.fn().mockReturnValue(<div>AnnouncementThreadBody</div>);
});
jest.mock('../../../Modals/AnnouncementModal/AddAnnouncementModal', () => {
return jest.fn().mockReturnValue(<div>AddAnnouncementModal</div>);
@ -51,21 +48,31 @@ describe('Test Announcement drawer component', () => {
it('Should render the component', async () => {
render(<AnnouncementDrawer {...mockProps} />);
const drawer = await screen.findByTestId('announcement-drawer');
const announcementHeader = screen.getByText('label.announcement-plural');
const addAnnouncementButton = screen.getByTestId('add-announcement');
const addButton = await screen.findByTestId('add-announcement');
const addButton = screen.getByTestId('add-announcement');
const announcements = await screen.findByText('ActivityThreadPanelBody');
const announcements = screen.getByText('AnnouncementThreadBody');
expect(drawer).toBeInTheDocument();
expect(announcementHeader).toBeInTheDocument();
expect(addAnnouncementButton).toBeInTheDocument();
expect(addButton).toBeInTheDocument();
expect(announcements).toBeInTheDocument();
});
it('Should be disabled if not having permission to create', async () => {
render(<AnnouncementDrawer {...mockProps} createPermission={false} />);
const addButton = screen.getByTestId('add-announcement');
expect(addButton).toBeDisabled();
});
it('Should open modal on click of add button', async () => {
render(<AnnouncementDrawer {...mockProps} />);
const addButton = await screen.findByTestId('add-announcement');
const addButton = screen.getByTestId('add-announcement');
fireEvent.click(addButton);

View File

@ -15,29 +15,24 @@ import { CloseOutlined } from '@ant-design/icons';
import { Button, Drawer, Space, Tooltip, Typography } from 'antd';
import { AxiosError } from 'axios';
import { Operation } from 'fast-json-patch';
import { uniqueId } from 'lodash';
import React, { FC, useState } from 'react';
import React, { FC, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
CreateThread,
ThreadType,
} from '../../../../generated/api/feed/createThread';
import { Post } from '../../../../generated/entity/feed/thread';
import { postFeedById, postThread } from '../../../../rest/feedsAPI';
import { postFeedById } from '../../../../rest/feedsAPI';
import { getEntityFeedLink } from '../../../../utils/EntityUtils';
import { deletePost, updateThreadData } from '../../../../utils/FeedUtils';
import { showErrorToast } from '../../../../utils/ToastUtils';
import ActivityThreadPanelBody from '../../../ActivityFeed/ActivityThreadPanel/ActivityThreadPanelBody';
import { useApplicationStore } from '../../../../hooks/useApplicationStore';
import AnnouncementThreadBody from '../../../Announcement/AnnouncementThreadBody.component';
import AddAnnouncementModal from '../../../Modals/AnnouncementModal/AddAnnouncementModal';
interface Props {
open: boolean;
entityType: string;
entityFQN: string;
createPermission: boolean;
onClose: () => void;
createPermission?: boolean;
}
const AnnouncementDrawer: FC<Props> = ({
@ -45,11 +40,13 @@ const AnnouncementDrawer: FC<Props> = ({
onClose,
entityFQN,
entityType,
createPermission,
createPermission = false,
}) => {
const { t } = useTranslation();
const { currentUser } = useApplicationStore();
const [isAnnouncement, setIsAnnouncement] = useState<boolean>(false);
const [isAddAnnouncementOpen, setIsAddAnnouncementOpen] =
useState<boolean>(false);
const [refetchThread, setRefetchThread] = useState<boolean>(false);
const title = (
<Space
@ -64,92 +61,94 @@ const AnnouncementDrawer: FC<Props> = ({
</Space>
);
const createThread = async (data: CreateThread) => {
const deletePostHandler = async (
threadId: string,
postId: string,
isThread: boolean
): Promise<void> => {
await deletePost(threadId, postId, isThread);
};
const postFeedHandler = async (value: string, id: string): Promise<void> => {
const data = {
message: value,
from: currentUser?.name,
} as Post;
try {
await postThread(data);
await postFeedById(id, data);
} catch (err) {
showErrorToast(err as AxiosError);
}
};
const deletePostHandler = (
threadId: string,
postId: string,
isThread: boolean
) => {
deletePost(threadId, postId, isThread);
};
const postFeedHandler = (value: string, id: string) => {
const data = {
message: value,
from: currentUser?.name,
} as Post;
postFeedById(id, data).catch((err: AxiosError) => {
showErrorToast(err);
});
};
const updateThreadHandler = (
const updateThreadHandler = async (
threadId: string,
postId: string,
isThread: boolean,
data: Operation[]
) => {
): Promise<void> => {
const callback = () => {
return;
};
updateThreadData(threadId, postId, isThread, data, callback);
await updateThreadData(threadId, postId, isThread, data, callback);
};
return (
<>
<div data-testid="announcement-drawer">
<Drawer
closable={false}
open={open}
placement="right"
title={title}
width={576}
onClose={onClose}>
<div className="d-flex justify-end">
<Tooltip
title={!createPermission && t('message.no-permission-to-view')}>
<Button
data-testid="add-announcement"
disabled={!createPermission}
type="primary"
onClick={() => setIsAnnouncement(true)}>
{t('label.add-entity', { entity: t('label.announcement') })}
</Button>
</Tooltip>
</div>
const handleCloseAnnouncementModal = useCallback(
() => setIsAddAnnouncementOpen(false),
[]
);
const handleOpenAnnouncementModal = useCallback(
() => setIsAddAnnouncementOpen(true),
[]
);
<ActivityThreadPanelBody
className="p-0"
createThread={createThread}
deletePostHandler={deletePostHandler}
editAnnouncementPermission={createPermission}
key={uniqueId()}
postFeedHandler={postFeedHandler}
showHeader={false}
threadLink={getEntityFeedLink(entityType, entityFQN)}
threadType={ThreadType.Announcement}
updateThreadHandler={updateThreadHandler}
/>
</Drawer>
const handleSaveAnnouncement = useCallback(() => {
handleCloseAnnouncementModal();
setRefetchThread((prev) => !prev);
}, []);
return (
<Drawer
closable={false}
open={open}
placement="right"
title={title}
width={576}
onClose={onClose}>
<div className="d-flex justify-end">
<Tooltip
title={!createPermission && t('message.no-permission-to-view')}>
<Button
data-testid="add-announcement"
disabled={!createPermission}
type="primary"
onClick={handleOpenAnnouncementModal}>
{t('label.add-entity', { entity: t('label.announcement') })}
</Button>
</Tooltip>
</div>
{isAnnouncement && (
<AnnouncementThreadBody
deletePostHandler={deletePostHandler}
editPermission={createPermission}
postFeedHandler={postFeedHandler}
refetchThread={refetchThread}
threadLink={getEntityFeedLink(entityType, entityFQN)}
updateThreadHandler={updateThreadHandler}
/>
{isAddAnnouncementOpen && (
<AddAnnouncementModal
entityFQN={entityFQN || ''}
entityType={entityType || ''}
open={isAnnouncement}
onCancel={() => setIsAnnouncement(false)}
open={isAddAnnouncementOpen}
onCancel={handleCloseAnnouncementModal}
onSave={handleSaveAnnouncement}
/>
)}
</>
</Drawer>
);
};

View File

@ -237,6 +237,7 @@ const UserPopOverCard: FC<Props> = ({
}) => {
const profilePicture = (
<ProfilePicture
avatarType="outlined"
isTeam={type === UserTeam.Team}
name={userName}
width={`${profileWidth}`}

View File

@ -11,7 +11,7 @@
* limitations under the License.
*/
import { findByText, render } from '@testing-library/react';
import { findByTestId, render } from '@testing-library/react';
import React from 'react';
import ProfilePicture from './ProfilePicture';
@ -40,7 +40,7 @@ describe('Test ProfilePicture component', () => {
it('ProfilePicture component should render with Avatar', async () => {
const { container } = render(<ProfilePicture {...mockData} width="36" />);
const avatar = await findByText(container, 'Avatar');
const avatar = await findByTestId(container, 'profile-avatar');
expect(avatar).toBeInTheDocument();
});

View File

@ -11,16 +11,16 @@
* limitations under the License.
*/
import { Avatar } from 'antd';
import classNames from 'classnames';
import { ImageShape } from 'Models';
import React, { useMemo } from 'react';
import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider';
import { ResourceEntity } from '../../../context/PermissionProvider/PermissionProvider.interface';
import { EntityReference, User } from '../../../generated/entity/teams/user';
import { User } from '../../../generated/entity/teams/user';
import { useUserProfile } from '../../../hooks/user-profile/useUserProfile';
import { getEntityName } from '../../../utils/EntityUtils';
import { getRandomColor } from '../../../utils/CommonUtils';
import { userPermissions } from '../../../utils/PermissionsUtils';
import Avatar from '../AvatarComponent/Avatar';
import Loader from '../Loader/Loader';
type UserData = Pick<User, 'name' | 'displayName'>;
@ -28,25 +28,28 @@ type UserData = Pick<User, 'name' | 'displayName'>;
interface Props extends UserData {
width?: string;
type?: ImageShape;
textClass?: string;
className?: string;
height?: string;
profileImgClasses?: string;
isTeam?: boolean;
size?: number | 'small' | 'default' | 'large';
avatarType?: 'solid' | 'outlined';
}
const ProfilePicture = ({
name,
displayName,
className = '',
textClass = '',
type = 'circle',
width = '36',
height,
profileImgClasses,
isTeam = false,
size,
avatarType = 'solid',
}: Props) => {
const { permissions } = usePermissionProvider();
const { color, character, backgroundColor } = getRandomColor(
displayName ?? name
);
const viewUserPermission = useMemo(() => {
return userPermissions.hasViewPermissions(ResourceEntity.USER, permissions);
@ -61,12 +64,17 @@ const ProfilePicture = ({
const getAvatarByName = () => {
return (
<Avatar
className={className}
height={height}
name={getEntityName({ name, displayName } as EntityReference)}
textClass={textClass}
type={type}
width={width}
className={classNames('flex-center', className)}
data-testid="profile-avatar"
icon={character}
shape={type}
size={size ?? parseInt(width)}
style={{
color: avatarType === 'solid' ? 'default' : color,
backgroundColor: avatarType === 'solid' ? color : backgroundColor,
fontWeight: avatarType === 'solid' ? 400 : 500,
border: `0.5px solid ${avatarType === 'solid' ? 'default' : color}`,
}}
/>
);
};
@ -97,17 +105,13 @@ const ProfilePicture = ({
};
return profileURL ? (
<div
className={classNames('profile-image', type, className)}
style={{ height: `${height || width}px`, width: `${width}px` }}>
<img
alt="user"
className={profileImgClasses}
data-testid="profile-image"
referrerPolicy="no-referrer"
src={profileURL}
/>
</div>
<Avatar
className={className}
data-testid="profile-image"
shape={type}
size={size ?? parseInt(width)}
src={profileURL}
/>
) : (
getAvatarElement()
);

View File

@ -0,0 +1,124 @@
/*
* Copyright 2024 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 { ThreadType } from '../generated/api/feed/createThread';
export const MOCK_ANNOUNCEMENT_DATA = {
data: [
{
id: '36ea94c9-7f12-489c-94df-56cbefe14b2f',
type: ThreadType.Announcement,
href: 'http://localhost:8585/api/v1/feed/36ea94c9-7f12-489c-94df-56cbefe14b2f',
threadTs: 1714026576902,
about:
'<#E::database::cy-database-service-373851.cypress-database-1714026557974>',
entityId: '123f24e3-2a00-432e-b42b-b709f7ae74c0',
createdBy: 'admin',
updatedAt: 1714037939788,
updatedBy: 'shreya',
resolved: false,
message: 'Cypress announcement',
postsCount: 4,
posts: [
{
id: 'ccf1ad4a-4cf0-4be9-bcc7-1459f533bab0',
message: 'this is done!',
postTs: 1714036398114,
from: 'admin',
reactions: [],
},
{
id: '738eb0ae-0b71-4a13-8dd2-d7d7d73073b6',
message: 'having a look on it!',
postTs: 1714037894068,
from: 'david',
reactions: [],
},
{
id: 'fdc984e7-2d69-4f06-8b94-531ff8b696f7',
message: 'this if fixed and RCA given!',
postTs: 1714037939785,
from: 'shreya',
reactions: [],
},
{
id: '62434a57-57ec-4b5f-83c1-9ae5870337b6',
message: 'test',
postTs: 1714027952172,
from: 'admin',
reactions: [],
},
],
reactions: [],
announcement: {
description: 'Cypress announcement description',
startTime: 1713983400,
endTime: 1714415400,
},
},
],
paging: {
total: 1,
},
};
export const MOCK_ANNOUNCEMENT_FEED_DATA = {
id: '36ea94c9-7f12-489c-94df-56cbefe14b2f',
type: 'Announcement',
href: 'http://localhost:8585/api/v1/feed/36ea94c9-7f12-489c-94df-56cbefe14b2f',
threadTs: 1714026576902,
about:
'<#E::database::cy-database-service-373851.cypress-database-1714026557974>',
entityId: '123f24e3-2a00-432e-b42b-b709f7ae74c0',
createdBy: 'admin',
updatedAt: 1714047427117,
updatedBy: 'admin',
resolved: false,
message: 'Cypress announcement',
postsCount: 4,
posts: [
{
id: '62434a57-57ec-4b5f-83c1-9ae5870337b6',
message: 'test',
postTs: 1714027952172,
from: 'admin',
reactions: [],
},
{
id: 'ccf1ad4a-4cf0-4be9-bcc7-1459f533bab0',
message: 'this is done!',
postTs: 1714036398114,
from: 'admin',
reactions: [],
},
{
id: '738eb0ae-0b71-4a13-8dd2-d7d7d73073b6',
message: 'having a look on it!',
postTs: 1714037894068,
from: 'david',
reactions: [],
},
{
id: 'fdc984e7-2d69-4f06-8b94-531ff8b696f7',
message: 'this if fixed and RCA given!',
postTs: 1714037939785,
from: 'shreya',
reactions: [],
},
],
reactions: [],
announcement: {
description: 'Cypress announcement description.',
startTime: 1713983400,
endTime: 1714415400,
},
};

View File

@ -81,9 +81,10 @@
@global-border: 1px solid @border-color;
@active-color: #e8f4ff;
@background-color: #ffffff;
@announcement-background: #e3f2fd30;
@announcement-background-dark: #9dd6ff;
@announcement-border: @info-color;
@announcement-background: #0950c50d;
@announcement-background-dark: #e1edff;
@announcement-border: @blue-3;
@announcement-border-light: #e2e2e2;
@test-parameter-bg-color: #e7ebf0;
@group-title-color: #76746f;
@light-border-color: #f0f0f0;

View File

@ -184,7 +184,6 @@ export const generateSearchDropdownLabel = (
<ProfilePicture
displayName={option.label}
name={option.label || ''}
textClass="text-xs"
width="18"
/>
)}

View File

@ -421,17 +421,18 @@ export const getNameFromFQN = (fqn: string): string => {
export const getRandomColor = (name: string) => {
const firstAlphabet = name.charAt(0).toLowerCase();
const asciiCode = firstAlphabet.charCodeAt(0);
const colorNum =
asciiCode.toString() + asciiCode.toString() + asciiCode.toString();
// Convert the user's name to a numeric value
let nameValue = 0;
for (let i = 0; i < name.length; i++) {
nameValue += name.charCodeAt(i) * 8;
}
const num = Math.round(0xffffff * parseInt(colorNum));
const r = (num >> 16) & 255;
const g = (num >> 8) & 255;
const b = num & 255;
// Generate a random hue based on the name value
const hue = nameValue % 360;
return {
color: 'rgb(' + r + ', ' + g + ', ' + b + ', 0.6)',
color: `hsl(${hue}, 70%, 40%)`,
backgroundColor: `hsl(${hue}, 100%, 92%)`,
character: firstAlphabet.toUpperCase(),
};
};

View File

@ -385,9 +385,9 @@ export const deletePost = async (
}
} else {
try {
const deletResponse = await deletePostById(threadId, postId);
const deleteResponse = await deletePostById(threadId, postId);
// get updated thread only if delete response and callback is present
if (deletResponse && callback) {
if (deleteResponse && callback) {
const data = await getUpdatedThread(threadId);
callback((pre) => {
return pre.map((thread) => {
@ -437,62 +437,60 @@ export const getEntityFieldDisplay = (entityField: string) => {
return null;
};
export const updateThreadData = (
export const updateThreadData = async (
threadId: string,
postId: string,
isThread: boolean,
data: Operation[],
callback: (value: React.SetStateAction<Thread[]>) => void
) => {
): Promise<void> => {
if (isThread) {
updateThread(threadId, data)
.then((res) => {
callback((prevData) => {
return prevData.map((thread) => {
if (isEqual(threadId, thread.id)) {
return {
...thread,
reactions: res.reactions,
message: res.message,
announcement: res?.announcement,
};
} else {
return thread;
}
});
try {
const res = await updateThread(threadId, data);
callback((prevData) => {
return prevData.map((thread) => {
if (isEqual(threadId, thread.id)) {
return {
...thread,
reactions: res.reactions,
message: res.message,
announcement: res?.announcement,
};
} else {
return thread;
}
});
})
.catch((err: AxiosError) => {
showErrorToast(err);
});
} catch (error) {
showErrorToast(error as AxiosError);
}
} else {
updatePost(threadId, postId, data)
.then((res) => {
callback((prevData) => {
return prevData.map((thread) => {
if (isEqual(threadId, thread.id)) {
const updatedPosts = (thread.posts || []).map((post) => {
if (isEqual(postId, post.id)) {
return {
...post,
reactions: res.reactions,
message: res.message,
};
} else {
return post;
}
});
try {
const res = await updatePost(threadId, postId, data);
callback((prevData) => {
return prevData.map((thread) => {
if (isEqual(threadId, thread.id)) {
const updatedPosts = (thread.posts || []).map((post) => {
if (isEqual(postId, post.id)) {
return {
...post,
reactions: res.reactions,
message: res.message,
};
} else {
return post;
}
});
return { ...thread, posts: updatedPosts };
} else {
return thread;
}
});
return { ...thread, posts: updatedPosts };
} else {
return thread;
}
});
})
.catch((err: AxiosError) => {
showErrorToast(err);
});
} catch (error) {
showErrorToast(error as AxiosError);
}
}
};