From 548c6b8fb29d42dfc21ed62c0563e136484574cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 24 May 2022 16:55:22 +0200 Subject: [PATCH] Notification box for card limit (#3074) * Notification box for card limit * Updating snapshots * adding notification box tests * Fix linter errors * Working on limits store * Implemeting the redux integration with the new limits * Refining store * Simplifying cards hidding/showing * Adding the secondary warning about hidding cards in other boards * Fixing tests * Fixed isLimited to limited * Fixing snooze of other boards hidden warning * Fixing linter error * Removing some unneeded coode * Moving limit logic to the cards selector * Changing the approach to limit cards * Fixing eslint errors Co-authored-by: Scott Bishel --- webapp/src/app.tsx | 20 +-- webapp/src/components/cardBadges.test.tsx | 2 + .../src/components/cardLimitNotification.scss | 0 .../src/components/cardLimitNotification.tsx | 149 ++++++++++++++++++ webapp/src/components/centerPanel.scss | 7 + webapp/src/components/centerPanel.tsx | 8 +- .../src/components/gallery/gallery.test.tsx | 2 + webapp/src/components/workspace.tsx | 13 +- webapp/src/store/cards.ts | 84 +++++++++- webapp/src/store/index.ts | 4 +- webapp/src/store/limits.ts | 6 + webapp/src/store/users.ts | 28 ++++ webapp/src/styles/main.scss | 2 +- webapp/src/telemetry/telemetryClient.ts | 2 + .../notification-box.test.tsx.snap | 140 ++++++++++++++++ webapp/src/widgets/editable.scss | 2 +- webapp/src/widgets/editableDayPicker.scss | 2 +- webapp/src/widgets/icons/alert.tsx | 15 ++ webapp/src/widgets/notification-box.scss | 39 +++++ webapp/src/widgets/notification-box.test.tsx | 85 ++++++++++ webapp/src/widgets/notification-box.tsx | 57 +++++++ webapp/src/wsclient.ts | 4 +- 22 files changed, 643 insertions(+), 28 deletions(-) create mode 100644 webapp/src/components/cardLimitNotification.scss create mode 100644 webapp/src/components/cardLimitNotification.tsx create mode 100644 webapp/src/widgets/__snapshots__/notification-box.test.tsx.snap create mode 100644 webapp/src/widgets/icons/alert.tsx create mode 100644 webapp/src/widgets/notification-box.scss create mode 100644 webapp/src/widgets/notification-box.test.tsx create mode 100644 webapp/src/widgets/notification-box.tsx diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index 256b3d28fd8..c4df1cc1b06 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -67,27 +67,29 @@ const App = (props: Props): JSX.Element => { dispatch(fetchClientConfig()) }, []) - if (Utils.isFocalboardPlugin()) { - useEffect(() => { + useEffect(() => { + if (Utils.isFocalboardPlugin()) { if (window.frontendBaseURL) { browserHistory.replace(window.location.pathname.replace(window.frontendBaseURL, '')) } - }, []) - } + } + }, []) // this is a temporary solution while we're using legacy routes // for shared boards as a way to disable websockets, and should be // removed when anonymous plugin routes are implemented. This // check is used to detect if we're running inside the plugin but // in a legacy route - if (!Utils.isFocalboardLegacy()) { - useEffect(() => { + useEffect(() => { + if (!Utils.isFocalboardLegacy()) { wsClient.open() - return () => { + } + return () => { + if (!Utils.isFocalboardLegacy()) { wsClient.close() } - }, []) - } + } + }, []) useEffect(() => { if (me) { diff --git a/webapp/src/components/cardBadges.test.tsx b/webapp/src/components/cardBadges.test.tsx index eb2253ef21b..f04f95d1703 100644 --- a/webapp/src/components/cardBadges.test.tsx +++ b/webapp/src/components/cardBadges.test.tsx @@ -36,8 +36,10 @@ describe('components/cardBadges', () => { const state: Partial = { cards: { current: '', + limitTimestamp: 0, cards: blocksById([card, emptyCard]), templates: {}, + cardHiddenWarning: true, }, comments: { comments: blocksById(comments), diff --git a/webapp/src/components/cardLimitNotification.scss b/webapp/src/components/cardLimitNotification.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/webapp/src/components/cardLimitNotification.tsx b/webapp/src/components/cardLimitNotification.tsx new file mode 100644 index 00000000000..23b9ea22405 --- /dev/null +++ b/webapp/src/components/cardLimitNotification.tsx @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React, {useCallback, useEffect, useState} from 'react' +import {useIntl, FormattedMessage} from 'react-intl' + +import AlertIcon from '../widgets/icons/alert' + +import {useAppSelector, useAppDispatch} from '../store/hooks' +import {IUser, UserConfigPatch} from '../user' +import {getMe, patchProps, getCardLimitSnoozeUntil, getCardHiddenWarningSnoozeUntil} from '../store/users' +import {getHiddenByLimitCards, getCardHiddenWarning} from '../store/cards' +import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../telemetry/telemetryClient' +import octoClient from '../octoClient' + +import NotificationBox from '../widgets/notification-box' +import './cardLimitNotification.scss' + +const snoozeTime = 1000 * 60 * 60 * 24 * 10 +const checkSnoozeInterval = 1000 * 60 * 5 + +const CardLimitNotification = () => { + const intl = useIntl() + const [time, setTime] = useState(Date.now()) + + const hiddenCards = useAppSelector(getHiddenByLimitCards) + const cardHiddenWarning = useAppSelector(getCardHiddenWarning) + const me = useAppSelector(getMe) + const snoozedUntil = useAppSelector(getCardLimitSnoozeUntil) + const snoozedCardHiddenWarningUntil = useAppSelector(getCardHiddenWarningSnoozeUntil) + const dispatch = useAppDispatch() + + const onCloseHidden = useCallback(async () => { + if (me) { + const patch: UserConfigPatch = { + updatedFields: { + focalboard_cardLimitSnoozeUntil: `${Date.now() + snoozeTime}`, + }, + } + + const patchedProps = await octoClient.patchUserConfig(me.id, patch) + if (patchedProps) { + dispatch(patchProps(patchedProps)) + } + } + }, [me]) + + const onCloseWarning = useCallback(async () => { + if (me) { + const patch: UserConfigPatch = { + updatedFields: { + focalboard_cardHiddenWarningSnoozeUntil: `${Date.now() + snoozeTime}`, + }, + } + + const patchedProps = await octoClient.patchUserConfig(me.id, patch) + if (patchedProps) { + dispatch(patchProps(patchedProps)) + } + } + }, [me]) + + let show = false + let onClose = onCloseHidden + let title = intl.formatMessage( + { + id: 'notification-box-card-limit-reached.title', + defaultMessage: '{cards} cards hidden from board', + }, + {cards: hiddenCards}, + ) + + if (hiddenCards > 0 && time > snoozedUntil) { + show = true + } + + if (!show && cardHiddenWarning) { + show = time > snoozedCardHiddenWarningUntil + onClose = onCloseWarning + title = intl.formatMessage( + { + id: 'notification-box-cards-hidden.title', + defaultMessage: 'Your action hidden another card', + }, + ) + } + + useEffect(() => { + if (!show) { + const interval = setInterval(() => setTime(Date.now()), checkSnoozeInterval) + return () => { + clearInterval(interval) + } + } + return () => null + }, [show]) + + useEffect(() => { + if (show) { + TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.LimitCardLimitReached, {}) + } + }, [show]) + + const onClick = useCallback(() => { + // TODO: Show the modal to upgrade + TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.LimitCardLimitLinkOpen, {}) + }, []) + + const hasPermissionToUpgrade = me?.roles?.split(' ').indexOf('system_admin') !== -1 + + if (!show) { + return null + } + + return ( + } + title={title} + onClose={onClose} + closeTooltip={intl.formatMessage({ + id: 'notification-box-card-limit-reached.close-tooltip', + defaultMessage: 'Snooze for 10 days', + })} + > + {hasPermissionToUpgrade && + + + ), + }} + />} + {!hasPermissionToUpgrade && + } + + ) +} + +export default React.memo(CardLimitNotification) diff --git a/webapp/src/components/centerPanel.scss b/webapp/src/components/centerPanel.scss index 8c46f5cc178..c41a0256b09 100644 --- a/webapp/src/components/centerPanel.scss +++ b/webapp/src/components/centerPanel.scss @@ -74,4 +74,11 @@ position: relative; flex: 0 0 auto; } + + .NotificationBox { + .AlertIcon { + color: #ffbc1f; + font-size: 24px; + } + } } diff --git a/webapp/src/components/centerPanel.tsx b/webapp/src/components/centerPanel.tsx index 79e6a532df9..f3da66e11a5 100644 --- a/webapp/src/components/centerPanel.tsx +++ b/webapp/src/components/centerPanel.tsx @@ -17,7 +17,7 @@ import {CardFilter} from '../cardFilter' import mutator from '../mutator' import {Utils} from '../utils' import {UserSettings} from '../userSettings' -import {addCard, addTemplate} from '../store/cards' +import {addCard, addTemplate, showCardHiddenWarning} from '../store/cards' import {updateView} from '../store/views' import {getVisibleAndHiddenGroups} from '../boardUtils' import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../../webapp/src/telemetry/telemetryClient' @@ -51,6 +51,8 @@ import Table from './table/table' import CalendarFullView from './calendar/fullCalendar' +import CardLimitNotification from './cardLimitNotification' + import Gallery from './gallery/gallery' import {BoardTourSteps, FINISHED, TOUR_BOARD, TOUR_CARD} from './onboardingTour' import ShareBoardTourStep from './onboardingTour/shareBoard/shareBoard' @@ -66,6 +68,7 @@ type Props = { intl: IntlShape readonly: boolean addCard: (card: Card) => void + showCardHiddenWarning: (hidden: boolean) => void updateView: (view: BoardView) => void addTemplate: (template: Card) => void shownCardId?: string @@ -187,6 +190,7 @@ class CenterPanel extends React.Component { this.backgroundClicked(e) }} > + { this.showCard(undefined) }, ) + this.props.showCardHiddenWarning(true) await mutator.changeViewCardOrder(activeView, [...activeView.fields.cardOrder, newCard.id], 'add-card') }) } @@ -494,6 +499,7 @@ function mapStateToProps(state: RootState) { } export default connect(mapStateToProps, { + showCardHiddenWarning, addCard, addTemplate, updateView, diff --git a/webapp/src/components/gallery/gallery.test.tsx b/webapp/src/components/gallery/gallery.test.tsx index c84cb2c7bfa..8964c5fdf22 100644 --- a/webapp/src/components/gallery/gallery.test.tsx +++ b/webapp/src/components/gallery/gallery.test.tsx @@ -36,10 +36,12 @@ describe('src/components/gallery/Gallery', () => { }, cards: { current: '', + limitTimestamp: 0, cards: { [card.id]: card, }, templates: {}, + cardHiddenWarning: true, }, comments: { comments: {}, diff --git a/webapp/src/components/workspace.tsx b/webapp/src/components/workspace.tsx index f731ce7c047..fb5d66beeff 100644 --- a/webapp/src/components/workspace.tsx +++ b/webapp/src/components/workspace.tsx @@ -5,8 +5,8 @@ import {generatePath, useRouteMatch, useHistory} from 'react-router-dom' import {FormattedMessage} from 'react-intl' import {getCurrentWorkspace} from '../store/workspace' -import {getCurrentBoard} from '../store/boards' -import {getCurrentViewCardsSortedFilteredAndGrouped, setCurrent as setCurrentCard} from '../store/cards' +import {getCurrentBoard, getTemplates} from '../store/boards' +import {refreshCards, getCardLimitTimestamp, setLimitTimestamp, getCurrentViewCardsSortedFilteredAndGrouped, setCurrent as setCurrentCard} from '../store/cards' import {getView, getCurrentBoardViews, getCurrentViewGroupBy, getCurrentView, getCurrentViewDisplayBy} from '../store/views' import {useAppSelector, useAppDispatch} from '../store/hooks' @@ -30,12 +30,14 @@ function CenterContent(props: Props) { const workspace = useAppSelector(getCurrentWorkspace) const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string}>() const board = useAppSelector(getCurrentBoard) + const templates = useAppSelector(getTemplates) const cards = useAppSelector(getCurrentViewCardsSortedFilteredAndGrouped) const activeView = useAppSelector(getView(match.params.viewId)) const views = useAppSelector(getCurrentBoardViews) const groupByProperty = useAppSelector(getCurrentViewGroupBy) const dateDisplayProperty = useAppSelector(getCurrentViewDisplayBy) const clientConfig = useAppSelector(getClientConfig) + const cardLimitTimestamp = useAppSelector(getCardLimitTimestamp) const history = useHistory() const dispatch = useAppDispatch() @@ -56,7 +58,10 @@ function CenterContent(props: Props) { wsClient.addOnConfigChange(onConfigChangeHandler) const onCardLimitTimestampChangeHandler = (_: WSClient, timestamp: number) => { - Utils.log(`HANDLING TIMESTAMP: ${timestamp}`) + dispatch(setLimitTimestamp({timestamp, templates})) + if (cardLimitTimestamp > timestamp) { + dispatch(refreshCards(timestamp)) + } } wsClient.addOnCardLimitTimestampChange(onCardLimitTimestampChangeHandler) @@ -64,7 +69,7 @@ function CenterContent(props: Props) { wsClient.removeOnConfigChange(onConfigChangeHandler) wsClient.removeOnCardLimitTimestampChange(onCardLimitTimestampChangeHandler) } - }, []) + }, [cardLimitTimestamp, match.params.boardId, templates]) if (board && activeView) { let property = groupByProperty diff --git a/webapp/src/store/cards.ts b/webapp/src/store/cards.ts index 41d7f87b8c3..bf48d619f2f 100644 --- a/webapp/src/store/cards.ts +++ b/webapp/src/store/cards.ts @@ -1,15 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {createSlice, PayloadAction, createSelector} from '@reduxjs/toolkit' +import {createSlice, PayloadAction, createSelector, createAsyncThunk} from '@reduxjs/toolkit' import {Card} from '../blocks/card' import {IUser} from '../user' import {Board} from '../blocks/board' +import {Block} from '../blocks/block' import {BoardView} from '../blocks/boardView' import {Utils} from '../utils' import {Constants} from '../constants' import {CardFilter} from '../cardFilter' +import {default as client} from '../octoClient' import {initialLoad, initialReadOnlyLoad} from './initialLoad' import {getCurrentBoard} from './boards' @@ -21,28 +23,76 @@ import {RootState} from './index' type CardsState = { current: string + limitTimestamp: number cards: {[key: string]: Card} templates: {[key: string]: Card} + cardHiddenWarning: boolean +} + +export const refreshCards = createAsyncThunk( + 'refreshCards', + async (cardLimitTimestamp: number, thunkAPI) => { + const {cards} = thunkAPI.getState().cards + const blocksPromises = [] + + for (const card of Object.values(cards)) { + if (card.limited && card.updateAt >= cardLimitTimestamp) { + blocksPromises.push(client.getSubtree(card.id).then((blocks) => blocks.find((b) => b?.type === 'card'))) + } + } + const blocks = await Promise.all(blocksPromises) + + return blocks.filter((b: Block|undefined): boolean => Boolean(b)) as Block[] + }, +) + +const limitCard = (isBoardTemplate: boolean, limitTimestamp:number, card: Card): Card => { + if (isBoardTemplate) { + return card + } + if (card.updateAt >= limitTimestamp) { + return card + } + return { + ...card, + fields: { + icon: card.fields.icon, + properties: {}, + contentOrder: [], + }, + limited: true, + } } const cardsSlice = createSlice({ name: 'cards', initialState: { current: '', + limitTimestamp: 0, cards: {}, templates: {}, + cardHiddenWarning: false, } as CardsState, reducers: { setCurrent: (state, action: PayloadAction) => { state.current = action.payload }, + setLimitTimestamp: (state, action: PayloadAction<{timestamp: number, templates: {[key: string]: Board}}>) => { + state.limitTimestamp = action.payload.timestamp + for (const card of Object.values(state.cards)) { + state.cards[card.id] = limitCard(Boolean(action.payload.templates[card.id]), state.limitTimestamp, card) + } + }, addCard: (state, action: PayloadAction) => { state.cards[action.payload.id] = action.payload }, - addTemplate: (state, action: PayloadAction) => { + showCardHiddenWarning: (state, action: PayloadAction) => { + state.cardHiddenWarning = action.payload + }, + addTemplate: (state: CardsState, action: PayloadAction) => { state.templates[action.payload.id] = action.payload }, - updateCards: (state, action: PayloadAction) => { + updateCards: (state: CardsState, action: PayloadAction) => { for (const card of action.payload) { if (card.deleteAt !== 0) { delete state.cards[card.id] @@ -56,6 +106,11 @@ const cardsSlice = createSlice({ }, }, extraReducers: (builder) => { + builder.addCase(refreshCards.fulfilled, (state, action) => { + for (const block of action.payload) { + state.cards[block.id] = block as Card + } + }) builder.addCase(initialReadOnlyLoad.fulfilled, (state, action) => { state.cards = {} state.templates = {} @@ -70,6 +125,7 @@ const cardsSlice = createSlice({ builder.addCase(initialLoad.fulfilled, (state, action) => { state.cards = {} state.templates = {} + state.limitTimestamp = action.payload.limits?.card_limit_timestamp || 0 for (const block of action.payload.blocks) { if (block.type === 'card' && block.fields.isTemplate) { state.templates[block.id] = block as Card @@ -81,7 +137,7 @@ const cardsSlice = createSlice({ }, }) -export const {updateCards, addCard, addTemplate, setCurrent} = cardsSlice.actions +export const {updateCards, addCard, addTemplate, setCurrent, setLimitTimestamp, showCardHiddenWarning} = cardsSlice.actions export const {reducer} = cardsSlice export const getCards = (state: RootState): {[key: string]: Card} => state.cards.cards @@ -104,7 +160,7 @@ export const getSortedTemplates = createSelector( export function getCard(cardId: string): (state: RootState) => Card|undefined { return (state: RootState): Card|undefined => { - return state.cards.cards[cardId] || state.cards.templates[cardId] + return getCards(state)[cardId] || getTemplates(state)[cardId] } } @@ -294,7 +350,7 @@ function searchFilterCards(cards: Card[], board: Board, searchTextRaw: string): }) } -export const getCurrentViewCardsSortedFilteredAndGrouped = createSelector( +export const getCurrentViewCardsSortedFilteredAndGroupedWithoutLimit = createSelector( getCurrentBoardCards, getCurrentBoard, getCurrentView, @@ -317,8 +373,22 @@ export const getCurrentViewCardsSortedFilteredAndGrouped = createSelector( }, ) +export const getCurrentViewCardsSortedFilteredAndGrouped = createSelector( + getCurrentViewCardsSortedFilteredAndGroupedWithoutLimit, + (cards) => cards.filter((c) => !c.limited), +) + export const getCurrentCard = createSelector( (state: RootState) => state.cards.current, - (state: RootState) => state.cards.cards, + getCards, (current, cards) => cards[current], ) + +export const getCardLimitTimestamp = (state: RootState): number => state.cards.limitTimestamp +export const getHiddenByLimitCards = createSelector( + getCurrentViewCardsSortedFilteredAndGroupedWithoutLimit, + getCurrentViewCardsSortedFilteredAndGrouped, + (allCards, shownCards) => allCards.length - shownCards.length, +) + +export const getCardHiddenWarning = (state: RootState): boolean => state.cards.cardHiddenWarning diff --git a/webapp/src/store/index.ts b/webapp/src/store/index.ts index fc25a62a1e3..17e493b25d2 100644 --- a/webapp/src/store/index.ts +++ b/webapp/src/store/index.ts @@ -14,8 +14,8 @@ import {reducer as contentsReducer} from './contents' import {reducer as commentsReducer} from './comments' import {reducer as searchTextReducer} from './searchText' import {reducer as globalErrorReducer} from './globalError' -import {reducer as clientConfigReducer} from './clientConfig' import {reducer as limitsReducer} from './limits' +import {reducer as clientConfigReducer} from './clientConfig' const store = configureStore({ reducer: { @@ -30,8 +30,8 @@ const store = configureStore({ comments: commentsReducer, searchText: searchTextReducer, globalError: globalErrorReducer, - clientConfig: clientConfigReducer, limits: limitsReducer, + clientConfig: clientConfigReducer, }, }) diff --git a/webapp/src/store/limits.ts b/webapp/src/store/limits.ts index e6e050c082b..5f12961ed72 100644 --- a/webapp/src/store/limits.ts +++ b/webapp/src/store/limits.ts @@ -31,6 +31,9 @@ const limitsSlice = createSlice({ setLimits: (state, action: PayloadAction) => { state.limits = action.payload }, + setCardLimitTimestamp: (state, action: PayloadAction) => { + state.limits.card_limit_timestamp = action.payload + }, }, extraReducers: (builder) => { builder.addCase(initialLoad.fulfilled, (state, action) => { @@ -40,4 +43,7 @@ const limitsSlice = createSlice({ }) export const {reducer} = limitsSlice +export const {setCardLimitTimestamp} = limitsSlice.actions + export const getLimits = (state: RootState): BoardsCloudLimits | undefined => state.limits.limits +export const getGCardLimitTimestamp = (state: RootState): number => state.limits.limits.card_limit_timestamp diff --git a/webapp/src/store/users.ts b/webapp/src/store/users.ts index af2451c9c5c..f14fb0cc007 100644 --- a/webapp/src/store/users.ts +++ b/webapp/src/store/users.ts @@ -141,3 +141,31 @@ export const getCloudMessageCanceled = createSelector( return Boolean(me.props?.focalboard_cloudMessageCanceled) }, ) + +export const getCardLimitSnoozeUntil = createSelector( + getMe, + (me): number => { + if (!me) { + return 0 + } + try { + return parseInt(me.props?.focalboard_cardLimitSnoozeUntil, 10) || 0 + } catch (_) { + return 0 + } + }, +) + +export const getCardHiddenWarningSnoozeUntil = createSelector( + getMe, + (me): number => { + if (!me) { + return 0 + } + try { + return parseInt(me.props?.focalboard_cardHiddenWarningSnoozeUntil, 10) || 0 + } catch (_) { + return 0 + } + }, +) diff --git a/webapp/src/styles/main.scss b/webapp/src/styles/main.scss index febb3aab87b..2a1a192fa8e 100644 --- a/webapp/src/styles/main.scss +++ b/webapp/src/styles/main.scss @@ -55,7 +55,7 @@ html { a { text-decoration: none; - color: var(--link-color-rgb); + color: rgb(var(--link-color-rgb)); } hr { diff --git a/webapp/src/telemetry/telemetryClient.ts b/webapp/src/telemetry/telemetryClient.ts index 7e941e3e319..de143b0783d 100644 --- a/webapp/src/telemetry/telemetryClient.ts +++ b/webapp/src/telemetry/telemetryClient.ts @@ -45,6 +45,8 @@ export const TelemetryActions = { CloudMoreInfo: 'cloud_more_info', ViewLimitReached: 'limit_ViewLimitReached', ViewLimitCTAPerformed: 'limit_ViewLimitLinkOpen', + LimitCardLimitReached: 'limit_cardLimitReached', + LimitCardLimitLinkOpen: 'limit_cardLimitLinkOpen', } interface IEventProps { diff --git a/webapp/src/widgets/__snapshots__/notification-box.test.tsx.snap b/webapp/src/widgets/__snapshots__/notification-box.test.tsx.snap new file mode 100644 index 00000000000..f83240d98ff --- /dev/null +++ b/webapp/src/widgets/__snapshots__/notification-box.test.tsx.snap @@ -0,0 +1,140 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`widgets/NotificationBox should match snapshot with close with tooltip 1`] = ` +
+
+
+

+ title +

+ CONTENT +
+
+ +
+
+
+`; + +exports[`widgets/NotificationBox should match snapshot with close without tooltip 1`] = ` +
+
+
+

+ title +

+ CONTENT +
+ +
+
+`; + +exports[`widgets/NotificationBox should match snapshot with icon 1`] = ` +
+
+
+ ICON +
+
+

+ title +

+ CONTENT +
+
+
+`; + +exports[`widgets/NotificationBox should match snapshot with icon and close with tooltip 1`] = ` +
+
+
+ ICON +
+
+

+ title +

+ CONTENT +
+
+ +
+
+
+`; + +exports[`widgets/NotificationBox should match snapshot without icon and close 1`] = ` +
+
+
+

+ title +

+ CONTENT +
+
+
+`; diff --git a/webapp/src/widgets/editable.scss b/webapp/src/widgets/editable.scss index 631a48d42f0..0b0b168a754 100644 --- a/webapp/src/widgets/editable.scss +++ b/webapp/src/widgets/editable.scss @@ -15,7 +15,7 @@ } &.error { - border: 1px solid var(--error-text-rgb); + border: 1px solid rgb(var(--error-text-rgb)); border-radius: var(--default-rad); } diff --git a/webapp/src/widgets/editableDayPicker.scss b/webapp/src/widgets/editableDayPicker.scss index c8f0cad8166..99834377093 100644 --- a/webapp/src/widgets/editableDayPicker.scss +++ b/webapp/src/widgets/editableDayPicker.scss @@ -17,7 +17,7 @@ } &.error { - border: 1px solid var(--error-text-rgb); + border: 1px solid rgb(var(--error-text-rgb)); border-radius: var(--default-rad); } } diff --git a/webapp/src/widgets/icons/alert.tsx b/webapp/src/widgets/icons/alert.tsx new file mode 100644 index 00000000000..0d80acc2449 --- /dev/null +++ b/webapp/src/widgets/icons/alert.tsx @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react' + +import CompassIcon from './compassIcon' + +export default function AlertIcon(): JSX.Element { + return ( + + ) +} diff --git a/webapp/src/widgets/notification-box.scss b/webapp/src/widgets/notification-box.scss new file mode 100644 index 00000000000..b922ebd2e47 --- /dev/null +++ b/webapp/src/widgets/notification-box.scss @@ -0,0 +1,39 @@ +.NotificationBox { + position: fixed; + bottom: 52px; + right: 32px; + border-radius: 4px; + background: rgb(var(--center-channel-bg-rgb)); + box-shadow: rgba(var(--center-channel-color-rgb), 0.1) 0 0 0 1px, + rgba(var(--center-channel-color-rgb), 0.1) 0 2px 4px; + display: flex; + padding: 22px; + width: 400px; + z-index: 1000; + + .NotificationBox__icon { + margin-right: 10px; + } + + .content { + font-size: 14px; + font-weight: 400; + + .title { + font-size: 14px; + font-weight: 600; + margin-bottom: 0; + line-height: 25px; + } + } + + .octo-tooltip { + font-size: 12px; + font-weight: 600; + + .IconButton { + font-size: 14px; + font-weight: 400; + } + } +} diff --git a/webapp/src/widgets/notification-box.test.tsx b/webapp/src/widgets/notification-box.test.tsx new file mode 100644 index 00000000000..69f8461bc71 --- /dev/null +++ b/webapp/src/widgets/notification-box.test.tsx @@ -0,0 +1,85 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react' +import {render} from '@testing-library/react' +import '@testing-library/jest-dom' + +import {wrapIntl} from '../testUtils' + +import NotificationBox from './notification-box' + +describe('widgets/NotificationBox', () => { + beforeEach(() => { + // Quick fix to disregard console error when unmounting a component + console.error = jest.fn() + document.execCommand = jest.fn() + }) + + test('should match snapshot without icon and close', () => { + const component = wrapIntl( + + {'CONTENT'} + , + ) + const {container} = render(component) + expect(container).toMatchSnapshot() + }) + + test('should match snapshot with icon', () => { + const component = wrapIntl( + + {'CONTENT'} + , + ) + const {container} = render(component) + expect(container).toMatchSnapshot() + }) + + test('should match snapshot with close without tooltip', () => { + const component = wrapIntl( + null} + > + {'CONTENT'} + , + ) + const {container} = render(component) + expect(container).toMatchSnapshot() + }) + + test('should match snapshot with close with tooltip', () => { + const component = wrapIntl( + null} + closeTooltip='tooltip' + > + {'CONTENT'} + , + ) + const {container} = render(component) + expect(container).toMatchSnapshot() + }) + + test('should match snapshot with icon and close with tooltip', () => { + const component = wrapIntl( + null} + closeTooltip='tooltip' + > + {'CONTENT'} + , + ) + const {container} = render(component) + expect(container).toMatchSnapshot() + }) +}) diff --git a/webapp/src/widgets/notification-box.tsx b/webapp/src/widgets/notification-box.tsx new file mode 100644 index 00000000000..8980fdcc37c --- /dev/null +++ b/webapp/src/widgets/notification-box.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react' + +import IconButton from './buttons/iconButton' +import CloseIcon from './icons/close' +import Tooltip from './tooltip' + +import './notification-box.scss' + +type Props = { + title: string + icon?: React.ReactNode + children: React.ReactNode + onClose?: () => void + closeTooltip?: string +} + +function renderClose(onClose?: () => void, closeTooltip?: string) { + if (!onClose) { + return null + } + + if (closeTooltip) { + return ( + + } + onClick={onClose} + /> + ) + } + + return ( + } + onClick={onClose} + />) +} + +function NotificationBox(props: Props): JSX.Element { + return ( +
+ {props.icon && +
+ {props.icon} +
} +
+

{props.title}

+ {props.children} +
+ {renderClose(props.onClose, props.closeTooltip)} +
+ ) +} + +export default React.memo(NotificationBox) diff --git a/webapp/src/wsclient.ts b/webapp/src/wsclient.ts index a0b692e1b7f..09ac8fc7d7a 100644 --- a/webapp/src/wsclient.ts +++ b/webapp/src/wsclient.ts @@ -350,9 +350,9 @@ class WSClient { } } - updateCardLimitTimestampHandler(timestamp: number): void { + updateCardLimitTimestampHandler(action: {action: string, timestamp: number}): void { for (const handler of this.onCardLimitTimestampChange) { - handler(this, timestamp) + handler(this, action.timestamp) } }