main Cherrypick: Hidden card view via direct URL & unfurl (#3071) (#3126)

* Hidden card view via direct URL & unfurl (#3071)

* wip: directly view a hidden (limited) card

* wip: use `limited` from Card

* feat(plugin): render hidden message when unfurling

* feat: `limited` direct card view + tests

* style: apply eslint

* fix: `large` <Button>

* test: update snapshot

* fix: wrap raw SVG in TSX component for styling

* feat: open pricing modal instead of /pricing URL

* fix: close card modal when opening pricing modal

* test: update snapshots

* chore: update i18n strings

* chore(Makefile): tests & dependencies in plugin

* test(plugin): add BoardsUnfurl snapshot tests

* test(cypress): 'Log in' -> 'Login'

* chore: i18n extract

* fix: use globstar matching in `i18n-extract`

* fix: `i18n-extract`, the sequel

* fix: / -> /error button `Login` to `Log in`

* style: fix linting

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>

* fix: add `limited` to `Card`

* test: fix tests

* test: fix BoardsUnfurl

* chore: `npm run i18n-extract``

* Reducing the props used in the svgs to the minimum needed

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Jesús Espino <jespinog@gmail.com>
This commit is contained in:
Paul Esch-Laurent 2022-06-21 12:22:47 -05:00 committed by GitHub
parent 90fcae066b
commit c704729561
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 16001 additions and 3955 deletions

View file

@ -29,11 +29,13 @@ all: webapp server ## Build server and webapp.
prebuild: ## Run prebuild actions (install dependencies etc.).
cd webapp; npm install
cd mattermost-plugin/webapp; npm install
ci: server-test
cd webapp; npm run check
cd webapp; npm run test
cd webapp; npm run cypress:ci
cd mattermost-plugin/webapp; npm run test
templates-archive: ## Build templates archive file
cd server/assets/build-template-archive; go run -tags '$(BUILD_TAGS)' main.go --dir="../templates-boardarchive" --out="../templates.boardarchive"

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,7 @@
"@babel/preset-typescript": "7.16.7",
"@babel/runtime": "7.17.8",
"@formatjs/ts-transformer": "3.9.2",
"@testing-library/react": "11.2.7",
"@types/enzyme": "3.10.11",
"@types/jest": "27.4.1",
"@types/node": "17.0.23",
@ -35,6 +36,7 @@
"@types/react-redux": "7.1.23",
"@types/react-router-dom": "5.3.3",
"@types/react-transition-group": "4.4.4",
"@types/redux-mock-store": "1.0.3",
"@typescript-eslint/eslint-plugin": "5.16.0",
"@typescript-eslint/parser": "5.16.0",
"babel-eslint": "10.1.0",
@ -62,9 +64,12 @@
"imagemin-pngquant": "^9.0.2",
"imagemin-svgo": "^10.0.1",
"imagemin-webp": "7.0.0",
"isomorphic-fetch": "3.0.0",
"jest": "27.5.1",
"jest-canvas-mock": "2.3.1",
"jest-junit": "13.0.0",
"jest-mock": "27.5.1",
"redux-mock-store": "1.5.4",
"sass": "1.49.9",
"sass-loader": "12.6.0",
"style-loader": "3.3.1",
@ -78,14 +83,15 @@
"glob-parent": "6.0.2",
"marked": ">=4.0.12",
"mattermost-redux": "5.33.1",
"react": "^17.0.2",
"react-dom": "17.0.2",
"react-intl": "^5.24.7",
"react-redux": "^7.2.8",
"react-router-dom": "5.2.0",
"trim-newlines": "4.0.2"
},
"jest": {
"snapshotSerializers": [
"<rootDir>/node_modules/enzyme-to-json/serializer"
],
"testEnvironment": "jsdom",
"testPathIgnorePatterns": [
"/node_modules/",
"/non_npm_dependencies/"

View file

@ -0,0 +1,94 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/boardsUnfurl/BoardsUnfurl renders normally 1`] = `
<div>
<a
class="FocalboardUnfurl"
href="http://localhost:8065/test"
rel="noopener noreferrer"
target="_blank"
>
<div
class="header"
>
<span
class="icon"
/>
<div
class="information"
>
<span
class="card_title"
>
test card
</span>
<span
class="board_title"
>
test board
</span>
</div>
</div>
<div
class="footer"
>
<div
class="avatar"
>
</div>
<div
class="timestamp_properties"
>
<div
class="properties"
/>
<span
class="post-preview__time"
>
Updated
</span>
</div>
</div>
</a>
</div>
`;
exports[`components/boardsUnfurl/BoardsUnfurl renders when limited 1`] = `
<div>
<a
class="FocalboardUnfurl"
href="http://localhost:8065/test"
rel="noopener noreferrer"
target="_blank"
>
<div
class="header"
>
<span
class="icon"
/>
<div
class="information"
>
<span
class="card_title"
>
test card
</span>
<span
class="board_title"
>
test board
</span>
</div>
</div>
<p
class="limited"
>
Additional details are hidden due to the card being archived
</p>
</a>
</div>
`;

View file

@ -71,6 +71,12 @@
}
}
.limited {
font-size: 14px;
color: rgba(var(--center-channel-color-rgb), 0.6);
margin-top: 12px;
}
.footer {
display: flex;
align-items: center;

View file

@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {act, render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {Provider as ReduxProvider} from 'react-redux'
import {mocked} from 'jest-mock'
import {createCard} from '../../../../../webapp/src/blocks/card'
import {createBoard} from '../../../../../webapp/src/blocks/board'
import octoClient from '../../../../../webapp/src/octoClient'
import {wrapIntl} from '../../../../../webapp/src/testUtils'
import BoardsUnfurl from './boardsUnfurl'
jest.mock('../../../../../webapp/src/octoClient')
const mockedOctoClient = mocked(octoClient, true)
describe('components/boardsUnfurl/BoardsUnfurl', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('renders normally', async () => {
const mockStore = configureStore([])
const store = mockStore({
entities: {
users: {
currentUserId: 'id_1',
profiles: {
id_1: {
locale: 'en',
},
},
},
},
})
const cards = [{...createCard(), title: 'test card'}]
const board = {...createBoard(), title: 'test board'}
mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards)
mockedOctoClient.getBoard.mockResolvedValueOnce(board)
const component = (
<ReduxProvider store={store}>
{wrapIntl(
<BoardsUnfurl
embed={{data: '{"workspaceID": "foo", "cardID": "bar", "boardID": "baz", "readToken": "abc", "originalPath": "/test"}'}}
/>,
)}
</ReduxProvider>
)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(component)
container = result.container
})
expect(container).toMatchSnapshot()
})
it('renders when limited', async () => {
const mockStore = configureStore([])
const store = mockStore({
entities: {
users: {
currentUserId: 'id_1',
profiles: {
id_1: {
locale: 'en',
},
},
},
},
})
const cards = [{...createCard(), title: 'test card', limited: true}]
const board = {...createBoard(), title: 'test board'}
mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards)
mockedOctoClient.getBoard.mockResolvedValueOnce(board)
const component = (
<ReduxProvider store={store}>
{wrapIntl(
<BoardsUnfurl
embed={{data: '{"workspaceID": "foo", "cardID": "bar", "boardID": "baz", "readToken": "abc", "originalPath": "/test"}'}}
/>,
)}
</ReduxProvider>
)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(component)
container = result.container
})
expect(container).toMatchSnapshot()
})
})

