[Feature] Comments feature updates (#4579)

This commit is contained in:
Rishabh Saxena 2021-05-20 17:33:08 +05:30 committed by GitHub
parent 63db439183
commit 8964aea9df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
122 changed files with 3951 additions and 963 deletions

View File

@ -49,6 +49,9 @@ describe("API Panel Test Functionality", function() {
"https://mock-api.appsmith.com/users",
);
cy.enterDatasourceAndPath(testdata.baseUrl, testdata.methodWithQueryParam);
cy.ValidateQueryParams({ key: "q", value:"mimeType='application/vnd.google-apps.spreadsheet'" });
cy.ValidateQueryParams({
key: "q",
value: "mimeType='application/vnd.google-apps.spreadsheet'",
});
});
});

View File

@ -10,7 +10,7 @@ describe("Check for product updates button and modal", function() {
.its("store")
.invoke("getState")
.then((state) => {
const { releaseItems, newReleasesCount } = state.ui.releases;
const { newReleasesCount, releaseItems } = state.ui.releases;
if (Array.isArray(releaseItems) && releaseItems.length > 0) {
cy.get("[data-cy=t--product-updates-btn]")
.contains("What's New?")

View File

@ -2,104 +2,92 @@ const dsl = require("../../../fixtures/ListWidgetDsl.json");
describe("List Widget test ideas ", function() {
it("List widget background colour and deploy ", function() {
// Drag and drop a List widget
// Open Property pane
// Scroll down to Styles
// Add background colour
// Add item background colour
// Ensure the colour are added appropriately
// Click on Deploy and ensure it is deployed appropriately
}
)
// Drag and drop a List widget
// Open Property pane
// Scroll down to Styles
// Add background colour
// Add item background colour
// Ensure the colour are added appropriately
// Click on Deploy and ensure it is deployed appropriately
});
it("Adding large item Spacing for item card", function() {
// Drag and drop a List widget
// Open Property pane
// Scroll down to Styles
// Add large item spacing (>100)
// Ensure the cards get spaced appropriately
}
)
// Ensure the cards get spaced appropriately
});
it("Binding an API data to list widget ", function() {
//Add an API
it("Binding an API data to list widget ", function() {
//Add an API
// Drag and drop a List widget
// Open list Property pane
// Bind the API to list widget
// Add Input widget into the list widget
// Bind the input widgte to the list widget
}
)
});
it("Copy Paste and Delete the List Widget ", function() {
it("Copy Paste and Delete the List Widget ", function() {
// Drag and drop a List widget
// Click on the property pane
// Click on Copy the widget
// Paste(cmd+v) the list widget
// Click on the delete option of the Parent widget
}
)
});
it("Renaming the widget from Property pane and Entity explorer ", function() {
// Drag and drop a List widget
// Click on the property pane
// Click name of the widget
// Rename the widget
// Navigate to the Entity Explorer
// Navigate to the Entity Explorer
// Click on the Widget expands
// Navigate to List widget (Double Click)
// Rename the widget
// Ensure the name of the widget is possible from both the place
}
)
// Rename the widget
// Ensure the name of the widget is possible from both the place
});
it("Verify the Pagination functionlaity within List Widget", function() {
// Drag and Drop list Widget
it("Verify the Pagination functionlaity within List Widget", function() {
// Drag and Drop list Widget
// Click on page 2
// Ensure list widget will be redirected to page 2
// Click on next button
// Click on next button
// Ensure the list widget will be redirected to page 3
// Click on Previous button
// Ensure the list widget will be redirected to page 2
// Mouse Hover on the next button
// Ensure the tool tip message is appropriate
// Mouse Hover on the Previous button
// Mouse Hover on the Previous button
// Ensure the tool tip message is appropriate
}
)
});
it("Add new item in the list widget array object", function(){
it("Add new item in the list widget array object", function() {
//Drag and drop list widget
//Click to open an property pane
//Click to open an property pane
//Expand Genearl section
//Add the following new item
//Add the following new item
//("id": 7,"num": 007",name": Charizard",img": "http://www.serebii.net/pokemongo/pokemon/006.png")
//Ensure the new item gets added to the list widget without any error
//Check for the new page is added upon adding new items
}
)
//Check for the new page is added upon adding new items
});
it("Adding apt widget into the List widget", function(){
//Drag and Drop List widget
//Expand the section 1 size in the list widget
it("Adding apt widget into the List widget", function() {
//Drag and Drop List widget
//Expand the section 1 size in the list widget
//Ensure by exapdning section inside list widget the page size gets increased
//Drag and Drop button widget inside list widget
//Drag and Drop button widget inside list widget
//Ensure Button widget can be placed inside list Widget
//Drag and Drop Image widget inside list widget
//Ensure Image widget can be placed inside list widget
// Drag and drop the text widget inside the list widget
// Ensure text widget can be place inside the list widget
}
)
});
it("Adding unapt widget to identify the error message", function(){
//Drag and Drop List widget
//Expand the section 1 size in the list widget
it("Adding unapt widget to identify the error message", function() {
//Drag and Drop List widget
//Expand the section 1 size in the list widget
//Drag and Drop widgets ie: Chart ,Date Picker radio button etc
// Ensure an understandable error message is displayed to user
}
)
})
// Ensure an understandable error message is displayed to user
});
});

View File

@ -1,117 +1,106 @@
const homePage = require("../../../locators/Textwidget.json");
describe("Test Ideas to test different feature of text widget ", function() {
it("Add New Text widget along with BG and text colour ", function() {
// Navigate to application
// Drag and drop a Text Widget
// Navigate to Property Pane
// Add a text
// Scroll to BG colour and add a colour
// Next add a text colour
// Click on Deploy
}
)
it("Add New Text widget along with BG and text colour ", function() {
// Navigate to application
// Drag and drop a Text Widget
// Navigate to Property Pane
// Add a text
// Scroll to BG colour and add a colour
// Next add a text colour
// Click on Deploy
});
it("Enable Scroll feature with text colour ", function() {
// Navigate to application
// Drag and drop a Text Widget
// Add a long text in the "Label"
// Enable scroll option
// Navigate to Text colour and add a colour
// and ensure it is scrolling
// Click on deploy and check if it scrollable and colour selected is visible
}
)
it("Enable Scroll feature with text colour ", function() {
// Navigate to application
// Drag and drop a Text Widget
// Add a long text in the "Label"
// Enable scroll option
// Navigate to Text colour and add a colour
// and ensure it is scrolling
// Click on deploy and check if it scrollable and colour selected is visible
});
it("Adding text Size to the Text along with BG colour ", function() {
// Navigate to application
// Drag and drop a Text Widget
// Navigate to Property pane
// Add a medium text in the "Label"
// Increase the area of the Text Widget
// Navigate to BG colour and add a colour
// Naviaget to "Text Size"
// Select Paragarph option
// Ensure the text size varies accordingly
}
)
it("Adding text Size to the Text along with BG colour ", function() {
// Navigate to application
// Drag and drop a Text Widget
// Navigate to Property pane
// Add a medium text in the "Label"
// Increase the area of the Text Widget
// Navigate to BG colour and add a colour
// Naviaget to "Text Size"
// Select Paragarph option
// Ensure the text size varies accordingly
});
it("Adding Bold Font style and Centre Text Alignment ", function() {
// Navigate to application
// Drag and drop a Text Widget
// Navigate to Property pane
// Add a medium text in the "Label"
// Increase the area of the Text Widget
// Navigate to Font Style
// Make it Bold
// and Navigate to Alignment and make it centre
// Ensure the changes are visible to user
}
)
it("Adding Bold Font style and Centre Text Alignment ", function() {
// Navigate to application
// Drag and drop a Text Widget
// Navigate to Property pane
// Add a medium text in the "Label"
// Increase the area of the Text Widget
// Navigate to Font Style
// Make it Bold
// and Navigate to Alignment and make it centre
// Ensure the changes are visible to user
});
it("Adding Italic Font style and Text Alignment to exsisting text widget ", function() {
// Navigate to already exsisting Text widget
// Ensure the text is added
// Navigate to Property pane
// Navigate to Font Style
// Make it Italic font
// and Navigate to Alignment and make it Right
// Ensure the changes are visible to user
}
)
it("Adding Italic Font style and Text Alignment to exsisting text widget ", function() {
// Navigate to already exsisting Text widget
// Ensure the text is added
// Navigate to Property pane
// Navigate to Font Style
// Make it Italic font
// and Navigate to Alignment and make it Right
// Ensure the changes are visible to user
});
it("Expand and Contract text widget Property pane", function() {
// Navigate to already exsisting Text widget
// Navigate to Property pane
// Click on collapse option
// Observe that the property pane is contracted
// Now click again on the arrow
//and ensure it collapses
}
)
it("Expand and Contract text widget Property pane", function() {
// Navigate to already exsisting Text widget
// Navigate to Property pane
// Click on collapse option
// Observe that the property pane is contracted
// Now click again on the arrow
//and ensure it collapses
});
it("Copy and paste a text widget", function() {
// Navigate to already exsisting Text widget
// Ensure Clour and font feature exsists
// Copy and paste the widget
// Ensure the new widget retrives the feature exsisting from parent widget
}
)
it("Copy and paste a text widget", function() {
// Navigate to already exsisting Text widget
// Ensure Clour and font feature exsists
// Copy and paste the widget
// Ensure the new widget retrives the feature exsisting from parent widget
});
it("Rename and search a text widget", function() {
// Ensure there are multiple Text widget
// Navigate to Entity Explorer
// Search for "Text" keyword
// Click on one of the text widget
// Rename the text widget from the Entity explorer
// Clear the search keyword
// enter the new text widget name
// and observe the user is navigated to same text widget and properties of the widget does not change on renaming
}
)
it("Rename and search a text widget", function() {
// Ensure there are multiple Text widget
// Navigate to Entity Explorer
// Search for "Text" keyword
// Click on one of the text widget
// Rename the text widget from the Entity explorer
// Clear the search keyword
// enter the new text widget name
// and observe the user is navigated to same text widget and properties of the widget does not change on renaming
});
it("Search and delete a text widget", function() {
// Ensure there are multiple Text widget
// Navigate to Entity Explorer
// Search for "Text" keyword
// Click on one of the text widget
// Ensure user is navigated to Text widget
// Click on Delete option
// Ensure the Text widget is delete
// Click on Deploy adn ensure the Widget is delete
}
)
it("Search and delete a text widget", function() {
// Ensure there are multiple Text widget
// Navigate to Entity Explorer
// Search for "Text" keyword
// Click on one of the text widget
// Ensure user is navigated to Text widget
// Click on Delete option
// Ensure the Text widget is delete
// Click on Deploy adn ensure the Widget is delete
});
it("Search and delete a text widget", function() {
// Ensure there are multiple Text widget
// Navigate to Entity Explorer
// Search for "Text" keyword
// Click on one of the text widget
// Ensure user is navigated to Text widget
// Click on Delete option
// Ensure the Text widget is delete
// Click on Deploy adn ensure the Widget is delete
}
)
}
)
it("Search and delete a text widget", function() {
// Ensure there are multiple Text widget
// Navigate to Entity Explorer
// Search for "Text" keyword
// Click on one of the text widget
// Ensure user is navigated to Text widget
// Click on Delete option
// Ensure the Text widget is delete
// Click on Deploy adn ensure the Widget is delete
});
});

View File

