1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-12-21 13:38:56 +02:00

Porting the card limits frontend to main branch (#3174)

* Porting the cards limits to main branch

* Fixing Issue 3124 (#3162)

* Fixing Issue 3124

* Update webapp/src/components/cardLimitNotification.tsx

Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>

* Some polishing in the card limit notifications components (#3169)

* Adding missing selector

* Adding some small missing parts

* Fixing some tests type checks adding the roles attribute to the users

* Feature to show hidden card count (#3094)

* Shows "(Deleted User)" instead of UUID when user not found (#2354) (#2465)

* Shows "(Deleted User)" instead of long, unreadable UUID in case the user is not found

In case a user is not found, at present unreadable and long UUIDs are shown which kill the look and feel of the application. This patch replaces the UUID with a more explanatory string.

* Update server/services/store/mattermostauthlayer/mattermostauthlayer.go

Co-authored-by: Doug Lauder <wiggin77@warpmail.net>
(cherry picked from commit 68819185a4)

Co-authored-by: Akshay Vasudeva Rao <51395864+akkivasu@users.noreply.github.com>

* Update CHANGELOG.md for v0.15

Added one more merged PR to the list

* GH-2212 - Update global link on board (#2492) (#2495)

(cherry picked from commit 49df41f9b2)

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>

* GH-2387 - Fixing link in comments (#2480) (#2498)

(cherry picked from commit 5e2cf0b386)

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>

* Addead feature to start product tour on using the welcome template (#2468)

* Fixed a bug where images of the welcome board were not copied over. (#2453)

* Fixed a buig where images of welcome board were not copied over

* Lint fixes

* Fixed test

* Fixed test

* GH-2496 - Updating board title truncation issue (#2497) (#2503)

(cherry picked from commit f9cef8e4a0)

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>

* set min-height on empty date to allow click (#2466) (#2504)

(cherry picked from commit 20fe19a50d)

Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>

* Fix problem with viewId 0 in the URL (#2474) (#2510)

(cherry picked from commit 4cb3a0fae4)

Co-authored-by: Jesús Espino <jespinog@gmail.com>

* don't display temlate page if readonly and access revoked (#2499) (#2515)

(cherry picked from commit 61f1a3cc65)

Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>

* GH-2447 - Updating label overflow (#2479) (#2517)

* GH-2447 - Updating label overflow

* Updating labels css

(cherry picked from commit 923437cc57)

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>

* Gh-2437 - Updating share board modal (#2511) (#2522)

* Gh-2437 - Updating share board modal

* Updating test

* Updating card dialog and test

* Updating comment list

(cherry picked from commit 50ded69852)

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>

* updated/synced prior PR (#2509) (#2523)

* updated/synced prior PR

* add title back for cypress tests

* update unit test for cypress fix

* move to function

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
(cherry picked from commit 5b309e8e25)

Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>

* modify error page redirects (#2518) (#2532)

(cherry picked from commit 84a3f8f1fb)

Co-authored-by: Doug Lauder <wiggin77@warpmail.net>

* Dismiss tour from overlay (#2525) (#2531)

* Shows "(Deleted User)" instead of UUID when user not found (#2354) (#2465)

* Shows "(Deleted User)" instead of long, unreadable UUID in case the user is not found

In case a user is not found, at present unreadable and long UUIDs are shown which kill the look and feel of the application. This patch replaces the UUID with a more explanatory string.

* Update server/services/store/mattermostauthlayer/mattermostauthlayer.go

Co-authored-by: Doug Lauder <wiggin77@warpmail.net>
(cherry picked from commit 68819185a4)

Co-authored-by: Akshay Vasudeva Rao <51395864+akkivasu@users.noreply.github.com>

* Update CHANGELOG.md for v0.15

Added one more merged PR to the list

* Added ability to dismiss tour from overlay

* GH-2212 - Update global link on board (#2492) (#2495)

(cherry picked from commit 49df41f9b2)

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>

* GH-2387 - Fixing link in comments (#2480) (#2498)

(cherry picked from commit 5e2cf0b386)

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>

* Addead feature to start product tour on using the welcome template (#2468)

* Fixed a bug where images of the welcome board were not copied over. (#2453)

* Fixed a buig where images of welcome board were not copied over

* Lint fixes

* Fixed test

* Fixed test

* Fixed intended behavio

* lint fixes

* Fixed tests

* Fixed tests

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Akshay Vasudeva Rao <51395864+akkivasu@users.noreply.github.com>
Co-authored-by: Winson Wu <93531870+wuwinson@users.noreply.github.com>
Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
(cherry picked from commit a53e947489)

Co-authored-by: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com>

* Updated Mac what's new for v0.15

* Done with the frontend for limited card count

* Updated the code and done with the unit test case

* Updated the code according to the review comment and fixed the test cases and ES lints issue

* Fixed the import for css file

* Changes made according to review comments, Reverted back whatsnew.txt

* Minor changes

* Updated the code considering review comments

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Akshay Vasudeva Rao <51395864+akkivasu@users.noreply.github.com>
Co-authored-by: Winson Wu <93531870+wuwinson@users.noreply.github.com>
Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Jesús Espino <jespinog@gmail.com>
Co-authored-by: Doug Lauder <wiggin77@warpmail.net>
Co-authored-by: Chen-I Lim <chenilim@gmail.com>
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>

* Fixing some tests

* Fixing tests

* Fixing some compilation errors

* More types error fixes

* Fixing eslint

* Fixing a typing problem

* Updating snapshots

* Fixing two small problems

* Removing unneeded scss empty file

* fixing jest test

Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Rajat Dabade <rajat.dabade@mattermost.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Akshay Vasudeva Rao <51395864+akkivasu@users.noreply.github.com>
Co-authored-by: Winson Wu <93531870+wuwinson@users.noreply.github.com>
Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com>
Co-authored-by: Doug Lauder <wiggin77@warpmail.net>
Co-authored-by: Chen-I Lim <chenilim@gmail.com>
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
This commit is contained in:
Jesús Espino 2022-06-20 20:39:20 +02:00 committed by GitHub
parent 70dabf50f7
commit 5979d19e73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 2353 additions and 99 deletions

View File

@ -303,9 +303,16 @@
"error.page.title": "Sorry, something went wrong",
"generic.previous": "Previous",
"imagePaste.upload-failed": "Some files not uploaded. File size limit reached",
"limitedCard.title": "Cards Hidden",
"login.log-in-button": "Log in",
"login.log-in-title": "Log in",
"login.register-button": "or create an account if you don't have one",
"notification-box-card-limit-reached.close-tooltip": "Snooze for 10 days",
"notification-box-card-limit-reached.link": "upgrade to a paid plan",
"notification-box-card-limit-reached.title": "{cards} cards hidden from board",
"notification-box-cards-hidden.title": "Your action hidden another card",
"notification-box.card-limit-reached.not-admin.text": "To access archived cards, contact your admin to upgrade to a paid plan.",
"notification-box.card-limit-reached.text": "Card limit reached, to view older cards, {link}",
"register.login-button": "or log in if you already have an account",
"register.signup-title": "Sign up for your account",
"share-board.publish": "Publish",

View File

@ -42,14 +42,16 @@ const App = (props: Props): JSX.Element => {
// 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) {

View File

@ -40,6 +40,8 @@ interface Block {
createAt: number
updateAt: number
deleteAt: number
limited?: boolean
}
interface FileInfo {

View File

@ -11,7 +11,7 @@ export function groupCardsByOptions(cards: Card[], optionIds: string[], groupByP
if (optionId) {
const option = groupByProperty?.options.find((o) => o.id === optionId)
if (option) {
const c = cards.filter((o) => optionId === o.fields.properties[groupByProperty!.id])
const c = cards.filter((o) => optionId === o.fields?.properties[groupByProperty!.id])
const group: BoardGroup = {
option,
cards: c,

View File

@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const LimitUnlimited = 0
export interface BoardsCloudLimits {
cards: number
used_cards: number
card_limit_timestamp: number
views: number
}

View File

@ -577,6 +577,713 @@ exports[`components/centerPanel return centerPanel and click on card to show car
</div>
`;
exports[`components/centerPanel return centerPanel and click on new card to edit template 1`] = `
<div>
<div
class="BoardComponent"
>
<div
class="top-head"
>
<div
class="TopBar"
>
<a
class="link"
href="https://www.focalboard.com/fwlink/feedback-focalboard.html?v=1.0.0"
rel="noreferrer"
target="_blank"
>
Give feedback
</a>
<a
href="https://www.focalboard.com/guide/user?utm_source=webapp"
rel="noreferrer"
target="_blank"
>
<i
class="CompassIcon icon-help-circle-outline HelpIcon"
/>
</a>
</div>
<div
class="mid-head"
>
<div
class="ViewTitle"
>
<div
class="add-buttons add-visible"
>
<button
type="button"
>
<i
class="CompassIcon icon-eye-off-outline undefined"
/>
<span>
hide description
</span>
</button>
</div>
<div
class="title"
>
<div
class="IconSelector"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="octo-icon size-m"
>
<span>
i
</span>
</div>
</div>
</div>
<input
class="Editable title"
placeholder="Untitled board"
spellcheck="true"
title="board title"
value="board title"
/>
</div>
<div
class="description"
>
<div
class="MarkdownEditor octo-editor "
>
<div
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="shareButtonWrapper"
>
<div
class="ShareBoardButton"
>
<button
title="Share board"
type="button"
>
<i
class="CompassIcon icon-lock-outline LockOutlineIcon"
/>
<span>
Share
</span>
</button>
</div>
</div>
</div>
<div
class="ViewHeader"
>
<div
class="viewSelector"
>
<input
class="Editable "
placeholder="Untitled View"
spellcheck="true"
title="view title"
value="view title"
/>
<div>
<div
aria-label="View menu"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</button>
</div>
</div>
</div>
<div
class="octo-spacer"
/>
<div
aria-label="Properties menu"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<span>
Properties
</span>
</button>
</div>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<span>
Group by:
<span
id="groupByLabel"
>
name
</span>
</span>
</button>
</div>
<div
class="ModalWrapper"
>
<button
type="button"
>
<span>
Filter
</span>
</button>
</div>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<span>
Sort
</span>
</button>
</div>
<div
class="board-search-field"
>
<i
class="CompassIcon icon-magnify board-search-icon"
/>
<input
class="Editable "
placeholder="Search cards"
value=""
/>
</div>
<div
class="ModalWrapper"
>
<div
aria-label="View header menu"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
<div
class="ButtonWithMenu"
>
<div
class="button-text"
>
New
</div>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="button-dropdown"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="Table"
>
<div
class="octo-table-body"
>
<div
class="octo-table-header TableHeaders"
id="mainBoardHeader"
>
<div
class="octo-table-cell header-cell"
style="overflow: unset; opacity: 1; width: 100px;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
>
Name
</span>
</div>
<div
class="octo-spacer"
/>
<div
class="HorizontalGrip"
/>
</div>
</div>
<div
class="table-row-container"
>
<div
class="octo-table-group"
>
<div
class="octo-group-header-cell expanded"
draggable="true"
style="opacity: 1;"
>
<div
class="octo-table-cell"
style="width: 100px;"
>
<button
type="button"
>
<svg
class="DisclosureTriangleIcon Icon"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="37,35 37,65 63,50"
/>
</svg>
</button>
<span
class="Label empty "
title="Items with an empty name property will go here. This column cannot be removed."
>
No name
</span>
</div>
<button
type="button"
>
<span>
0
</span>
</button>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
<button
type="button"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</button>
</div>
</div>
<div
class="octo-table-group"
>
<div
class="octo-group-header-cell expanded"
draggable="true"
style="opacity: 1;"
>
<div
class="octo-table-cell"
style="width: 100px;"
>
<button
type="button"
>
<svg
class="DisclosureTriangleIcon Icon"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="37,35 37,65 63,50"
/>
</svg>
</button>
<span
class="Label propColorOrange "
>
<input
class="Editable "
placeholder="New Select"
spellcheck="true"
title="Q1"
value="Q1"
/>
</span>
</div>
<button
type="button"
>
<span>
2
</span>
</button>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
<button
type="button"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</button>
</div>
<div
class="TableRow octo-table-row"
draggable="true"
style="opacity: 1;"
>
<div
class="action-cell octo-table-cell-btn"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<button
aria-label="MenuBtn"
title="MenuBtn"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
<button
type="button"
>
<svg
class="GripIcon Icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0V0z"
fill="none"
/>
<path
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</button>
</div>
<div
class="octo-table-cell title-cell"
id="mainBoardHeader"
style="width: 100px;"
>
<div
class="octo-icontitle"
>
<div
class="octo-icon"
>
i
</div>
<input
class="Editable "
placeholder="Untitled"
spellcheck="true"
title="card1"
value="card1"
/>
</div>
<div
class="open-button"
>
<button
type="button"
>
<span>
Open
</span>
</button>
</div>
</div>
</div>
<div
class="TableRow octo-table-row"
draggable="true"
style="opacity: 1;"
>
<div
class="action-cell octo-table-cell-btn"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<button
aria-label="MenuBtn"
title="MenuBtn"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
<button
type="button"
>
<svg
class="GripIcon Icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0V0z"
fill="none"
/>
<path
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</button>
</div>
<div
class="octo-table-cell title-cell"
id="mainBoardHeader"
style="width: 100px;"
>
<div
class="octo-icontitle"
>
<div
class="octo-icon"
>
i
</div>
<input
class="Editable "
placeholder="Untitled"
spellcheck="true"
title="card2"
value="card2"
/>
</div>
<div
class="open-button"
>
<button
type="button"
>
<span>
Open
</span>
</button>
</div>
</div>
</div>
</div>
<div
class="octo-table-group"
>
<div
class="octo-group-header-cell expanded"
draggable="true"
style="opacity: 1;"
>
<div
class="octo-table-cell"
style="width: 100px;"
>
<button
type="button"
>
<svg
class="DisclosureTriangleIcon Icon"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="37,35 37,65 63,50"
/>
</svg>
</button>
<span
class="Label propColorBlue "
>
<input
class="Editable "
placeholder="New Select"
spellcheck="true"
title="Q2"
value="Q2"
/>
</span>
</div>
<button
type="button"
>
<span>
0
</span>
</button>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
<button
type="button"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</button>
</div>
</div>
</div>
<div
class="octo-table-footer"
/>
<div
class="CalculationRow octo-table-row"
>
<div
class="Calculation count octo-table-cell "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Count
</span>
<span
class="calculationValue"
>
2
</span>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`components/centerPanel return centerPanel and press touch 1 with readonly 1`] = `
<div>
<div

View File

@ -59,7 +59,8 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => {
props: {},
create_at: 0,
update_at: 0,
is_bot: false
is_bot: false,
roles: 'system_user',
}
const template1Title = 'Template 1'
const globalTemplateTitle = 'Template Global'

View File

@ -96,13 +96,14 @@ describe('components/boardTemplateSelector/boardTemplateSelectorItem', () => {
}
const me: IUser = {
id: 'user-id-1',
username: 'username_1',
email: '',
props: {},
create_at: 0,
update_at: 0,
is_bot: false
id: 'user-id-1',
username: 'username_1',
email: '',
props: {},
create_at: 0,
update_at: 0,
is_bot: false,
roles: 'system_user',
}
let store:MockStoreEnhanced<unknown, unknown>

View File

@ -78,6 +78,7 @@ jest.mock('../../octoClient', () => {
'group-prop-id': 'test',
},
},
limited: false,
},
])),
}

View File

@ -105,6 +105,7 @@ const BoardTemplateSelectorPreview = (props: Props) => {
onCardClicked={() => null}
addCard={() => Promise.resolve()}
showCard={() => null}
hiddenCardsCount={0}
/>}
{activeView?.fields.viewType === 'table' &&
<Table
@ -120,6 +121,7 @@ const BoardTemplateSelectorPreview = (props: Props) => {
onCardClicked={() => null}
addCard={() => Promise.resolve()}
showCard={() => null}
hiddenCardsCount={0}
/>}
{activeView?.fields.viewType === 'gallery' &&
<Gallery
@ -130,6 +132,7 @@ const BoardTemplateSelectorPreview = (props: Props) => {
selectedCardIds={[]}
onCardClicked={() => null}
addCard={() => Promise.resolve()}
hiddenCardsCount={0}
/>}
{activeView?.fields.viewType === 'calendar' &&
<CalendarFullView

View File

@ -36,8 +36,10 @@ describe('components/cardBadges', () => {
const state: Partial<RootState> = {
cards: {
current: '',
limitTimestamp: 0,
cards: blocksById([card, emptyCard]),
templates: {},
cardHiddenWarning: true,
},
comments: {
comments: blocksById(comments),

View File

@ -0,0 +1,148 @@
// 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 {getCurrentBoardHiddenCardsCount, getCardHiddenWarning} from '../store/cards'
import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../telemetry/telemetryClient'
import octoClient from '../octoClient'
import NotificationBox from '../widgets/notification-box'
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<number>(getCurrentBoardHiddenCardsCount)
const cardHiddenWarning = useAppSelector<boolean>(getCardHiddenWarning)
const me = useAppSelector<IUser|null>(getMe)
const snoozedUntil = useAppSelector<number>(getCardLimitSnoozeUntil)
const snoozedCardHiddenWarningUntil = useAppSelector<number>(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(() => {
(window as any).openPricingModal()()
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.LimitCardLimitLinkOpen, {})
}, [])
const hasPermissionToUpgrade = me?.roles?.split(' ').indexOf('system_admin') !== -1
if (!show) {
return null
}
return (
<NotificationBox
icon={<AlertIcon/>}
title={title}
onClose={onClose}
closeTooltip={intl.formatMessage({
id: 'notification-box-card-limit-reached.close-tooltip',
defaultMessage: 'Snooze for 10 days',
})}
>
{hasPermissionToUpgrade &&
<FormattedMessage
id='notification-box.card-limit-reached.text'
defaultMessage='Card limit reached, to view older cards, {link}'
values={{
link: (
<a
onClick={onClick}
>
<FormattedMessage
id='notification-box-card-limit-reached.link'
defaultMessage='upgrade to a paid plan'
/>
</a>),
}}
/>}
{!hasPermissionToUpgrade &&
<FormattedMessage
id='notification-box.card-limit-reached.not-admin.text'
defaultMessage='To access archived cards, contact your admin to upgrade to a paid plan.'
/>}
</NotificationBox>
)
}
export default React.memo(CardLimitNotification)

View File

@ -81,4 +81,11 @@
position: relative;
flex: 0 0 auto;
}
.NotificationBox {
.AlertIcon {
color: #ffbc1f;
font-size: 24px;
}
}
}

View File

@ -105,6 +105,14 @@ describe('components/centerPanel', () => {
[board.id]: {userId: 'user_id_1', schemeAdmin: true},
},
},
limits: {
limits: {
cards: 0,
used_cards: 0,
card_limit_timestamp: 0,
views: 0,
},
},
cards: {
templates: [card1, card2],
cards: [card1, card2],
@ -149,6 +157,7 @@ describe('components/centerPanel', () => {
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -166,6 +175,7 @@ describe('components/centerPanel', () => {
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -184,6 +194,7 @@ describe('components/centerPanel', () => {
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -202,6 +213,7 @@ describe('components/centerPanel', () => {
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -221,6 +233,7 @@ describe('components/centerPanel', () => {
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -251,6 +264,7 @@ describe('components/centerPanel', () => {
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -273,6 +287,7 @@ describe('components/centerPanel', () => {
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -301,6 +316,7 @@ describe('components/centerPanel', () => {
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -338,6 +354,7 @@ describe('components/centerPanel', () => {
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -365,6 +382,7 @@ describe('components/centerPanel', () => {
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -393,6 +411,7 @@ describe('components/centerPanel', () => {
showCard={mockedShowCard}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -417,6 +436,7 @@ describe('components/centerPanel', () => {
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -438,6 +458,7 @@ describe('components/centerPanel', () => {
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -449,63 +470,63 @@ describe('components/centerPanel', () => {
expect(mockedMutator.insertBlock).toBeCalledTimes(1)
})
// TODO: Fix this
// test('click on new card to add card from template', () => {
// activeView.fields.viewType = 'table'
// activeView.fields.defaultTemplateId = '1'
// const {container} = render(wrapDNDIntl(
// <ReduxProvider store={store}>
// <CenterPanel
// cards={[card1, card2]}
// views={[activeView]}
// board={board}
// activeView={activeView}
// readonly={false}
// showCard={jest.fn()}
// showShared={true}
// groupByProperty={groupProperty}
// shownCardId={card1.id}
// />
// </ReduxProvider>,
// ))
// const elementMenuWrapper = container.querySelector('.ButtonWithMenu > div.MenuWrapper')
// expect(elementMenuWrapper).not.toBeNull()
// userEvent.click(elementMenuWrapper!)
// const elementCard1 = within(elementMenuWrapper!.parentElement!).getByRole('button', {name: 'card1'})
// expect(elementCard1).not.toBeNull()
// userEvent.click(elementCard1)
// expect(mockedMutator.performAsUndoGroup).toBeCalledTimes(1)
// })
// test('click on new card to edit template', () => {
// activeView.fields.viewType = 'table'
// activeView.fields.defaultTemplateId = '1'
// const {container} = render(wrapDNDIntl(
// <ReduxProvider store={store}>
// <CenterPanel
// cards={[card1, card2]}
// views={[activeView]}
// board={board}
// activeView={activeView}
// readonly={false}
// showCard={jest.fn()}
// showShared={true}
// groupByProperty={groupProperty}
// shownCardId={card1.id}
// />
// </ReduxProvider>,
// ))
// const elementMenuWrapper = container.querySelector('.ButtonWithMenu > div.MenuWrapper')
// expect(elementMenuWrapper).not.toBeNull()
// userEvent.click(elementMenuWrapper!)
// const elementCard1 = within(elementMenuWrapper!.parentElement!).getByRole('button', {name: 'card1'})
// expect(elementCard1).not.toBeNull()
// const elementMenuWrapperCard1 = within(elementCard1).getByRole('button', {name: 'menuwrapper'})
// expect(elementMenuWrapperCard1).not.toBeNull()
// userEvent.click(elementMenuWrapperCard1)
// const elementEditMenuTemplate = within(elementMenuWrapperCard1).getByRole('button', {name: 'Edit'})
// expect(elementMenuWrapperCard1).not.toBeNull()
// userEvent.click(elementEditMenuTemplate)
// expect(container).toMatchSnapshot()
// })
test('click on new card to add card from template', () => {
activeView.fields.viewType = 'table'
activeView.fields.defaultTemplateId = '1'
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<CenterPanel
cards={[card1, card2]}
views={[activeView]}
board={board}
activeView={activeView}
readonly={false}
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
const elementMenuWrapper = container.querySelector('.ButtonWithMenu > div.MenuWrapper')
expect(elementMenuWrapper).not.toBeNull()
userEvent.click(elementMenuWrapper!)
const elementCard1 = within(elementMenuWrapper!.parentElement!).getByRole('button', {name: 'card1'})
expect(elementCard1).not.toBeNull()
userEvent.click(elementCard1)
expect(mockedMutator.performAsUndoGroup).toBeCalledTimes(1)
})
test('click on new card to edit template', () => {
activeView.fields.viewType = 'table'
activeView.fields.defaultTemplateId = '1'
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<CenterPanel
cards={[card1, card2]}
views={[activeView]}
board={board}
activeView={activeView}
readonly={false}
showCard={jest.fn()}
groupByProperty={groupProperty}
shownCardId={card1.id}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
const elementMenuWrapper = container.querySelector('.ButtonWithMenu > div.MenuWrapper')
expect(elementMenuWrapper).not.toBeNull()
userEvent.click(elementMenuWrapper!)
const elementCard1 = within(elementMenuWrapper!.parentElement!).getByRole('button', {name: 'card1'})
expect(elementCard1).not.toBeNull()
const elementMenuWrapperCard1 = within(elementCard1).getByRole('button', {name: 'menuwrapper'})
expect(elementMenuWrapperCard1).not.toBeNull()
userEvent.click(elementMenuWrapperCard1)
const elementEditMenuTemplate = within(elementMenuWrapperCard1).getByRole('button', {name: 'Edit'})
expect(elementMenuWrapperCard1).not.toBeNull()
userEvent.click(elementEditMenuTemplate)
expect(container).toMatchSnapshot()
})
})
})

View File

@ -16,7 +16,8 @@ import {CardFilter} from '../cardFilter'
import mutator from '../mutator'
import {Utils} from '../utils'
import {UserSettings} from '../userSettings'
import {getCurrentCard, addCard as addCardAction, addTemplate as addTemplateAction} from '../store/cards'
import {getCurrentCard, addCard as addCardAction, addTemplate as addTemplateAction, showCardHiddenWarning} from '../store/cards'
import {getCardLimitTimestamp} from '../store/limits'
import {updateView} from '../store/views'
import {getVisibleAndHiddenGroups} from '../boardUtils'
import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../../webapp/src/telemetry/telemetryClient'
@ -51,6 +52,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 +69,7 @@ type Props = {
readonly: boolean
shownCardId?: string
showCard: (cardId?: string) => void
hiddenCardsCount: number
}
const CenterPanel = (props: Props) => {
@ -76,6 +80,7 @@ const CenterPanel = (props: Props) => {
const onboardingTourStarted = useAppSelector(getOnboardingTourStarted)
const onboardingTourCategory = useAppSelector(getOnboardingTourCategory)
const onboardingTourStep = useAppSelector(getOnboardingTourStep)
const cardLimitTimestamp = useAppSelector(getCardLimitTimestamp)
const me = useAppSelector(getMe)
const currentCard = useAppSelector(getCurrentCard)
const dispatch = useAppDispatch()
@ -198,6 +203,7 @@ const CenterPanel = (props: Props) => {
showCard(undefined)
},
)
dispatch(showCardHiddenWarning(cardLimitTimestamp > 0))
await mutator.changeViewCardOrder(board.id, activeView.id, activeView.fields.cardOrder, [...activeView.fields.cardOrder, newCard.id], 'add-card')
})
}, [props.activeView, props.board.id, props.board.cardProperties, props.groupByProperty, showCard])
@ -349,6 +355,7 @@ const CenterPanel = (props: Props) => {
className='BoardComponent'
onClick={backgroundClicked}
>
<CardLimitNotification/>
{props.shownCardId &&
<RootPortal>
<CardDialog
@ -412,6 +419,7 @@ const CenterPanel = (props: Props) => {
onCardClicked={cardClicked}
addCard={addCard}
showCard={showCard}
hiddenCardsCount={props.hiddenCardsCount}
/>}
{activeView.fields.viewType === 'table' &&
<Table
@ -427,6 +435,7 @@ const CenterPanel = (props: Props) => {
showCard={showCard}
addCard={addCard}
onCardClicked={cardClicked}
hiddenCardsCount={props.hiddenCardsCount}
/>}
{activeView.fields.viewType === 'calendar' &&
<CalendarFullView
@ -450,6 +459,7 @@ const CenterPanel = (props: Props) => {
onCardClicked={cardClicked}
selectedCardIds={selectedCardIds}
addCard={(show) => addCard('', show)}
hiddenCardsCount={props.hiddenCardsCount}
/>}
</div>
)

View File

@ -33,6 +33,7 @@ const checkboxBlock: ContentBlock = {
createAt: 0,
updateAt: 0,
deleteAt: 0,
limited: false,
}
const cardDetailContextValue = (autoAdded: boolean): CardDetailContextType => ({

View File

@ -30,6 +30,7 @@ const contentBlock: ContentBlock = {
createAt: 0,
updateAt: 0,
deleteAt: 0,
limited: false,
}
const wrap = (child: ReactNode): ReactElement => (

View File

@ -36,6 +36,7 @@ describe('components/content/ImageElement', () => {
createAt: 0,
updateAt: 0,
deleteAt: 0,
limited: false,
}
test('should match snapshot', async () => {

View File

@ -37,6 +37,7 @@ const defaultBlock: TextBlock = {
createAt: 0,
updateAt: 0,
deleteAt: 0,
limited: false,
}
describe('components/content/TextElement', () => {
beforeAll(() => {

View File

@ -1,5 +1,87 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`src/components/gallery/Gallery limited card count check 1`] = `
<div>
<div
class="Gallery"
>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
<div
class="gallery-item"
/>
</div>
<div
class="GalleryCard"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
<div
class="gallery-item"
/>
</div>
<div
class="octo-gallery-new"
>
+ New
</div>
<div
class="gallery-hidden-cards"
>
<div
class="HiddenCardCount"
>
<div
class="hidden-card-title"
>
Cards Hidden
</div>
<button
class="Button"
title="hidden-card-count"
type="button"
>
<span>
2
</span>
</button>
</div>
</div>
</div>
</div>
`;
exports[`src/components/gallery/Gallery return Gallery and click new 1`] = `
<div>
<div

View File

@ -25,4 +25,8 @@
background-color: rgba(var(--center-channel-color-rgb), 0.05);
}
}
.gallery-hidden-cards {
margin-left: 6px;
}
}

View File

@ -38,10 +38,12 @@ describe('src/components/gallery/Gallery', () => {
},
cards: {
current: '',
limitTimestamp: 0,
cards: {
[card.id]: card,
},
templates: {},
cardHiddenWarning: true,
},
teams: {
current: {id: 'team-id'},
@ -63,7 +65,7 @@ describe('src/components/gallery/Gallery', () => {
id: 'user_id_1',
props: {},
},
}
},
}
const store = mockStateStore([], state)
beforeEach(() => {
@ -80,6 +82,7 @@ describe('src/components/gallery/Gallery', () => {
addCard={jest.fn()}
selectedCardIds={[card.id]}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -99,6 +102,7 @@ describe('src/components/gallery/Gallery', () => {
addCard={jest.fn()}
selectedCardIds={[card.id]}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -118,6 +122,7 @@ describe('src/components/gallery/Gallery', () => {
addCard={mockAddCard}
selectedCardIds={[card.id]}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -140,6 +145,7 @@ describe('src/components/gallery/Gallery', () => {
addCard={jest.fn()}
selectedCardIds={[card.id]}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -156,6 +162,7 @@ describe('src/components/gallery/Gallery', () => {
addCard={jest.fn()}
selectedCardIds={[]}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
))
@ -168,4 +175,67 @@ describe('src/components/gallery/Gallery', () => {
fireEvent.drop(drop)
expect(mockedMutator.performAsUndoGroup).toBeCalledTimes(1)
})
test('limited card count check', () => {
const boardTest = TestBlockFactory.createBoard()
const card1 = TestBlockFactory.createCard(boardTest)
const card3 = TestBlockFactory.createCard(boardTest)
const stateTest = {
contents: {
contents: blocksById(contents),
contentsByCard: {
[card.id]: [contents[0], contents[1]],
[card2.id]: [contents[2]],
},
},
cards: {
current: '',
cards: {
[card1.id]: card1,
[card3.id]: card3,
},
templates: {},
cardHiddenWarning: true,
limitTimestamp: 2,
},
users: {
me: {
id: 'user_id_1',
props: {},
},
},
teams: {
current: {id: 'team-id'},
},
comments: {
comments: {},
},
boards: {
current: board.id,
boards: {
[board.id]: board,
},
myBoardMemberships: {
[board.id]: {userId: 'user_id_1', schemeAdmin: true},
},
},
}
const storeTest = mockStateStore([], stateTest)
const {container, getByTitle} = render(wrapDNDIntl(
<ReduxProvider store={storeTest}>
<Gallery
board={boardTest}
cards={[card1, card3]}
activeView={activeView}
readonly={false}
addCard={jest.fn()}
selectedCardIds={[card1.id]}
onCardClicked={jest.fn()}
hiddenCardsCount={2}
/>
</ReduxProvider>,
))
expect(getByTitle('hidden-card-count').innerHTML).toBe('<span>2</span>')
expect(container).toMatchSnapshot()
})
})

View File

@ -4,6 +4,8 @@ import React, {useMemo, useCallback} from 'react'
import {FormattedMessage} from 'react-intl'
import {Constants, Permission} from '../../constants'
import HiddenCardCount from '../../components/hiddenCardCount/hiddenCardCount'
import {Card} from '../../blocks/card'
import {Board, IPropertyTemplate} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
@ -23,10 +25,11 @@ type Props = {
addCard: (show: boolean) => Promise<void>
selectedCardIds: string[]
onCardClicked: (e: React.MouseEvent, card: Card) => void
hiddenCardsCount: number
}
const Gallery = (props: Props): JSX.Element => {
const {activeView, board, cards} = props
const {activeView, board, cards, hiddenCardsCount} = props
const visiblePropertyTemplates = useMemo(() => {
return board.cardProperties.filter(
(template: IPropertyTemplate) => activeView.fields.visiblePropertyIds.includes(template.id),
@ -61,6 +64,7 @@ const Gallery = (props: Props): JSX.Element => {
const visibleBadges = activeView.fields.visiblePropertyIds.includes(Constants.badgesColumnId)
return (
<div className='Gallery'>
{cards.filter((c) => c.boardId === board.id).map((card) => {
return (
@ -97,6 +101,12 @@ const Gallery = (props: Props): JSX.Element => {
</div>
</BoardPermissionGate>
}
{hiddenCardsCount > 0 &&
<div className='gallery-hidden-cards'>
<HiddenCardCount
hiddenCardsCount={hiddenCardsCount}
/>
</div>}
</div>
)
}

View File

@ -0,0 +1,13 @@
.HiddenCardCount {
display: flex;
height: 30px;
.hidden-card-title {
background: rgba(243, 192, 199, 0.2);
color: #d24b4e;
padding: 3px 6px;
border-radius: 4px;
text-transform: uppercase;
font-weight: bold;
}
}

View File

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {useIntl} from 'react-intl'
import Button from '../../widgets/buttons/button'
import './hiddenCardCount.scss'
type Props = {
hiddenCardsCount: number
}
const HiddenCardCount = (props: Props): JSX.Element => {
const intl = useIntl()
return (
<div className='HiddenCardCount'>
<div className='hidden-card-title'>{intl.formatMessage({id: 'limitedCard.title', defaultMessage: 'Cards Hidden'})}</div>
<Button title='hidden-card-count'>{props.hiddenCardsCount}</Button>
</div>
)
}
export default HiddenCardCount

View File

@ -1,5 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`src/components/kanban/kanbanHiddenColumnItem limited card check 1`] = `
<div>
<div
class="octo-board-hidden-item"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label propColorDefault "
>
propOption
</span>
</div>
<button
class="Button"
title="hidden-card-count"
type="button"
>
<span>
2
</span>
</button>
</div>
</div>
`;
exports[`src/components/kanban/kanbanHiddenColumnItem return kanbanHiddenColumnItem and click menuwrapper 1`] = `
<div>
<div

View File

@ -93,4 +93,12 @@
margin: 5px;
}
}
.hidden-card {
display: block;
}
.kanban-hidden-cards {
margin-left: 4px;
}
}

View File

@ -127,6 +127,7 @@ describe('src/component/kanban/kanban', () => {
onCardClicked={jest.fn()}
addCard={jest.fn()}
showCard={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
), {wrapper: MemoryRouter})
@ -161,6 +162,7 @@ describe('src/component/kanban/kanban', () => {
onCardClicked={jest.fn()}
addCard={jest.fn()}
showCard={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
), {wrapper: MemoryRouter})
@ -194,6 +196,7 @@ describe('src/component/kanban/kanban', () => {
onCardClicked={jest.fn()}
addCard={jest.fn()}
showCard={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
), {wrapper: MemoryRouter})
@ -229,6 +232,7 @@ describe('src/component/kanban/kanban', () => {
onCardClicked={jest.fn()}
addCard={jest.fn()}
showCard={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
), {wrapper: MemoryRouter})
@ -274,6 +278,7 @@ describe('src/component/kanban/kanban', () => {
onCardClicked={jest.fn()}
addCard={jest.fn()}
showCard={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
), {wrapper: MemoryRouter})
@ -319,6 +324,7 @@ describe('src/component/kanban/kanban', () => {
onCardClicked={jest.fn()}
addCard={jest.fn()}
showCard={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
), {wrapper: MemoryRouter})
@ -365,6 +371,7 @@ describe('src/component/kanban/kanban', () => {
onCardClicked={jest.fn()}
addCard={mockedAddCard}
showCard={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
), {wrapper: MemoryRouter})
@ -402,6 +409,7 @@ describe('src/component/kanban/kanban', () => {
onCardClicked={jest.fn()}
addCard={jest.fn()}
showCard={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
), {wrapper: MemoryRouter})
@ -439,6 +447,7 @@ describe('src/component/kanban/kanban', () => {
onCardClicked={jest.fn()}
addCard={jest.fn()}
showCard={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
), {wrapper: MemoryRouter})
@ -483,6 +492,7 @@ describe('src/component/kanban/kanban', () => {
onCardClicked={jest.fn()}
addCard={jest.fn()}
showCard={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
), {wrapper: MemoryRouter})

View File

@ -19,6 +19,7 @@ import {Constants, Permission} from '../../constants'
import {dragAndDropRearrange} from '../cardDetail/cardDetailContentsUtility'
import BoardPermissionGate from '../permissions/boardPermissionGate'
import HiddenCardCount from '../../components/hiddenCardCount/hiddenCardCount'
import KanbanCard from './kanbanCard'
import KanbanColumn from './kanbanColumn'
@ -40,6 +41,7 @@ type Props = {
onCardClicked: (e: React.MouseEvent, card: Card) => void
addCard: (groupByOptionId?: string, show?:boolean) => Promise<void>
showCard: (cardId?: string) => void
hiddenCardsCount: number
}
const ScrollingComponent = withScrolling('div')
@ -47,7 +49,7 @@ const hStrength = createHorizontalStrength(Utils.isMobile() ? 60 : 250)
const vStrength = createVerticalStrength(Utils.isMobile() ? 60 : 250)
const Kanban = (props: Props) => {
const {board, activeView, cards, groupByProperty, visibleGroups, hiddenGroups} = props
const {board, activeView, cards, groupByProperty, visibleGroups, hiddenGroups, hiddenCardsCount} = props
if (!groupByProperty) {
Utils.assertFailure('Board views must have groupByProperty set')
@ -232,7 +234,7 @@ const Kanban = (props: Props) => {
{/* Hidden column header */}
{hiddenGroups.length > 0 &&
{(hiddenGroups.length > 0 || hiddenCardsCount > 0) &&
<div className='octo-board-header-cell narrow'>
<FormattedMessage
id='BoardComponent.hidden-columns'
@ -304,7 +306,7 @@ const Kanban = (props: Props) => {
{/* Hidden columns */}
{hiddenGroups.length > 0 &&
{(hiddenGroups.length > 0 || hiddenCardsCount > 0) &&
<div className='octo-board-column narrow'>
{hiddenGroups.map((group) => (
<KanbanHiddenColumnItem
@ -316,6 +318,10 @@ const Kanban = (props: Props) => {
onDrop={(card: Card) => onDropToColumn(group.option, card)}
/>
))}
{hiddenCardsCount > 0 &&
<div className='ml-1'>
<HiddenCardCount hiddenCardsCount={hiddenCardsCount}/>
</div>}
</div>}
</div>
</ScrollingComponent>

View File

@ -22,6 +22,7 @@ describe('src/components/kanban/kanbanHiddenColumnItem', () => {
const board = TestBlockFactory.createBoard()
const activeView = TestBlockFactory.createBoardView(board)
const card = TestBlockFactory.createCard(board)
const card2 = TestBlockFactory.createCard(board)
const option:IPropertyOption = {
id: 'id1',
value: 'propOption',
@ -99,4 +100,24 @@ describe('src/components/kanban/kanbanHiddenColumnItem', () => {
userEvent.click(buttonShow)
expect(mockedMutator.unhideViewColumn).toBeCalledWith(activeView.boardId, activeView, option.id)
})
test('limited card check', () => {
card.limited = true
card2.limited = true
option.id = 'hidden-card-group-id'
const {container, getByTitle} = render(wrapDNDIntl(
<KanbanHiddenColumnItem
activeView={activeView}
group={{
option,
cards: [card, card2],
}}
readonly={false}
onDrop={jest.fn()}
intl={intl}
/>,
))
expect(getByTitle('hidden-card-count')).toHaveTextContent('2')
expect(container).toMatchSnapshot()
})
})

View File

@ -6,7 +6,6 @@ import {IntlShape} from 'react-intl'
import {useDrop} from 'react-dnd'
import mutator from '../../mutator'
import Button from '../../widgets/buttons/button'
import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import ShowIcon from '../../widgets/icons/show'
@ -15,6 +14,8 @@ import {Card} from '../../blocks/card'
import {BoardGroup} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import Button from '../../widgets/buttons/button'
type Props = {
activeView: BoardView
group: BoardGroup
@ -25,6 +26,8 @@ type Props = {
export default function KanbanHiddenColumnItem(props: Props): JSX.Element {
const {activeView, intl, group} = props
const hiddenCardGroupId = 'hidden-card-group-id'
const [{isOver}, drop] = useDrop(() => ({
accept: 'card',
collect: (monitor) => ({
@ -64,7 +67,8 @@ export default function KanbanHiddenColumnItem(props: Props): JSX.Element {
/>
</Menu>
</MenuWrapper>
<Button>{`${group.cards.length}`}</Button>
{props.group.option.id !== hiddenCardGroupId && <Button>{`${group.cards.length}`}</Button>}
{props.group.option.id === hiddenCardGroupId && <Button title='hidden-card-count'>{`${group.cards.length}`}</Button>}
</div>
)
}

View File

@ -45,6 +45,7 @@ describe('components/messages/CloudMessage', () => {
create_at: 0,
update_at: 0,
is_bot: false,
roles: 'system_user',
}
const state = {
users: {
@ -75,6 +76,7 @@ describe('components/messages/CloudMessage', () => {
create_at: 0,
update_at: 0,
is_bot: false,
roles: 'system_user',
}
const state = {
users: {
@ -103,6 +105,7 @@ describe('components/messages/CloudMessage', () => {
create_at: 0,
update_at: 0,
is_bot: false,
roles: 'system_user',
}
const state = {
users: {
@ -139,6 +142,7 @@ describe('components/messages/CloudMessage', () => {
create_at: 0,
update_at: Date.now() - (1000 * 60 * 60 * 24), //24 hours,
is_bot: false,
roles: 'system_user',
}
const state = {
users: {

View File

@ -96,7 +96,7 @@ card3.id = 'card3'
card3.title = 'card-3'
card3.boardId = fakeBoard.id
const me: IUser = {id: 'user-id-1', username: 'username_1', email: '', props: {}, create_at: 0, update_at: 0, is_bot: false}
const me: IUser = {id: 'user-id-1', username: 'username_1', email: '', props: {}, create_at: 0, update_at: 0, is_bot: false, roles: 'system_user'}
const categoryAttribute1 = TestBlockFactory.createCategoryBoards()
categoryAttribute1.name = 'Category 1'

View File

@ -1856,6 +1856,414 @@ exports[`components/table/Table extended should match snapshot with UpdatedBy 1`
</div>
`;
exports[`components/table/Table limited card in table view 1`] = `
<div>
<div
class="Table"
>
<div
class="octo-table-body"
>
<div
class="octo-table-header TableHeaders"
id="mainBoardHeader"
>
<div
class="octo-table-cell header-cell"
style="overflow: unset; opacity: 1; width: 100px;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper disabled"
role="button"
>
<span
class="Label empty "
>
Name
</span>
</div>
<div
class="octo-spacer"
/>
</div>
<div
class="octo-table-cell header-cell"
draggable="true"
style="overflow: unset; opacity: 1; width: 100px;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper disabled"
role="button"
>
<span
class="Label empty "
>
Property 1
<svg
class="SortDownIcon Icon"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="50,20 50,80"
/>
<polyline
points="30,60 50,80 70,60"
/>
</svg>
</span>
</div>
<div
class="octo-spacer"
/>
</div>
<div
class="octo-table-cell header-cell"
draggable="true"
style="overflow: unset; opacity: 1; width: 100px;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper disabled"
role="button"
>
<span
class="Label empty "
>
Property 2
<svg
class="SortUpIcon Icon"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="50,20 50,80"
/>
<polyline
points="30,40 50,20 70,40"
/>
</svg>
</span>
</div>
<div
class="octo-spacer"
/>
</div>
</div>
<div
class="table-row-container"
>
<div
class="TableRow octo-table-row"
draggable="true"
style="opacity: 1;"
>
<div
class="action-cell octo-table-cell-btn"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<button
aria-label="MenuBtn"
class="IconButton"
title="MenuBtn"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
<button
class="IconButton"
type="button"
>
<svg
class="GripIcon Icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0V0z"
fill="none"
/>
<path
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</button>
</div>
<div
class="octo-table-cell title-cell"
id="mainBoardHeader"
style="width: 100px;"
>
<div
class="octo-icontitle"
>
<div
class="octo-icon"
>
i
</div>
<input
class="Editable readonly "
placeholder="Untitled"
readonly=""
spellcheck="true"
title="title"
value="title"
/>
</div>
<div
class="open-button"
>
<button
class="Button"
type="button"
>
<span>
Open
</span>
</button>
</div>
</div>
<div
class="octo-table-cell"
style="width: 100px;"
>
<div
class="octo-propertyvalue octo-propertyvalue--readonly"
data-testid="select-non-editable"
tabindex="0"
>
<span
class="Label propColorBrown "
>
<span
class="Label-text"
>
value 1
</span>
</span>
</div>
</div>
<div
class="octo-table-cell"
style="width: 100px;"
>
<div
class="octo-propertyvalue octo-propertyvalue--readonly"
data-testid="select-non-editable"
tabindex="0"
>
<span
class="Label empty "
>
<span
class="Label-text"
/>
</span>
</div>
</div>
</div>
<div
class="TableRow octo-table-row"
draggable="true"
style="opacity: 1;"
>
<div
class="action-cell octo-table-cell-btn"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<button
aria-label="MenuBtn"
class="IconButton"
title="MenuBtn"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
<button
class="IconButton"
type="button"
>
<svg
class="GripIcon Icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0V0z"
fill="none"
/>
<path
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</button>
</div>
<div
class="octo-table-cell title-cell"
id="mainBoardHeader"
style="width: 100px;"
>
<div
class="octo-icontitle"
>
<div
class="octo-icon"
>
i
</div>
<input
class="Editable readonly "
placeholder="Untitled"
readonly=""
spellcheck="true"
title="title"
value="title"
/>
</div>
<div
class="open-button"
>
<button
class="Button"
type="button"
>
<span>
Open
</span>
</button>
</div>
</div>
<div
class="octo-table-cell"
style="width: 100px;"
>
<div
class="octo-propertyvalue octo-propertyvalue--readonly"
data-testid="select-non-editable"
tabindex="0"
>
<span
class="Label propColorBrown "
>
<span
class="Label-text"
>
value 1
</span>
</span>
</div>
</div>
<div
class="octo-table-cell"
style="width: 100px;"
>
<div
class="octo-propertyvalue octo-propertyvalue--readonly"
data-testid="select-non-editable"
tabindex="0"
>
<span
class="Label empty "
>
<span
class="Label-text"
/>
</span>
</div>
</div>
</div>
</div>
<div
class="octo-table-footer"
/>
<div
class="CalculationRow octo-table-row"
>
<div
class="Calculation count octo-table-cell disabled "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Count
</span>
<span
class="calculationValue"
>
2
</span>
</div>
<div
class="Calculation none octo-table-cell disabled "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Calculate
</span>
<i
class="CompassIcon icon-chevron-up ChevronUpIcon"
/>
</div>
<div
class="Calculation none octo-table-cell disabled "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Calculate
</span>
<i
class="CompassIcon icon-chevron-up ChevronUpIcon"
/>
</div>
</div>
</div>
<div
class="HiddenCardCount"
>
<div
class="hidden-card-title"
>
Cards Hidden
</div>
<button
class="Button"
title="hidden-card-count"
type="button"
>
<span>
2
</span>
</button>
</div>
</div>
</div>
`;
exports[`components/table/Table should match snapshot 1`] = `
<div>
<div

View File

@ -99,6 +99,7 @@ describe('components/table/Table', () => {
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
)
@ -127,6 +128,7 @@ describe('components/table/Table', () => {
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
)
@ -155,6 +157,7 @@ describe('components/table/Table', () => {
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
)
@ -190,12 +193,74 @@ describe('components/table/Table', () => {
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('limited card in table view', () => {
const callback = jest.fn()
const addCard = jest.fn()
const boardTest = TestBlockFactory.createBoard()
const card1 = TestBlockFactory.createCard(boardTest)
const card2 = TestBlockFactory.createCard(boardTest)
const mockStore = configureStore([])
const stateTest = {
comments: {
comments: {},
},
contents: {
contents: {},
},
cards: {
cards: {
[card1.id]: card1,
[card2.id]: card2,
},
},
teams: {
current: {id: 'team-id'},
},
boards: {
current: boardTest.id,
boards: {
[boardTest.id]: boardTest,
},
myBoardMemberships: {
[boardTest.id]: {userId: 'user_id_1', schemeAdmin: true},
},
},
}
const storeTest = mockStore(stateTest)
card.limited = true
const component = wrapDNDIntl(
<ReduxProvider store={storeTest}>
<Table
board={boardTest}
activeView={view}
visibleGroups={[]}
cards={[card1, card2]}
views={[view, view2]}
selectedCardIds={[]}
readonly={true}
cardIdToFocusOnRender=''
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
hiddenCardsCount={2}
/>
</ReduxProvider>,
)
const {container, getByTitle} = render(component)
expect(getByTitle('hidden-card-count')).toHaveTextContent('2')
expect(container).toMatchSnapshot()
})
})
describe('components/table/Table extended', () => {
@ -295,6 +360,7 @@ describe('components/table/Table extended', () => {
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
)
@ -379,6 +445,7 @@ describe('components/table/Table extended', () => {
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
)
@ -436,6 +503,7 @@ describe('components/table/Table extended', () => {
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
)
@ -526,6 +594,7 @@ describe('components/table/Table extended', () => {
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
)
@ -577,6 +646,7 @@ describe('components/table/Table extended', () => {
showCard={jest.fn()}
addCard={jest.fn()}
onCardClicked={jest.fn()}
hiddenCardsCount={0}
/>
</ReduxProvider>,
)

View File

@ -18,6 +18,8 @@ import BoardPermissionGate from '../permissions/boardPermissionGate'
import './table.scss'
import HiddenCardCount from '../../components/hiddenCardCount/hiddenCardCount'
import TableHeaders from './tableHeaders'
import TableRows from './tableRows'
import TableGroup from './tableGroup'
@ -37,10 +39,11 @@ type Props = {
showCard: (cardId?: string) => void
addCard: (groupByOptionId?: string) => Promise<void>
onCardClicked: (e: React.MouseEvent, card: Card) => void
hiddenCardsCount: number
}
const Table = (props: Props): JSX.Element => {
const {board, cards, activeView, visibleGroups, groupByProperty, views} = props
const {board, cards, activeView, visibleGroups, groupByProperty, views, hiddenCardsCount} = props
const isManualSort = activeView.fields.sortOptions?.length === 0
const canEditBoardProperties = useHasCurrentBoardPermissions([Permission.ManageBoardProperties])
const canEditCards = useHasCurrentBoardPermissions([Permission.ManageBoardCards])
@ -251,6 +254,11 @@ const Table = (props: Props): JSX.Element => {
/>
</div>
</ColumnResizeProvider>
{hiddenCardsCount > 0 &&
<HiddenCardCount
hiddenCardsCount={hiddenCardsCount}
/>}
</div>
)
}

View File

@ -72,7 +72,7 @@ card3.id = 'card3'
card3.title = 'card-3'
card3.boardId = fakeBoard.id
const me: IUser = {id: 'user-id-1', username: 'username_1', email: '', props: {}, create_at: 0, update_at: 0, is_bot: false}
const me: IUser = {id: 'user-id-1', username: 'username_1', email: '', props: {}, create_at: 0, update_at: 0, is_bot: false, roles: 'system_user'}
const categoryAttribute1 = TestBlockFactory.createCategoryBoards()
categoryAttribute1.name = 'Category 1'
@ -114,6 +114,14 @@ describe('src/components/workspace', () => {
[board.id]: {userId: 'user_id_1', schemeAdmin: true},
},
},
limits: {
limits: {
cards: 0,
used_cards: 0,
card_limit_timestamp: 0,
views: 0,
},
},
globalTemplates: {
value: [],
},
@ -302,6 +310,7 @@ describe('src/components/workspace', () => {
create_at: 0,
update_at: 0,
is_bot: false,
roles: 'system_user',
},
boardUsers: [me],
blockSubscriptions: [],
@ -316,6 +325,14 @@ describe('src/components/workspace', () => {
[welcomeBoard.id]: {userId: 'user_id_1', schemeAdmin: true},
},
},
limits: {
limits: {
cards: 0,
used_cards: 0,
card_limit_timestamp: 0,
views: 0,
},
},
globalTemplates: {
value: [],
},
@ -393,6 +410,7 @@ describe('src/components/workspace', () => {
create_at: 0,
update_at: 0,
is_bot: false,
roles: 'system_user',
},
boardUsers: [me],
blockSubscriptions: [],
@ -407,6 +425,14 @@ describe('src/components/workspace', () => {
[welcomeBoard.id]: {userId: 'user_id_1', schemeAdmin: true},
},
},
limits: {
limits: {
cards: 0,
used_cards: 0,
card_limit_timestamp: 0,
views: 0,
},
},
globalTemplates: {
value: [],
},
@ -489,6 +515,7 @@ describe('src/components/workspace', () => {
create_at: 0,
update_at: 0,
is_bot: false,
roles: 'system_user',
},
boardUsers: [me],
blockSubscriptions: [],
@ -503,6 +530,14 @@ describe('src/components/workspace', () => {
[welcomeBoard.id]: {userId: 'user_id_1', schemeAdmin: true},
},
},
limits: {
limits: {
cards: 0,
used_cards: 0,
card_limit_timestamp: 0,
views: 0,
},
},
globalTemplates: {
value: [],
},

View File

@ -5,8 +5,8 @@ import {generatePath, useRouteMatch, useHistory} from 'react-router-dom'
import {FormattedMessage} from 'react-intl'
import {getCurrentTeam} from '../store/teams'
import {getCurrentBoard, isLoadingBoard} from '../store/boards'
import {getCurrentViewCardsSortedFilteredAndGrouped, setCurrent as setCurrentCard} from '../store/cards'
import {getCurrentBoard, isLoadingBoard, getTemplates} from '../store/boards'
import {refreshCards, getCardLimitTimestamp, getCurrentBoardHiddenCardsCount, setLimitTimestamp, getCurrentViewCardsSortedFilteredAndGrouped, setCurrent as setCurrentCard} from '../store/cards'
import {
getCurrentBoardViews,
getCurrentViewGroupBy,
@ -37,12 +37,15 @@ function CenterContent(props: Props) {
const isLoading = useAppSelector(isLoadingBoard)
const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string}>()
const board = useAppSelector(getCurrentBoard)
const templates = useAppSelector(getTemplates)
const cards = useAppSelector(getCurrentViewCardsSortedFilteredAndGrouped)
const activeView = useAppSelector(getCurrentView)
const views = useAppSelector(getCurrentBoardViews)
const groupByProperty = useAppSelector(getCurrentViewGroupBy)
const dateDisplayProperty = useAppSelector(getCurrentViewDisplayBy)
const clientConfig = useAppSelector(getClientConfig)
const hiddenCardsCount = useAppSelector(getCurrentBoardHiddenCardsCount)
const cardLimitTimestamp = useAppSelector(getCardLimitTimestamp)
const history = useHistory()
const dispatch = useAppDispatch()
@ -61,10 +64,19 @@ function CenterContent(props: Props) {
dispatch(setClientConfig(config))
}
wsClient.addOnConfigChange(onConfigChangeHandler)
const onCardLimitTimestampChangeHandler = (_: WSClient, timestamp: number) => {
dispatch(setLimitTimestamp({timestamp, templates}))
if (cardLimitTimestamp > timestamp) {
dispatch(refreshCards(timestamp))
}
}
wsClient.addOnCardLimitTimestampChange(onCardLimitTimestampChangeHandler)
return () => {
wsClient.removeOnConfigChange(onConfigChangeHandler)
}
}, [])
}, [cardLimitTimestamp, match.params.boardId, templates])
if (board && activeView) {
let property = groupByProperty
@ -89,6 +101,7 @@ function CenterContent(props: Props) {
groupByProperty={property}
dateDisplayProperty={displayProperty}
views={views}
hiddenCardsCount={hiddenCardsCount}
/>
)
}

View File

@ -13,6 +13,7 @@ import {Team} from './store/teams'
import {Subscription} from './wsclient'
import {PrepareOnboardingResponse} from './onboardingTour'
import {Constants} from "./constants"
import {BoardsCloudLimits} from './boardsCloudLimits'
//
// OctoClient is the client interface to the server APIs
@ -806,6 +807,19 @@ class OctoClient {
return (await this.getJson(response, {})) as PrepareOnboardingResponse
}
// limits
async getBoardsCloudLimits(): Promise<BoardsCloudLimits | undefined> {
const path = '/api/v2/limits'
const response = await fetch(this.getBaseURL() + path, {headers: this.headers()})
if (response.status !== 200) {
return undefined
}
const limits = (await this.getJson(response, {})) as BoardsCloudLimits
Utils.log(`Cloud limits: cards=${limits.cards} views=${limits.views}`)
return limits
}
}
const octoClient = new OctoClient()

View File

@ -1,18 +1,20 @@
// 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 {CommentBlock} from '../blocks/commentBlock'
import {Utils} from '../utils'
import {Constants} from '../constants'
import {CardFilter} from '../cardFilter'
import {default as client} from '../octoClient'
import {loadBoardData, initialReadOnlyLoad} from './initialLoad'
import {loadBoardData, initialReadOnlyLoad, initialLoad} from './initialLoad'
import {getCurrentBoard} from './boards'
import {getBoardUsers} from './users'
import {getLastCommentByCard} from './comments'
@ -23,28 +25,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<Block[], number, {state: RootState}>(
'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.getBlocksWithBlockID(card.id, card.boardId).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<string>) => {
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<Card>) => {
state.cards[action.payload.id] = action.payload
},
addTemplate: (state, action: PayloadAction<Card>) => {
showCardHiddenWarning: (state, action: PayloadAction<boolean>) => {
state.cardHiddenWarning = action.payload
},
addTemplate: (state: CardsState, action: PayloadAction<Card>) => {
state.templates[action.payload.id] = action.payload
},
updateCards: (state, action: PayloadAction<Card[]>) => {
updateCards: (state: CardsState, action: PayloadAction<Card[]>) => {
for (const card of action.payload) {
if (card.deleteAt !== 0) {
delete state.cards[card.id]
@ -58,6 +108,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 = {}
@ -69,6 +124,9 @@ const cardsSlice = createSlice({
}
}
})
builder.addCase(initialLoad.fulfilled, (state, action) => {
state.limitTimestamp = action.payload.limits?.card_limit_timestamp || 0
})
builder.addCase(loadBoardData.fulfilled, (state, action) => {
state.cards = {}
state.templates = {}
@ -83,7 +141,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
@ -106,7 +164,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 +352,7 @@ function searchFilterCards(cards: Card[], board: Board, searchTextRaw: string):
})
}
export const getCurrentViewCardsSortedFilteredAndGrouped = createSelector(
export const getCurrentViewCardsSortedFilteredAndGroupedWithoutLimit = createSelector(
getCurrentBoardCards,
getLastCommentByCard,
getCurrentBoard,
@ -305,7 +363,7 @@ export const getCurrentViewCardsSortedFilteredAndGrouped = createSelector(
if (!view || !board || !users || !cards) {
return []
}
let result = cards
let result = cards.filter((c) => !c.limited)
if (view.fields.filter) {
result = CardFilter.applyFilterGroup(view.fields.filter, board.cardProperties, result)
}
@ -318,8 +376,21 @@ export const getCurrentViewCardsSortedFilteredAndGrouped = createSelector(
},
)
export const getCurrentViewCardsSortedFilteredAndGrouped = createSelector(
getCurrentViewCardsSortedFilteredAndGroupedWithoutLimit,
(cards) => cards.filter((c) => !c.limited),
)
export const getCurrentBoardHiddenCardsCount = createSelector(
getCurrentBoardCards,
(cards) => Object.values(cards).filter((c) => c.limited).length,
)
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 getCardHiddenWarning = (state: RootState): boolean => state.cards.cardHiddenWarning

View File

@ -18,6 +18,7 @@ import {reducer as searchTextReducer} from './searchText'
import {reducer as globalErrorReducer} from './globalError'
import {reducer as clientConfigReducer} from './clientConfig'
import {reducer as sidebarReducer} from './sidebar'
import {reducer as limitsReducer} from './limits'
const store = configureStore({
reducer: {
@ -36,6 +37,7 @@ const store = configureStore({
globalError: globalErrorReducer,
clientConfig: clientConfigReducer,
sidebar: sidebarReducer,
limits: limitsReducer,
},
})

View File

@ -12,13 +12,14 @@ import {RootState} from './index'
export const initialLoad = createAsyncThunk(
'initialLoad',
async () => {
const [me, team, teams, boards, boardsMemberships, boardTemplates] = await Promise.all([
const [me, team, teams, boards, boardsMemberships, boardTemplates, limits] = await Promise.all([
client.getMe(),
client.getTeam(),
client.getTeams(),
client.getBoards(),
client.getMyBoardMemberships(),
client.getTeamTemplates(),
client.getBoardsCloudLimits(),
])
// if no me, normally user not logged in
@ -36,6 +37,7 @@ export const initialLoad = createAsyncThunk(
boards,
boardsMemberships,
boardTemplates,
limits,
}
},
)

View File

@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSlice, PayloadAction} from '@reduxjs/toolkit'
import {BoardsCloudLimits} from '../boardsCloudLimits'
import {initialLoad} from './initialLoad'
import {RootState} from './index'
type LimitsState = {
limits: BoardsCloudLimits,
}
const defaultLimits = {
cards: 0,
used_cards: 0,
card_limit_timestamp: 0,
views: 0,
}
const initialState = {
limits: defaultLimits,
} as LimitsState
const limitsSlice = createSlice({
name: 'limits',
initialState,
reducers: {
setLimits: (state, action: PayloadAction<BoardsCloudLimits>) => {
state.limits = action.payload
},
setCardLimitTimestamp: (state, action: PayloadAction<number>) => {
state.limits.card_limit_timestamp = action.payload
},
},
extraReducers: (builder) => {
builder.addCase(initialLoad.fulfilled, (state, action) => {
state.limits = action.payload.limits || defaultLimits
})
},
})
export const {reducer} = limitsSlice
export const {setCardLimitTimestamp} = limitsSlice.actions
export const getLimits = (state: RootState): BoardsCloudLimits | undefined => state.limits.limits
export const getCardLimitTimestamp = (state: RootState): number => state.limits.limits.card_limit_timestamp

View File

@ -155,3 +155,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
}
},
)

View File

@ -44,6 +44,10 @@ export const TelemetryActions = {
StartTour: 'welcomeScreen_startTour',
SkipTour: 'welcomeScreen_skipTour',
CloudMoreInfo: 'cloud_more_info',
ViewLimitReached: 'limit_ViewLimitReached',
ViewLimitCTAPerformed: 'limit_ViewLimitLinkOpen',
LimitCardLimitReached: 'limit_cardLimitReached',
LimitCardLimitLinkOpen: 'limit_cardLimitLinkOpen',
}
interface IEventProps {

View File

@ -118,7 +118,6 @@ class TestBlockFactory {
card.title = 'title'
card.fields.icon = 'i'
card.fields.properties.property1 = 'value1'
return card
}
@ -192,6 +191,7 @@ class TestBlockFactory {
create_at: Date.now(),
update_at: Date.now(),
is_bot: false,
roles: 'system_user',
}
}
}

View File

@ -10,6 +10,7 @@ interface IUser {
create_at: number,
update_at: number,
is_bot: boolean,
roles: string,
}
interface UserWorkspace {

View File

@ -0,0 +1,140 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`widgets/NotificationBox should match snapshot with close with tooltip 1`] = `
<div>
<div
class="NotificationBox"
>
<div
class="content"
>
<p
class="title"
>
title
</p>
CONTENT
</div>
<div
class="octo-tooltip tooltip-top"
data-tooltip="tooltip"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
</div>
</div>
`;
exports[`widgets/NotificationBox should match snapshot with close without tooltip 1`] = `
<div>
<div
class="NotificationBox"
>
<div
class="content"
>
<p
class="title"
>
title
</p>
CONTENT
</div>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
</div>
`;
exports[`widgets/NotificationBox should match snapshot with icon 1`] = `
<div>
<div
class="NotificationBox"
>
<div
class="NotificationBox__icon"
>
ICON
</div>
<div
class="content"
>
<p
class="title"
>
title
</p>
CONTENT
</div>
</div>
</div>
`;
exports[`widgets/NotificationBox should match snapshot with icon and close with tooltip 1`] = `
<div>
<div
class="NotificationBox"
>
<div
class="NotificationBox__icon"
>
ICON
</div>
<div
class="content"
>
<p
class="title"
>
title
</p>
CONTENT
</div>
<div
class="octo-tooltip tooltip-top"
data-tooltip="tooltip"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
</div>
</div>
`;
exports[`widgets/NotificationBox should match snapshot without icon and close 1`] = `
<div>
<div
class="NotificationBox"
>
<div
class="content"
>
<p
class="title"
>
title
</p>
CONTENT
</div>
</div>
</div>
`;

View File

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

View File

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

View File

@ -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 (
<CompassIcon
icon='alert-outline'
className='AlertIcon'
/>
)
}

View File

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

View File

@ -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(
<NotificationBox
title='title'
>
{'CONTENT'}
</NotificationBox>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('should match snapshot with icon', () => {
const component = wrapIntl(
<NotificationBox
title='title'
icon='ICON'
>
{'CONTENT'}
</NotificationBox>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('should match snapshot with close without tooltip', () => {
const component = wrapIntl(
<NotificationBox
title='title'
onClose={() => null}
>
{'CONTENT'}
</NotificationBox>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('should match snapshot with close with tooltip', () => {
const component = wrapIntl(
<NotificationBox
title='title'
onClose={() => null}
closeTooltip='tooltip'
>
{'CONTENT'}
</NotificationBox>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('should match snapshot with icon and close with tooltip', () => {
const component = wrapIntl(
<NotificationBox
title='title'
icon='ICON'
onClose={() => null}
closeTooltip='tooltip'
>
{'CONTENT'}
</NotificationBox>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
})

View File

@ -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 (
<Tooltip title={closeTooltip}>
<IconButton
icon={<CloseIcon/>}
onClick={onClose}
/>
</Tooltip>)
}
return (
<IconButton
icon={<CloseIcon/>}
onClick={onClose}
/>)
}
function NotificationBox(props: Props): JSX.Element {
return (
<div className='NotificationBox'>
{props.icon &&
<div className='NotificationBox__icon'>
{props.icon}
</div>}
<div className='content'>
<p className='title'>{props.title}</p>
{props.children}
</div>
{renderClose(props.onClose, props.closeTooltip)}
</div>
)
}
export default React.memo(NotificationBox)