View file

@ -14,9 +14,10 @@ import {Board} from './../../../../../webapp/src/blocks/board'
import {ContentBlock} from './../../../../../webapp/src/blocks/contentBlock'
import octoClient from './../../../../../webapp/src/octoClient'
const Avatar = (window as any).Components.Avatar
const Timestamp = (window as any).Components.Timestamp
const imageURLForUser = (window as any).Components.imageURLForUser
const noop = () => ''
const Avatar = (window as any).Components?.Avatar || noop
const Timestamp = (window as any).Components?.Timestamp || noop
const imageURLForUser = (window as any).Components?.imageURLForUser || noop
import './boardsUnfurl.scss'
import '../../../../../webapp/src/styles/labels.scss'
@ -53,7 +54,7 @@ class FocalboardEmbeddedData {
}
}
const BoardsUnfurl = (props: Props): JSX.Element => {
export const BoardsUnfurl = (props: Props): JSX.Element => {
if (!props.embed || !props.embed.data) {
return <></>
}
@ -182,7 +183,7 @@ const BoardsUnfurl = (props: Props): JSX.Element => {
</div>
{/* Body of the Card*/}
{html !== '' &&
{!card.limited && html !== '' &&
<div className='body'>
<div
dangerouslySetInnerHTML={{__html: html}}
@ -190,7 +191,16 @@ const BoardsUnfurl = (props: Props): JSX.Element => {
</div>
}
{card.limited &&
<p className='limited'>
<FormattedMessage
id='BoardsUnfurl.Limited'
defaultMessage={'Additional details are hidden due to the card being archived'}
/>
</p>}
{/* Footer of the Card*/}
{!card.limited &&
<div className='footer'>
<div className='avatar'>
<Avatar
@ -245,7 +255,7 @@ const BoardsUnfurl = (props: Props): JSX.Element => {
/>
</span>
</div>
</div>
</div>}
</a>
}
{loading &&

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Channel, ChannelMembership} from 'mattermost-redux/types/channels';
import {Channel, ChannelMembership} from 'mattermost-redux/types/channels'
export interface PluginRegistry {
registerPostTypeComponent(typeName: string, component: React.ElementType)

View file

@ -25,6 +25,7 @@
"BoardTemplateSelector.title": "Create a board",
"BoardTemplateSelector.use-this-template": "Use this template",
"BoardsSwitcher.Title": "Find Boards",
"BoardsUnfurl.Limited": "Additional details are hidden due to the card being archived",
"BoardsUnfurl.Remainder": "+{remainder} more",
"BoardsUnfurl.Updated": "Updated {time}",
"Calculations.Options.average.displayName": "Average",
@ -70,6 +71,9 @@
"CardDetail.add-icon": "Add icon",
"CardDetail.add-property": "+ Add a property",
"CardDetail.addCardText": "add card text",
"CardDetail.limited-body": "Upgrade to our Professional or Enterprise plan to view archived cards, have unlimited views per boards, unlimited cards and more.",
"CardDetail.limited-button": "Upgrade",
"CardDetail.limited-title": "This card is hidden",
"CardDetail.moveContent": "Move card content",
"CardDetail.new-comment-placeholder": "Add a comment...",
"CardDetailProperty.confirm-delete-heading": "Confirm delete property",
@ -81,7 +85,7 @@
"CardDetailProperty.property-changed": "Changed property successfully!",
"CardDetailProperty.property-deleted": "Deleted {propertyName} successfully!",
"CardDetailProperty.property-name-change-subtext": "type from \"{oldPropType}\" to \"{newPropType}\"",
"CardDetailProperty.property-type-change-subtext": "name to \"{newPropName}\"",
"CardDetial.limited-link": "Learn more about our plans.",
"CardDialog.copiedLink": "Copied!",
"CardDialog.copyLink": "Copy link",
"CardDialog.delete-confirmation-dialog-button-text": "Delete",
@ -151,6 +155,7 @@
"KanbanCard.delete": "Delete",
"KanbanCard.duplicate": "Duplicate",
"KanbanCard.untitled": "Untitled",
"Mutator.new-board-from-template": "new board from template",
"Mutator.new-card-from-template": "new card from template",
"Mutator.new-template-from-card": "new template from card",
"OnboardingTour.AddComments.Body": "You can comment on issues, and even @mention your fellow Mattermost users to get their attention.",
@ -260,6 +265,7 @@
"View.NewCalendarTitle": "Calendar view",
"View.NewGalleryTitle": "Gallery view",
"View.NewTableTitle": "Table view",
"View.NewTemplateTitle": "Untitled Template",
"View.Table": "Table",
"ViewHeader.add-template": "New template",
"ViewHeader.delete-template": "Delete",
@ -300,7 +306,15 @@
"createImageBlock.failed": "Unable to upload the file. File size limit reached.",
"default-properties.badges": "Comments and description",
"default-properties.title": "Title",
"error.back-to-home": "Back to Home",
"error.back-to-team": "Back to team",
"error.board-not-found": "Board not found.",
"error.go-login": "Login",
"error.invalid-read-only-board": "You dont have access to this board. Log in to access Boards.",
"error.not-logged-in": "Your session may have expired or you're not logged in. Log in again to access Boards.",
"error.page.title": "Sorry, something went wrong",
"error.team-undefined": "Not a valid team.",
"error.unknown": "An error occurred.",
"generic.previous": "Previous",
"imagePaste.upload-failed": "Some files not uploaded. File size limit reached",
"limitedCard.title": "Cards Hidden",

View file

@ -12,7 +12,7 @@
"check": "eslint --ext .tsx,.ts . --quiet --cache && stylelint **/*.scss",
"fix": "eslint --ext .tsx,.ts . --quiet --fix --cache && stylelint --fix **/*.scss",
"fix:scss": "prettier --write './src/**/*.scss'",
"i18n-extract": "formatjs extract ../mattermost-plugin/webapp/src/*/*/*.ts? src/*.ts? src/*/*.ts? src/*/*/*.ts? src/*/*/*/*.ts? --out-file i18n/tmp.json; formatjs compile i18n/tmp.json --out-file i18n/en.json; rm i18n/tmp.json",
"i18n-extract": "formatjs extract '../mattermost-plugin/webapp/src/**/*.{ts,tsx}' 'src/**/*.{ts,tsx}' --ignore '**/*.d.ts' '../**/*.d.ts' --out-file i18n/tmp.json && formatjs compile i18n/tmp.json --out-file i18n/en.json && rm i18n/tmp.json",
"runserver-test": "cd cypress && \"../../bin/focalboard-server\"",
"cypress:ci": "start-server-and-test runserver-test http://localhost:8088 cypress:run",
"cypress:debug": "start-server-and-test runserver-test http://localhost:8088 cypress:open",

View file

@ -67,6 +67,7 @@ function createBlock(block?: Block): Block {
createAt: block?.createAt || now,
updateAt: block?.updateAt || now,
deleteAt: block?.deleteAt || 0,
limited: Boolean(block?.limited),
}
}

View file

@ -283,6 +283,230 @@ exports[`components/cardDialog already following card 1`] = `
</div>
`;
exports[`components/cardDialog limited card shows hidden view (no toolbar) 1`] = `
<div>
<div
class="Dialog dialog-back cardDialog"
>
<div
class="backdrop"
/>
<div
class="wrapper"
>
<div
class="dialog"
role="dialog"
>
<div
class="toolbar"
>
<button
aria-label="Close dialog"
title="Close dialog"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="toolbar--right"
/>
</div>
<div
class="CardDetail content is-limited"
>
<div
class="IconSelector"
>
<div
class="octo-icon size-l readonly"
>
<span>
i
</span>
</div>
</div>
<div
class="EditableAreaWrap"
>
<textarea
class="EditableArea Editable readonly title"
height="0"
placeholder="Untitled"
readonly=""
rows="1"
spellcheck="true"
title="title"
>
title
</textarea>
<div
class="EditableAreaContainer"
>
<textarea
aria-hidden="true"
class="EditableAreaReference Editable readonly title"
dir="auto"
disabled=""
rows="1"
>
title
</textarea>
</div>
</div>
<div
class="CardDetail__limited-wrapper"
>
<span
class="CardDetail__limited-bg"
>
<svg
fill="none"
height="521"
viewBox="0 0 468 521"
width="468"
xmlns="http://www.w3.org/2000/svg"
>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="156"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="296"
x="172"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="156"
y="48"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="296"
x="172"
y="48"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="156"
y="96"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="296"
x="172"
y="96"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="156"
y="144"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="296"
x="172"
y="144"
/>
<rect
fill="#3D3C40"
fill-opacity="0.16"
height="1"
width="468"
y="192"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="468"
y="209"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="468"
y="257"
/>
<rect
fill="#3D3C40"
fill-opacity="0.16"
height="1"
width="468"
y="305"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="199"
rx="4"
width="468"
y="322"
/>
</svg>
</span>
<p
class="CardDetail__limited-title"
>
This card is hidden
</p>
<p
class="CardDetail__limited-body"
>
Upgrade to our Professional or Enterprise plan to view archived cards, have unlimited views per boards, unlimited cards and more.
<br />
<a
class="CardDetail__limited-link"
role="button"
>
Learn more about our plans.
</a>
</p>
<button
type="button"
>
<span>
Upgrade
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`components/cardDialog return a cardDialog readonly 1`] = `
<div>
<div

View file

@ -1,5 +1,198 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/cardDetail/CardDetail should render hidden view if limited 1`] = `
<div>
<div
class="CardDetail content is-limited"
>
<div
class="IconSelector"
>
<div
class="octo-icon size-l readonly"
>
<span>
i
</span>
</div>
</div>
<div
class="EditableAreaWrap"
>
<textarea
class="EditableArea Editable readonly title"
height="0"
placeholder="Untitled"
readonly=""
rows="1"
spellcheck="true"
title="title"
>
title
</textarea>
<div
class="EditableAreaContainer"
>
<textarea
aria-hidden="true"
class="EditableAreaReference Editable readonly title"
dir="auto"
disabled=""
rows="1"
>
title
</textarea>
</div>
</div>
<div
class="CardDetail__limited-wrapper"
>
<span
class="CardDetail__limited-bg"
>
<svg
fill="none"
height="521"
viewBox="0 0 468 521"
width="468"
xmlns="http://www.w3.org/2000/svg"
>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="156"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="296"
x="172"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="156"
y="48"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="296"
x="172"
y="48"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="156"
y="96"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="296"
x="172"
y="96"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="156"
y="144"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="296"
x="172"
y="144"
/>
<rect
fill="#3D3C40"
fill-opacity="0.16"
height="1"
width="468"
y="192"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="468"
y="209"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="32"
rx="4"
width="468"
y="257"
/>
<rect
fill="#3D3C40"
fill-opacity="0.16"
height="1"
width="468"
y="305"
/>
<rect
fill="#3F4350"
fill-opacity="0.08"
height="199"
rx="4"
width="468"
y="322"
/>
</svg>
</span>
<p
class="CardDetail__limited-title"
>
This card is hidden
</p>
<p
class="CardDetail__limited-body"
>
Upgrade to our Professional or Enterprise plan to view archived cards, have unlimited views per boards, unlimited cards and more.
<br />
<a
class="CardDetail__limited-link"
role="button"
>
Learn more about our plans.
</a>
</p>
<button
class="Button emphasis--primary size--large CardDetail__limited-button"
type="button"
>
<span>
Upgrade
</span>
</button>
</div>
</div>
</div>
`;
exports[`components/cardDetail/CardDetail should show add comments tour tip 1`] = `
<div
class="tippy-box tutorial-tour-tip__box AddCommentTourStep"

View file

@ -172,4 +172,55 @@
}
}
}
&.content.is-limited {
flex: 1;
}
&__limited-wrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-size: 14px;
position: relative;
> :not(.CardDetail__limited-bg) {
position: relative;
}
}
&__limited-bg {
filter: blur(12px);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
> svg {
width: 100%;
height: auto;
> rect {
fill: rgba(var(--center-channel-color-rgb));
}
}
}
&__limited-title {
font-weight: bold;
font-size: 22px;
line-height: 28px;
}
&__limited-link {
color: var(--link-color);
}
&__limited-button {
margin-top: 24px;
}
}

