[Feature] Comments feature updates (#4579)
|
|
@ -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'",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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?")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
22
app/client/src/actions/tourActions.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
3
app/client/src/assets/icons/comments/chat.svg
Normal 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 |
|
|
@ -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 |
BIN
app/client/src/assets/icons/comments/commentCursor.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
5
app/client/src/assets/icons/comments/context-menu.svg
Normal 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 |
4
app/client/src/assets/icons/comments/down-arrow.svg
Normal 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 |
3
app/client/src/assets/icons/comments/edit-mode.svg
Normal 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 |
|
|
@ -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 |
3
app/client/src/assets/icons/comments/filter.svg
Normal 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 |
|
|
@ -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 |
3
app/client/src/assets/icons/comments/pen.svg
Normal 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 |
3
app/client/src/assets/icons/comments/pin_3.svg
Normal 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 |
13
app/client/src/assets/icons/comments/reaction-2.svg
Normal 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 |
6
app/client/src/assets/icons/comments/reaction.svg
Normal 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 |
3
app/client/src/assets/icons/comments/read-pin.svg
Normal 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 |
3
app/client/src/assets/icons/comments/unpin.svg
Normal 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 |
3
app/client/src/assets/icons/comments/unread-pin.svg
Normal 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 |
BIN
app/client/src/assets/images/comments-onboarding/step-1.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
app/client/src/assets/images/comments-onboarding/step-2.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
app/client/src/assets/images/comments-onboarding/step-3.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
app/client/src/assets/images/comments-onboarding/step-4.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
3
app/client/src/assets/images/profile-placeholder.svg
Normal 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 |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
128
app/client/src/comments/AppComments/AppCommentsFilterPopover.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
173
app/client/src/comments/CommentsShowcaseCarousel/index.tsx
Normal 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-worker’s 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 else’s 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
22
app/client/src/comments/tour/AddCommentTourComponent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
app/client/src/comments/tour/commentsTourSteps.ts
Normal 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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
175
app/client/src/components/ads/DisplayImageUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
157
app/client/src/components/ads/EmojiReactions.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
117
app/client/src/components/ads/ShowcaseCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
57
app/client/src/components/ads/tour/TourTooltipWrapper.tsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
8
app/client/src/constants/TourSteps.tsx
Normal 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;
|
||||
|
|
@ -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 ";
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
3
app/client/src/entities/Tour/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export enum TourType {
|
||||
COMMENTS_TOUR = "COMMENTS_TOUR",
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
|
|
|
|||
20
app/client/src/globalStyles/portals.ts
Normal 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};
|
||||
}
|
||||
`;
|
||||
11
app/client/src/globalStyles/uppy.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
182
app/client/src/pages/Editor/ToggleModeButton.tsx
Normal 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;
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]),
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -334,7 +334,6 @@ export const createCommentThreadSuccessPayload = {
|
|||
},
|
||||
],
|
||||
new: false,
|
||||
isVisible: true,
|
||||
};
|
||||
|
||||
export const addCommentToThreadSuccessPayload = {
|
||||
|
|
|
|||