@ -2347,8 +2347,12 @@ Cypress.Commands.add("assertPageSave", () => {
Cypress.Commands.add("ValidateQueryParams", (param) => {
cy.xpath(apiwidget.paramsTab)
.should("be.visible")
.click({ force: true });
cy.xpath(apiwidget.paramKey).first().contains(param.key);
cy.xpath(apiwidget.paramValue).first().contains(param.value);
.should("be.visible")
.click({ force: true });
cy.xpath(apiwidget.paramKey)
.first()
.contains(param.key);
cy.xpath(apiwidget.paramValue)
.first()
.contains(param.value);
});

View File

@ -45,6 +45,7 @@
"@uppy/dashboard": "^1.16.0",
"@uppy/file-input": "^1.4.22",
"@uppy/google-drive": "^1.5.22",
"@uppy/image-editor": "^0.2.4",
"@uppy/onedrive": "^1.1.22",
"@uppy/react": "^1.11.2",
"@uppy/url": "^1.5.16",
@ -62,7 +63,7 @@
"deep-diff": "^1.0.2",
"downloadjs": "^1.4.7",
"draft-js": "^0.11.7",
"emoji-picker-react": "^3.4.2",
"emoji-mart": "^3.0.1",
"eslint": "^7.11.0",
"fast-deep-equal": "^3.1.1",
"fast-xml-parser": "^3.17.5",
@ -197,6 +198,7 @@
"@types/deep-diff": "^1.0.0",
"@types/downloadjs": "^1.4.2",
"@types/draft-js": "^0.11.1",
"@types/emoji-mart": "^3.0.4",
"@types/jest": "^24.0.22",
"@types/marked": "^1.2.2",
"@types/react-beautiful-dnd": "^11.0.4",
@ -206,6 +208,7 @@
"@types/react-window": "^1.8.2",
"@types/redux-form": "^8.1.9",
"@types/redux-mock-store": "^1.0.2",
"@types/resize-observer-browser": "^0.1.5",
"@types/styled-system": "^5.1.9",
"@types/tern": "0.22.0",
"@types/toposort": "^2.0.3",

View File

@ -33,6 +33,11 @@
<!-- End Google Tag Manager (noscript) -->
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="loader" style="width: 30vw;"></div>
<!--
To keep zIndex for tooltips higher than app comments, todo remove when migrating to Tooltip2
Currently the className does not apply to the portal root, so we're unable to work with z-indexes based on that
-->
<div id="tooltip-root"></div>
<div id="header-root"></div>
<div id="root"></div>
<script type="text/javascript">

View File

@ -1,5 +1,7 @@
import { ReduxActionTypes } from "constants/ReduxActionConstants";
import { COMMENT_EVENTS_CHANNEL } from "constants/CommentConstants";
import { options as filterOptions } from "comments/AppComments/AppCommentsFilterPopover";
import {
CommentThread,
CommentEventPayload,
@ -11,6 +13,8 @@ import {
NewCommentThreadPayload,
} from "entities/Comments/CommentsInterfaces";
import { RawDraftContentState } from "draft-js";
export const setCommentThreadsRequest = () => ({
type: ReduxActionTypes.SET_COMMENT_THREADS_REQUEST,
});
@ -81,14 +85,6 @@ export const setCommentMode = (payload: boolean) => ({
payload,
});
export const setIsCommentThreadVisible = (payload: {
commentThreadId: string;
isVisible: boolean;
}) => ({
type: ReduxActionTypes.SET_IS_COMMENT_THREAD_VISIBLE,
payload,
});
export const fetchApplicationCommentsRequest = () => ({
type: ReduxActionTypes.FETCH_APPLICATION_COMMENTS_REQUEST,
});
@ -130,7 +126,10 @@ export const updateCommentThreadEvent = (payload: Partial<CommentThread>) => ({
payload,
});
export const pinCommentThreadRequest = (payload: { threadId: string }) => ({
export const pinCommentThreadRequest = (payload: {
threadId: string;
pin: boolean;
}) => ({
type: ReduxActionTypes.PIN_COMMENT_THREAD_REQUEST,
payload,
});
@ -158,3 +157,104 @@ export const deleteCommentSuccess = (payload: {
type: ReduxActionTypes.DELETE_COMMENT_SUCCESS,
payload,
});
export const setShouldShowResolvedComments = (payload: boolean) => ({
type: ReduxActionTypes.SET_SHOULD_SHOW_RESOLVED_COMMENTS,
payload,
});
export const setAppCommentsFilter = (
payload: typeof filterOptions[number]["value"],
) => ({
type: ReduxActionTypes.SET_APP_COMMENTS_FILTER,
payload,
});
export const resetVisibleThread = (threadId?: string) => ({
type: ReduxActionTypes.RESET_VISIBLE_THREAD,
payload: threadId,
});
export const setVisibleThread = (threadId: string) => ({
type: ReduxActionTypes.SET_VISIBLE_THREAD,
payload: threadId,
});
export const markThreadAsReadRequest = (threadId: string) => ({
type: ReduxActionTypes.MARK_THREAD_AS_READ_REQUEST,
payload: { threadId },
});
export const editCommentRequest = ({
body,
commentId,
commentThreadId,
}: {
commentThreadId: string;
commentId: string;
body: RawDraftContentState;
}) => ({
type: ReduxActionTypes.EDIT_COMMENT_REQUEST,
payload: {
body,
commentId,
commentThreadId,
},
});
export const updateCommentSuccess = (payload: {
comment: Comment;
commentThreadId: string;
}) => ({
type: ReduxActionTypes.EDIT_COMMENT_SUCCESS,
payload,
});
export const deleteCommentThreadRequest = (commentThreadId: string) => ({
type: ReduxActionTypes.DELETE_THREAD_REQUEST,
payload: commentThreadId,
});
export const deleteCommentThreadSuccess = (payload: {
commentThreadId: string;
appId: string;
}) => ({
type: ReduxActionTypes.DELETE_THREAD_SUCCESS,
payload,
});
export const addCommentReaction = (payload: {
emoji: string;
commentId: string;
}) => ({
type: ReduxActionTypes.ADD_COMMENT_REACTION,
payload,
});
export const removeCommentReaction = (payload: {
emoji: string;
commentId: string;
}) => ({
type: ReduxActionTypes.REMOVE_COMMENT_REACTION,
payload,
});
export const updateCommentEvent = (payload: Comment) => ({
type: ReduxActionTypes.UPDATE_COMMENT_EVENT,
payload,
});
export const showCommentsIntroCarousel = () => ({
type: ReduxActionTypes.SHOW_COMMENTS_INTRO_CAROUSEL,
payload: undefined,
});
export const hideCommentsIntroCarousel = () => ({
type: ReduxActionTypes.HIDE_COMMENTS_INTRO_CAROUSEL,
payload: undefined,
});
export const setAreCommentsEnabled = (flag: boolean) => ({
type: ReduxActionTypes.SET_ARE_COMMENTS_ENABLED,
payload: flag,
});

View File

@ -0,0 +1,22 @@
import { ReduxActionTypes } from "constants/ReduxActionConstants";
import { TourType } from "entities/Tour";
export const setActiveTour = (tourType: TourType) => ({
type: ReduxActionTypes.SET_ACTIVE_TOUR,
payload: tourType,
});
export const resetActiveTour = () => ({
type: ReduxActionTypes.RESET_ACTIVE_TOUR,
payload: undefined,
});
export const setActiveTourIndex = (index: number) => ({
type: ReduxActionTypes.SET_ACTIVE_TOUR_INDEX,
payload: index,
});
export const proceedToNextTourStep = () => ({
type: ReduxActionTypes.PROCEED_TO_NEXT_TOUR_STEP,
payload: undefined,
});

View File

@ -65,3 +65,16 @@ export const updateUserDetails = (payload: UpdateUserRequest) => ({
type: ReduxActionTypes.UPDATE_USER_DETAILS_INIT,
payload,
});
export const updatePhoto = (payload: {
file: File;
callback?: () => void;
}) => ({
type: ReduxActionTypes.UPLOAD_PROFILE_PHOTO,
payload,
});
export const removePhoto = (callback: () => void) => ({
type: ReduxActionTypes.REMOVE_PROFILE_PHOTO,
payload: { callback },
});

View File

@ -10,6 +10,8 @@ class CommentsApi extends Api {
static baseURL = "v1/comments";
static getThreadsAPI = `${CommentsApi.baseURL}/threads`;
static getCommentsAPI = CommentsApi.baseURL;
static getReactionsAPI = (commentId: string) =>
`${CommentsApi.getCommentsAPI}/${commentId}/reactions`;
static createNewThread(
request: CreateCommentThreadRequest,
@ -33,23 +35,48 @@ class CommentsApi extends Api {
}
static updateCommentThread(
updateCommentRequest: Partial<CreateCommentThreadRequest>,
updateCommentThreadRequest: Partial<CreateCommentThreadRequest>,
threadId: string,
): AxiosPromise<ApiResponse> {
return Api.put(
`${CommentsApi.getThreadsAPI}/${threadId}`,
updateCommentRequest,
updateCommentThreadRequest,
);
}
static pinCommentThread(threadId: string) {
console.log(threadId);
return Promise.resolve();
static updateComment(
updateCommentRequest: Partial<CreateCommentRequest>,
commentId: string,
): AxiosPromise<ApiResponse> {
return Api.put(
`${CommentsApi.getCommentsAPI}/${commentId}`,
updateCommentRequest,
);
}
static deleteComment(commentId: string): AxiosPromise<ApiResponse> {
return Api.delete(`${CommentsApi.getCommentsAPI}/${commentId}`);
}
static deleteCommentThread(threadId: string): AxiosPromise<ApiResponse> {
return Api.delete(`${CommentsApi.getThreadsAPI}/${threadId}`);
}
static addCommentReaction(
commentId: string,
request: { emoji: string },
): AxiosPromise<ApiResponse> {
return Api.post(CommentsApi.getReactionsAPI(commentId), request);
}
static removeCommentReaction(
commentId: string,
request: { emoji: string },
): AxiosPromise<ApiResponse> {
return Api.delete(CommentsApi.getReactionsAPI(commentId), null, {
data: request,
});
}
}
export default CommentsApi;

View File

@ -47,7 +47,8 @@ export interface InviteUserRequest {
}
export interface UpdateUserRequest {
name: string;
name?: string;
email?: string;
}
class UserApi extends Api {
@ -61,6 +62,7 @@ class UserApi extends Api {
static addOrgURL = `${UserApi.usersURL}/addOrganization`;
static logoutURL = "v1/logout";
static currentUserURL = "v1/users/me";
static photoURL = "v1/users/photo";
static createUser(
request: CreateUserRequest,
@ -117,6 +119,23 @@ class UserApi extends Api {
static logoutUser(): AxiosPromise<ApiResponse> {
return Api.post(UserApi.logoutURL);
}
static uploadPhoto(request: { file: File }): AxiosPromise<ApiResponse> {
const formData = new FormData();
if (request.file) {
formData.append("file", request.file);
}
return Api.post(UserApi.photoURL, formData, null, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
static deletePhoto(): AxiosPromise<ApiResponse> {
return Api.delete(UserApi.photoURL);
}
}
export default UserApi;

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.3999 3.59998V18.3012H9.15755L11.9445 21.002L14.6917 18.3012H21.4493V3.59998H2.3999ZM17.3081 7.79998V9.59998H6.57873V7.79998H17.3081ZM12.6775 13.8V12H6.57873V13.8H12.6775Z" fill="#4B4848"/>
</svg>

After

Width:  |  Height:  |  Size: 344 B

View File

@ -0,0 +1,4 @@
<svg width="36" height="34" viewBox="0 0 36 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.9272 10V22.251H16.5586L18.881 24.5017L21.1704 22.251H26.8018V10H10.9272ZM23.3508 13.5V15H14.4096V13.5H23.3508ZM19.492 18.5V17H14.4096V18.5H19.492Z" fill="#858282"/>
<circle cx="26.9272" cy="10" r="5.35" fill="#F22B2B" stroke="white" stroke-width="1.3"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,5 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="7.77479" cy="8.49944" rx="1.71429" ry="1.71429" transform="rotate(90 7.77479 8.49944)" fill="#A9A7A7"/>
<ellipse cx="7.77479" cy="14.2143" rx="1.71429" ry="1.71429" transform="rotate(90 7.77479 14.2143)" fill="#A9A7A7"/>
<circle cx="7.77479" cy="2.78557" r="1.71429" transform="rotate(90 7.77479 2.78557)" fill="#A9A7A7"/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@ -0,0 +1,4 @@
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.49995 0.700195V8.2602" stroke="#F86A2B" stroke-width="1.5" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.1001 6.85986L7.5001 13.2999L1.9001 6.85986L13.1001 6.85986Z" fill="#F86A2B"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@ -0,0 +1,3 @@
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.2216 0L9.07798 2.11939L12.7836 5.78317L14.9272 3.66379L11.2216 0ZM1.54625 9.91114L0.927246 14L5.01795 13.1758C5.25441 13.1281 5.47179 13.0124 5.64333 12.8428L11.8773 6.67931L8.17161 3.01552L1.89961 9.21666C1.71008 9.40404 1.58615 9.64763 1.54625 9.91114Z" fill="#4B4848"/>
</svg>

After

Width:  |  Height:  |  Size: 429 B

View File

@ -1,3 +1,3 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.19629 14.0371C11.0107 14.0371 14.1689 10.8721 14.1689 7.06445C14.1689 3.25 11.0039 0.0917969 7.18945 0.0917969C3.38184 0.0917969 0.223633 3.25 0.223633 7.06445C0.223633 10.8721 3.38867 14.0371 7.19629 14.0371ZM7.19629 12.875C3.96973 12.875 1.39258 10.291 1.39258 7.06445C1.39258 3.83789 3.96289 1.25391 7.18945 1.25391C10.416 1.25391 13 3.83789 13.0068 7.06445C13.0137 10.291 10.4229 12.875 7.19629 12.875ZM5.21387 6.44238C5.61719 6.44238 5.95215 6.08691 5.95215 5.58789C5.95215 5.08887 5.61719 4.7334 5.21387 4.7334C4.81055 4.7334 4.48242 5.08887 4.48242 5.58789C4.48242 6.08691 4.81055 6.44238 5.21387 6.44238ZM9.20605 6.44238C9.60938 6.44238 9.94434 6.08691 9.94434 5.58789C9.94434 5.08887 9.60938 4.7334 9.20605 4.7334C8.80957 4.7334 8.47461 5.08887 8.47461 5.58789C8.47461 6.08691 8.80957 6.44238 9.20605 6.44238ZM4.80371 9.08789C4.80371 9.53906 5.76074 10.4893 7.18945 10.4893C8.61816 10.4893 9.5752 9.53906 9.5752 9.08789C9.5752 8.92383 9.41797 8.84863 9.27441 8.91699C8.77539 9.19043 8.18066 9.49805 7.18945 9.49805C6.19824 9.49805 5.60352 9.18359 5.11133 8.91699C4.96094 8.84863 4.80371 8.92383 4.80371 9.08789Z" fill="#A9A7A7"/>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.88562 9.88562C8.84422 10.927 7.15578 10.927 6.11438 9.88562M6 6.66667H6.00667M10 6.66667H10.0067M14 8C14 11.3137 11.3137 14 8 14C4.68629 14 2 11.3137 2 8C2 4.68629 4.68629 2 8 2C11.3137 2 14 4.68629 14 8Z" stroke="#716E6E" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 410 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="12" viewBox="0 0 13 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5809 2.0722L8.26649 7.06702L7.98814 10.7165C7.98123 10.7872 7.9553 10.8547 7.9131 10.9119C7.8709 10.9691 7.814 11.0138 7.74846 11.0412L5.5062 11.9691C5.4602 11.9904 5.40997 12.0009 5.35929 12C5.2619 12.0003 5.16798 11.9638 5.09631 11.8979C5.02463 11.832 4.98049 11.7414 4.9727 11.6443L4.61703 7.06702L0.302615 2.0722C0.146342 1.89011 0.0455507 1.66703 0.0121888 1.42941C-0.0211732 1.19178 0.0142928 0.949577 0.114383 0.731494C0.214474 0.513411 0.374991 0.328596 0.576911 0.198956C0.778831 0.0693155 1.01369 0.000284716 1.25364 4.57764e-05H11.6299C11.8698 0.000284716 12.1047 0.0693155 12.3066 0.198956C12.5085 0.328596 12.669 0.513411 12.7691 0.731494C12.8692 0.949577 12.9047 1.19178 12.8713 1.42941C12.838 1.66703 12.7372 1.89011 12.5809 2.0722Z" fill="#A9A7A7"/>
</svg>

After

Width:  |  Height:  |  Size: 882 B

View File

@ -1,4 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.84001 8.16016C11.04 9.36016 11.04 11.2202 9.84001 12.4202L8.16001 14.1002C6.96001 15.3002 5.1 15.3002 3.9 14.1002C2.7 12.9002 2.7 11.0402 3.9 9.84016L5.4 8.40016" stroke="#090707" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.15977 9.84001C6.95977 8.64001 6.95977 6.78 8.15977 5.58L9.83977 3.9C11.0398 2.7 12.8998 2.7 14.0998 3.9C15.2998 5.1 15.2998 6.96001 14.0998 8.16001L12.5998 9.60001" stroke="#090707" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.8402 8.16003C11.0402 9.36007 11.0402 11.2201 9.8402 12.4202L8.16015 14.1002C6.96012 15.3002 5.10006 15.3002 3.90003 14.1002C2.69999 12.9002 2.69999 11.0401 3.90003 9.84008L5.40007 8.40004" stroke="#858282" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.15979 9.84008C6.95976 8.64004 6.95976 6.77999 8.15979 5.57995L9.83984 3.8999C11.0399 2.69987 12.8999 2.69987 14.1 3.8999C15.3 5.09994 15.3 6.95999 14.1 8.16003L12.5999 9.60007" stroke="#858282" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 625 B

After

Width:  |  Height:  |  Size: 663 B

View File

@ -0,0 +1,3 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.2216 3L12.078 5.11939L15.7836 8.78317L17.9272 6.66379L14.2216 3ZM4.54625 12.9111L3.92725 17L8.01795 16.1758C8.25441 16.1281 8.47179 16.0124 8.64333 15.8428L14.8773 9.67931L11.1716 6.01552L4.89961 12.2167C4.71008 12.404 4.58615 12.6476 4.54625 12.9111Z" fill="#F0F0F0"/>
</svg>

After

Width:  |  Height:  |  Size: 426 B

View File

@ -0,0 +1,3 @@
<svg width="8" height="10" viewBox="0 0 8 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.12158 1.4H0.998746V0.199999H6.98718V1.4H5.86435L6.23863 5.6C6.99044 5.6 7.5999 6.20947 7.5999 6.96128V7.39926H4.59181V9.2L3.99296 9.8L3.39412 9.2V7.4L0.399904 7.39926V6.9474C0.399904 6.20325 1.00315 5.6 1.7473 5.6L2.12158 1.4Z" fill="#716E6E"/>
</svg>

After

Width:  |  Height:  |  Size: 398 B

View File

@ -0,0 +1,13 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path d="M7.3629 2.42747C3.69209 2.42747 0.716309 5.40325 0.716309 9.07407C0.716309 12.7449 3.69209 15.7207 7.3629 15.7207C11.0337 15.7207 14.0095 12.7449 14.0095 9.07407" stroke="#A9A7A7" stroke-width="1.5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.52299 8.07707C10.1654 8.07707 10.6861 7.55631 10.6861 6.91392C10.6861 6.27152 10.1654 5.75076 9.52299 5.75076C8.8806 5.75076 8.35983 6.27152 8.35983 6.91392C8.35983 7.55631 8.8806 8.07707 9.52299 8.07707ZM5.20285 8.07708C5.84524 8.07708 6.366 7.55632 6.366 6.91393C6.366 6.27153 5.84524 5.75077 5.20285 5.75077C4.56045 5.75077 4.03969 6.27153 4.03969 6.91393C4.03969 7.55632 4.56045 8.07708 5.20285 8.07708ZM7.36285 13.062C5.52744 13.062 4.03955 11.5741 4.03955 9.73872H10.6861C10.6861 11.5741 9.19825 13.062 7.36285 13.062Z" fill="#A9A7A7"/>
<path d="M12.6743 0.499611V6.64805" stroke="#A9A7A7" stroke-width="1.2"/>
<path d="M15.7485 3.62524L9.6001 3.62524" stroke="#A9A7A7" stroke-width="1.2"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="16" height="16" fill="white" transform="translate(0 0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,6 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 1.5C2.73858 1.5 0.5 3.73858 0.5 6.5C0.5 9.26142 2.73858 11.5 5.5 11.5C8.26142 11.5 10.5 9.26142 10.5 6.5" stroke="#716E6E" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.875 5.75C4.35825 5.75 4.75 5.35825 4.75 4.875C4.75 4.39175 4.35825 4 3.875 4C3.39175 4 3 4.39175 3 4.875C3 5.35825 3.39175 5.75 3.875 5.75ZM7.125 5.75C7.60825 5.75 8 5.35825 8 4.875C8 4.39175 7.60825 4 7.125 4C6.64175 4 6.25 4.39175 6.25 4.875C6.25 5.35825 6.64175 5.75 7.125 5.75ZM5.5 9.5C4.11929 9.5 3 8.38071 3 7H8C8 8.38071 6.88071 9.5 5.5 9.5Z" fill="#716E6E"/>
<path d="M9.64554 0.84082V4.34082" stroke="#716E6E" stroke-linecap="round"/>
<path d="M11.3955 2.62012L7.89554 2.62012" stroke="#716E6E" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 846 B

View File

@ -0,0 +1,3 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.999997 15C0.999996 7.26802 7.26801 1 15 0.999999C22.732 0.999999 29 7.26801 29 15L29 29L15 29C7.26801 29 0.999997 22.732 0.999997 15Z" fill="white" stroke="#E0DEDE" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.49948 3.60005H6.18595L6.08493 4.73217L12.2829 8.92729L11.8075 3.60005H13.494V1.80005H4.49948V3.60005ZM2.95899 4.27167L2.2052 5.39132L5.80942 7.81977L5.62379 9.90005H5.60003C4.49546 9.90005 3.60003 10.7955 3.60003 11.9001V12.5989L8.09728 12.6001V15.3001L8.99673 16.2001L9.89619 15.3001V12.5989H12.8978L15.4014 14.2863L16.1552 13.1667L2.95899 4.27167Z" fill="#939090"/>
</svg>

After

Width:  |  Height:  |  Size: 523 B

View File

@ -0,0 +1,3 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.999997 15C0.999996 7.26802 7.26801 1 15 0.999999C22.732 0.999999 29 7.26801 29 15L29 29L15 29C7.26801 29 0.999997 22.732 0.999997 15Z" fill="#F86A2B" stroke="white" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,3 @@
<svg width="35" height="38" viewBox="0 0 35 38" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.2708 17.9773C22.2966 17.9773 26.3708 14.0089 26.3708 9.11364C26.3708 4.21838 22.2966 0.25 17.2708 0.25C12.245 0.25 8.17078 4.21838 8.17078 9.11364C8.17078 14.0089 12.245 17.9773 17.2708 17.9773ZM8.44971 20.8506C3.78306 20.8506 0 24.6337 0 29.3003C0 33.9669 3.78307 37.75 8.44971 37.75H26.5503C31.2169 37.75 35 33.9669 35 29.3003C35 24.6337 31.2169 20.8506 26.5503 20.8506H8.44971Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@ -1,14 +1,30 @@
import React, { useMemo } from "react";
import React, { useMemo, useRef, useState } from "react";
import { useSelector } from "react-redux";
import styled from "styled-components";
import {
getSortedAppCommentThreadIds,
getSortedAndFilteredAppCommentThreadIds,
applicationCommentsSelector,
allCommentThreadsMap,
getAppCommentThreads,
shouldShowResolved as shouldShowResolvedSelector,
appCommentsFilter as appCommentsFilterSelector,
} from "selectors/commentsSelectors";
import { getCurrentApplicationId } from "selectors/editorSelectors";
import { useSelector } from "react-redux";
import CommentThread from "comments/CommentThread/connectedCommentThread";
import AppCommentsPlaceholder from "./AppCommentsPlaceholder";
import { getCurrentUser } from "selectors/usersSelectors";
import useResizeObserver from "utils/hooks/useResizeObserver";
import { get } from "lodash";
const Container = styled.div`
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
`;
function AppCommentThreads() {
const applicationId = useSelector(getCurrentApplicationId) as string;
@ -17,23 +33,54 @@ function AppCommentThreads() {
);
const appCommentThreadIds = getAppCommentThreads(appCommentThreadsByRefMap);
const commentThreadsMap = useSelector(allCommentThreadsMap);
const shouldShowResolved = useSelector(shouldShowResolvedSelector);
const appCommentsFilter = useSelector(appCommentsFilterSelector);
const currentUser = useSelector(getCurrentUser);
const currentUsername = currentUser?.username;
const containerRef = useRef<HTMLDivElement>(null);
const [appThreadsHeightEqZero, setAppThreadsHeightEqZero] = useState(true);
useResizeObserver(containerRef.current, (entries) => {
const { height } = get(entries, "0.contentRect", {});
setAppThreadsHeightEqZero(height === 0);
});
const commentThreadIds = useMemo(
() => getSortedAppCommentThreadIds(appCommentThreadIds, commentThreadsMap),
[appCommentThreadIds, commentThreadsMap],
() =>
getSortedAndFilteredAppCommentThreadIds(
appCommentThreadIds,
commentThreadsMap,
shouldShowResolved,
appCommentsFilter,
currentUsername,
),
[
appCommentThreadIds,
commentThreadsMap,
shouldShowResolved,
appCommentsFilter,
currentUsername,
],
);
return (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{commentThreadIds?.map((commentThreadId: string) => (
<CommentThread
commentThreadId={commentThreadId}
hideInput
key={commentThreadId}
/>
))}
</>
<Container>
<div ref={containerRef}>
{commentThreadIds.map((commentThreadId: string) => (
<CommentThread
commentThreadId={commentThreadId}
hideChildren
hideInput
key={commentThreadId}
showSubheader
/>
))}
</div>
{appThreadsHeightEqZero && <AppCommentsPlaceholder />}
</Container>
);
}

View File

@ -1,28 +1,19 @@
import React, { useState } from "react";
import React from "react";
import { useSelector } from "react-redux";
import { commentModeSelector } from "selectors/commentsSelectors";
import AppCommentsHeader from "./AppCommentsHeader";
import AppCommentThreads from "./AppCommentThreadsContainer";
import AppCommentThreads from "./AppCommentThreads";
import Container from "./Container";
import { useCallback } from "react";
function AppComments() {
const [isOpen, setIsOpen] = useState(false);
const isCommentMode = useSelector(commentModeSelector);
const onClose = useCallback(() => {
setIsOpen(!isOpen);
}, [isOpen]);
if (!isCommentMode) return null;
return (
<Container>
<AppCommentsHeader
isOpen={isOpen}
onClose={onClose}
setIsOpen={setIsOpen}
/>
<AppCommentThreads isOpen={isOpen} />
<AppCommentsHeader />
<AppCommentThreads />
</Container>
);
}

View File

@ -0,0 +1,128 @@
import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import styled, { withTheme } from "styled-components";
import Icon, { IconSize } from "components/ads/Icon";
import { Popover2 } from "@blueprintjs/popover2";
import RadioGroup, { Radio } from "components/ads/Radio";
import { Theme } from "constants/DefaultTheme";
import Checkbox from "components/ads/Checkbox";
import {
shouldShowResolved as shouldShowResolvedSelector,
appCommentsFilter as appCommentsFilterSelector,
} from "selectors/commentsSelectors";
import {
setShouldShowResolvedComments,
setAppCommentsFilter,
} from "actions/commentActions";
import "@blueprintjs/popover2/lib/css/blueprint-popover2.css";
import useProceedToNextTourStep from "utils/hooks/useProceedToNextTourStep";
import { TourType } from "entities/Tour";
import TourTooltipWrapper from "components/ads/tour/TourTooltipWrapper";
export const options = [
{ label: "Show all comments", value: "show-all" },
{ label: "Show only pinned", value: "show-only-pinned" },
{ label: "Show only yours", value: "show-only-yours" },
];
const checkboxes = [
{ label: "Show resolved comments", value: "show-resolved" },
];
const Container = styled.div`
width: 180px;
padding: ${(props) => props.theme.spaces[5]}px;
&& ${Radio} {
margin-bottom: ${(props) => props.theme.spaces[6]}px;
}
`;
const Row = styled.div`
margin-bottom: ${(props) => props.theme.spaces[6]}px;
&:last-child {
margin-bottom: 0;
}
`;
const useSetResolvedFilterFromQuery = () => {
const dispatch = useDispatch();
useEffect(() => {
const url = new URL(window.location.href);
const searchParams = url.searchParams;
console.log(window.location.href, "window.location.href");
if (searchParams.get("isResolved")) {
dispatch(setShouldShowResolvedComments(true));
}
}, []);
};
const AppCommentsFilter = withTheme(({ theme }: { theme: Theme }) => {
const dispatch = useDispatch();
const shouldShowResolved = useSelector(shouldShowResolvedSelector);
const appCommentsFilter = useSelector(appCommentsFilterSelector);
return (
<Container>
<RadioGroup
backgroundColor={theme.colors.comments.commentsFilter}
defaultValue={appCommentsFilter}
onSelect={(value) => dispatch(setAppCommentsFilter(value))}
options={options}
rows={3}
/>
{checkboxes.map(({ label }) => (
<Row key={label}>
<Checkbox
backgroundColor={theme.colors.comments.commentsFilter}
isDefaultChecked={shouldShowResolved}
label={label}
onCheckChange={(isChecked) =>
dispatch(setShouldShowResolvedComments(isChecked))
}
/>
</Row>
))}
</Container>
);
});
function AppCommentsFilterPopover() {
const proceedToNextTourStep = useProceedToNextTourStep(
TourType.COMMENTS_TOUR,
3,
);
useSetResolvedFilterFromQuery();
return (
<Popover2
content={<AppCommentsFilter />}
modifiers={{
offset: {
enabled: true,
options: {
offset: [7, 10],
},
},
preventOverflow: {
enabled: true,
},
}}
placement={"bottom-end"}
portalClassName="comment-context-menu"
>
<TourTooltipWrapper
onClick={proceedToNextTourStep}
tourIndex={3}
tourType={TourType.COMMENTS_TOUR}
>
<Icon name="filter" size={IconSize.LARGE} />
</TourTooltipWrapper>
</Popover2>
);
}
export default AppCommentsFilterPopover;

View File

@ -1,49 +1,32 @@
import React, { useCallback } from "react";
import styled, { withTheme } from "styled-components";
import { getTypographyByKey, Theme } from "constants/DefaultTheme";
import React from "react";
import styled from "styled-components";
import { getTypographyByKey } from "constants/DefaultTheme";
import { COMMENTS, createMessage } from "constants/messages";
import Icon from "components/ads/Icon";
import AppCommentsFilterPopover from "./AppCommentsFilterPopover";
const AppCommentHeaderTitle = styled.div`
color: ${(props) => props.theme.colors.comments.appCommentsHeaderTitle};
${(props) => getTypographyByKey(props, "h2")}
${(props) => getTypographyByKey(props, "h5")}
`;
const Header = styled.div<{ isOpen: boolean }>`
const Header = styled.div`
display: flex;
padding: ${(props) =>
`${props.theme.spaces[6]}px ${props.theme.spaces[8]}px`};
padding: ${(props) => props.theme.spaces[6]}px;
width: 100%;
justify-content: space-between;
cursor: ${(props) => (!props.isOpen ? "pointer" : "auto")};
align-items: center;
border-bottom: 1px solid
${(props) => props.theme.colors.comments.appCommentsHeaderBorder};
`;
type Props = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
onClose: () => void;
theme: Theme;
};
const AppCommentsHeader = withTheme(
({ isOpen, onClose, setIsOpen, theme }: Props) => {
const showCommentThreads = useCallback(() => {
if (!isOpen) setIsOpen(true);
}, [isOpen]);
return (
<Header isOpen={isOpen} onClick={showCommentThreads}>
<AppCommentHeaderTitle>{createMessage(COMMENTS)}</AppCommentHeaderTitle>
{isOpen && (
<Icon
fillColor={theme.colors.comments.appCommentsClose}
name="close-x"
onClick={onClose}
/>
)}
</Header>
);
},
);
function AppCommentsHeader() {
return (
<Header>
<AppCommentHeaderTitle>{createMessage(COMMENTS)}</AppCommentHeaderTitle>
<AppCommentsFilterPopover />
</Header>
);
}
export default AppCommentsHeader;

View File

@ -0,0 +1,34 @@
import React from "react";
import styled from "styled-components";
import {
NO_COMMENTS_CLICK_ON_CANVAS_TO_ADD,
createMessage,
} from "constants/messages";
import Icon, { IconSize } from "components/ads/Icon";
const Container = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
white-space: pre-line;
& .message {
text-align: center;
}
color: ${(props) => props.theme.colors.comments.appCommentsPlaceholderText};
`;
function AppCommentsPlaceholder() {
return (
<Container>
<Icon keepColors name="chat" size={IconSize.XXL} />
<span className="message">
{createMessage(NO_COMMENTS_CLICK_ON_CANVAS_TO_ADD)}
</span>
</Container>
);
}
export default AppCommentsPlaceholder;

View File

@ -1,13 +1,17 @@
import styled from "styled-components";
import { Colors } from "constants/Colors";
import { Layers } from "constants/Layers";
const Container = styled.div`
background: ${Colors.WHITE};
width: 280px;
width: 250px;
position: fixed;
right: 30px;
bottom: 0;
z-index: 11;
left: 0;
top: ${(props) => props.theme.smallHeaderHeight};
z-index: ${Layers.appComments};
height: calc(100% - ${(props) => props.theme.smallHeaderHeight});
display: flex;
flex-direction: column;
`;
export default Container;

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import Editor from "@draft-js-plugins/editor";
import {
CompositeDecorator,
@ -8,54 +8,66 @@ import {
RawDraftContentState,
} from "draft-js";
import styled from "styled-components";
import ProfileImage from "pages/common/ProfileImage";
import { Comment } from "entities/Comments/CommentsInterfaces";
import ProfileImage, { Profile } from "pages/common/ProfileImage";
import { Comment, Reaction } from "entities/Comments/CommentsInterfaces";
import { getTypographyByKey } from "constants/DefaultTheme";
import CommentContextMenu from "./CommentContextMenu";
import ResolveCommentButton from "comments/CommentCard/ResolveCommentButton";
import { MentionComponent } from "components/ads/MentionsInput";
import Icon, { IconSize } from "components/ads/Icon";
import EmojiReactions, {
Reaction as ComponentReaction,
Reactions,
ReactionOperation,
} from "components/ads/EmojiReactions";
import { Toaster } from "components/ads/Toast";
import AddCommentInput from "comments/inlineComments/AddCommentInput";
import createMentionPlugin from "@draft-js-plugins/mention";
import { flattenDeep, noop } from "lodash";
import copy from "copy-to-clipboard";
import moment from "moment";
import history from "utils/history";
import UserApi from "api/UserApi";
import {
deleteCommentRequest,
markThreadAsReadRequest,
pinCommentThreadRequest,
editCommentRequest,
deleteCommentThreadRequest,
addCommentReaction,
removeCommentReaction,
} from "actions/commentActions";
import { useDispatch } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import { commentThreadsSelector } from "selectors/commentsSelectors";
import { getCurrentUser } from "selectors/usersSelectors";
import { createMessage, LINK_COPIED_SUCCESSFULLY } from "constants/messages";
import { Variant } from "components/ads/common";
import TourTooltipWrapper from "components/ads/tour/TourTooltipWrapper";
import { TourType } from "entities/Tour";
const StyledContainer = styled.div`
width: 100%;
padding: ${(props) =>
`${props.theme.spaces[5]}px ${props.theme.spaces[7]}px`};
`${props.theme.spaces[6]}px ${props.theme.spaces[5]}px`};
border-radius: 0;
&:hover {
background-color: ${(props) =>
props.theme.colors.comments.cardHoverBackground};
}
`;
const Separator = styled.div`
background-color: ${(props) =>
props.theme.colors.comments.childCommentsIndent};
height: 1px;
width: calc(100% - ${(props) => props.theme.spaces[7] * 2}px);
margin-left: ${(props) => props.theme.spaces[7]}px;
`;
// ${(props) => getTypographyByKey(props, "p1")};
// line-height: 24px;
// color: ${(props) => props.theme.colors.comments.commentBody};
// margin-top: ${(props) => props.theme.spaces[3]}px;
const CommentBodyContainer = styled.div`
background-color: ${(props) => props.theme.colors.comments.commentBackground};
border-radius: ${(props) => props.theme.spaces[3]}px;
padding: ${(props) =>
`${props.theme.spaces[4]}px ${props.theme.spaces[5]}px`};
padding-bottom: ${(props) => props.theme.spaces[4]}px;
`;
const CommentHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: ${(props) => props.theme.spaces[5]}px;
padding-bottom: ${(props) => props.theme.spaces[4]}px;
`;
const UserName = styled.span`
@ -64,105 +76,400 @@ const UserName = styled.span`
margin-left: ${(props) => props.theme.spaces[4]}px;
display: flex;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; /* number of lines to show */
-webkit-box-orient: vertical;
`;
const HeaderSection = styled.div`
display: flex;
align-items: center;
& ${Profile} {
flex-shrink: 0;
}
`;
const mentionPlugin = createMentionPlugin();
const CommentTime = styled.div`
color: ${(props) => props.theme.colors.comments.commentTime};
${(props) => getTypographyByKey(props, "p3")}
display: flex;
justify-content: space-between;
margin-bottom: ${(props) => props.theme.spaces[4]}px;
`;
const CommentSubheader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: ${(props) => props.theme.spaces[4]}px;
white-space: nowrap;
${(props) => getTypographyByKey(props, "p3")}
color: ${(props) => props.theme.colors.comments.pinnedByText};
& .thread-id {
flex-shrink: 0;
max-width: 50px;
}
& .pin {
margin: 0 ${(props) => props.theme.spaces[3]}px;
}
strong {
white-space: pre;
margin-left: ${(props) => props.theme.spaces[0]}px;
text-overflow: ellipsis;
overflow: hidden;
}
`;
const CommentThreadId = styled.div`
color: ${(props) => props.theme.colors.comments.commentTime};
${(props) => getTypographyByKey(props, "p3")}
overflow: hidden;
text-overflow: ellipsis;
`;
const Section = styled.div`
display: flex;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
`;
const UnreadIndicator = styled.div`
width: 6px;
height: 6px;
border-radius: 50%;
background-color: ${(props) =>
props.theme.colors.comments.unreadIndicatorCommentCard};
margin-right: ${(props) => props.theme.spaces[2]}px;
flex-shrink: 0;
`;
const ReactionsRow = styled.div`
display: flex;
`;
const EmojiReactionsBtnContainer = styled.div``;
const mentionPlugin = createMentionPlugin({
mentionComponent: MentionComponent,
});
const plugins = [mentionPlugin];
const decorators = flattenDeep(plugins.map((plugin) => plugin.decorators));
const decorator = new CompositeDecorator(
decorators.filter((_decorator, index) => index !== 1) as DraftDecorator[],
);
const useSelectCommentUsingQuery = (commentId: string) => {
useEffect(() => {
const searchParams = new URL(window.location.href).searchParams;
const commentIdInUrl = searchParams.get("commentId");
if (commentIdInUrl && commentIdInUrl === commentId) {
const commentCard = document.getElementById(`comment-card-${commentId}`);
commentCard?.scrollIntoView();
}
}, []);
function StopClickPropagation({ children }: { children: React.ReactNode }) {
return (
<div
// flex to unset height, so that align-items works as expected
onClick={(e: React.MouseEvent) => e.stopPropagation()}
style={{ display: "flex" }}
>
{children}
</div>
);
}
const replyText = (replies?: number) => {
if (!replies) return "";
return replies > 1 ? `${replies} Replies` : `1 Reply`;
};
enum CommentCardModes {
EDIT = "EDIT",
VIEW = "VIEW",
}
const reduceReactions = (
reactions: Array<Reaction> | undefined,
username?: string,
) => {
return (
(Array.isArray(reactions) &&
reactions.reduce(
(res: Record<string, ComponentReaction>, reaction: Reaction) => {
const { byUsername, emoji } = reaction;
if (res[reaction.emoji]) {
res[reaction.emoji].count++;
} else {
res[emoji] = {
count: 1,
reactionEmoji: emoji,
} as ComponentReaction;
}
if (byUsername === username) {
res[reaction.emoji].active = true;
}
return res;
},
{},
)) ||
undefined
);
};
const ResolveButtonContainer = styled.div`
margin-left: ${(props) => props.theme.spaces[2]}px;
`;
function CommentCard({
comment,
commentThreadId,
inline,
isParentComment,
numberOfReplies,
resolved,
showReplies,
showSubheader,
toggleResolved,
unread = true,
visible,
}: {
comment: Comment;
isEditMode?: boolean;
isParentComment?: boolean;
resolved?: boolean;
toggleResolved?: () => void;
commentThreadId: string;
numberOfReplies?: number;
showReplies?: boolean;
showSubheader?: boolean;
unread?: boolean;
inline?: boolean;
visible?: boolean;
}) {
const [isHovered, setIsHovered] = useState(false);
const [cardMode, setCardMode] = useState(CommentCardModes.VIEW);
const dispatch = useDispatch();
const { authorName, body, id: commentId } = comment;
const { authorName, authorUsername, body, id: commentId } = comment;
const contentState = convertFromRaw(body as RawDraftContentState);
const editorState = EditorState.createWithContent(contentState, decorator);
const commentThread = useSelector(commentThreadsSelector(commentThreadId));
const [reactions, setReactions] = useState<Reactions>();
const currentUser = useSelector(getCurrentUser);
const currentUserUsername = currentUser?.username;
const copyCommentLink = useCallback(() => {
const isPinned = commentThread.pinnedState?.active;
const pinnedByUsername = commentThread.pinnedState?.authorUsername;
let pinnedBy = commentThread.pinnedState?.authorName;
if (currentUserUsername === pinnedByUsername) {
pinnedBy = "You";
}
const getCommentURL = () => {
const url = new URL(window.location.href);
url.searchParams.set("commentId", commentId);
// we only link the comment thread currently
// url.searchParams.set("commentId", commentId);
url.searchParams.set("commentThreadId", commentThreadId);
url.searchParams.set("isCommentMode", "true");
if (commentThread.resolvedState?.active) {
url.searchParams.set("isResolved", "true");
}
return url;
};
const copyCommentLink = useCallback(() => {
const url = getCommentURL();
copy(url.toString());
Toaster.show({
text: createMessage(LINK_COPIED_SUCCESSFULLY),
variant: Variant.success,
});
}, []);
const pin = useCallback(() => {
dispatch(pinCommentThreadRequest({ threadId: commentThreadId }));
}, []);
dispatch(
pinCommentThreadRequest({ threadId: commentThreadId, pin: !isPinned }),
);
}, [isPinned]);
const deleteComment = useCallback(() => {
dispatch(deleteCommentRequest({ threadId: commentThreadId, commentId }));
}, []);
const deleteThread = () => {
dispatch(deleteCommentThreadRequest(commentThreadId));
};
const isCreatedByMe = currentUserUsername === comment.authorUsername;
const switchToEditCommentMode = () => setCardMode(CommentCardModes.EDIT);
const switchToViewCommentMode = () => setCardMode(CommentCardModes.VIEW);
const onSaveComment = (body: RawDraftContentState) => {
dispatch(editCommentRequest({ commentId, commentThreadId, body }));
setCardMode(CommentCardModes.VIEW);
};
const contextMenuProps = {
switchToEditCommentMode,
pin,
copyCommentLink,
deleteComment,
deleteThread,
isParentComment,
isCreatedByMe,
isPinned,
};
useSelectCommentUsingQuery(comment.id);
// TODO enable when comments links are enabled
// useSelectCommentUsingQuery(comment.id);
// Dont make inline cards clickable
const handleCardClick = () => {
if (inline) return;
const url = getCommentURL();
history.push(`${url.pathname}${url.search}${url.hash}`);
if (!commentThread.isViewed) {
dispatch(markThreadAsReadRequest(commentThreadId));
}
};
useEffect(() => {
setReactions(reduceReactions(comment.reactions, currentUserUsername));
}, [comment.reactions]);
const handleReaction = (
_event: React.MouseEvent,
emojiData: string,
updatedReactions: Reactions,
addOrRemove: ReactionOperation,
) => {
setReactions(updatedReactions);
if (addOrRemove == ReactionOperation.ADD) {
dispatch(addCommentReaction({ emoji: emojiData, commentId }));
} else {
dispatch(removeCommentReaction({ emoji: emojiData, commentId }));
}
};
const showOptions = visible || isHovered;
const showResolveBtn =
(showOptions || !!resolved) && isParentComment && toggleResolved;
const hasReactions = !!reactions && Object.keys(reactions).length > 0;
return (
<>
<StyledContainer
data-cy={`t--comment-card-${comment.id}`}
id={`comment-card-${comment.id}`}
>
<CommentHeader>
<HeaderSection>
<ProfileImage side={30} userName={authorName || ""} />
<UserName>{authorName}</UserName>
</HeaderSection>
<HeaderSection>
{isParentComment && toggleResolved && (
<ResolveCommentButton
handleClick={toggleResolved}
resolved={!!resolved}
/>
<StyledContainer
data-cy={`t--comment-card-${comment.id}`}
onClick={handleCardClick}
onMouseLeave={() => setIsHovered(false)}
onMouseOver={() => setIsHovered(true)}
>
{showSubheader && (
<CommentSubheader>
<Section className="thread-id">
{unread && <UnreadIndicator />}
<CommentThreadId>{commentThread.sequenceId}</CommentThreadId>
</Section>
<Section className="pinned-by" onClick={pin}>
{isPinned && (
<>
<Icon className="pin" name="pin-3" />
<span>Pinned By</span>
<strong>{` ${pinnedBy}`}</strong>
</>
)}
<CommentContextMenu {...contextMenuProps} />
</HeaderSection>
</CommentHeader>
<CommentBodyContainer>
</Section>
</CommentSubheader>
)}
<CommentHeader>
<HeaderSection>
<ProfileImage
side={25}
source={`/api/${UserApi.photoURL}/${authorUsername}`}
userName={authorName || ""}
/>
<UserName>{authorName}</UserName>
</HeaderSection>
<HeaderSection>
{showOptions && (
<StopClickPropagation>
<EmojiReactionsBtnContainer>
<EmojiReactions
hideReactions
iconSize={IconSize.XXL}
onSelectReaction={handleReaction}
reactions={reactions}
/>
</EmojiReactionsBtnContainer>
</StopClickPropagation>
)}
{showResolveBtn && (
<StopClickPropagation>
<ResolveButtonContainer>
{inline ? (
<TourTooltipWrapper
tourIndex={2}
tourType={TourType.COMMENTS_TOUR}
>
<ResolveCommentButton
handleClick={toggleResolved as () => void}
resolved={!!resolved}
/>
</TourTooltipWrapper>
) : (
<ResolveCommentButton
handleClick={toggleResolved as () => void}
resolved={!!resolved}
/>
)}
</ResolveButtonContainer>
</StopClickPropagation>
)}
{showOptions && (
<StopClickPropagation>
<CommentContextMenu {...contextMenuProps} />
</StopClickPropagation>
)}
</HeaderSection>
</CommentHeader>
<CommentBodyContainer>
{cardMode === CommentCardModes.EDIT ? (
<AddCommentInput
initialEditorState={editorState}
onCancel={switchToViewCommentMode}
onSave={onSaveComment}
removePadding
/>
) : (
<Editor
editorState={editorState}
onChange={noop}
plugins={plugins}
readOnly
/>
</CommentBodyContainer>
</StyledContainer>
{!isParentComment && <Separator />}
</>
)}
</CommentBodyContainer>
<CommentTime>
<span>{moment(comment.creationTime).fromNow()}</span>
<span>{showReplies && replyText(numberOfReplies)}</span>
</CommentTime>
{hasReactions && (
<ReactionsRow>
<StopClickPropagation>
<EmojiReactions
iconSize={IconSize.LARGE}
onSelectReaction={handleReaction}
reactions={reactions}
/>
</StopClickPropagation>
</ReactionsRow>
)}
</StyledContainer>
);
}

View File

@ -5,7 +5,10 @@ import {
PIN_COMMENT,
COPY_LINK,
DELETE_COMMENT,
DELETE_THREAD,
UNPIN_COMMENT,
createMessage,
EDIT_COMMENT,
} from "constants/messages";
import { noop } from "lodash";
@ -14,9 +17,7 @@ import "@blueprintjs/popover2/lib/css/blueprint-popover2.css";
import { Popover2 } from "@blueprintjs/popover2";
// render over popover portals
const Container = styled.div`
z-index: 11;
`;
const Container = styled.div``;
const MenuItem = styled.div`
display: flex;
@ -29,14 +30,7 @@ const MenuItem = styled.div`
`;
const StyledIcon = styled(Icon)`
&& path {
stroke: ${(props) => props.theme.colors.comments.contextMenuIcon};
fill: ${(props) => props.theme.colors.comments.contextMenuIcon};
}
${MenuItem}:hover & path {
stroke: ${(props) =>
props.theme.colors.comments.contextMenuIconStrokeHover};
}
margin-left: ${(props) => props.theme.spaces[2]}px;
`;
const MenuIcon = styled.div`
@ -56,31 +50,68 @@ type Props = {
pin: typeof noop;
copyCommentLink: typeof noop;
deleteComment: typeof noop;
deleteThread: typeof noop;
switchToEditCommentMode: typeof noop;
isParentComment?: boolean;
isCreatedByMe?: boolean;
isPinned?: boolean;
};
function CommentContextMenu({ copyCommentLink, deleteComment, pin }: Props) {
function CommentContextMenu({
copyCommentLink,
deleteComment,
deleteThread,
isCreatedByMe,
isParentComment,
isPinned,
pin,
switchToEditCommentMode,
}: Props) {
const [isOpen, setIsOpen] = useState(false);
const options = useMemo(
() => [
{
icon: "pin-2",
display: createMessage(PIN_COMMENT),
onClick: pin,
},
{
icon: "link-2",
display: createMessage(COPY_LINK),
onClick: copyCommentLink,
},
{
icon: "trash",
display: createMessage(DELETE_COMMENT),
onClick: deleteComment,
},
],
[],
);
const options = useMemo(() => {
const options = [];
if (isParentComment) {
options.push(
{
icon: isPinned ? "unpin" : "pin-3",
display: isPinned
? createMessage(UNPIN_COMMENT)
: createMessage(PIN_COMMENT),
onClick: pin,
},
{
icon: "link-2",
display: createMessage(COPY_LINK),
onClick: copyCommentLink,
},
);
}
if (isCreatedByMe) {
if (!isParentComment) {
options.push({
icon: "trash",
display: createMessage(DELETE_COMMENT),
onClick: deleteComment,
});
} else {
options.push({
icon: "trash",
display: createMessage(DELETE_THREAD),
onClick: deleteThread,
});
}
options.push({
icon: "edit",
display: createMessage(EDIT_COMMENT),
onClick: switchToEditCommentMode,
});
}
return options;
}, [isPinned]);
const handleInteraction = useCallback((isOpen) => {
setIsOpen(isOpen);
@ -91,6 +122,8 @@ function CommentContextMenu({ copyCommentLink, deleteComment, pin }: Props) {
option.onClick();
}, []);
if (!options.length) return null;
return (
<Popover2
content={
@ -98,7 +131,11 @@ function CommentContextMenu({ copyCommentLink, deleteComment, pin }: Props) {
{options.map((option) => (
<MenuItem key={option.icon} onClick={() => handleClick(option)}>
<MenuIcon>
<StyledIcon name={option.icon as IconName} size={IconSize.XL} />
<Icon
keepColors
name={option.icon as IconName}
size={IconSize.XL}
/>
</MenuIcon>
<MenuTitle>{option.display}</MenuTitle>
</MenuItem>
@ -107,11 +144,12 @@ function CommentContextMenu({ copyCommentLink, deleteComment, pin }: Props) {
}
isOpen={isOpen}
minimal
modifiers={{ offset: { enabled: true, options: { offset: [7, 15] } } }}
onInteraction={handleInteraction}
placement={"bottom-end"}
portalClassName="comment-context-menu"
>
<StyledIcon name="context-menu" size={IconSize.LARGE} />
<StyledIcon name="comment-context-menu" size={IconSize.LARGE} />
</Popover2>
);
}

View File

@ -2,11 +2,13 @@ import React from "react";
import styled, { withTheme } from "styled-components";
import Icon, { IconSize } from "components/ads/Icon";
import { Theme } from "constants/DefaultTheme";
import { TourType } from "entities/Tour";
import useProceedToNextTourStep from "utils/hooks/useProceedToNextTourStep";
const Container = styled.div`
display: flex;
cursor: pointer;
margin-right: ${(props) => props.theme.spaces[4]}px;
margin-left: ${(props) => props.theme.spaces[2]}px;
`;
type Props = {
@ -15,30 +17,57 @@ type Props = {
theme: Theme;
};
const StyledResolveIcon = styled(Icon)<{ strokeColor: string }>`
& circle,
& path {
stroke: ${(props) => props.strokeColor};
const StyledResolveIcon = styled(Icon)<{
strokeColorCircle: string;
strokeColorPath: string;
fillColor: string;
}>`
& circle {
stroke: ${(props) => props.strokeColorCircle};
}
&& path {
stroke: ${(props) => props.strokeColorPath};
fill: transparent;
}
&& svg {
fill: ${(props) => props.fillColor};
}
`;
const ResolveCommentButton = withTheme(
({ handleClick, resolved, theme }: Props) => {
const {
resolved: resolvedColor,
resolvedFill: resolvedFillColor,
resolvedPath: resolvedPathColor,
unresolved: unresolvedColor,
unresolvedFill: unresolvedFillColor,
} = theme.colors.comments;
const strokeColor = resolved ? resolvedColor : unresolvedColor;
const strokeColorCircle = resolved ? resolvedColor : unresolvedColor;
const strokeColorPath = resolved ? resolvedPathColor : unresolvedColor;
const fillColor = resolved ? resolvedFillColor : unresolvedFillColor;
const proceedToNextTourStep = useProceedToNextTourStep(
TourType.COMMENTS_TOUR,
2,
);
const _handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
handleClick();
proceedToNextTourStep();
};
return (
<Container onClick={handleClick}>
<Container onClick={_handleClick}>
<StyledResolveIcon
fillColor={"transparent"}
fillColor={fillColor}
keepColors
name="oval-check"
size={IconSize.XXL}
strokeColor={strokeColor}
strokeColorCircle={strokeColorCircle}
strokeColorPath={strokeColorPath}
/>
</Container>
);

View File

@ -1,5 +1,5 @@
import React, { useRef } from "react";
import { useDispatch } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import CommentCard from "comments/CommentCard/CommentCard";
import AddCommentInput from "comments/inlineComments/AddCommentInput";
@ -7,38 +7,44 @@ import ScrollToLatest from "./ScrollToLatest";
import {
addCommentToThreadRequest,
resetVisibleThread,
setCommentResolutionRequest,
} from "actions/commentActions";
import { shouldShowResolved as shouldShowResolvedSelector } from "selectors/commentsSelectors";
import useIsScrolledToBottom from "utils/hooks/useIsScrolledToBottom";
import { CommentThread } from "entities/Comments/CommentsInterfaces";
import { RawDraftContentState } from "draft-js";
import styled from "styled-components";
import { animated, useTransition } from "react-spring";
import { AppState } from "reducers";
const ThreadContainer = styled.div`
width: 400px;
const ThreadContainer = styled(animated.div)<{
visible?: boolean;
inline?: boolean;
pinned?: boolean;
}>`
width: 280px;
max-width: 100%;
background-color: ${(props) =>
props.inline
? "transparent"
: props.pinned
? props.theme.colors.comments.pinnedThreadBackground
: props.visible
? props.theme.colors.comments.visibleThreadBackground
: "transparent"};
max-height: ${(props) =>
props.inline ? `calc(100vh - ${props.theme.smallHeaderHeight})` : "unset"};
/* overflow: auto collapses the comment threads in the sidebar */
overflow: ${(props) => (props.inline ? "auto" : "unset")};
`;
const CommentsContainer = styled.div<{ inline?: boolean }>`
position: relative;
max-height: ${(props) => (!props.inline ? "unset" : "285px")};
overflow: auto;
`;
const ChildCommentsContainer = styled.div`
display: flex;
`;
const ChildCommentIndent = styled.div`
width: 1px;
background-color: ${(props) =>
props.theme.colors.comments.childCommentsIndent};
margin-left: ${(props) => props.theme.spaces[11]}px;
margin-bottom: ${(props) => props.theme.spaces[7]}px;
margin-top: ${(props) => props.theme.spaces[5]}px;
`;
const ChildComments = styled.div`
@ -47,19 +53,48 @@ const ChildComments = styled.div`
function CommentThreadContainer({
commentThread,
hideChildren,
hideInput,
inline,
showSubheader,
}: {
commentThread: CommentThread;
isOpen?: boolean;
hideInput?: boolean;
inline?: boolean;
hideChildren?: boolean;
showSubheader?: boolean;
}) {
const dispatch = useDispatch();
const { comments, id: commentThreadId } = commentThread;
const { comments, id: commentThreadId } = commentThread || {};
const messagesBottomRef = useRef<HTMLDivElement>(null);
const commentsContainerRef = useRef<HTMLDivElement>(null);
const shouldShowResolved = useSelector(shouldShowResolvedSelector);
const isThreadVisible =
shouldShowResolved || !commentThread?.resolvedState?.active;
const config = inline
? {
from: { opacity: 0 },
enter: { opacity: 1 },
leave: { opacity: 0 },
config: { duration: 300 },
}
: {
from: { opacity: 0, transform: "translateX(-100%)" },
enter: { opacity: 1, transform: "translateX(0)" },
leave: { opacity: 0, transform: "translateX(-100%)" },
config: { duration: 300 },
};
const transition = useTransition(isThreadVisible, null, config);
const isVisible = useSelector(
(state: AppState) =>
state.ui.comments.visibleCommentThreadId === commentThreadId,
);
// Check if the comments window is scrolled to the bottom
// We don't autoscroll for the user receiving the updates
// for better UX, instead we'd show a helper message to indicate
@ -90,51 +125,83 @@ function CommentThreadContainer({
const resolveCommentThread = () => {
dispatch(
setCommentResolutionRequest({
threadId: commentThread.id,
resolved: !commentThread.resolved,
threadId: commentThread?.id,
resolved: !commentThread?.resolvedState?.active,
}),
);
};
const parentComment = Array.isArray(comments) && comments[0];
const childComments = Array.isArray(comments) && comments.slice(1);
const numberOfReplies =
(Array.isArray(childComments) && childComments.length) || 0;
const handleCancel = () => dispatch(resetVisibleThread(commentThreadId));
if (!commentThread) return null;
return (
<ThreadContainer tabIndex={0}>
<div style={{ position: "relative" }}>
<CommentsContainer inline={inline} ref={commentsContainerRef}>
{parentComment && (
<CommentCard
comment={parentComment}
commentThreadId={commentThreadId}
isParentComment
key={parentComment.id}
resolved={!!commentThread.resolved}
toggleResolved={resolveCommentThread}
/>
)}
{childComments && childComments.length > 0 && (
<ChildCommentsContainer>
<ChildCommentIndent />
<ChildComments>
{childComments.map((comment) => (
<CommentCard
comment={comment}
commentThreadId={commentThreadId}
key={comment.id}
<>
{transition.map(
({ item: show, props: springProps }: { item: boolean; props: any }) =>
show ? (
<animated.div key={commentThread.id} style={springProps}>
<ThreadContainer
inline={inline}
pinned={commentThread.pinnedState?.active}
tabIndex={0}
visible={isVisible}
>
<div style={{ position: "relative" }}>
<CommentsContainer inline={inline} ref={commentsContainerRef}>
{parentComment && (
<CommentCard
comment={parentComment}
commentThreadId={commentThreadId}
inline={inline}
isParentComment
key={parentComment.id}
numberOfReplies={numberOfReplies}
resolved={!!commentThread.resolvedState?.active}
showReplies={hideChildren}
showSubheader={showSubheader}
toggleResolved={resolveCommentThread}
unread={!commentThread.isViewed}
visible={isVisible}
/>
)}
{!hideChildren &&
childComments &&
childComments.length > 0 && (
<ChildComments>
{childComments.map((comment) => (
<CommentCard
comment={comment}
commentThreadId={commentThreadId}
inline={inline}
key={comment.id}
visible={isVisible}
/>
))}
</ChildComments>
)}
<div ref={messagesBottomRef} />
</CommentsContainer>
{!isScrolledToBottom && (
<ScrollToLatest scrollToBottom={scrollToBottom} />
)}
</div>
{!hideInput && (
<AddCommentInput
onCancel={handleCancel}
onSave={addComment}
/>
))}
</ChildComments>
</ChildCommentsContainer>
)}
<div ref={messagesBottomRef} />
</CommentsContainer>
{!isScrolledToBottom && (
<ScrollToLatest scrollToBottom={scrollToBottom} />
)}
</div>
{!hideInput && <AddCommentInput onSave={addComment} />}
</ThreadContainer>
)}
</ThreadContainer>
</animated.div>
) : null,
)}
</>
);
}

View File

@ -1,15 +1,37 @@
import Icon from "components/ads/Icon";
import { Theme } from "constants/DefaultTheme";
import React from "react";
import styled from "styled-components";
import styled, { withTheme } from "styled-components";
import { getTypographyByKey } from "constants/DefaultTheme";
import { VIEW_LATEST, createMessage } from "constants/messages";
const Container = styled.div`
position: absolute;
right: 10px;
bottom: 10px;
cursor: pointer;
color: ${(props) => props.theme.colors.comments.viewLatest};
text-transform: uppercase;
display: flex;
${(props) => getTypographyByKey(props, "btnMedium")}
& .view-latest {
margin-right: ${(props) => props.theme.spaces[1]}px;
}
`;
function ScrollToLatest({ scrollToBottom }: { scrollToBottom: () => void }) {
return <Container onClick={scrollToBottom}>View latest</Container>;
}
const ScrollToLatest = withTheme(
({ scrollToBottom, theme }: { scrollToBottom: () => void; theme: Theme }) => {
return (
<Container onClick={scrollToBottom}>
<span className="view-latest">{createMessage(VIEW_LATEST)}</span>
<Icon
fillColor={theme.colors.comments.viewLatest}
hoverFillColor={theme.colors.comments.viewLatest}
name="down-arrow-2"
/>
</Container>
);
},
);
export default ScrollToLatest;

View File

@ -0,0 +1,25 @@
import React from "react";
import ModalComponent from "components/designSystems/blueprint/ModalComponent";
function ShowcaseCarouselModal({ children }: { children: React.ReactNode }) {
return (
<ModalComponent
bottom={25}
canEscapeKeyClose
canOutsideClickClose
data-cy={"help-modal"}
hasBackDrop={false}
isOpen
onClose={() => {
console.log("handle close");
}}
right={25}
scrollContents
width={325}
>
{children}
</ModalComponent>
);
}
export default ShowcaseCarouselModal;

View File

@ -0,0 +1,50 @@
import React, { useState, useRef, useEffect } from "react";
import { updatePhoto } from "actions/userActions";
import { useDispatch } from "react-redux";
import DisplayImageUpload from "components/ads/DisplayImageUpload";
import UserApi from "api/UserApi";
import Uppy from "@uppy/core";
function FormDisplayImage() {
const [file, setFile] = useState<any>();
const [imageURL, setImageURL] = useState(`/api/${UserApi.photoURL}`);
const dispatch = useDispatch();
const dispatchActionRef = useRef<(uppy: Uppy.Uppy) => void | null>();
const onUploadComplete = (uppy: Uppy.Uppy) => {
uppy.reset();
setImageURL(`/api/${UserApi.photoURL}?${new Date().getTime()}`);
};
const onSelectFile = (file: any) => {
setFile(file.data);
};
useEffect(() => {
dispatchActionRef.current = (uppy: Uppy.Uppy) => {
dispatch(updatePhoto({ file, callback: () => onUploadComplete(uppy) }));
};
}, [file]);
const upload = (uppy: Uppy.Uppy) => {
if (typeof dispatchActionRef.current === "function")
dispatchActionRef.current(uppy);
};
// TODO implement remove
// const removeProfileImage = () => {
// dispatch(removePhoto(() => {}));
// };
return (
<DisplayImageUpload
onChange={onSelectFile}
submit={upload}
value={imageURL}
/>
);
}
export default FormDisplayImage;

View File

@ -0,0 +1,81 @@
import React from "react";
import { reduxForm } from "redux-form";
import FormGroup from "components/ads/formFields/FormGroup";
import FormTextField from "components/ads/formFields/TextField";
import FormDisplayImage from "./FormDisplayImage";
import { createMessage, DISPLAY_NAME, EMAIL_ADDRESS } from "constants/messages";
import styled from "styled-components";
import { isEmail, isEmptyString } from "utils/formhelpers";
export type FormValues = {
fullName?: string;
displayName?: string;
emailAddress?: string;
};
const Container = styled.div`
padding: ${(props) => props.theme.spaces[5]}px;
`;
export const PROFILE_FORM = "PROFILE_FORM";
const fieldNames = {
displayName: "displayName",
emailAddress: "emailAddress",
};
const validate = (values: any) => {
const errors: any = {};
const displayName = values[fieldNames.displayName] || "";
const emailAddress = values[fieldNames.emailAddress] || "";
if (!displayName || isEmptyString(displayName)) {
errors[fieldNames.displayName] = "Required";
}
if (!emailAddress || isEmptyString(emailAddress) || !isEmail(emailAddress)) {
errors[fieldNames.emailAddress] = "Required";
}
return errors;
};
function ProfileForm(props: any) {
return (
<Container>
<div style={{ marginBottom: 10 }}>
<FormDisplayImage />
</div>
<FormGroup label={createMessage(DISPLAY_NAME)}>
<FormTextField
hideErrorMessage
name={fieldNames.displayName}
placeholder={createMessage(DISPLAY_NAME)}
/>
</FormGroup>
<FormGroup label={createMessage(EMAIL_ADDRESS)}>
<FormTextField
disabled={props.emailDisabled}
hideErrorMessage
name={fieldNames.emailAddress}
placeholder={createMessage(EMAIL_ADDRESS)}
type="email"
/>
</FormGroup>
</Container>
);
}
export default reduxForm({
// Currently while using this feature,
// a destroy action is dispatched
// so the initial values don't get set
// TODO: triage this issue
destroyOnUnmount: false,
form: PROFILE_FORM,
validate,
})(ProfileForm);

View File

@ -0,0 +1,173 @@
import React from "react";
import Text, { TextType } from "components/ads/Text";
import ShowcaseCarousel, { Steps } from "components/ads/ShowcaseCarousel";
import ProfileForm, { PROFILE_FORM } from "./ProfileForm";
import CommentsCarouselModal from "./CommentsCarouselModal";
import CommentsOnboardingStep1 from "assets/images/comments-onboarding/step-1.png";
import CommentsOnboardingStep2 from "assets/images/comments-onboarding/step-2.png";
import CommentsOnboardingStep3 from "assets/images/comments-onboarding/step-3.png";
import CommentsOnboardingStep4 from "assets/images/comments-onboarding/step-4.png";
import styled, { withTheme } from "styled-components";
import { Theme } from "constants/DefaultTheme";
import { useDispatch, useSelector } from "react-redux";
import { getFormSyncErrors } from "redux-form";
import { getFormValues } from "redux-form";
import { isIntroCarouselVisibleSelector } from "selectors/commentsSelectors";
import { getCurrentUser } from "selectors/usersSelectors";
import { setActiveTour } from "actions/tourActions";
import { TourType } from "entities/Tour";
import { hideCommentsIntroCarousel } from "actions/commentActions";
import { setCommentsIntroSeen } from "utils/storage";
import { updateUserDetails } from "actions/userActions";
const title1 = "Introducing Live Comments";
const title2 = "Give feedback";
const title3 = "Invite other people to your conversations";
const title4 = "You are all set!";
const content1 =
"We are introducing live comments. From now on you will be able to comment on your apps, tag other people and exchange thoughts in threads. Click Next to learn more about comments and start commenting.";
const content2 =
"Comment on your co-workers work and share your thoughts on what works and what needs change.";
const content3 =
"When leaving a comment you can tag oter people by writing @ and their name. This way the person you tagged will get a notification and an e-mail that you tagged them in a comment.";
const content4 =
"By clicking on the comments icon in the top right corner you will activate the collaboration mode and will be able to start a thread or answer to someone elses comment.";
const IntroContentContainer = styled.div`
padding: ${(props) => props.theme.spaces[5]}px;
`;
function IntroStep(props: {
title: string;
content: string;
banner: typeof CommentsOnboardingStep1;
theme: Theme;
}) {
return (
<>
<img alt="" src={props.banner} />
<IntroContentContainer>
<div style={{ marginBottom: props.theme.spaces[4] }}>
<Text
style={{
color: props.theme.colors.comments.introTitle,
}}
type={TextType.H1}
>
{props.title}
</Text>
</div>
<Text
style={{ color: props.theme.colors.comments.introContent }}
type={TextType.P1}
>
{props.content}
</Text>
</IntroContentContainer>
</>
);
}
const IntroStepThemed = withTheme(IntroStep);
const getSteps = (
onSubmitProfileForm: any,
isSubmitProfileFormDisabled: boolean,
startTutorial: () => void,
initialProfileFormValues: { emailAddress?: string; displayName?: string },
emailDisabled: boolean,
) => [
{
component: IntroStepThemed,
props: {
title: title1,
content: content1,
banner: CommentsOnboardingStep1,
hideBackBtn: true,
},
},
{
component: IntroStepThemed,
props: {
title: title2,
content: content2,
banner: CommentsOnboardingStep2,
},
},
{
component: IntroStepThemed,
props: {
title: title3,
content: content3,
banner: CommentsOnboardingStep3,
},
},
{
component: ProfileForm,
props: {
isSubmitDisabled: isSubmitProfileFormDisabled,
onSubmit: onSubmitProfileForm,
initialValues: initialProfileFormValues,
emailDisabled,
},
},
{
component: IntroStepThemed,
props: {
title: title4,
content: content4,
banner: CommentsOnboardingStep4,
hideBackBtn: true,
nextBtnText: "Start Tutorial",
onSubmit: startTutorial,
},
},
];
export default function CommentsShowcaseCarousel() {
const dispatch = useDispatch();
const isIntroCarouselVisible = useSelector(isIntroCarouselVisibleSelector);
const profileFormValues = useSelector(getFormValues(PROFILE_FORM));
const profileFormErrors = useSelector(getFormSyncErrors("PROFILE_FORM"));
const isSubmitDisabled = Object.keys(profileFormErrors).length !== 0;
const currentUser = useSelector(getCurrentUser);
const { email, name } = currentUser || {};
const initialProfileFormValues = { emailAddress: email, displayName: name };
const onSubmitProfileForm = () => {
const { displayName: name, emailAddress: email } = profileFormValues as {
displayName: string;
emailAddress: string;
};
dispatch(updateUserDetails({ name, email }));
};
const startTutorial = () => {
dispatch(setActiveTour(TourType.COMMENTS_TOUR));
dispatch(hideCommentsIntroCarousel());
setCommentsIntroSeen(true);
};
const steps = getSteps(
onSubmitProfileForm,
isSubmitDisabled,
startTutorial,
initialProfileFormValues,
!!email,
);
if (!isIntroCarouselVisible) return null;
return (
<CommentsCarouselModal>
<ShowcaseCarousel steps={steps as Steps} />
</CommentsCarouselModal>
);
}

View File

@ -1,105 +0,0 @@
import React, { useCallback, useEffect } from "react";
import styled from "styled-components";
import { useDispatch, useSelector } from "react-redux";
import { ReactComponent as ToggleCommmentMode } from "assets/icons/comments/toggle-comment-mode.svg";
import {
setCommentMode as setCommentModeAction,
fetchApplicationCommentsRequest,
} from "actions/commentActions";
import {
commentModeSelector,
areCommentsEnabledForUser as areCommentsEnabledForUserSelector,
} from "../selectors/commentsSelectors";
import { useLocation } from "react-router";
import history from "utils/history";
const StyledToggleCommentMode = styled.div<{ isCommentMode: boolean }>`
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: ${(props) =>
!props.isCommentMode
? props.theme.colors.comments.commentModeButtonBackground
: props.theme.colors.comments.commentModeButtonIcon};
svg path {
fill: ${(props) =>
props.isCommentMode
? "#fff"
: props.theme.colors.comments.commentModeButtonIcon};
}
height: ${(props) => props.theme.smallHeaderHeight};
width: ${(props) => props.theme.smallHeaderHeight};
`;
// update isCommentMode in the store based on the query search param
const useUpdateCommentModeInStore = () => {
const location = useLocation();
const dispatch = useDispatch();
const setCommentModeInStore = useCallback(
(updatedIsCommentMode) =>
dispatch(setCommentModeAction(updatedIsCommentMode)),
[],
);
useEffect(() => {
if (window.location.href) {
const searchParams = new URL(window.location.href).searchParams;
const isCommentMode = searchParams.get("isCommentMode");
if (isCommentMode) {
const updatedIsCommentMode = isCommentMode === "true" ? true : false;
setCommentModeInStore(updatedIsCommentMode);
}
}
}, [location]);
};
/**
* Toggle comment mode:
* This component is also responsible for fetching
* application comments
*/
function ToggleCommentModeButton() {
const dispatch = useDispatch();
const commentsEnabled = useSelector(areCommentsEnabledForUserSelector);
const isCommentMode = useSelector(commentModeSelector);
const setCommentModeInUrl = useCallback(() => {
const currentURL = new URL(window.location.href);
const searchParams = currentURL.searchParams;
searchParams.set("isCommentMode", `${!isCommentMode}`);
// remove comment link params so that they don't get retriggered
// on toggling comment mode
searchParams.delete("commentId");
searchParams.delete("commentThreadId");
history.replace({
pathname: currentURL.pathname,
search: searchParams.toString(),
hash: currentURL.hash,
});
}, [isCommentMode]);
// fetch applications comments when comment mode is turned on
useEffect(() => {
if (isCommentMode) {
dispatch(fetchApplicationCommentsRequest());
}
}, [isCommentMode]);
useUpdateCommentModeInStore();
return commentsEnabled ? (
<StyledToggleCommentMode
isCommentMode={isCommentMode}
onClick={setCommentModeInUrl}
>
<ToggleCommmentMode />
</StyledToggleCommentMode>
) : null;
}
export default ToggleCommentModeButton;

View File

@ -6,35 +6,30 @@ import React, {
useMemo,
useState,
} from "react";
import Icon, { IconSize } from "components/ads/Icon";
import styled, { withTheme } from "styled-components";
import { EditorState, convertToRaw, Modifier, SelectionState } from "draft-js";
import EmojiPicker from "components/ads/EmojiPicker";
import MentionsInput from "components/ads/MentionsInput";
import useOrgUsers from "./useOrgUsers";
import { MentionData } from "@draft-js-plugins/mention";
import { OrgUser } from "constants/orgConstants";
import { IEmojiData } from "emoji-picker-react";
import Button, { Category } from "components/ads/Button";
import { createMessage, ADD_COMMENT_PLACEHOLDER } from "constants/messages";
import { BaseEmoji } from "emoji-mart";
import styled from "styled-components";
import { EditorState, convertToRaw, Modifier, SelectionState } from "draft-js";
import { MentionData } from "@draft-js-plugins/mention";
import { OrgUser } from "constants/orgConstants";
import useOrgUsers from "./useOrgUsers";
import { RawDraftContentState } from "draft-js";
import {
createMessage,
ADD_COMMENT_PLACEHOLDER,
CANCEL,
POST,
} from "constants/messages";
const StyledInputContainer = styled.div`
display: flex;
align-items: center;
width: 100%;
padding: ${(props) =>
`${props.theme.spaces[3]}px ${props.theme.spaces[4]}px`};
background: ${(props) =>
props.theme.colors.comments.addCommentInputBackground};
`;
const StyledSendButton = styled.button`
display: inline-flex;
background: transparent;
border: none;
align-items: center;
position: relative;
top: -1px;
margin-bottom: ${(props) => props.theme.spaces[7]}px;
`;
const StyledEmojiTrigger = styled.div`
@ -43,11 +38,24 @@ const StyledEmojiTrigger = styled.div`
margin-right: ${(props) => props.theme.spaces[4]}px;
`;
const PaddingContainer = styled.div`
const PaddingContainer = styled.div<{ removePadding?: boolean }>`
padding: ${(props) =>
`${props.theme.spaces[4]}px ${props.theme.spaces[6]}px`};
!props.removePadding
? `${props.theme.spaces[7]}px ${props.theme.spaces[5]}px`
: 0};
& .cancel-button {
margin-right: ${(props) => props.theme.spaces[5]}px;
}
`;
const Row = styled.div`
display: flex;
justify-content: space-between;
`;
// Trigger tests
const insertCharacter = (
characterToInsert: string,
editorState: EditorState,
@ -100,17 +108,38 @@ const useUserSuggestions = (
setSuggestions: Dispatch<SetStateAction<Array<MentionData>>>,
) => {
useEffect(() => {
setSuggestions(users.map((user) => ({ name: user.username })));
setSuggestions(users.map((user) => ({ name: user.username, user })));
}, [users]);
};
const AddCommentInput = withTheme(({ onSave, theme }: any) => {
function AddCommentInput({
initialEditorState,
onCancel,
onSave,
removePadding,
}: {
removePadding?: boolean;
initialEditorState?: EditorState;
onSave: (state: RawDraftContentState) => void;
onCancel?: () => void;
}) {
const users = useOrgUsers();
const [suggestions, setSuggestions] = useState<Array<MentionData>>([]);
useUserSuggestions(users, setSuggestions);
const [editorState, setEditorState] = useState(EditorState.createEmpty());
const [editorState, setEditorState] = useState(
initialEditorState || EditorState.createEmpty(),
);
const [suggestionsQuery, setSuggestionsQuery] = useState("");
const clearEditor = useCallback(() => {
setEditorState(resetEditorState(editorState));
}, [editorState]);
const _onCancel = () => {
clearEditor();
if (onCancel) onCancel();
};
const onSaveComment = useCallback(
(editorStateArg?: EditorState) => {
const latestEditorState = editorStateArg || editorState;
@ -119,16 +148,16 @@ const AddCommentInput = withTheme(({ onSave, theme }: any) => {
const contentState = latestEditorState.getCurrentContent();
const rawContent = convertToRaw(contentState);
clearEditor();
onSave(rawContent);
setEditorState(resetEditorState(latestEditorState));
},
[editorState],
);
const handleSubmit = useCallback(() => onSaveComment(), [editorState]);
const handleEmojiClick = useCallback(
(e: React.MouseEvent, emojiObject: IEmojiData) => {
const newEditorState = insertCharacter(emojiObject.emoji, editorState);
(e: React.MouseEvent, emojiObject: BaseEmoji) => {
const newEditorState = insertCharacter(emojiObject.native, editorState);
setEditorState(newEditorState);
},
[editorState],
@ -153,30 +182,44 @@ const AddCommentInput = withTheme(({ onSave, theme }: any) => {
}, [suggestionsQuery, suggestions]);
return (
<PaddingContainer>
<StyledInputContainer>
<MentionsInput
autoFocus
editorState={editorState}
onSearchSuggestions={onSearchChange}
onSubmit={onSaveComment}
placeholder={createMessage(ADD_COMMENT_PLACEHOLDER)}
setEditorState={setEditorState}
suggestions={filteredSuggestions}
/>
<PaddingContainer removePadding={removePadding}>
<Row>
<StyledInputContainer>
<MentionsInput
autoFocus
editorState={editorState}
onSearchSuggestions={onSearchChange}
onSubmit={onSaveComment}
placeholder={createMessage(ADD_COMMENT_PLACEHOLDER)}
setEditorState={setEditorState}
suggestions={filteredSuggestions}
/>
</StyledInputContainer>
</Row>
<Row>
<StyledEmojiTrigger>
<EmojiPicker onSelectEmoji={handleEmojiClick} />
</StyledEmojiTrigger>
<StyledSendButton data-cy="add-comment-submit" onClick={handleSubmit}>
<Icon
fillColor={theme.colors.comments.sendButton}
name="send-button"
size={IconSize.XL}
<Row>
<Button
category={Category.tertiary}
className={"cancel-button"}
onClick={_onCancel}
text={createMessage(CANCEL)}
type="button"
/>
</StyledSendButton>
</StyledInputContainer>
<Button
category={Category.primary}
data-cy="add-comment-submit"
disabled={!editorState.getCurrentContent().hasText()}
onClick={handleSubmit}
text={createMessage(POST)}
type="button"
/>
</Row>
</Row>
</PaddingContainer>
);
});
}
export default AddCommentInput;

View File

@ -1,23 +1,42 @@
import React, { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import styled, { withTheme } from "styled-components";
import styled from "styled-components";
import CommentThread from "comments/CommentThread/CommentThread";
import Icon, { IconSize } from "components/ads/Icon";
import { Popover } from "@blueprintjs/core";
import { get } from "lodash";
import { commentThreadsSelector } from "selectors/commentsSelectors";
import { Theme } from "constants/DefaultTheme";
import { setIsCommentThreadVisible as setIsCommentThreadVisibleAction } from "actions/commentActions";
import {
commentThreadsSelector,
shouldShowResolved as shouldShowResolvedSelector,
} from "selectors/commentsSelectors";
import { getTypographyByKey } from "constants/DefaultTheme";
import {
setVisibleThread,
resetVisibleThread,
markThreadAsReadRequest,
} from "actions/commentActions";
import { useTransition, animated } from "react-spring";
import { useLocation } from "react-router";
import scrollIntoView from "scroll-into-view-if-needed";
import { AppState } from "reducers";
import "@blueprintjs/popover2/lib/css/blueprint-popover2.css";
import { Popover2 } from "@blueprintjs/popover2";
/**
* The relavent pixel position is bottom right for the comment cursor
* instead of the top left for the default arrow cursor
*/
const CommentTriggerContainer = styled.div<{ top: number; left: number }>`
position: absolute;
top: ${(props) => props.top}%;
left: ${(props) => props.left}%;
bottom: calc(${(props) => 100 - props.top}% - 2px);
right: calc(${(props) => 100 - props.left}% - 2px);
z-index: 1;
`;
const useSelectCommentThreadUsingQuery = (commentThreadId: string) => {
const dispatch = useDispatch();
const location = useLocation();
useEffect(() => {
const searchParams = new URL(window.location.href).searchParams;
const commentThreadIdInUrl = searchParams.get("commentThreadId");
@ -26,82 +45,177 @@ const useSelectCommentThreadUsingQuery = (commentThreadId: string) => {
`comment-thread-pin-${commentThreadId}`,
);
const commentPin = elements && elements[0];
commentPin?.scrollIntoView();
if (commentPin) {
scrollIntoView(commentPin, {
scrollMode: "if-needed",
block: "nearest",
inline: "nearest",
});
}
// set comment thread visible after scrollIntoView is complete
setTimeout(() => {
dispatch(
setIsCommentThreadVisibleAction({
commentThreadId,
isVisible: true,
}),
);
dispatch(setVisibleThread(commentThreadId));
});
}
}, []);
}, [location]);
};
const StyledPinContainer = styled.div<{ unread?: boolean }>`
position: relative;
& .pin-id {
position: absolute;
top: 15%;
left: 50%;
transform: translate(-50%);
color: ${(props) =>
props.unread ? "#fff" : props.theme.colors.comments.pinId};
${(props) => getTypographyByKey(props, "p1")}
max-width: 25px;
text-overflow: ellipsis;
overflow: hidden;
}
& svg {
width: 30px;
height: 30px;
box-shadow: 0px 8px 10px rgb(0 0 0 / 15%);
border-radius: 15px;
overflow: visible;
}
cursor: pointer;
`;
function Pin({
commentThreadId,
onClick,
sequenceId = "",
unread,
}: {
commentThreadId: string;
sequenceId?: string;
unread?: boolean;
onClick: () => void;
}) {
return (
<StyledPinContainer onClick={onClick} unread={unread}>
<Icon
className={`comment-thread-pin-${commentThreadId}`}
data-cy={`t--inline-comment-pin-trigger-${commentThreadId}`}
keepColors
name={unread ? "unread-pin" : "read-pin"}
size={IconSize.XXL}
/>
<div className="pin-id">{sequenceId.slice(1)}</div>
</StyledPinContainer>
);
}
const Container = document.getElementById("root");
/**
* Comment pins that toggle comment thread popover visibility on click
* They position themselves using position absolute based on top and left values (in percent)
*/
const InlineCommentPin = withTheme(
({ commentThreadId, theme }: { commentThreadId: string; theme: Theme }) => {
const commentThread = useSelector(commentThreadsSelector(commentThreadId));
const { left, top } = get(commentThread, "position", {
top: 0,
left: 0,
});
function InlineCommentPin({ commentThreadId }: { commentThreadId: string }) {
const commentThread = useSelector(commentThreadsSelector(commentThreadId));
const { left, top } = get(commentThread, "position", {
top: 0,
left: 0,
});
const dispatch = useDispatch();
const setIsCommentThreadVisible = (isVisible: boolean) =>
dispatch(
setIsCommentThreadVisibleAction({
commentThreadId,
isVisible,
}),
);
const dispatch = useDispatch();
useSelectCommentThreadUsingQuery(commentThreadId);
useSelectCommentThreadUsingQuery(commentThreadId);
return (
<CommentTriggerContainer
data-cy="inline-comment-pin"
left={left}
onClick={(e: any) => {
// capture clicks so that create new thread is not triggered
e.preventDefault();
e.stopPropagation();
}}
top={top}
>
<Popover
autoFocus
canEscapeKeyClose
hasBackdrop
isOpen={!!commentThread.isVisible}
minimal
// isOpen is controlled so that newly created threads are set to be visible
onInteraction={(nextOpenState) => {
setIsCommentThreadVisible(nextOpenState);
}}
popoverClassName="comment-thread"
>
<Icon
className={`comment-thread-pin-${commentThreadId}`}
data-cy={`t--inline-comment-pin-trigger-${commentThreadId}`}
fillColor={theme.colors.comments.pin}
name="pin"
size={IconSize.XXL}
/>
<CommentThread
commentThread={commentThread}
inline
isOpen={!!commentThread.isVisible}
/>
</Popover>
</CommentTriggerContainer>
);
},
);
const shouldShowResolved = useSelector(shouldShowResolvedSelector);
const isPinVisible =
shouldShowResolved || !commentThread?.resolvedState?.active;
const isCommentThreadVisible = useSelector(
(state: AppState) =>
state.ui.comments.visibleCommentThreadId === commentThreadId,
);
const transition = useTransition(isPinVisible, null, {
from: { opacity: 0 },
enter: { opacity: 1 },
leave: { opacity: 0 },
config: { duration: 300 },
});
const handlePinClick = () => {
if (!commentThread?.isViewed) {
dispatch(markThreadAsReadRequest(commentThreadId));
}
};
if (!commentThread) return null;
return (
<>
{transition.map(
({ item: show, props: springProps }: { item: boolean; props: any }) =>
show ? (
<animated.div key={commentThreadId} style={springProps}>
<CommentTriggerContainer
data-cy="inline-comment-pin"
draggable="true"
left={left}
onClick={(e: any) => {
// capture clicks so that create new thread is not triggered
e.preventDefault();
e.stopPropagation();
}}
top={top}
>
<Popover2
autoFocus
boundary={Container as HTMLDivElement}
canEscapeKeyClose
content={
<animated.div style={springProps}>
<CommentThread
commentThread={commentThread}
inline
isOpen={!!isCommentThreadVisible}
/>
</animated.div>
}
hasBackdrop
isOpen={!!isCommentThreadVisible}
minimal
// isOpen is controlled so that newly created threads are set to be visible
modifiers={{
preventOverflow: { enabled: true },
offset: {
enabled: true,
options: {
offset: [-8, 10],
},
},
}}
onInteraction={(nextOpenState: boolean) => {
if (nextOpenState) {
dispatch(setVisibleThread(commentThreadId));
} else {
dispatch(resetVisibleThread(commentThreadId));
}
}}
placement={"right-start"}
popoverClassName="comment-thread"
portalClassName="inline-comment-thread"
>
<Pin
commentThreadId={commentThreadId}
onClick={handlePinClick}
sequenceId={commentThread.sequenceId}
unread={!commentThread.isViewed}
/>
</Popover2>
</CommentTriggerContainer>
</animated.div>
) : null,
)}
</>
);
}
export default InlineCommentPin;

View File

@ -4,54 +4,21 @@ import { useSelector, useDispatch } from "react-redux";
import Comments from "./Comments";
import { commentModeSelector } from "selectors/commentsSelectors";
import { createUnpublishedCommentThreadRequest } from "actions/commentActions";
import commentIcon from "assets/icons/ads/commentIcon.png";
import commentIcon from "assets/icons/comments/commentCursor.png";
import { getOffsetPos } from "comments/utils";
import useProceedToNextTourStep from "utils/hooks/useProceedToNextTourStep";
import { TourType } from "entities/Tour";
type Props = {
children: React.ReactNode;
refId: string;
};
/**
* Returns the offset position relative to the container
* using the coordinates from the click event
* @param clickEvent
* @param containerRef
*/
const getOffsetPos = (
clickEvent: React.MouseEvent,
containerRef: HTMLDivElement,
) => {
const boundingClientRect = containerRef.getBoundingClientRect();
const containerPosition = {
left: boundingClientRect.left,
top: boundingClientRect.top,
};
const clickPosition = {
left: clickEvent.clientX,
top: clickEvent.clientY,
};
const offsetLeft = clickPosition.left - containerPosition.left;
const offsetTop = clickPosition.top - containerPosition.top;
const offsetLeftPercent = parseInt(
`${(offsetLeft / boundingClientRect.width) * 100}`,
);
const offsetTopPercent = parseInt(
`${(offsetTop / boundingClientRect.height) * 100}`,
);
return {
left: offsetLeftPercent,
top: offsetTopPercent,
};
};
const Container = styled.div`
width: 100%;
height: 100%;
position: relative;
cursor: -webkit-image-set(url("${commentIcon}") 2x) 11 17, auto;
cursor: url("${commentIcon}") 25 20 , auto;
`;
/**
@ -62,8 +29,15 @@ function OverlayCommentsWrapper({ children, refId }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const isCommentMode = useSelector(commentModeSelector);
const dispatch = useDispatch();
const proceedToNextTourStep = useProceedToNextTourStep(
TourType.COMMENTS_TOUR,
1,
);
// create new unpublished thread
const clickHandler = (e: any) => {
proceedToNextTourStep();
e.persist();
if (containerRef.current) {
const position = getOffsetPos(e, containerRef.current);

View File

@ -2,9 +2,7 @@ import styled from "styled-components";
import { getTypographyByKey } from "constants/DefaultTheme";
export const ThreadContainer = styled.div`
border: 1px solid
${(props) => props.theme.colors.comments.threadContainerBorder};
width: 400px;
width: 280px;
max-width: 100%;
`;
@ -21,6 +19,4 @@ export const ThreadHeaderTitle = styled.div`
export const CommentsContainer = styled.div`
position: relative;
max-height: 285px;
overflow: auto;
`;

View File

@ -1,93 +1,94 @@
import React from "react";
import { Popover, Position } from "@blueprintjs/core";
import AddCommentInput from "./AddCommentInput";
import {
ThreadContainer,
ThreadHeaderTitle,
ThreadHeader,
} from "./StyledComponents";
import { ThreadContainer } from "./StyledComponents";
import { useDispatch } from "react-redux";
import styled, { withTheme } from "styled-components";
import styled from "styled-components";
import { get } from "lodash";
import {
createCommentThreadRequest as createCommentThreadAction,
removeUnpublishedCommentThreads,
} from "actions/commentActions";
import Icon, { IconSize } from "components/ads/Icon";
import { Theme } from "constants/DefaultTheme";
import Icon from "components/ads/Icon";
import { RawDraftContentState } from "draft-js";
import { CommentThread } from "entities/Comments/CommentsInterfaces";
const CommentTriggerContainer = styled.div<{ top: number; left: number }>`
position: absolute;
top: ${(props) => props.top}%;
left: ${(props) => props.left}%;
bottom: calc(${(props) => 100 - props.top}% - 2px);
right: calc(${(props) => 100 - props.left}% - 2px);
& svg {
width: 30px;
height: 30px;
box-shadow: 0px 8px 10px rgb(0 0 0 / 15%);
border-radius: 15px;
overflow: visible;
}
`;
// TODO look into drying this up using comment thread component
const UnpublishedCommentThread = withTheme(
({
commentThread,
theme,
}: {
commentThread: CommentThread;
theme: Theme;
}) => {
const { left, top } = get(commentThread, "position", {
top: 0,
left: 0,
});
const dispatch = useDispatch();
const onClosing = () => {
dispatch(removeUnpublishedCommentThreads());
};
function UnpublishedCommentThread({
commentThread,
}: {
commentThread: CommentThread;
}) {
const { left, top } = get(commentThread, "position", {
top: 0,
left: 0,
});
const dispatch = useDispatch();
const onClosing = () => {
dispatch(removeUnpublishedCommentThreads());
};
const createCommentThread = (text: RawDraftContentState) => {
dispatch(createCommentThreadAction({ commentBody: text, commentThread }));
};
const createCommentThread = (text: RawDraftContentState) => {
dispatch(createCommentThreadAction({ commentBody: text, commentThread }));
};
return (
<div
data-cy="unpublished-comment-thread"
key={`${top}-${left}`}
onClick={(e: any) => {
// capture clicks so that create new thread is not triggered
e.preventDefault();
e.stopPropagation();
}}
>
<CommentTriggerContainer left={left} top={top}>
<Popover
autoFocus
boundary="viewport"
canEscapeKeyClose
hasBackdrop
isOpen
minimal
onInteraction={(nextOpenState) => {
if (!nextOpenState) {
onClosing();
}
}}
popoverClassName="comment-thread"
position={Position.BOTTOM_RIGHT}
>
<Icon
fillColor={theme.colors.comments.pin}
name="pin"
size={IconSize.XXL}
return (
<div
data-cy="unpublished-comment-thread"
key={`${top}-${left}`}
onClick={(e: any) => {
// capture clicks so that create new thread is not triggered
e.preventDefault();
e.stopPropagation();
}}
>
<CommentTriggerContainer left={left} top={top}>
<Popover
autoFocus
boundary="viewport"
canEscapeKeyClose
hasBackdrop
isOpen
minimal
modifiers={{
offset: {
enabled: true,
offset: "-8, 10",
},
}}
onInteraction={(nextOpenState) => {
if (!nextOpenState) {
onClosing();
}
}}
popoverClassName="comment-thread"
position={Position.RIGHT_TOP}
>
<Icon keepColors name="unread-pin" />
<ThreadContainer tabIndex={0}>
<AddCommentInput
onCancel={onClosing}
onSave={createCommentThread}
/>
<ThreadContainer tabIndex={0}>
<ThreadHeader>
<ThreadHeaderTitle>Add a Comment</ThreadHeaderTitle>
</ThreadHeader>
<AddCommentInput onSave={createCommentThread} />
</ThreadContainer>
</Popover>
</CommentTriggerContainer>
</div>
);
},
);
</ThreadContainer>
</Popover>
</CommentTriggerContainer>
</div>
);
}
export default UnpublishedCommentThread;

View File

@ -0,0 +1,22 @@
import React from "react";
import styled from "styled-components";
import TourTooltipWrapper from "components/ads/tour/TourTooltipWrapper";
import { TourType } from "entities/Tour";
const Dot = styled.div`
position: fixed;
top: 50%;
left: calc(125px + 50%);
width: 0px;
height: 0px;
`;
export default function AddCommentTourComponent() {
return (
<Dot>
<TourTooltipWrapper tourIndex={1} tourType={TourType.COMMENTS_TOUR}>
<div />
</TourTooltipWrapper>
</Dot>
);
}

View File

@ -0,0 +1,25 @@
const steps = [
{
id: "ENTER_COMMENTS_MODE",
data: { message: "Click on the comment icon \n to enter comments mode" },
},
{
id: "CREATE_UNPUBLISHED_COMMENT",
data: { message: "Click anywhere on the canvas \n and leave a comment." },
},
{
id: "RESOLVE_COMMENT",
data: {
message:
"Great job! You can resolve this \n comment by clicking on the \n resolve button.",
},
},
{
id: "COMMENTS_SECTION_FILTER",
data: {
message:
"You will be able to see all of the \n resolved comments when you \n filter the comment section.",
},
},
];
export default steps;

View File

@ -42,3 +42,39 @@ export const transformUnpublishCommentThreadToCreateNew = (payload: any) => {
comments: [{ body: commentBody }],
};
};
/**
* Returns the offset position relative to the container
* using the coordinates from the click event
* @param clickEvent
* @param containerRef
*/
export const getOffsetPos = (
clickEvent: React.MouseEvent,
containerRef: HTMLDivElement,
) => {
const boundingClientRect = containerRef.getBoundingClientRect();
const containerPosition = {
left: boundingClientRect.left,
top: boundingClientRect.top,
};
const clickPosition = {
left: clickEvent.clientX,
top: clickEvent.clientY,
};
const offsetLeft = clickPosition.left - containerPosition.left;
const offsetTop = clickPosition.top - containerPosition.top;
const offsetLeftPercent = parseFloat(
`${(offsetLeft / boundingClientRect.width) * 100}`,
);
const offsetTopPercent = parseFloat(
`${(offsetTop / boundingClientRect.height) * 100}`,
);
return {
left: offsetLeftPercent,
top: offsetTopPercent,
};
};

View File

@ -9,12 +9,14 @@ export type CheckboxProps = CommonComponentProps & {
isDefaultChecked?: boolean;
onCheckChange?: (isChecked: boolean) => void;
info?: string;
backgroundColor?: string;
};
const Checkmark = styled.span<{
disabled?: boolean;
isChecked?: boolean;
info?: string;
backgroundColor?: string;
}>`
position: absolute;
top: ${(props) => (props.info ? "6px" : "1px")};
@ -25,14 +27,14 @@ const Checkmark = styled.span<{
props.isChecked
? props.disabled
? props.theme.colors.checkbox.disabled
: props.theme.colors.info.main
: props.backgroundColor || props.theme.colors.info.main
: "transparent"};
border: 2px solid
${(props) =>
props.isChecked
? props.disabled
? props.theme.colors.checkbox.disabled
: props.theme.colors.info.main
: props.backgroundColor || props.theme.colors.info.main
: props.theme.colors.checkbox.unchecked};
&::after {
@ -129,6 +131,7 @@ function Checkbox(props: CheckboxProps) {
type="checkbox"
/>
<Checkmark
backgroundColor={props.backgroundColor}
disabled={props.disabled}
info={props.info}
isChecked={checked}

View File

@ -0,0 +1,175 @@
import React, { useState } from "react";
import { ReactComponent as ProfileImagePlaceholder } from "assets/images/profile-placeholder.svg";
import Uppy from "@uppy/core";
import Dialog from "components/ads/DialogComponent";
import { Dashboard, useUppy } from "@uppy/react";
import { getTypographyByKey } from "constants/DefaultTheme";
import styled from "styled-components";
import ImageEditor from "@uppy/image-editor";
import "@uppy/core/dist/style.css";
import "@uppy/dashboard/dist/style.css";
import "@uppy/image-editor/dist/style.css";
import "@blueprintjs/popover2/lib/css/blueprint-popover2.css";
type Props = {
onChange: (file: File) => void;
submit: (uppy: Uppy.Uppy) => void;
value: string;
label?: string;
};
const Container = styled.div`
display: flex;
justify-content: center;
align-items: center;
& .input-component {
display: none;
}
& .view {
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
.image-view {
width: 80px;
height: 80px;
display: flex;
justify-content: center;
align-items: center;
background-color: ${(props) =>
props.theme.colors.displayImageUpload.background};
border-radius: 50%;
margin-bottom: ${(props) => props.theme.spaces[7]}px;
img {
height: 100%;
width: 100%;
border-radius: 50%;
object-fit: cover;
}
}
.label {
${(props) => getTypographyByKey(props, "h6")}
color: ${(props) => props.theme.colors.displayImageUpload.label};
}
}
`;
const defaultLabel = "Upload Display Picture";
export default function DisplayImageUpload({ onChange, submit, value }: Props) {
const [loadError, setLoadError] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const uppy = useUppy(() => {
const uppy = Uppy({
id: "uppy",
autoProceed: false,
allowMultipleUploads: false,
restrictions: {
maxNumberOfFiles: 1,
maxFileSize: 250000,
allowedFileTypes: [".jpg", ".jpeg", ".png"],
},
infoTimeout: 5000,
locale: {
strings: {},
},
});
uppy.setOptions({
locale: {
strings: {
cancel: "Cancel",
done: "Cancel",
},
},
});
uppy.use(ImageEditor, {
id: "ImageEditor",
quality: 0.8,
cropperOptions: {
viewMode: 1,
background: false,
autoCropArea: 1,
responsive: true,
},
actions: {
revert: false,
rotate: false,
flip: false,
zoomIn: false,
zoomOut: false,
cropSquare: false,
cropWidescreen: false,
cropWidescreenVertical: false,
},
});
uppy.on("file-added", (file: File) => {
onChange(file);
// TO trigger edit modal
const dashboard = uppy.getPlugin("uppy-img-upload-dashboard");
setTimeout(() => {
(dashboard as any).openFileEditor(file);
});
});
uppy.on("upload", () => {
submit(uppy);
setIsModalOpen(false);
});
uppy.on("file-editor:complete", (updatedFile) => {
onChange(updatedFile);
});
return uppy;
});
return (
<Container onClick={() => setIsModalOpen(true)}>
<Dialog
canEscapeKeyClose
canOutsideClickClose
isOpen={isModalOpen}
maxHeight={"80vh"}
trigger={
<div className="view">
<div className="image-view">
{!value || loadError ? (
<ProfileImagePlaceholder />
) : (
<img
onError={(e) => {
console.log(e, "error");
setLoadError(true);
}}
onLoad={() => setLoadError(false)}
src={value}
/>
)}
</div>
{(!value || loadError) && (
<span className="label">{defaultLabel}</span>
)}
</div>
}
>
<Dashboard
id="uppy-img-upload-dashboard"
plugins={["ImageEditor"]}
uppy={uppy}
/>
</Dialog>
</Container>
);
}

View File

@ -1,46 +1,62 @@
import React, { useCallback, useState } from "react";
import Picker, { IEmojiData } from "emoji-picker-react";
import { withTheme } from "styled-components";
import Icon, { IconSize } from "components/ads/Icon";
import { Popover, Position } from "@blueprintjs/core";
import { Theme } from "constants/DefaultTheme";
import { Picker, BaseEmoji } from "emoji-mart";
import { Popover2 } from "@blueprintjs/popover2";
import Icon, { IconName, IconSize } from "components/ads/Icon";
import { withTheme } from "styled-components";
import { Theme } from "constants/DefaultTheme";
import "@blueprintjs/popover2/lib/css/blueprint-popover2.css";
import "emoji-mart/css/emoji-mart.css";
// TODO remove: (trigger tests)
const EmojiPicker = withTheme(
({
iconName,
iconSize,
onSelectEmoji,
theme,
}: {
iconName?: IconName;
theme: Theme;
onSelectEmoji: (e: React.MouseEvent, emojiObject: IEmojiData) => void;
onSelectEmoji: (e: React.MouseEvent, emojiObject: BaseEmoji) => void;
iconSize?: IconSize;
}) => {
const [isOpen, setIsOpen] = useState(false);
const handleSelectEmoji = useCallback(
(e: React.MouseEvent, emojiObject: IEmojiData) => {
onSelectEmoji(e, emojiObject);
(emoji, event) => {
onSelectEmoji(event, emoji);
setIsOpen(false);
},
[onSelectEmoji],
);
return (
<Popover
boundary="viewport"
<Popover2
content={
<Picker
onClick={handleSelectEmoji}
showPreview={false}
showSkinTones={false}
style={{
border: "none",
borderRadius: 0,
}}
/>
}
isOpen={isOpen}
minimal
onInteraction={(nextOpenState) => {
setIsOpen(nextOpenState);
}}
position={Position.BOTTOM_RIGHT}
portalClassName="emoji-picker-portal"
>
<Icon
fillColor={theme.colors.comments.emojiPicker}
name="emoji"
size={IconSize.LARGE}
keepColors
name={iconName || "emoji"}
size={iconSize || IconSize.XXXL}
/>
<Picker onEmojiClick={handleSelectEmoji} />
</Popover>
</Popover2>
);
},
);

View File

@ -0,0 +1,157 @@
import React from "react";
import styled from "styled-components";
import EmojiPicker from "./EmojiPicker";
import { IconSize } from "./Icon";
const Container = styled.div`
display: flex;
flex-wrap: wrap;
`;
const Bubble = styled.div<{ active?: boolean }>`
font-size: 16px; // emoji
cursor: pointer;
display: flex;
align-items: center;
padding: ${(props) => `2px ${props.theme.spaces[2]}px`};
background-color: ${(props) =>
props.active
? props.theme.colors.reactionsComponent.reactionBackgroundActive
: props.theme.colors.reactionsComponent.reactionBackground};
border: 1px solid
${(props) =>
props.active
? props.theme.colors.reactionsComponent.borderActive
: "transparent"};
border-radius: ${(props) => `${props.theme.radii[4]}px`};
margin-left: ${(props) => `${props.theme.radii[1]}px`};
&:first-child {
margin-left: 0;
}
`;
const Count = styled.div<{ active?: boolean }>`
display: inline;
color: ${(props) =>
props.active
? props.theme.colors.reactionsComponent.textActive
: props.theme.colors.reactionsComponent.text};
margin-left: ${(props) => props.theme.spaces[1]}px;
max-width: 30px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
`;
export type Reaction = {
count: number;
reactionEmoji: string;
active?: boolean;
};
export type Reactions = Record<string, Reaction>;
const transformReactions = (reactions: Reactions): Array<Reaction> => {
return Object.keys(reactions).map((emojiReaction: string) => ({
...reactions[emojiReaction],
emojiReaction,
}));
};
export enum ReactionOperation {
ADD = "ADD",
REMOVE = "REMOVE",
}
function EmojiReactions({
onSelectReaction,
reactions = {},
hideReactions = false,
iconSize = IconSize.XXL,
}: {
onSelectReaction: (
event: React.MouseEvent,
emojiData: string,
updatedReactions: Reactions,
addOrRemove: ReactionOperation,
) => void;
reactions?: Reactions;
hideReactions?: boolean;
iconSize?: IconSize;
}) {
const handleSelectReaction = (
_event: React.MouseEvent,
emojiData: string,
) => {
const reactionsObject = reactions[emojiData];
let addOrRemove;
if (reactionsObject) {
if (reactionsObject.active) {
addOrRemove = ReactionOperation.REMOVE;
reactions[emojiData] = {
active: false,
reactionEmoji: emojiData,
count: reactionsObject.count - 1,
};
if (reactions[emojiData].count === 0) delete reactions[emojiData];
} else {
reactions[emojiData] = {
active: true,
reactionEmoji: emojiData,
count: reactionsObject.count + 1,
};
}
} else {
addOrRemove = ReactionOperation.ADD;
reactions[emojiData] = {
active: true,
reactionEmoji: emojiData,
count: 1,
};
}
onSelectReaction(
_event,
emojiData,
{ ...reactions },
addOrRemove as ReactionOperation,
);
};
return (
<Container>
{!hideReactions &&
transformReactions(reactions).map((reaction: Reaction) => (
<Bubble
active={reaction.active}
key={reaction.reactionEmoji}
onClick={(e) => handleSelectReaction(e, reaction.reactionEmoji)}
>
<span>{reaction.reactionEmoji}</span>
{reaction.count > 1 && (
<Count active={reaction.active}>{reaction.count}</Count>
)}
</Bubble>
))}
{!hideReactions ? (
<Bubble>
<EmojiPicker
iconName="reaction"
iconSize={iconSize}
onSelectEmoji={(e, emoji) => handleSelectReaction(e, emoji.native)}
/>
</Bubble>
) : (
<EmojiPicker
iconName="reaction-2"
iconSize={iconSize}
onSelectEmoji={(e, emoji) => handleSelectReaction(e, emoji.native)}
/>
)}
</Container>
);
}
export default EmojiReactions;

View File

@ -53,8 +53,17 @@ import { ReactComponent as Pin } from "assets/icons/comments/pin.svg";
import { ReactComponent as OvalCheck } from "assets/icons/comments/check-oval.svg";
import { ReactComponent as ContextMenu } from "assets/icons/ads/context-menu.svg";
import { ReactComponent as Trash } from "assets/icons/comments/trash.svg";
import { ReactComponent as Pin2 } from "assets/icons/comments/pin_2.svg";
import { ReactComponent as ReadPin } from "assets/icons/comments/read-pin.svg";
import { ReactComponent as UnreadPin } from "assets/icons/comments/unread-pin.svg";
import { ReactComponent as Link2 } from "assets/icons/comments/link.svg";
import { ReactComponent as CommentContextMenu } from "assets/icons/comments/context-menu.svg";
import { ReactComponent as DownArrow2 } from "assets/icons/comments/down-arrow.svg";
import { ReactComponent as Filter } from "assets/icons/comments/filter.svg";
import { ReactComponent as Chat } from "assets/icons/comments/chat.svg";
import { ReactComponent as Pin3 } from "assets/icons/comments/pin_3.svg";
import { ReactComponent as Unpin } from "assets/icons/comments/unpin.svg";
import { ReactComponent as Reaction } from "assets/icons/comments/reaction.svg";
import { ReactComponent as Reaction2 } from "assets/icons/comments/reaction-2.svg";
import styled from "styled-components";
import { CommonComponentProps, Classes } from "./common";
import { noop } from "lodash";
@ -168,9 +177,18 @@ export const IconCollection = [
"PARAGRAPH_TWO",
"context-menu",
"trash",
"pin-2",
"link-2",
"close-x",
"comment-context-menu",
"down-arrow-2",
"read-pin",
"unread-pin",
"filter",
"chat",
"pin-3",
"unpin",
"reaction",
"reaction-2",
] as const;
export type IconName = typeof IconCollection[number];
@ -203,17 +221,22 @@ export const IconWrapper = styled.span<IconProps>`
!props.keepColors
? `
path {
fill: ${props.theme.colors.icon.hover};
fill: ${props.hoverFillColor || props.theme.colors.icon.hover};
}
`
: ""}
}
&:active {
&:hover {
cursor: pointer;
${(props) =>
!props.keepColors
? `
path {
fill: ${(props) => props.theme.colors.icon.active};
fill: ${props.hoverFillColor || props.theme.colors.icon.hover};
}
`
: ""}
}
`;
@ -224,6 +247,7 @@ export type IconProps = {
className?: string;
onClick?: (e: React.MouseEvent) => void;
fillColor?: string;
hoverFillColor?: string;
keepColors?: boolean;
};
@ -405,8 +429,12 @@ const Icon = forwardRef(
returnIcon = <ContextMenu />;
break;
case "pin-2":
returnIcon = <Pin2 />;
case "read-pin":
returnIcon = <ReadPin />;
break;
case "unread-pin":
returnIcon = <UnreadPin />;
break;
case "link-2":
@ -417,6 +445,38 @@ const Icon = forwardRef(
returnIcon = <Trash />;
break;
case "comment-context-menu":
returnIcon = <CommentContextMenu />;
break;
case "down-arrow-2":
returnIcon = <DownArrow2 />;
break;
case "filter":
returnIcon = <Filter />;
break;
case "chat":
returnIcon = <Chat />;
break;
case "pin-3":
returnIcon = <Pin3 />;
break;
case "unpin":
returnIcon = <Unpin />;
break;
case "reaction":
returnIcon = <Reaction />;
break;
case "reaction-2":
returnIcon = <Reaction2 />;
break;
default:
returnIcon = null;
break;

View File

@ -1,16 +1,87 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { DraftHandleValue, EditorState } from "draft-js";
import Editor from "@draft-js-plugins/editor";
import ProfileImage, { Profile } from "pages/common/ProfileImage";
import createMentionPlugin, { MentionData } from "@draft-js-plugins/mention";
import styled from "styled-components";
import "@draft-js-plugins/mention/lib/plugin.css";
import "draft-js/dist/Draft.css";
import { getTypographyByKey } from "constants/DefaultTheme";
import { EntryComponentProps } from "@draft-js-plugins/mention/lib/MentionSuggestions/Entry/Entry";
import UserApi from "api/UserApi";
const StyledMention = styled.span`
color: ${(props) => props.theme.colors.comments.mention};
`;
export function MentionComponent(props: {
children: React.ReactNode;
entityKey: string;
}) {
const { children } = props;
return <StyledMention>@{children}</StyledMention>;
}
const StyledSuggestionsComponent = styled.div<{ isFocused?: boolean }>`
display: flex;
padding: ${(props) =>
`${props.theme.spaces[4]}px ${props.theme.spaces[6]}px`};
& ${Profile} {
margin-right: ${(props) => props.theme.spaces[4]}px;
}
align-items: center;
&:hover {
background-color: ${(props) => props.theme.colors.mentionSuggestion.hover};
}
${(props) =>
props.isFocused
? `
background-color: ${props.theme.colors.mentionSuggestion.hover};
`
: ""}
`;
const Name = styled.div`
${(props) => getTypographyByKey(props, "h5")}
color: ${(props) => props.theme.colors.mentionSuggestion.nameText};
`;
const Username = styled.div`
${(props) => getTypographyByKey(props, "p3")}
color: ${(props) => props.theme.colors.mentionSuggestion.usernameText};
`;
function SuggestionComponent(props: EntryComponentProps) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { theme, ...parentProps } = props;
const { user } = props.mention;
return (
<StyledSuggestionsComponent {...parentProps}>
<ProfileImage
side={25}
source={`/api/${UserApi.photoURL}/${user.username}`}
userName={user.username || ""}
/>
<div>
<Name>{user.username}</Name>
<Username>{user.username}</Username>
</div>
</StyledSuggestionsComponent>
);
}
const StyledContainer = styled.div`
max-height: 60px;
overflow: auto;
flex: 1;
& [id^="mentions-list"] {
padding: 0;
}
`;
type Props = {
@ -36,7 +107,10 @@ function MentionsInput({
const ref = useRef<Editor | null>(null);
const [open, setOpen] = useState(false);
const { MentionSuggestions, plugins } = useMemo(() => {
const mentionPlugin = createMentionPlugin({ mentionTrigger: "@" });
const mentionPlugin = createMentionPlugin({
mentionTrigger: "@",
mentionComponent: MentionComponent,
});
const { MentionSuggestions } = mentionPlugin;
return { plugins: [mentionPlugin], MentionSuggestions };
}, []);
@ -79,6 +153,7 @@ function MentionsInput({
ref={setRef}
/>
<MentionSuggestions
entryComponent={SuggestionComponent}
onOpenChange={onOpenChange}
onSearchChange={onSearchSuggestions}
open={open}

View File

@ -15,6 +15,7 @@ export type RadioProps = CommonComponentProps & {
defaultValue: string;
onSelect?: (value: string) => void;
options: OptionProps[];
backgroundColor?: string;
};
const RadioGroup = styled.div<{
@ -35,10 +36,11 @@ const RadioGroup = styled.div<{
: null};
`;
const Radio = styled.label<{
export const Radio = styled.label<{
disabled?: boolean;
columns?: number;
rows?: number;
backgroundColor?: string;
}>`
display: block;
position: relative;
@ -100,7 +102,8 @@ const Radio = styled.label<{
${(props) =>
props.disabled
? `background-color: ${props.theme.colors.radio.disabled}`
: `background-color: ${props.theme.colors.info.main};`};
: `background-color: ${props.backgroundColor ||
props.theme.colors.info.main};`};
top: ${(props) => props.theme.spaces[1] - 2}px;
left: ${(props) => props.theme.spaces[1] - 2}px;
border-radius: 50%;
@ -137,6 +140,7 @@ export default function RadioComponent(props: RadioProps) {
>
{props.options.map((option: OptionProps, index: number) => (
<Radio
backgroundColor={props.backgroundColor}
columns={props.columns}
disabled={props.disabled || option.disabled}
key={index}

View File

@ -0,0 +1,117 @@
import React, { useState } from "react";
import Button, { Category, Size } from "components/ads/Button";
import styled from "styled-components";
import { createMessage, NEXT, BACK } from "constants/messages";
import { useTransition, animated } from "react-spring";
const Container = styled.div`
box-shadow: 1px 0px 10px 5px rgba(0, 0, 0, 0.15);
`;
const Footer = styled.div`
padding: ${(props) => props.theme.spaces[7]}px;
justify-content: space-between;
display: flex;
`;
const Dot = styled.div<{ active: boolean }>`
width: 5px;
height: 5px;
border-radius: 50%;
margin-right: ${(props) => props.theme.spaces[1]}px;
background-color: ${(props) =>
props.active
? props.theme.colors.showcaseCarousel.activeStepDot
: props.theme.colors.showcaseCarousel.inactiveStepDot};
`;
const Row = styled.div`
display: flex;
align-items: center;
`;
const Buttons = styled.div`
display: flex;
& button:last-child {
margin-left: ${(props) => props.theme.spaces[1]}px;
`;
type Step = {
component: any;
props: any;
};
export type Steps = Array<Step>;
type Props = {
steps: Steps;
};
type DotsProps = {
count: number;
activeIndex: number;
};
function Dots(props: DotsProps) {
return (
<Row>
{Array.from(new Array(props.count)).map((_a, index) => (
<Dot active={index === props.activeIndex} key={index} />
))}
</Row>
);
}
export default function ShowcaseCarousel(props: Props) {
const { steps } = props;
const [activeIndex, setCurrentIdx] = useState(0);
const currentStep = steps[activeIndex];
const { component: ContentComponent, props: componentProps } = currentStep;
const length = steps.length;
const transition = useTransition("key", null, {
from: { transform: "translateY(+2%)" },
enter: { transform: "translateY(0%)" },
leave: { transform: "translateY(0%)" },
config: { duration: 300 },
});
return (
<Container>
{transition.map(
({ item, props: springProps }: { item: string; props: any }) => (
<animated.div key={item} style={springProps}>
<ContentComponent {...componentProps} />
</animated.div>
),
)}
<Footer>
<Dots activeIndex={activeIndex} count={length} />
<Buttons>
{!componentProps.hideBackBtn && (
<Button
category={Category.tertiary}
onClick={() => setCurrentIdx(Math.max(0, activeIndex - 1))}
size={Size.large}
tag="button"
text={createMessage(BACK)}
/>
)}
<Button
disabled={componentProps.isSubmitDisabled}
onClick={() => {
setCurrentIdx(Math.min(length - 1, activeIndex + 1));
if (typeof componentProps.onSubmit === "function") {
componentProps.onSubmit();
}
}}
size={Size.large}
tag="button"
text={componentProps.nextBtnText || createMessage(NEXT)}
/>
</Buttons>
</Footer>
</Container>
);
}

View File

@ -8,7 +8,7 @@ type Variant = "dark" | "light";
type TooltipProps = CommonComponentProps & {
content: JSX.Element | string;
position?: Position;
children: JSX.Element;
children: JSX.Element | React.ReactNode;
variant?: Variant;
maxWidth?: string;
boundary?: PopperBoundary;
@ -17,8 +17,11 @@ type TooltipProps = CommonComponentProps & {
autoFocus?: boolean;
hoverOpenDelay?: number;
minimal?: boolean;
isOpen?: boolean;
};
const portalContainer = document.getElementById("tooltip-root");
function TooltipComponent(props: TooltipProps) {
return (
<Tooltip
@ -26,12 +29,14 @@ function TooltipComponent(props: TooltipProps) {
boundary={props.boundary || "scrollParent"}
content={props.content}
hoverOpenDelay={props.hoverOpenDelay}
isOpen={props.isOpen}
minimal={props.minimal}
modifiers={{
preventOverflow: { enabled: false },
}}
openOnTargetFocus={props.openOnTargetFocus}
popoverClassName={GLOBAL_STYLE_TOOLTIP_CLASSNAME}
portalContainer={portalContainer as HTMLDivElement}
position={props.position}
usePortal
>

View File

@ -19,7 +19,9 @@ const renderComponent = (
return (
<>
<InputComponent {...componentProps} {...componentProps.input} fill />
<FormFieldError error={showError && componentProps.meta.error} />
{!componentProps.hideErrorMessage && (
<FormFieldError error={showError && componentProps.meta.error} />
)}
</>
);
};
@ -32,6 +34,7 @@ type FormTextFieldProps = {
intent?: Intent;
disabled?: boolean;
autoFocus?: boolean;
hideErrorMessage?: boolean;
};
function FormTextField(props: FormTextFieldProps) {

View File

@ -0,0 +1,57 @@
import React from "react";
import TooltipComponent from "components/ads/Tooltip";
import { useSelector } from "react-redux";
import Text, { TextType } from "../Text";
import { Position } from "@blueprintjs/core";
import { getActiveTourIndex, getActiveTourType } from "selectors/tourSelectors";
import { TourType } from "entities/Tour";
import TourStepsByType from "constants/TourSteps";
import { AppState } from "reducers";
import { noop } from "lodash";
type Props = {
children: React.ReactNode;
tourType: TourType;
tourIndex: number;
onClick?: () => void;
};
function TourTooltipWrapper(props: Props) {
const { children, tourIndex, tourType } = props;
const isCurrentStepActive = useSelector(
(state: AppState) => getActiveTourIndex(state) === tourIndex,
);
const isCurrentTourActive = useSelector(
(state: AppState) => getActiveTourType(state) === tourType,
);
const tourStepsConfig = TourStepsByType[tourType as TourType];
const tourStepConfig = tourStepsConfig[tourIndex];
const isOpen = isCurrentStepActive && isCurrentTourActive;
return (
<div onClick={props.onClick ? props.onClick : noop}>
<TooltipComponent
boundary={"viewport"}
content={
<Text
style={{
whiteSpace: "pre",
color: "#fff",
display: "flex",
textAlign: "center",
}}
type={TextType.P3}
>
{tourStepConfig?.data.message}
</Text>
}
isOpen={!!isOpen}
position={Position.BOTTOM}
>
{children}
</TooltipComponent>
</div>
);
}
export default TourTooltipWrapper;

View File

@ -5,10 +5,12 @@ import { getCanvasClassName } from "utils/generators";
import { Layers } from "constants/Layers";
const Container = styled.div<{
width: number;
height: number;
width?: number;
height?: number;
top?: number;
left?: number;
bottom?: number;
right?: number;
zIndex?: number;
}>`
&&& {
@ -28,39 +30,43 @@ const Container = styled.div<{
align-items: center;
& .${Classes.OVERLAY_CONTENT} {
max-width: 95%;
width: ${(props) => props.width}px;
min-height: ${(props) => props.height}px;
width: ${(props) => (props.width ? `${props.width}px` : "auto")};
min-height: ${(props) => (props.height ? `${props.height}px` : "auto")};
background: white;
border-radius: ${(props) => props.theme.radii[0]}px;
top: ${(props) => props.top}px;
left: ${(props) => props.left}px;
bottom: ${(props) => props.bottom}px;
right: ${(props) => props.right}px;
}
}
}
`;
const Content = styled.div<{
height: number;
height?: number;
scroll: boolean;
ref: RefObject<HTMLDivElement>;
}>`
overflow-y: ${(props) => (props.scroll ? "visible" : "hidden")};
overflow-x: hidden;
width: 100%;
height: ${(props) => props.height}px;
height: ${(props) => (props.height ? `${props.height}px` : "auto")};
`;
export type ModalComponentProps = {
isOpen: boolean;
onClose: (e: any) => void;
children: ReactNode;
width: number;
width?: number;
className?: string;
canOutsideClickClose: boolean;
canEscapeKeyClose: boolean;
scrollContents: boolean;
height: number;
height?: number;
top?: number;
left?: number;
bottom?: number;
right?: number;
hasBackDrop?: boolean;
zIndex?: number;
};
@ -77,8 +83,10 @@ export function ModalComponent(props: ModalComponentProps) {
}, [props.scrollContents]);
return (
<Container
bottom={props.bottom}
height={props.height}
left={props.left}
right={props.bottom}
top={props.top}
width={props.width}
zIndex={props.zIndex !== undefined ? props.zIndex : Layers.modalWidget}

View File

@ -5,4 +5,5 @@ export const COMMENT_EVENTS = {
INSERT_COMMENT_THREAD: "insert:commentThread",
INSERT_COMMENT: "insert:comment",
UPDATE_COMMENT_THREAD: "update:commentThread",
UPDATE_COMMENT: "update:comment",
};

View File

@ -898,11 +898,15 @@ type ColorType = {
sendButton: string;
addCommentInputBackground: string;
pin: string;
commentModeButtonIcon: string;
commentModeButtonBackground: string;
activeModeBackground: string;
activeModeIcon: string;
modeIcon: string;
emojiPicker: string;
resolved: string;
unresolved: string;
resolvedFill: string;
unresolvedFill: string;
resolvedPath: string;
childCommentsIndent: string;
commentBackground: string;
contextMenuTrigger: string;
@ -915,6 +919,32 @@ type ColorType = {
contextMenuTitleHover: ShadeColor;
appCommentsHeaderTitle: ShadeColor;
appCommentsClose: ShadeColor;
viewLatest: string;
commentTime: string;
pinId: string;
commentsFilter: string;
appCommentsHeaderBorder: string;
unreadIndicator: string;
unreadIndicatorCommentCard: string;
pinnedByText: string;
pinnedThreadBackground: string;
visibleThreadBackground: string;
cardOptionsIcon: string;
appCommentsPlaceholderText: string;
cardHoverBackground: string;
introTitle: string;
introContent: string;
};
mentionSuggestion: {
nameText: string;
usernameText: string;
hover: string;
};
reactionsComponent: {
reactionBackground: string;
reactionBackgroundActive: string;
text: string;
textActive: string;
};
treeDropdown: {
targetBg: string;
@ -990,9 +1020,38 @@ type ColorType = {
background: string;
};
mentionsInput: Record<string, string>;
showcaseCarousel: Record<string, string>;
displayImageUpload: Record<string, string>;
};
const displayImageUpload = {
background: "#AEBAD9",
label: "#457AE6",
};
const showcaseCarousel = {
activeStepDot: "#F86A2B",
inactiveStepDot: "#FEEDE5",
};
const reactionsComponent = {
reactionBackground: lightShades[2],
reactionBackgroundActive: "#FEEDE5",
text: lightShades[7],
textActive: "#BF4109",
borderActive: "#BF4109",
};
const mentionSuggestion = {
nameText: "#090707",
usernameText: "#716E6E",
hover: "#EBEBEB",
};
const comments = {
introTitle: "#090707",
introContent: "#716E6E",
commentsFilter: "#6A86CE",
profileUserName: darkShades[11],
threadTitle: darkShades[8],
commentBody: darkShades[8],
@ -1003,11 +1062,13 @@ const comments = {
sendButton: "#6A86CE",
addCommentInputBackground: "#FAFAFA",
pin: "#EF4141",
commentModeButtonIcon: "#6A86CE",
commentModeButtonBackground: "#262626",
activeModeBackground: "#090707",
emojiPicker: lightShades[5],
resolved: Colors.GREEN,
resolved: Colors.BLACK,
unresolved: lightShades[5],
resolvedFill: Colors.BLACK,
unresolvedFill: "transparent",
resolvedPath: Colors.WHITE,
childCommentsIndent: lightShades[13],
commentBackground: lightShades[2],
contextMenuTrigger: darkShades[6],
@ -1020,6 +1081,20 @@ const comments = {
contextMenuTitleHover: darkShades[11],
appCommentsHeaderTitle: darkShades[11],
appCommentsClose: lightShades[15],
viewLatest: "#F86A2B",
commentTime: lightShades[7],
pinId: lightShades[8],
appCommentsHeaderBorder: lightShades[3],
unreadIndicator: "#E00D0D",
unreadIndicatorCommentCard: "#F86A2B",
pinnedByText: lightShades[7],
pinnedThreadBackground: "#FFFAE9",
visibleThreadBackground: "#FBEED0",
cardOptionsIcon: "#777272",
appCommentsPlaceholderText: lightShades[8],
activeModeIcon: "#F0F0F0",
modeIcon: "#6D6D6D",
cardHoverBackground: "#FAFAFA",
};
const auth: any = {
@ -1089,6 +1164,10 @@ const mentionsInput = {
};
export const dark: ColorType = {
displayImageUpload,
showcaseCarousel,
mentionSuggestion,
reactionsComponent,
mentionsInput,
helpModal,
globalSearch,
@ -1534,13 +1613,14 @@ export const dark: ColorType = {
};
export const light: ColorType = {
displayImageUpload,
showcaseCarousel,
mentionSuggestion,
reactionsComponent,
mentionsInput,
helpModal,
globalSearch,
comments: {
...comments,
commentModeButtonBackground: "#FAFAFA",
},
comments,
selected: lightShades[12],
header: {
separator: "#E0DEDE",
@ -2056,7 +2136,7 @@ export const theme: Theme = {
},
btnSmall: {
fontSize: 11,
lineHeight: 13,
lineHeight: 12,
letterSpacing: 0.4,
fontWeight: 600,
},

View File

@ -1,6 +1,6 @@
import React from "react";
enum Indices {
export enum Indices {
Layer0,
Layer1,
Layer2,
@ -9,6 +9,8 @@ enum Indices {
Layer5,
Layer6,
Layer7,
Layer8,
Layer9,
LayerMax = 99999,
}
@ -36,6 +38,9 @@ export const Layers = {
dynamicAutoComplete: Indices.Layer5,
debugger: Indices.Layer6,
productUpdates: Indices.Layer7,
portals: Indices.Layer8,
header: Indices.Layer9,
appComments: Indices.Layer9,
max: Indices.LayerMax,
};

View File

@ -9,6 +9,27 @@ export const ReduxSagaChannels: { [key: string]: string } = {
};
export const ReduxActionTypes: { [key: string]: string } = {
UPDATE_COMMENT_EVENT: "UPDATE_COMMENT_EVENT",
ADD_COMMENT_REACTION: "ADD_COMMENT_REACTION",
REMOVE_COMMENT_REACTION: "REMOVE_COMMENT_REACTION",
UPLOAD_PROFILE_PHOTO: "UPLOAD_PROFILE_PHOTO",
REMOVE_PROFILE_PHOTO: "REMOVE_PROFILE_PHOTO",
HIDE_COMMENTS_INTRO_CAROUSEL: "HIDE_COMMENTS_INTRO_CAROUSEL",
SHOW_COMMENTS_INTRO_CAROUSEL: "SHOW_COMMENTS_INTRO_CAROUSEL",
PROCEED_TO_NEXT_TOUR_STEP: "PROCEED_TO_NEXT_TOUR_STEP",
SET_ACTIVE_TOUR: "SET_ACTIVE_TOUR",
RESET_ACTIVE_TOUR: "RESET_ACTIVE_TOUR",
SET_ACTIVE_TOUR_INDEX: "SET_ACTIVE_TOUR_INDEX",
SET_ARE_COMMENTS_ENABLED: "SET_ARE_COMMENTS_ENABLED",
DELETE_THREAD_REQUEST: "DELETE_THREAD_REQUEST",
DELETE_THREAD_SUCCESS: "DELETE_THREAD_SUCCESS",
EDIT_COMMENT_REQUEST: "EDIT_COMMENT_REQUEST",
EDIT_COMMENT_SUCCESS: "EDIT_COMMENT_SUCCESS",
MARK_THREAD_AS_READ_REQUEST: "MARK_THREAD_AS_READ_REQUEST",
SET_VISIBLE_THREAD: "SET_VISIBLE_THREAD",
RESET_VISIBLE_THREAD: "RESET_VISIBLE_THREAD",
SET_APP_COMMENTS_FILTER: "SET_APP_COMMENTS_FILTER",
SET_SHOULD_SHOW_RESOLVED_COMMENTS: "SET_SHOULD_SHOW_RESOLVED_COMMENTS",
DELETE_COMMENT_REQUEST: "DELETE_COMMENT_REQUEST",
DELETE_COMMENT_SUCCESS: "DELETE_COMMENT_SUCCESS",
PIN_COMMENT_THREAD_REQUEST: "PIN_COMMENT_THREAD_REQUEST",

View File

@ -0,0 +1,8 @@
import { TourType } from "entities/Tour";
import commentsTourSteps from "comments/tour/commentsTourSteps";
const TourStepsByType = {
[TourType.COMMENTS_TOUR]: commentsTourSteps,
};
export default TourStepsByType;

View File

@ -301,9 +301,26 @@ export const UNRESOLVE = () => "Unresolve";
// comments
export const ADD_COMMENT_PLACEHOLDER = () => "Add a comment. Use @ to mention";
export const PIN_COMMENT = () => "Pin Comment";
export const UNPIN_COMMENT = () => "Unpin Comment";
export const COPY_LINK = () => "Copy Link";
export const DELETE_COMMENT = () => "Delete Comment";
export const DELETE_THREAD = () => "Delete Thread";
export const EDIT_COMMENT = () => "Edit Comment";
export const COMMENTS = () => "Comments";
export const VIEW_LATEST = () => "View Latest";
export const POST = () => "Post";
export const CANCEL = () => "Cancel";
export const NO_COMMENTS_CLICK_ON_CANVAS_TO_ADD = () =>
`No comments. \n Click anywhere on the canvas \nto start a conversation.`;
export const LINK_COPIED_SUCCESSFULLY = () => "Link copied to clipboard";
export const FULL_NAME = () => "Full Name";
export const DISPLAY_NAME = () => "Display Name";
export const EMAIL_ADDRESS = () => "Email Address";
export const FIRST_AND_LAST_NAME = () => "First and last name";
// Showcase Carousel
export const NEXT = () => "NEXT";
export const BACK = () => "BACK";
// Debugger
export const CLICK_ON = () => "🙌 Click on ";

View File

@ -23,21 +23,46 @@ export type CreateCommentThreadRequest = {
tabId?: string;
position: { top: number; left: number }; // used as a percentage value
comments: Array<CreateCommentRequest>;
resolved?: boolean;
isPinned?: boolean;
resolvedState?: {
active: boolean;
};
pinnedState?: {
active: boolean;
authorName?: string;
authorUsername?: string;
updationTime?: {
epochSecond: number;
nano: number;
};
};
isViewed?: boolean;
};
export type Reaction = {
byName: string;
byUsername: string;
createdAt: string;
emoji: string;
};
export type Comment = CreateCommentRequest & {
id: string;
authorName?: string;
};
authorUsername?: string;
updationTime?: string;
creationTime?: string;
reactions?: Array<Reaction>;
threadId?: string;
} & { _id: string };
export type CommentThread = Omit<CreateCommentThreadRequest, "comments"> & {
id: string;
comments: Array<Comment>;
isVisible?: boolean;
userPermissions?: string[];
new?: boolean;
sequenceId?: string;
updationTime?: string;
creationTime?: string;
};
export type CommentEventPayload = {

View File

@ -0,0 +1,3 @@
export enum TourType {
COMMENTS_TOUR = "COMMENTS_TOUR",
}

View File

@ -1,12 +1,17 @@
import { Classes } from "@blueprintjs/core";
import { createGlobalStyle } from "styled-components";
import { Layers } from "constants/Layers";
export const CommentThreadPopoverStyles = createGlobalStyle`
.comment-thread .${Classes.POPOVER_CONTENT} {
border-radius: 0px;
.bp3-portal.comment-context-menu {
z-index: ${Layers.max};
}
.comment-context-menu {
z-index: 13;
.bp3-portal.emoji-picker-portal {
z-index: ${Layers.max};
}
.inline-comment-thread {
/* unable to reference headerHeight here */
margin-top: 35px;
}
`;

View File

@ -2,6 +2,8 @@ import React from "react";
import { TooltipStyles } from "./tooltip";
import { PopoverStyles } from "./popover";
import { CommentThreadPopoverStyles } from "./commentThreadPopovers";
import { UppyStyles } from "./uppy";
import { PortalStyles } from "./portals";
export default function GlobalStyles() {
return (
@ -9,6 +11,8 @@ export default function GlobalStyles() {
<TooltipStyles />
<PopoverStyles />
<CommentThreadPopoverStyles />
<PortalStyles />
<UppyStyles />
</>
);
}

View File

@ -1,11 +1,17 @@
import { createGlobalStyle } from "styled-components";
import { Classes } from "@blueprintjs/core";
import { Classes as PopoverClasses } from "@blueprintjs/popover2";
export const PopoverStyles = createGlobalStyle`
.${Classes.POPOVER} {
.${Classes.POPOVER}, .${PopoverClasses.POPOVER2} {
box-shadow: 0px 0px 2px rgb(0 0 0 / 20%), 0px 2px 10px rgb(0 0 0 / 10%);
}
.${Classes.POPOVER},
.${PopoverClasses.POPOVER2},
.${PopoverClasses.POPOVER2} .${PopoverClasses.POPOVER2_CONTENT},
.${Classes.POPOVER} .${Classes.POPOVER_CONTENT} {
border-radius: 0px;
}
.bp3-datepicker {
.DayPicker {
min-height: 251px !important ;

View File

@ -0,0 +1,20 @@
import { createGlobalStyle } from "styled-components";
import { Layers } from "constants/Layers";
export const PortalStyles = createGlobalStyle`
#header-root {
position: relative;
z-index: ${Layers.header};
}
#tooltip-root {
position: absolute;
top: 0;
width: 100vw;
z-index: ${Layers.max};
}
.bp3-portal {
z-index: ${Layers.portals};
}
`;

View File

@ -0,0 +1,11 @@
import { createGlobalStyle } from "styled-components";
export const UppyStyles = createGlobalStyle`
.uppy-Root .uppy-ImageCropper .uppy-u-reset.uppy-c-btn:first-child {
width: 100px;
}
a.uppy-Dashboard-poweredBy {
display: none;
}
`;

View File

@ -54,15 +54,6 @@ div.bp3-popover-arrow {
background: rgb(3, 179, 101) !important;
}
#header-root {
position: relative;
z-index: 11;
}
.bp3-portal {
z-index: 10;
}
.bp3-popover .bp3-input {
outline: 0;
box-shadow: none;

View File

@ -32,8 +32,8 @@ import ProfileDropdown from "pages/common/ProfileDropdown";
import { Profile } from "pages/common/ProfileImage";
import PageTabsContainer from "./PageTabsContainer";
import { getThemeDetails, ThemeMode } from "selectors/themeSelectors";
import ToggleCommentModeButton from "pages/Editor/ToggleModeButton";
import GetAppViewerHeaderCTA from "./GetAppViewerHeaderCTA";
import ToggleCommentModeButton from "comments/ToggleCommentModeButton";
const HeaderWrapper = styled(StyledHeader)<{ hasPages: boolean }>`
box-shadow: unset;

View File

@ -50,7 +50,7 @@ import HelpBar from "components/editorComponents/GlobalSearch/HelpBar";
import HelpButton from "./HelpButton";
import OnboardingIndicator from "components/editorComponents/Onboarding/Indicator";
import { getThemeDetails, ThemeMode } from "selectors/themeSelectors";
import ToggleCommentModeButton from "comments/ToggleCommentModeButton";
import ToggleModeButton from "pages/Editor/ToggleModeButton";
const HeaderWrapper = styled(StyledHeader)`
width: 100%;
@ -238,6 +238,7 @@ export function EditorHeader(props: EditorHeaderProps) {
}
/>
)}
<ToggleModeButton />
</Boxed>
</HeaderSection>
<HeaderSection>
@ -246,7 +247,6 @@ export function EditorHeader(props: EditorHeaderProps) {
</HeaderSection>
<HeaderSection>
<Boxed step={OnboardingStep.FINISH}>
<ToggleCommentModeButton />
<SaveStatusContainer className={"t--save-status-container"}>
{saveStatusIcon}
</SaveStatusContainer>

View File

@ -25,6 +25,8 @@ import {
import { setCommentMode as setCommentModeAction } from "actions/commentActions";
import { showDebugger } from "actions/debuggerActions";
import { setCommentModeInUrl } from "pages/Editor/ToggleModeButton";
type Props = {
copySelectedWidget: () => void;
pasteCopiedWidget: () => void;
@ -195,6 +197,18 @@ class GlobalHotKeys extends React.Component<Props> {
e.preventDefault();
}}
/>
<Hotkey
combo="v"
global
label="Edit Mode"
onKeyDown={this.props.resetCommentMode}
/>
<Hotkey
combo="c"
global
label="Comment Mode"
onKeyDown={() => setCommentModeInUrl(true)}
/>
</Hotkeys>
);
}

View File

@ -0,0 +1,182 @@
import React, { useCallback, useEffect } from "react";
import styled from "styled-components";
import { useDispatch, useSelector } from "react-redux";
import TooltipComponent from "components/ads/Tooltip";
import TourTooltipWrapper from "components/ads/tour/TourTooltipWrapper";
import { ReactComponent as Pen } from "assets/icons/comments/pen.svg";
import { ReactComponent as CommentModeUnread } from "assets/icons/comments/comment-mode-unread-indicator.svg";
import { ReactComponent as CommentMode } from "assets/icons/comments/chat.svg";
import {
setCommentMode as setCommentModeAction,
fetchApplicationCommentsRequest,
showCommentsIntroCarousel,
} from "actions/commentActions";
import {
commentModeSelector,
areCommentsEnabledForUserAndApp as areCommentsEnabledForUserAndAppSelector,
showUnreadIndicator as showUnreadIndicatorSelector,
} from "../../selectors/commentsSelectors";
import { getCurrentUser } from "selectors/usersSelectors";
import { useLocation } from "react-router";
import history from "utils/history";
import { Position } from "@blueprintjs/core/lib/esm/common/position";
import { TourType } from "entities/Tour";
import useProceedToNextTourStep from "utils/hooks/useProceedToNextTourStep";
import { getCommentsIntroSeen } from "utils/storage";
import { User } from "constants/userConstants";
const ModeButton = styled.div<{ active: boolean }>`
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
height: ${(props) => props.theme.smallHeaderHeight};
width: ${(props) => props.theme.smallHeaderHeight};
background: ${(props) =>
props.active
? props.theme.colors.comments.activeModeBackground
: "transparent"};
svg path {
fill: ${(props) =>
props.active
? props.theme.colors.comments.activeModeIcon
: props.theme.colors.comments.modeIcon};
}
svg circle {
stroke: transparent;
}
`;
const Container = styled.div`
display: flex;
flex: 1;
`;
/**
* Sync comment mode in store with comment mode in URL
* Fetch app comments when comment mode is selected
*/
// eslint-disable-next-line
const useUpdateCommentMode = async (currentUser?: User) => {
const location = useLocation();
const dispatch = useDispatch();
const isCommentMode = useSelector(commentModeSelector);
const setCommentModeInStore = useCallback(
(updatedIsCommentMode) =>
dispatch(setCommentModeAction(updatedIsCommentMode)),
[],
);
const handleLocationUpdate = async () => {
const searchParams = new URL(window.location.href).searchParams;
const isCommentMode = searchParams.get("isCommentMode");
const isCommentsIntroSeen = await getCommentsIntroSeen();
const updatedIsCommentMode = isCommentMode === "true" ? true : false;
if (updatedIsCommentMode && !isCommentsIntroSeen) {
dispatch(showCommentsIntroCarousel());
} else {
setCommentModeInStore(updatedIsCommentMode);
}
};
// sync comment mode in store with comment mode in URL
useEffect(() => {
if (window.location.href) {
handleLocationUpdate();
}
}, [location]);
// fetch applications comments when comment mode is turned on
useEffect(() => {
if (isCommentMode) {
dispatch(fetchApplicationCommentsRequest());
}
}, [isCommentMode]);
};
export const setCommentModeInUrl = (isCommentMode: boolean) => {
const currentURL = new URL(window.location.href);
const searchParams = currentURL.searchParams;
searchParams.set("isCommentMode", `${isCommentMode}`);
// remove comment link params so that they don't get retriggered
// on toggling comment mode
searchParams.delete("commentId");
searchParams.delete("commentThreadId");
history.replace({
pathname: currentURL.pathname,
search: searchParams.toString(),
hash: currentURL.hash,
});
};
function ToggleCommentModeButton() {
const commentsEnabled = useSelector(areCommentsEnabledForUserAndAppSelector);
const isCommentMode = useSelector(commentModeSelector);
const showUnreadIndicator = useSelector(showUnreadIndicatorSelector);
const currentUser = useSelector(getCurrentUser);
useUpdateCommentMode(currentUser);
const proceedToNextTourStep = useProceedToNextTourStep(
TourType.COMMENTS_TOUR,
0,
);
if (!commentsEnabled) return null;
const CommentModeIcon = showUnreadIndicator ? CommentModeUnread : CommentMode;
return (
<Container>
<ModeButton
active={!isCommentMode}
onClick={() => setCommentModeInUrl(false)}
>
<TooltipComponent
content={
<>
Edit Mode
<span style={{ color: "#fff", marginLeft: 20 }}>V</span>
</>
}
hoverOpenDelay={1000}
position={Position.BOTTOM}
>
<Pen />
</TooltipComponent>
</ModeButton>
<TourTooltipWrapper
onClick={() => {
proceedToNextTourStep();
}}
tourIndex={0}
tourType={TourType.COMMENTS_TOUR}
>
<ModeButton
active={isCommentMode}
onClick={() => setCommentModeInUrl(true)}
>
<TooltipComponent
content={
<>
Comment Mode
<span style={{ color: "#fff", marginLeft: 20 }}>C</span>
</>
}
hoverOpenDelay={1000}
position={Position.BOTTOM}
>
<CommentModeIcon />
</TooltipComponent>
</ModeButton>
</TourTooltipWrapper>
</Container>
);
}
export default ToggleCommentModeButton;

View File

@ -31,6 +31,8 @@ import { Theme } from "constants/DefaultTheme";
import GlobalHotKeys from "./GlobalHotKeys";
import { handlePathUpdated } from "actions/recentEntityActions";
import AppComments from "comments/AppComments/AppComments";
import AddCommentTourComponent from "comments/tour/AddCommentTourComponent";
import CommentShowCaseCarousel from "comments/CommentsShowcaseCarousel";
import history from "utils/history";
@ -125,6 +127,8 @@ class Editor extends Component<Props> {
<GlobalHotKeys>
<MainContainer />
<AppComments />
<AddCommentTourComponent />
<CommentShowCaseCarousel />
</GlobalHotKeys>
</div>
<ConfirmRunModal />

View File

@ -1,4 +1,4 @@
import React, { useContext } from "react";
import React, { useContext, useState } from "react";
import { getInitialsAndColorCode } from "utils/AppsmithUtils";
import Text, { TextType } from "components/ads/Text";
import styled, { ThemeContext } from "styled-components";
@ -15,6 +15,13 @@ export const Profile = styled.div<{ backgroundColor?: string; side?: number }>`
&& span {
color: ${(props) => props.theme.colors.text.highlight};
}
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
`;
export default function ProfileImage(props: {
@ -22,6 +29,7 @@ export default function ProfileImage(props: {
className?: string;
commonName?: string;
side?: number;
source?: string;
}) {
const theme = useContext(ThemeContext);
@ -30,15 +38,30 @@ export default function ProfileImage(props: {
theme.colors.appCardColors,
);
const [hasErrorLoadingImage, setHasErrorLoadingImage] = useState(false);
const shouldRenderImage = props.source && !hasErrorLoadingImage;
const backgroundColor = shouldRenderImage
? "transparent"
: initialsAndColorCode[1];
return (
<Profile
backgroundColor={initialsAndColorCode[1]}
backgroundColor={backgroundColor}
className={props.className}
side={props.side} // side since it's a square
>
<Text highlight type={TextType.H6}>
{props.commonName || initialsAndColorCode[0]}
</Text>
{!shouldRenderImage ? (
<Text highlight type={TextType.H6}>
{props.commonName || initialsAndColorCode[0]}
</Text>
) : (
<img
onError={() => setHasErrorLoadingImage(true)}
onLoad={() => setHasErrorLoadingImage(false)}
src={props.source}
/>
)}
</Profile>
);
}

View File

@ -7,6 +7,7 @@ import SearchInput, { SearchVariant } from "components/ads/SearchInput";
import Button, { Size } from "components/ads/Button";
import { useSelector } from "react-redux";
import { getIsFetchingApplications } from "selectors/applicationSelectors";
import { Indices } from "constants/Layers";
const SubHeaderWrapper = styled.div`
width: 100%;
@ -17,7 +18,7 @@ const SubHeaderWrapper = styled.div`
background: ${(props) => props.theme.colors.homepageBackground};
top: ${(props) => props.theme.homePage.header}px;
left: 369px;
z-index: 10;
z-index: ${Indices.Layer3};
`;
const SearchContainer = styled.div`
flex-grow: 1;

View File

@ -44,6 +44,7 @@ import { LoadingEntitiesState } from "./evaluationReducers/loadingEntitiesReduce
import { CommentsReduxState } from "./uiReducers/commentsReducer/interfaces";
import { WebsocketReduxState } from "./uiReducers/websocketReducer";
import { DebuggerReduxState } from "./uiReducers/debuggerReducer";
import { TourReducerState } from "./uiReducers/tourReducer";
const appReducer = combineReducers({
entities: entityReducer,
@ -86,6 +87,7 @@ export interface AppState {
comments: CommentsReduxState;
websocket: WebsocketReduxState;
debugger: DebuggerReduxState;
tour: TourReducerState;
};
entities: {
canvasWidgets: CanvasWidgetsReduxState;

View File

@ -22,11 +22,11 @@ import {
unpublishedCommentPayload,
createCommentThreadSuccessPayload,
addCommentToThreadSuccessPayload,
updateCommentThreadPayload,
newCommentThreadEventPayload,
updateCommentThreadEventPayload,
newCommentEventPayload,
} from "./testFixtures";
import { CommentThread } from "entities/Comments/CommentsInterfaces";
describe("Test comments reducer handles", () => {
let state: any;
@ -76,10 +76,10 @@ describe("Test comments reducer handles", () => {
],
[createCommentThreadSuccessPayload.refId]: Array.from(
new Set([
createCommentThreadSuccessPayload.id,
...((prevState.applicationCommentThreadsByRef[
createCommentThreadSuccessPayload.applicationId
] || {})[createCommentThreadSuccessPayload.refId] || []),
createCommentThreadSuccessPayload.id,
]),
),
},
@ -110,15 +110,15 @@ describe("Test comments reducer handles", () => {
});
it("thread updates", () => {
state = commentsReducer(
state,
updateCommentThreadSuccess(updateCommentThreadPayload),
);
expect(
state.commentThreadsMap[updateCommentThreadPayload.id],
).toStrictEqual({
...state.commentThreadsMap[updateCommentThreadPayload.id],
...updateCommentThreadPayload,
const threadUpdate: CommentThread =
fetchApplicationThreadsMockResponse.data[0];
threadUpdate.resolvedState = { active: true };
state = commentsReducer(state, updateCommentThreadSuccess(threadUpdate));
expect(state.commentThreadsMap[threadUpdate.id]).toStrictEqual({
...state.commentThreadsMap[threadUpdate.id],
...threadUpdate,
});
});
@ -134,7 +134,6 @@ describe("Test comments reducer handles", () => {
[newCommentThreadEventPayload.thread._id]: {
...newCommentThreadEventPayload.thread,
id: newCommentThreadEventPayload.thread._id,
isVisible: false,
comments:
state.commentThreadsMap[newCommentThreadEventPayload.thread._id]
.comments || [],
@ -149,10 +148,10 @@ describe("Test comments reducer handles", () => {
],
[newCommentThreadEventPayload.thread.refId]: Array.from(
new Set([
newCommentThreadEventPayload.thread._id,
...((prevState.applicationCommentThreadsByRef[
newCommentThreadEventPayload.thread.applicationId
] || {})[newCommentThreadEventPayload.thread.refId] || []),
newCommentThreadEventPayload.thread._id,
]),
),
},

View File

@ -7,6 +7,7 @@ import handleFetchApplicationCommentsSuccess from "./handleFetchApplicationComme
import handleNewCommentThreadEvent from "./handleNewCommentThreadEvent";
import handleUpdateCommentThreadSuccess from "./handleUpdateCommentThreadSuccess";
import handleUpdateCommentThreadEvent from "./handleUpdateCommentThreadEvent";
import handleUpdateCommentEvent from "./handleUpdateCommentEvent";
import { CommentsReduxState } from "./interfaces";
import {
@ -15,8 +16,11 @@ import {
CreateCommentThreadRequest,
NewCommentEventPayload,
NewCommentThreadPayload,
Comment,
} from "entities/Comments/CommentsInterfaces";
import { options as filterOptions } from "comments/AppComments/AppCommentsFilterPopover";
const initialState: CommentsReduxState = {
commentThreadsMap: {},
applicationCommentThreadsByRef: {},
@ -24,6 +28,12 @@ const initialState: CommentsReduxState = {
isCommentMode: false,
creatingNewThread: false,
creatingNewThreadComment: false,
appCommentsFilter: filterOptions[0].value,
shouldShowResolvedAppCommentThreads: false,
showUnreadIndicator: false,
visibleCommentThreadId: "",
isIntroCarouselVisible: false,
areCommentsEnabled: false,
};
/**
@ -71,18 +81,9 @@ const commentsReducer = createReducer(initialState, {
) => ({
...state,
isCommentMode: action.payload,
showUnreadIndicator: false,
isIntroCarouselVisible: false,
}),
[ReduxActionTypes.SET_IS_COMMENT_THREAD_VISIBLE]: (
state: CommentsReduxState,
action: ReduxAction<{ isVisible: boolean; commentThreadId: string }>,
) => {
state.commentThreadsMap[action.payload.commentThreadId] = {
...state.commentThreadsMap[action.payload.commentThreadId],
isVisible: action.payload.isVisible,
};
return { ...state };
},
[ReduxActionTypes.CREATE_COMMENT_THREAD_REQUEST]: (
state: CommentsReduxState,
) => ({
@ -123,7 +124,9 @@ const commentsReducer = createReducer(initialState, {
),
};
return { ...state };
const showUnreadIndicator = !state.isCommentMode;
return { ...state, showUnreadIndicator };
},
[ReduxActionTypes.UPDATE_COMMENT_THREAD_SUCCESS]: (
state: CommentsReduxState,
@ -137,22 +140,6 @@ const commentsReducer = createReducer(initialState, {
) => {
return handleUpdateCommentThreadEvent(state, action);
},
[ReduxActionTypes.PIN_COMMENT_THREAD_SUCCESS]: (
state: CommentsReduxState,
action: ReduxAction<{ threadId: string; applicationId: string }>,
) => {
const { applicationId, threadId } = action.payload;
state.commentThreadsMap[threadId] = {
...state.commentThreadsMap[threadId],
isPinned: true,
};
// so that changes are propagated to app comments
state.applicationCommentThreadsByRef[applicationId] = {
...state.applicationCommentThreadsByRef[applicationId],
};
return { ...state };
},
[ReduxActionTypes.DELETE_COMMENT_SUCCESS]: (
state: CommentsReduxState,
action: ReduxAction<{
@ -172,6 +159,109 @@ const commentsReducer = createReducer(initialState, {
return { ...state };
},
[ReduxActionTypes.SET_SHOULD_SHOW_RESOLVED_COMMENTS]: (
state: CommentsReduxState,
action: ReduxAction<boolean>,
) => ({
...state,
shouldShowResolvedAppCommentThreads: action.payload,
}),
[ReduxActionTypes.RESET_VISIBLE_THREAD]: (
state: CommentsReduxState,
action: ReduxAction<string>,
) => ({
...state,
/**
* To solve race cond, explicitly hide a visible thread using it's id
* so that we don't accidently hide another thread
*/
visibleCommentThreadId:
action.payload === state.visibleCommentThreadId
? ""
: state.visibleCommentThreadId,
}),
[ReduxActionTypes.SET_VISIBLE_THREAD]: (
state: CommentsReduxState,
action: ReduxAction<string>,
) => ({
...state,
visibleCommentThreadId: action.payload,
}),
[ReduxActionTypes.SET_APP_COMMENTS_FILTER]: (
state: CommentsReduxState,
action: ReduxAction<typeof filterOptions[number]["value"]>,
) => ({
...state,
appCommentsFilter: action.payload,
}),
[ReduxActionTypes.EDIT_COMMENT_SUCCESS]: (
state: CommentsReduxState,
action: ReduxAction<{ comment: Comment; commentThreadId: string }>,
) => {
const { comment, commentThreadId } = action.payload;
const { id: commentId } = comment;
const commentThread = state.commentThreadsMap[commentThreadId];
if (!commentThread) return state;
const commentIdx = commentThread.comments.findIndex(
(comment: Comment) => comment.id === commentId,
);
commentThread.comments.splice(commentIdx, 1, comment);
// propagate changes
state.commentThreadsMap[commentThreadId] = {
...state.commentThreadsMap[commentThreadId],
};
return { ...state };
},
[ReduxActionTypes.DELETE_THREAD_SUCCESS]: (
state: CommentsReduxState,
action: ReduxAction<{ commentThreadId: string; appId: string }>,
) => {
const { appId, commentThreadId } = action.payload;
if (!state.applicationCommentThreadsByRef[appId]) return false;
const { refId } = state.commentThreadsMap[commentThreadId];
let refComments = state.applicationCommentThreadsByRef[appId][refId];
if (refComments) {
refComments = refComments.filter(
(threadId: string) => threadId !== commentThreadId,
);
}
delete state.commentThreadsMap[commentThreadId];
return { ...state };
},
[ReduxActionTypes.SHOW_COMMENTS_INTRO_CAROUSEL]: (
state: CommentsReduxState,
) => ({
...state,
isIntroCarouselVisible: true,
}),
[ReduxActionTypes.HIDE_COMMENTS_INTRO_CAROUSEL]: (
state: CommentsReduxState,
) => ({
...state,
isIntroCarouselVisible: false,
}),
[ReduxActionTypes.SET_ARE_COMMENTS_ENABLED]: (
state: CommentsReduxState,
action: ReduxAction<boolean>,
) => ({
...state,
areCommentsEnabled: action.payload,
}),
[ReduxActionTypes.UPDATE_COMMENT_EVENT]: (
state: CommentsReduxState,
action: ReduxAction<Comment>,
) => {
return handleUpdateCommentEvent(state, action);
},
});
export default commentsReducer;

View File

@ -26,7 +26,7 @@ const handleCreateNewCommentThreadSuccess = (
state.applicationCommentThreadsByRef[applicationId] = {
...state.applicationCommentThreadsByRef[applicationId],
[refId]: Array.from(new Set([...commentThreadsIdsForRefId, id])),
[refId]: Array.from(new Set([id, ...commentThreadsIdsForRefId])),
};
return { ...state };

View File

@ -13,23 +13,17 @@ const handleNewCommentThreadEvent = (
{},
) as Record<string, Array<string>>;
const threadsForRefId = get(applicationCommentIdsByRefId, thread.refId, []);
// Prevent duplicate events from hiding the thread popover
// Can happen if the creator is also receiving the new comment thread updates
const isVisible = get(
state.commentThreadsMap,
`${thread._id}.isVisible`,
false,
);
const existingComments = get(
state.commentThreadsMap,
`${thread._id}.comments`,
[],
) as [];
// TODO override fields explicitly?
state.commentThreadsMap[thread._id] = {
id: thread._id,
...thread,
isVisible,
...(state.commentThreadsMap[thread._id] || {}),
id: thread._id,
comments: [...existingComments, ...(thread.comments || [])],
};
@ -38,11 +32,14 @@ const handleNewCommentThreadEvent = (
}
state.applicationCommentThreadsByRef[thread.applicationId] = {
...state.applicationCommentThreadsByRef[thread.applicationId],
[thread.refId]: Array.from(new Set([...threadsForRefId, thread._id])),
[thread.refId]: Array.from(new Set([thread._id, ...threadsForRefId])),
};
const showUnreadIndicator = !state.isCommentMode;
return {
...state,
showUnreadIndicator,
};
};

View File

@ -0,0 +1,21 @@
import { Comment } from "entities/Comments/CommentsInterfaces";
import { ReduxAction } from "constants/ReduxActionConstants";
import { CommentsReduxState } from "./interfaces";
const handleUpdateCommentThreadEvent = (
state: CommentsReduxState,
action: ReduxAction<Comment>,
) => {
const { _id, threadId } = action.payload;
const threadInState = state.commentThreadsMap[threadId as string];
const commentIdx = threadInState.comments.findIndex(
(comment) => comment.id === _id,
);
threadInState.comments.splice(commentIdx, 1, { ...action.payload, id: _id });
state.commentThreadsMap[threadId as string] = { ...threadInState };
return { ...state };
};
export default handleUpdateCommentThreadEvent;

View File

@ -14,10 +14,13 @@ const handleUpdateCommentThreadEvent = (
state.commentThreadsMap[id] = {
...commentThreadInStore,
...action.payload,
isViewed: commentThreadInStore.isViewed || action.payload.isViewed, // TODO refactor this
comments: uniqBy([...existingComments, ...newComments], "id"),
};
return { ...state };
const showUnreadIndicator = !state.isCommentMode;
return { ...state, showUnreadIndicator };
};
export default handleUpdateCommentThreadEvent;

View File

@ -11,6 +11,8 @@ const handleUpdateCommentThreadSuccess = (
const commentThreadInStore = state.commentThreadsMap[id];
const existingComments = get(commentThreadInStore, "comments", []);
if (!commentThreadInStore) return state;
state.commentThreadsMap[id] = {
...commentThreadInStore,
...action.payload,

View File

@ -1,4 +1,5 @@
import { CommentThread } from "entities/Comments/CommentsInterfaces";
import { options as filterOptions } from "comments/AppComments/AppCommentsFilterPopover";
export interface CommentsReduxState {
commentThreadsMap: Record<string, CommentThread>;
@ -7,4 +8,10 @@ export interface CommentsReduxState {
isCommentMode: boolean;
creatingNewThread: boolean;
creatingNewThreadComment: boolean;
appCommentsFilter: typeof filterOptions[number]["value"];
shouldShowResolvedAppCommentThreads: boolean;
showUnreadIndicator: boolean;
visibleCommentThreadId?: string;
isIntroCarouselVisible?: boolean;
areCommentsEnabled?: boolean;
}

View File

@ -334,7 +334,6 @@ export const createCommentThreadSuccessPayload = {
},
],
new: false,
isVisible: true,
};
export const addCommentToThreadSuccessPayload = {

Some files were not shown because too many files have changed in this diff Show More