View file

@ -109,6 +109,7 @@ describe('components/cardDetail/CardDetail', () => {
comments={[comment1, comment2]}
contents={[]}
readonly={false}
onClose={jest.fn()}
/>,
)}
</ReduxProvider>
@ -166,6 +167,7 @@ describe('components/cardDetail/CardDetail', () => {
comments={[comment1, comment2]}
contents={[]}
readonly={true}
onClose={jest.fn()}
/>,
)}
</ReduxProvider>
@ -256,6 +258,7 @@ describe('components/cardDetail/CardDetail', () => {
comments={[comment1, comment2]}
contents={[]}
readonly={false}
onClose={jest.fn()}
/>,
)}
</ReduxProvider>
@ -361,6 +364,7 @@ describe('components/cardDetail/CardDetail', () => {
comments={[comment1, comment2]}
contents={[]}
readonly={false}
onClose={jest.fn()}
/>,
)}
</ReduxProvider>
@ -470,6 +474,7 @@ describe('components/cardDetail/CardDetail', () => {
comments={[comment1, comment2]}
contents={[text]}
readonly={false}
onClose={jest.fn()}
/>,
)}
</ReduxProvider>
@ -507,4 +512,61 @@ describe('components/cardDetail/CardDetail', () => {
},
)
})
test('should render hidden view if limited', async () => {
const limitedCard = {...card, limited: true}
const mockStore = configureStore([])
const store = mockStore({
users: {
workspaceUsers: [
{username: 'username_1'},
],
},
teams: {
current: {id: 'team-id'},
},
boards: {
boards: {
[board.id]: board,
},
current: board.id,
myBoardMemberships: {
[board.id]: {userId: 'user_id_1', schemeAdmin: true},
},
},
cards: {
cards: {
[limitedCard.id]: limitedCard,
},
current: limitedCard.id,
},
})
const component = (
<ReduxProvider store={store}>
{wrapIntl(
<CardDetail
board={board}
activeView={view}
views={[view]}
cards={[limitedCard]}
card={limitedCard}
comments={[comment1, comment2]}
contents={[]}
readonly={false}
onClose={jest.fn()}
/>,
)}
</ReduxProvider>
)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(component)
container = result.container
})
expect(container).toMatchSnapshot()
})
})

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {FormattedMessage} from 'react-intl'
import React, {useCallback, useEffect, useRef, useState, Fragment} from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import {BlockIcons} from '../../blockIcons'
import {Card} from '../../blocks/card'
@ -23,6 +23,8 @@ import {setCurrent as setCurrentCard} from '../../store/cards'
import {Permission} from '../../constants'
import {useHasCurrentBoardPermissions} from '../../hooks/permissions'
import CardSkeleton from '../../svg/card-skeleton'
import CommentsList from './commentsList'
import {CardDetailProvider} from './cardDetailContext'
import CardDetailContents from './cardDetailContents'
@ -44,10 +46,12 @@ type Props = {
comments: CommentBlock[]
contents: Array<ContentBlock|ContentBlock[]>
readonly: boolean
onClose: () => void
}
const CardDetail = (props: Props): JSX.Element|null => {
const {card, comments} = props
const {limited} = card
const [title, setTitle] = useState(card.title)
const [serverTitle, setServerTitle] = useState(card.title)
const titleRef = useRef<Focusable>(null)
@ -60,6 +64,7 @@ const CardDetail = (props: Props): JSX.Element|null => {
const saveTitleRef = useRef<() => void>(saveTitle)
saveTitleRef.current = saveTitle
const intl = useIntl()
useImagePaste(props.board.id, card.id, card.fields.contentOrder)
@ -99,11 +104,11 @@ const CardDetail = (props: Props): JSX.Element|null => {
return (
<>
<div className='CardDetail content'>
<div className={`CardDetail content${limited ? ' is-limited' : ''}`}>
<BlockIconSelector
block={card}
size='l'
readonly={props.readonly || !canEditBoardCards}
readonly={props.readonly || !canEditBoardCards || limited}
/>
{!props.readonly && canEditBoardCards && !card.fields.icon &&
<div className='add-buttons'>
@ -127,12 +132,58 @@ const CardDetail = (props: Props): JSX.Element|null => {
saveOnEsc={true}
onSave={saveTitle}
onCancel={() => setTitle(props.card.title)}
readonly={props.readonly || !canEditBoardCards}
readonly={props.readonly || !canEditBoardCards || limited}
spellCheck={true}
/>
{/* Hidden (limited) card copy + CTA */}
{limited && <div className='CardDetail__limited-wrapper'>
<CardSkeleton
className='CardDetail__limited-bg'
/>
<p className='CardDetail__limited-title'>
<FormattedMessage
id='CardDetail.limited-title'
defaultMessage='This card is hidden'
/>
</p>
<p className='CardDetail__limited-body'>
<FormattedMessage
id='CardDetail.limited-body'
defaultMessage='Upgrade to our Professional or Enterprise plan to view archived cards, have unlimited views per boards, unlimited cards and more.'
/>
<br/>
<a
className='CardDetail__limited-link'
role='button'
onClick={() => {
props.onClose();
(window as any).openPricingModal()()
}}
>
<FormattedMessage
id='CardDetial.limited-link'
defaultMessage='Learn more about our plans.'
/>
</a>
</p>
<Button
className='CardDetail__limited-button'
onClick={() => {
props.onClose();
(window as any).openPricingModal()()
}}
emphasis='primary'
size='large'
>
{intl.formatMessage({id: 'CardDetail.limited-button', defaultMessage: 'Upgrade'})}
</Button>
</div>}
{/* Property list */}
{!limited &&
<CardDetailProperties
board={props.board}
card={props.card}
@ -140,22 +191,24 @@ const CardDetail = (props: Props): JSX.Element|null => {
activeView={props.activeView}
views={props.views}
readonly={props.readonly}
/>
/>}
{/* Comments */}
<hr/>
<CommentsList
comments={comments}
boardId={card.boardId}
cardId={card.id}
readonly={props.readonly || !canEditBoardCards}
/>
{!limited && <Fragment>
<hr/>
<CommentsList
comments={comments}
boardId={card.boardId}
cardId={card.id}
readonly={props.readonly || !canEditBoardCards}
/>
</Fragment>}
</div>
{/* Content blocks */}
<div className='CardDetail content fullwidth content-blocks'>
{!limited && <div className='CardDetail content fullwidth content-blocks'>
<CardDetailProvider card={card}>
<CardDetailContents
card={props.card}
@ -164,7 +217,7 @@ const CardDetail = (props: Props): JSX.Element|null => {
/>
{!props.readonly && canEditBoardCards && <CardDetailContentsMenu/>}
</CardDetailProvider>
</div>
</div>}
</>
)
}

View file

@ -353,4 +353,39 @@ describe('components/cardDialog', () => {
})
expect(container).toMatchSnapshot()
})
test('limited card shows hidden view (no toolbar)', async () => {
// simply doing {...state} gives a TypeScript error
// when you try updating it's values.
const newState = JSON.parse(JSON.stringify(state))
const limitedCard = {...card, limited: true}
newState.cards = {
cards: {
[limitedCard.id]: limitedCard,
},
current: limitedCard.id,
}
const newStore = mockStateStore([], newState)
let container
await act(async () => {
const result = render(wrapDNDIntl(
<ReduxProvider store={newStore}>
<CardDialog
board={board}
activeView={boardView}
views={[boardView]}
cards={[limitedCard]}
cardId={limitedCard.id}
onClose={jest.fn()}
showCard={jest.fn()}
readonly={false}
/>
</ReduxProvider>,
))
container = result.container
})
expect(container).toMatchSnapshot()
})
})

View file

@ -184,8 +184,8 @@ const CardDialog = (props: Props): JSX.Element => {
<Dialog
className='cardDialog'
onClose={props.onClose}
toolsMenu={!props.readonly && menu}
toolbar={!isTemplate && Utils.isFocalboardPlugin() && toolbar}
toolsMenu={!props.readonly && !card?.limited && menu}
toolbar={!isTemplate && Utils.isFocalboardPlugin() && !card?.limited && toolbar}
>
{isTemplate &&
<div className='banner'>
@ -205,6 +205,7 @@ const CardDialog = (props: Props): JSX.Element => {
contents={contents}
comments={comments}
readonly={props.readonly}
onClose={props.onClose}
/>}
{!card &&

View file

@ -68,7 +68,7 @@ function errorDefFromId(id: ErrorId | null): ErrorDef {
case ErrorId.NotLoggedIn: {
errDef.title = intl.formatMessage({id: 'error.not-logged-in', defaultMessage: 'Your session may have expired or you\'re not logged in. Log in again to access Boards.'})
errDef.button1Enabled = true
errDef.button1Text = intl.formatMessage({id: 'error.go-login', defaultMessage: 'Login'})
errDef.button1Text = intl.formatMessage({id: 'error.go-login', defaultMessage: 'Log in'})
errDef.button1Redirect = '/login'
errDef.button1Redirect = (params: URLSearchParams): string => {
const r = params.get('r')

View file

@ -0,0 +1,127 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
type Props = {
className?: string
}
export default function CardSkeleton(props: Props): JSX.Element {
return (
<span className={props.className}>
<svg
width='468'
height='521'
viewBox='0 0 468 521'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<rect
width='156'
height='32'
rx='4'
fill='#3F4350'
fillOpacity='0.08'
/>
<rect
x='172'
width='296'
height='32'
rx='4'
fill='#3F4350'
fillOpacity='0.08'
/>
<rect
y='48'
width='156'
height='32'
rx='4'
fill='#3F4350'
fillOpacity='0.08'
/>
<rect
x='172'
y='48'
width='296'
height='32'
rx='4'
fill='#3F4350'
fillOpacity='0.08'
/>
<rect
y='96'
width='156'
height='32'
rx='4'
fill='#3F4350'
fillOpacity='0.08'
/>
<rect
x='172'
y='96'
width='296'
height='32'
rx='4'
fill='#3F4350'
fillOpacity='0.08'
/>
<rect
y='144'
width='156'
height='32'
rx='4'
fill='#3F4350'
fillOpacity='0.08'
/>
<rect
x='172'
y='144'
width='296'
height='32'
rx='4'
fill='#3F4350'
fillOpacity='0.08'
/>
<rect
y='192'
width='468'
height='1'
fill='#3D3C40'
fillOpacity='0.16'
/>
<rect
y='209'
width='468'
height='32'
rx='4'
fill='#3F4350'
fillOpacity='0.08'
/>
<rect
y='257'
width='468'
height='32'
rx='4'
fill='#3F4350'
fillOpacity='0.08'
/>
<rect
y='305'
width='468'
height='1'
fill='#3D3C40'
fillOpacity='0.16'
/>
<rect
y='322'
width='468'
height='199'
rx='4'
fill='#3F4350'
fillOpacity='0.08'
/>
</svg>
</span>
)
}

View file

@ -3,9 +3,9 @@
import React from 'react'
export default function ErrorIllustration(props: React.HTMLAttributes<HTMLSpanElement>): JSX.Element {
export default function ErrorIllustration(): JSX.Element {
return (
<span {...props}>
<span>
<svg
width='355'
height='250'

View file

@ -3,9 +3,9 @@
import React from 'react'
export default function SearchIllustration(props: React.HTMLAttributes<HTMLSpanElement>): JSX.Element {
export default function SearchIllustration(): JSX.Element {
return (
<span {...props}>
<span>
<svg
width='160'
height='160'