1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-01-02 14:47:55 +02:00

Merge branch 'main' into GH-1543

This commit is contained in:
Mattermod 2021-10-21 14:39:10 +02:00 committed by GitHub
commit 83fa691fa4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
88 changed files with 14247 additions and 392 deletions

View File

@ -1,4 +1,10 @@
# Import scripts
This subfolder contains scripts to import data from other systems. It is at an early stage. At present, there are examples of basic importing from Trello, Asana, and Notion. [Contribute code](https://www.focalboard.com/contribute/getting-started/) to expand this.
This subfolder contains scripts to import data from other systems. It is at an early stage. At present, there are examples of basic importing from the following:
* Trello
* Asana
* Notion
* Jira
* Todoist
[Contribute code](https://www.focalboard.com/contribute/getting-started/) to expand this.

1
import/jira/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
test

23
import/jira/README.md Normal file
View File

@ -0,0 +1,23 @@
# Jira importer
This node app converts a Jira xml export into a Focalboard archive. To use:
1. Open Jira advanced search, and search for all the items to export
2. Select `Export`, then `Export XML`
3. Save it locally, e.g. to `jira_export.xml`
4. Run `npm install` from within `focalboard/webapp`
5. Run `npm install` from within `focalboard/import/jira`
6. Run `npx ts-node importJira.ts -i <path-to-jira.xml> -o archive.focalboard` (also from within `focalboard/import/jira`)
7. In Focalboard, click `Settings`, then `Import archive` and select `archive.focalboard`
## Import scope and known limitations
Currently, the script imports each item as a card into a single board. Note that Jira XML export is limited to 1000 issues at a time.
Users are imported as Select properties, with the name of the user.
The following aren't currently imported:
* Custom properties
* Comments
* Embedded files
[Contribute code](https://www.focalboard.com/contribute/getting-started/) to expand this.

16
import/jira/importJira.ts Normal file
View File

@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import minimist from 'minimist'
import {run} from './jiraImporter'
async function main() {
const args: minimist.ParsedArgs = minimist(process.argv.slice(2))
const inputFile = args['i']
const outputFile = args['o'] || 'archive.focalboard'
return run(inputFile, outputFile)
}
main()

View File

@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {run} from './jiraImporter'
import * as fs from 'fs'
import {ArchiveUtils} from '../../webapp/src/blocks/archive'
const inputFile = './test/jira-export.xml'
const outputFile = './test/jira.focalboard'
describe('import from Jira', () => {
test('import', async () => {
const blockCount = await run(inputFile, outputFile)
expect(blockCount === 4)
})
test('import was complete', async () => {
const archiveData = fs.readFileSync(outputFile, 'utf-8')
const blocks = ArchiveUtils.parseBlockArchive(archiveData)
console.debug(blocks)
blocks.forEach(block => {
console.log(block.title)
})
expect(blocks).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Jira import',
type: 'board'
}),
expect.objectContaining({
title: 'Board View',
type: 'view'
}),
expect.objectContaining({
title: 'Investigate feature area',
type: 'card'
}),
expect.objectContaining({
title: 'Investigate feature',
type: 'card'
}),
])
)
})
afterAll(() => {
fs.rmSync(outputFile)
});
})

243
import/jira/jiraImporter.ts Normal file
View File

@ -0,0 +1,243 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as fs from 'fs'
import {exit} from 'process'
import {ArchiveUtils} from '../../webapp/src/blocks/archive'
import {Block} from '../../webapp/src/blocks/block'
import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board'
import {createBoardView} from '../../webapp/src/blocks/boardView'
import {Card, createCard} from '../../webapp/src/blocks/card'
import {createTextBlock} from '../../webapp/src/blocks/textBlock'
import {Utils} from './utils'
import xml2js, {ParserOptions} from 'xml2js'
import TurndownService from 'turndown'
// HACKHACK: To allow Utils.CreateGuid to work
(global.window as any) = {}
const optionColors = [
'propColorGray',
'propColorBrown',
'propColorOrange',
'propColorYellow',
'propColorGreen',
'propColorBlue',
'propColorPurple',
'propColorPink',
'propColorRed',
]
let optionColorIndex = 0
var turndownService = new TurndownService()
async function run(inputFile: string, outputFile: string): Promise<number> {
console.log(`input: ${inputFile}`)
console.log(`output: ${outputFile}`)
if (!inputFile) {
showHelp()
}
if (!fs.existsSync(inputFile)) {
console.error(`File not found: ${inputFile}`)
exit(2)
}
// Read input
console.log(`Reading ${inputFile}`)
const inputData = fs.readFileSync(inputFile, 'utf-8')
if (!inputData) {
console.error(`Unable to read data from file: ${inputFile}`)
exit(2)
}
console.log(`Read ${Math.round(inputData.length / 1024)} KB`)
const parserOptions: ParserOptions = {
explicitArray: false
}
const parser = new xml2js.Parser(parserOptions);
const input = await parser.parseStringPromise(inputData)
if (!input?.rss?.channel) {
console.error(`No channels in xml: ${inputFile}`)
exit(2)
}
const channel = input.rss.channel
const items = channel.item
// console.dir(items);
// Convert
const blocks = convert(items)
// Save output
// TODO: Stream output
const outputData = ArchiveUtils.buildBlockArchive(blocks)
fs.writeFileSync(outputFile, outputData)
console.log(`Exported ${blocks.length} block(s) to ${outputFile}`)
return blocks.length
}
function convert(items: any[]) {
const blocks: Block[] = []
// Board
const board = createBoard()
board.rootId = board.id
board.title = 'Jira import'
// Compile standard properties
board.fields.cardProperties = []
const priorityProperty = buildCardPropertyFromValues('Priority', items.map(o => o.priority?._))
board.fields.cardProperties.push(priorityProperty)
const statusProperty = buildCardPropertyFromValues('Status', items.map(o => o.status?._))
board.fields.cardProperties.push(statusProperty)
const resolutionProperty = buildCardPropertyFromValues('Resolution', items.map(o => o.resolution?._))
board.fields.cardProperties.push(resolutionProperty)
const typeProperty = buildCardPropertyFromValues('Type', items.map(o => o.type?._))
board.fields.cardProperties.push(typeProperty)
const assigneeProperty = buildCardPropertyFromValues('Assignee', items.map(o => o.assignee?._))
board.fields.cardProperties.push(assigneeProperty)
const reporterProperty = buildCardPropertyFromValues('Reporter', items.map(o => o.reporter?._))
board.fields.cardProperties.push(reporterProperty)
const originalUrlProperty: IPropertyTemplate = {
id: Utils.createGuid(),
name: 'Original URL',
type: 'url',
options: []
}
board.fields.cardProperties.push(originalUrlProperty)
const createdDateProperty: IPropertyTemplate = {
id: Utils.createGuid(),
name: 'Created Date',
type: 'date',
options: []
}
board.fields.cardProperties.push(createdDateProperty)
blocks.push(board)
// Board view
const view = createBoardView()
view.title = 'Board View'
view.fields.viewType = 'board'
view.rootId = board.id
view.parentId = board.id
blocks.push(view)
for (const item of items) {
console.log(
`Item: ${item.summary}, ` +
`priority: ${item.priority?._}, ` +
`status: ${item.status?._}, ` +
`type: ${item.type?._}`)
const card = createCard()
card.title = item.summary
card.rootId = board.id
card.parentId = board.id
// Map standard properties
if (item.priority?._) { setSelectProperty(card, priorityProperty, item.priority._) }
if (item.status?._) { setSelectProperty(card, statusProperty, item.status._) }
if (item.resolution?._) { setSelectProperty(card, resolutionProperty, item.resolution._) }
if (item.type?._) { setSelectProperty(card, typeProperty, item.type._) }
if (item.assignee?._) { setSelectProperty(card, assigneeProperty, item.assignee._) }
if (item.reporter?._) { setSelectProperty(card, reporterProperty, item.reporter._) }
if (item.link) { setProperty(card, originalUrlProperty.id, item.link)}
if (item.created) {
const dateInMs = Date.parse(item.created)
setProperty(card, createdDateProperty.id, dateInMs.toString())
}
// TODO: Map custom properties
if (item.description) {
const description = turndownService.turndown(item.description)
console.log(`\t${description}`)
const text = createTextBlock()
text.title = description
text.rootId = board.id
text.parentId = card.id
blocks.push(text)
card.fields.contentOrder = [text.id]
}
blocks.push(card)
}
return blocks
}
function buildCardPropertyFromValues(propertyName: string, allValues: string[]) {
const options: IPropertyOption[] = []
// Remove empty and duplicate values
const values = allValues.
filter(o => !!o).
filter((x, y) => allValues.indexOf(x) == y);
for (const value of values) {
const optionId = Utils.createGuid()
const color = optionColors[optionColorIndex % optionColors.length]
optionColorIndex += 1
const option: IPropertyOption = {
id: optionId,
value,
color,
}
options.push(option)
}
const cardProperty: IPropertyTemplate = {
id: Utils.createGuid(),
name: propertyName,
type: 'select',
options
}
console.log(`Property: ${propertyName}, values: ${values}`)
return cardProperty
}
function setSelectProperty(card: Card, cardProperty: IPropertyTemplate, propertyValue: string) {
const option = optionForPropertyValue(cardProperty, propertyValue)
if (option) {
card.fields.properties[cardProperty.id] = option.id
}
}
function setProperty(card: Card, cardPropertyId: string, propertyValue: string) {
card.fields.properties[cardPropertyId] = propertyValue
}
function optionForPropertyValue(cardProperty: IPropertyTemplate, propertyValue: string): IPropertyOption | null {
const option = cardProperty.options.find(o => o.value === propertyValue)
if (!option) {
console.error(`Property value not found: ${propertyValue}`)
return null
}
return option
}
function showHelp() {
console.log('import -i <input.xml> -o [output.focalboard]')
exit(1)
}
export { run }

9337
import/jira/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
import/jira/package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "focalboard-jira-importer",
"version": "1.0.0",
"private": true,
"description": "",
"main": "importJira.js",
"scripts": {
"lint": "eslint --ext .tsx,.ts . --quiet --cache",
"fix": "eslint --ext .tsx,.ts . --quiet --fix --cache",
"test": "jest",
"testRun": "ts-node importJira.ts -i test/jira_export.xml -o test/jira-import.focalboard",
"debug:test": "node --inspect=5858 -r ts-node/register importJira.ts -i test/jira_export.xml -o test/jira-import.focalboard"
},
"keywords": [],
"author": "",
"jest": {
"globals": {
"ts-jest": {
"tsconfig": "./tsconfig.json"
}
},
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"collectCoverage": true,
"collectCoverageFrom": [
"*.{ts,tsx,js,jsx}",
"!test/**"
]
},
"devDependencies": {
"@types/jest": "^27.0.2",
"@types/minimist": "^1.2.1",
"@types/node": "^14.14.28",
"@types/turndown": "^5.0.1",
"@types/xml2js": "^0.4.9",
"@typescript-eslint/eslint-plugin": "^4.15.0",
"@typescript-eslint/parser": "^4.15.0",
"eslint": "^7.20.0",
"jest": "^27.3.1",
"ts-jest": "^27.0.7",
"ts-node": "^9.1.1",
"typescript": "^4.1.5"
},
"dependencies": {
"minimist": "^1.2.5",
"turndown": "^7.1.1",
"xml2js": "^0.4.23"
}
}

View File

@ -0,0 +1,138 @@
<!--
RSS generated by JIRA (1001.0.0-SNAPSHOT#100183-sha1:347f6eac38020c8d2c450799ab145942a8caa5a4) at Tue Oct 19 19:29:39 UTC 2021
It is possible to restrict the fields that are returned in this document by specifying the 'field' parameter in your request.
For example, to request only the issue key and summary add field=key&field=summary to the URL of your request.
-->
<!-- If you wish to do custom client-side styling of RSS, uncomment this:
<?xml-stylesheet href="<base-url>/styles/jiraxml2html.xsl" type="text/xsl"?>
-->
<rss version="0.92">
<channel>
<title>Jira</title>
<link>https://areca.atlassian.net/issues/?jql=text+%7E+%22Investigate%22</link>
<description>An XML representation of a search request</description>
<language>en-us</language>
<issue start="0" end="2" total="2"/>
<build-info>
<version>1001.0.0-SNAPSHOT</version>
<build-number>100183</build-number>
<build-date>18-10-2021</build-date>
</build-info>
<item>
<title>[AR-9] Investigate feature area</title>
<link>https://areca.atlassian.net/browse/AR-9</link>
<project id="10000" key="AR">Areca</project>
<description></description>
<environment></environment>
<key id="10008">AR-9</key>
<summary>Investigate feature area</summary>
<type id="10001" iconUrl="https://areca.atlassian.net/secure/viewavatar?size=medium&amp;avatarId=10318&amp;avatarType=issuetype">Task</type>
<priority id="3" iconUrl="https://areca.atlassian.net/images/icons/priorities/medium.svg">Medium</priority>
<status id="10001" iconUrl="https://areca.atlassian.net/" description="">In Progress</status>
<statusCategory id="4" key="indeterminate" colorName="yellow"/>
<resolution id="-1">Unresolved</resolution>
<assignee accountid="-1">Unassigned</assignee>
<reporter accountid="557058:10df6720-08d0-4747-86f6-1aa1a7e45332">Chen Lim</reporter>
<labels>
</labels>
<created>Fri, 24 Sep 2021 14:22:15 -0700</created>
<updated>Fri, 24 Sep 2021 14:22:15 -0700</updated>
<due></due>
<votes>0</votes>
<watches>1</watches>
<attachments>
</attachments>
<subtasks>
</subtasks>
<customfields>
<customfield id="customfield_10000" key="com.atlassian.jira.plugins.jira-development-integration-plugin:devsummarycf">
<customfieldname>Development</customfieldname>
<customfieldvalues>
</customfieldvalues>
</customfield>
<customfield id="customfield_10019" key="com.pyxis.greenhopper.jira:gh-lexo-rank">
<customfieldname>Rank</customfieldname>
<customfieldvalues>
<customfieldvalue>0|i0001r:</customfieldvalue>
</customfieldvalues>
</customfield>
<customfield id="customfield_10020" key="com.pyxis.greenhopper.jira:gh-sprint">
<customfieldname>Sprint</customfieldname>
<customfieldvalues>
</customfieldvalues>
</customfield>
</customfields>
</item>
<item>
<title>[AR-1] Investigate feature</title>
<link>https://areca.atlassian.net/browse/AR-1</link>
<project id="10000" key="AR">Areca</project>
<description></description>
<environment></environment>
<key id="10000">AR-1</key>
<summary>Investigate feature</summary>
<type id="10002" iconUrl="https://areca.atlassian.net/secure/viewavatar?size=medium&amp;avatarId=10307&amp;avatarType=issuetype">Epic</type>
<priority id="3" iconUrl="https://areca.atlassian.net/images/icons/priorities/medium.svg">Medium</priority>
<status id="10000" iconUrl="https://areca.atlassian.net/" description="">To Do</status>
<statusCategory id="2" key="new" colorName="blue-gray"/>
<resolution id="-1">Unresolved</resolution>
<assignee accountid="557058:10df6720-08d0-4747-86f6-1aa1a7e45332">Chen Lim</assignee>
<reporter accountid="557058:10df6720-08d0-4747-86f6-1aa1a7e45332">Chen Lim</reporter>
<labels>
<label>PM</label>
</labels>
<created>Fri, 24 Sep 2021 14:19:44 -0700</created>
<updated>Fri, 24 Sep 2021 14:21:32 -0700</updated>
<due>Fri, 12 Nov 2021 00:00:00 +0000</due>
<votes>0</votes>
<watches>1</watches>
<comments>
<comment id="10000" author="557058:10df6720-08d0-4747-86f6-1aa1a7e45332" created="Fri, 24 Sep 2021 14:21:15 -0700" >&lt;p&gt;Kicking off Project Areca&amp;#33; ��&lt;/p&gt;</comment>
</comments>
<attachments>
</attachments>
<subtasks>
</subtasks>
<customfields>
<customfield id="customfield_10000" key="com.atlassian.jira.plugins.jira-development-integration-plugin:devsummarycf">
<customfieldname>Development</customfieldname>
<customfieldvalues>
</customfieldvalues>
</customfield>
<customfield id="customfield_10017" key="com.pyxis.greenhopper.jira:jsw-issue-color">
<customfieldname>Issue color</customfieldname>
<customfieldvalues>
<customfieldvalue>blue</customfieldvalue>
</customfieldvalues>
</customfield>
<customfield id="customfield_10019" key="com.pyxis.greenhopper.jira:gh-lexo-rank">
<customfieldname>Rank</customfieldname>
<customfieldvalues>
<customfieldvalue>0|hzzzzz:</customfieldvalue>
</customfieldvalues>
</customfield>
<customfield id="customfield_10020" key="com.pyxis.greenhopper.jira:gh-sprint">
<customfieldname>Sprint</customfieldname>
<customfieldvalues>
</customfieldvalues>
</customfield>
<customfield id="customfield_10015" key="com.atlassian.jira.plugin.system.customfieldtypes:datepicker">
<customfieldname>Start date</customfieldname>
<customfieldvalues>
<customfieldvalue>Tue, 28 Sep 2021 00:00:00 +0000</customfieldvalue>
</customfieldvalues>
</customfield>
</customfields>
</item>
</channel>
</rss>

27
import/jira/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"jsx": "react",
"target": "es2019",
"module": "commonjs",
"esModuleInterop": true,
"noImplicitAny": true,
"strict": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"allowJs": true,
"resolveJsonModule": true,
"incremental": false,
"outDir": "./dist",
"moduleResolution": "node"
},
"include": [
"."
],
"exclude": [
".git",
"**/node_modules/*",
"dist",
"pack"
]
}

19
import/jira/utils.ts Normal file
View File

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as crypto from 'crypto'
class Utils {
static createGuid(): string {
function randomDigit() {
if (crypto && crypto.randomBytes) {
const rands = crypto.randomBytes(1)
return (rands[0] % 16).toString(16)
}
return (Math.floor((Math.random() * 16))).toString(16)
}
return 'xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx'.replace(/x/g, randomDigit)
}
}
export { Utils }

View File

@ -143,7 +143,7 @@ func (p *Plugin) OnActivate() error {
p.wsPluginAdapter = ws.NewPluginAdapter(p.API, auth.New(cfg, db))
mentionsBackend, err := createMentionsNotifyBackend(client, cfg.ServerRoot, logger)
mentionsBackend, err := createMentionsNotifyBackend(client, baseURL+"/boards", logger)
if err != nil {
return fmt.Errorf("error creating mentions notifications backend: %w", err)
}

View File

@ -392,7 +392,7 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session := ctx.Value(sessionContextKey).(*model.Session)
err = a.app.InsertBlocks(*container, blocks, session.UserID)
err = a.app.InsertBlocks(*container, blocks, session.UserID, true)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
@ -898,7 +898,7 @@ func (a *API) handleImport(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session := ctx.Value(sessionContextKey).(*model.Session)
err = a.app.InsertBlocks(*container, blocks, session.UserID)
err = a.app.InsertBlocks(*container, blocks, session.UserID, false)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return

View File

@ -69,7 +69,7 @@ func (a *App) InsertBlock(c store.Container, block model.Block, userID string) e
return err
}
func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID string) error {
func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID string, allowNotifications bool) error {
needsNotify := make([]model.Block, 0, len(blocks))
for i := range blocks {
err := a.store.InsertBlock(c, &blocks[i], userID)
@ -87,7 +87,9 @@ func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID strin
for _, b := range needsNotify {
block := b
a.webhook.NotifyUpdate(block)
a.notifyBlockChanged(notify.Add, c, &block, nil, userID)
if allowNotifications {
a.notifyBlockChanged(notify.Add, c, &block, nil, userID)
}
}
}()

View File

@ -26,7 +26,7 @@ type SQLStore struct {
// New creates a new SQL implementation of the store.
func New(dbType, connectionString, tablePrefix string, logger *mlog.Logger, db *sql.DB, isPlugin bool) (*SQLStore, error) {
logger.Info("connectDatabase", mlog.String("dbType", dbType), mlog.String("connStr", connectionString))
logger.Info("connectDatabase", mlog.String("dbType", dbType))
store := &SQLStore{
// TODO: add replica DB support too.
db: db,

View File

@ -14,6 +14,9 @@
"CardDetail.addCardText": "Kartentext hinzufügen",
"CardDetail.moveContent": "Karteninhalt verschieben",
"CardDetail.new-comment-placeholder": "Kommentar hinzufügen...",
"CardDetailProperty.confirm-delete": "Löschen der Eigenschaft bestätigen",
"CardDetailProperty.confirm-delete-subtext": "Sind Sie sicher, dass Sie die Eigenschaft \"{propertyName}\" löschen möchten? Wenn Sie diese löschen, wird die Eigenschaft von allen Karten in diesem Board gelöscht.",
"CardDetailProperty.property-deleted": "{propertyName} erfolgreich gelöscht!",
"CardDialog.copiedLink": "Kopiert!",
"CardDialog.copyLink": "Link kopieren",
"CardDialog.editing-template": "Sie bearbeiten eine Vorlage.",

View File

@ -8,6 +8,7 @@
"CardDetail.add-content": "Προσθήκη περιεχομένου",
"CardDetail.add-icon": "Προσθήκη εικονιδίου",
"CardDetail.new-comment-placeholder": "Προσθήκη σχολίου ...",
"CardDialog.copyLink": "Αντιγραφή συνδέσμου",
"CardDialog.nocard": "Αυτή η κάρτα δεν υπάρχει ή δεν είναι προσβάσιμη",
"Comment.delete": "Διαγραφή",
"CommentsList.send": "Αποστολή",
@ -19,6 +20,7 @@
"ContentBlock.moveDown": "Μετακίνηση κάτω",
"ContentBlock.moveUp": "Μετακίνηση επάνω",
"ContentBlock.text": "κείμενο",
"DashboardPage.showEmpty": "Προβολή άδειου",
"EditableDayPicker.today": "Σήμερα",
"Filter.includes": "περιέχει",
"Filter.is-empty": "είναι άδειο",
@ -45,5 +47,9 @@
"PropertyType.UpdatedBy": "Ενημερώθηκε από",
"PropertyType.UpdatedTime": "Ώρα Ενημέρωσης",
"RegistrationLink.copyLink": "Αντιγραφή συνδέσμου",
"RegistrationLink.description": "Μοιραστείτε αυτόν τον σύνδεσμο με άλλους για να δημιουργήσουν λογαριασμούς:"
"RegistrationLink.description": "Μοιραστείτε αυτόν τον σύνδεσμο με άλλους για να δημιουργήσουν λογαριασμούς:",
"ViewTitle.pick-icon": "Επιλογή εικονιδίου",
"ViewTitle.random-icon": "Τυχαίο",
"ViewTitle.remove-icon": "Αφαίρεση εικονιδίου",
"default-properties.title": "Τίτλος"
}

View File

@ -14,6 +14,9 @@
"CardDetail.addCardText": "add card text",
"CardDetail.moveContent": "move card content",
"CardDetail.new-comment-placeholder": "Add a comment...",
"CardDetailProperty.confirm-delete": "Confirm Delete Property",
"CardDetailProperty.confirm-delete-subtext": "Are you sure you want to delete the property \"{propertyName}\"? Deleting it will delete the property from all cards in this board.",
"CardDetailProperty.property-deleted": "Deleted {propertyName} Successfully!",
"CardDialog.editing-template": "You're editing a template.",
"CardDialog.nocard": "This card doesn't exist or is inaccessible.",
"CardDialog.copiedLink": "Copied!",

View File

@ -7,14 +7,21 @@
"BoardComponent.no-property": "Sin {property}",
"BoardComponent.no-property-title": "Elementos sin la propiedad {property} irán aquí. Esta columna no se puede eliminar.",
"BoardComponent.show": "Mostrar",
"BoardPage.syncFailed": "El tablero puede estar eliminado o el acceso fue revocado.",
"CardDetail.add-content": "Añadir contenido",
"CardDetail.add-icon": "Añadir icono",
"CardDetail.add-property": "+ Añadir propiedad",
"CardDetail.addCardText": "añade texto a la tarjeta",
"CardDetail.moveContent": "mover contenido de la tarjeta",
"CardDetail.new-comment-placeholder": "Añadir un comentario...",
"CardDialog.editing-template": "Está editando una plantilla",
"CardDialog.nocard": "Esta tarjeta no existe o es inaccesible",
"CardDetailProperty.confirm-delete": "Confirmar la eliminación de la propiedad",
"CardDetailProperty.confirm-delete-subtext": "¿Estas seguro de que quieres eliminar la propiedad \"{nombre de la propiedad}\"? Al eliminarla se borrará la propiedad de todas las tarjetas de este tablero.",
"CardDetailProperty.property-deleted": "¡{nombre de la propiedad} ha sido eliminado exitosamente!",
"CardDialog.copiedLink": "¡Copiado!",
"CardDialog.copyLink": "Copiar enlace",
"CardDialog.editing-template": "Estás editando una plantilla.",
"CardDialog.nocard": "Esta tarjeta no existe o es inaccesible.",
"ColorOption.selectColor": "Seleccionar {color} Color",
"Comment.delete": "Borrar",
"CommentsList.send": "Enviar",
"ContentBlock.Delete": "Borrar",
@ -26,25 +33,50 @@
"ContentBlock.editCardCheckboxText": "editar texto de la tarjeta",
"ContentBlock.editCardText": "editar texto de la tarjeta",
"ContentBlock.editText": "Editar texto...",
"ContentBlock.image": "imagen",
"ContentBlock.insertAbove": "Insertar encima",
"ContentBlock.moveDown": "Mover hacia abajo",
"ContentBlock.moveUp": "Mover hacia arriba",
"ContentBlock.text": "texto",
"DashboardPage.CenterPanel.ChangeChannels": "Usa el selector para cambiar fácilmente de canal",
"DashboardPage.CenterPanel.NoWorkspaces": "Lo sentimos, no pudimos encontrar ningún canal que coincida con ese término",
"DashboardPage.CenterPanel.NoWorkspacesDescription": "Por favor, intenta buscar otro término",
"DashboardPage.showEmpty": "Mostrar vacío",
"DashboardPage.title": "Tablero",
"Dialog.closeDialog": "Cerrar diálogo",
"EditableDayPicker.today": "Hoy",
"EmptyCenterPanel.no-content": "Añade o selecciona un panel en la barra lateral para comenzar.",
"EmptyCenterPanel.workspace": "Este es el espacio de trabajo para:",
"Error.websocket-closed": "Conexión de Websocket cerrada, conexión interrumpida. Si esto persiste, verifique la configuración de su servidor o proxy web.",
"Filter.includes": "incluye",
"Filter.is-empty": "está vacío",
"Filter.is-not-empty": "no está vacío",
"Filter.not-includes": "no incluye",
"FilterComponent.add-filter": "+ Añadir filtro",
"FilterComponent.delete": "Borrar",
"GalleryCard.copiedLink": "¡Copiado!",
"GalleryCard.copyLink": "Copiar enlace",
"GalleryCard.delete": "Borrar",
"GalleryCard.duplicate": "Duplicar",
"General.BoardCount": "{contar, plural, un{# Board} otro {# Boards}}",
"GroupBy.ungroup": "Desagrupar",
"KanbanCard.copiedLink": "¡Copiado!",
"KanbanCard.copyLink": "Copiar enlace",
"KanbanCard.delete": "Eliminar",
"KanbanCard.duplicate": "Duplicar",
"KanbanCard.untitled": "Sin título",
"Mutator.duplicate-board": "duplicar panel",
"Mutator.new-board-from-template": "nuevo panel desde una plantilla",
"Mutator.new-card-from-template": "nueva tarjeta desde una plantilla",
"Mutator.new-template-from-board": "nueva plantilla a partir de un panel",
"Mutator.new-template-from-card": "nueva plantilla a partir de una tarjeta",
"Mutator.new-template-from-card": "nueva plantilla desde una tarjeta",
"PropertyMenu.Delete": "Borrar",
"PropertyMenu.changeType": "Cambiar el tipo de propiedad",
"PropertyMenu.typeTitle": "Tipo",
"PropertyType.Checkbox": "Casilla de selección",
"PropertyType.CreatedBy": "Creado Por",
"PropertyType.CreatedTime": "Hora de Creación",
"PropertyType.CreatedBy": "Creado por",
"PropertyType.CreatedTime": "Hora de creación",
"PropertyType.Date": "Fecha",
"PropertyType.Email": "Email",
"PropertyType.File": "Fichero o Multimedia",
"PropertyType.MultiSelect": "Selección Múltiple",
@ -54,8 +86,8 @@
"PropertyType.Select": "Selector",
"PropertyType.Text": "Texto",
"PropertyType.URL": "URL",
"PropertyType.UpdatedBy": "Actualizado Por",
"PropertyType.UpdatedTime": "Hora de Actualización",
"PropertyType.UpdatedBy": "Última actualización por",
"PropertyType.UpdatedTime": "Hora de última actualización",
"RegistrationLink.confirmRegenerateToken": "Esto invalidará los enlaces compartidos previos. ¿Continuar?",
"RegistrationLink.copiedLink": "¡Copiado!",
"RegistrationLink.copyLink": "Copiar enlace",
@ -66,12 +98,12 @@
"ShareBoard.copiedLink": "¡Copiado!",
"ShareBoard.copyLink": "Copiar enlace",
"ShareBoard.regenerateToken": "Regenerar token",
"ShareBoard.share": "Publicar en la red y compartir este panel con cualquiera",
"ShareBoard.share": "Publicar y compartir este panel con cualquiera que tenga el enlace",
"ShareBoard.tokenRegenrated": "Token regenerado",
"ShareBoard.unshare": "Cualquiera con este enlace puede ver este panel",
"ShareBoard.unshare": "Cualquiera con este enlace puede ver este panel y todas las tarjetas en él.",
"Sidebar.about": "Sobre Focalboard",
"Sidebar.add-board": "+ Añadir Panel",
"Sidebar.add-template": "+ Nueva plantilla",
"Sidebar.add-board": "+ Añadir panel",
"Sidebar.add-template": "Nueva plantilla",
"Sidebar.changePassword": "Cambiar contraseña",
"Sidebar.delete-board": "Borrar Panel",
"Sidebar.delete-template": "Borrar",
@ -80,9 +112,11 @@
"Sidebar.empty-board": "Panel vacío",
"Sidebar.export-archive": "Exportar Archivo",
"Sidebar.import-archive": "Importar Archivo",
"Sidebar.invite-users": "Invitar Usuarios",
"Sidebar.invite-users": "Invitar usuarios",
"Sidebar.logout": "Cerrar sesión",
"Sidebar.no-more-workspaces": "No hay más espacios de trabajo",
"Sidebar.no-views-in-board": "No hay páginas dentro",
"Sidebar.random-icons": "Íconos random",
"Sidebar.select-a-template": "Seleccionar una plantilla",
"Sidebar.set-language": "Establecer idioma",
"Sidebar.set-theme": "Establecer apariencia",
@ -102,12 +136,22 @@
"TableHeaderMenu.sort-ascending": "Orden ascendente",
"TableHeaderMenu.sort-descending": "Orden descendente",
"TableRow.open": "Abrir",
"TopBar.give-feedback": "Dar feedback",
"ValueSelector.valueSelector": "Valorar el selector",
"ValueSelectorLabel.openMenu": "Abrir menú",
"View.AddView": "Añadir vista",
"View.Board": "Panel",
"View.DeleteView": "Eliminar vista",
"View.DuplicateView": "Duplicar vista",
"View.NewBoardTitle": "Vista de panel",
"View.NewGalleryTitle": "Vista de galería",
"View.NewTableTitle": "Vista de tabla",
"View.Table": "Tabla",
"ViewHeader.add-template": "+ Nueva plantilla",
"ViewHeader.delete-template": "Borrar",
"ViewHeader.edit-template": "Editar",
"ViewHeader.empty-card": "Tarjeta vacía",
"ViewHeader.export-board-archive": "Exportar archivo de tablero",
"ViewHeader.export-complete": "¡Se ha completado la exportación!",
"ViewHeader.export-csv": "Exportar a CSV",
"ViewHeader.export-failed": "¡Ha fallado la exportación!",
@ -126,5 +170,6 @@
"ViewTitle.random-icon": "Aleatorio",
"ViewTitle.remove-icon": "Quitar Icono",
"ViewTitle.show-description": "mostrar descripción",
"ViewTitle.untitled-board": "Panel sin título"
"ViewTitle.untitled-board": "Panel sin título",
"default-properties.title": "Título"
}

View File

@ -14,6 +14,8 @@
"CardDetail.addCardText": "ajouter une carte texte",
"CardDetail.moveContent": "déplacer le contenu de la carte",
"CardDetail.new-comment-placeholder": "Ajouter un commentaire...",
"CardDetailProperty.confirm-delete": "Confirmer la suppression de la propriété",
"CardDetailProperty.confirm-delete-subtext": "Êtes-vous sûr de vouloir supprimer la propriété « {propertyName} » ? La suppression retirera la propriété de toutes les cartes dans ce tableau.",
"CardDialog.copiedLink": "Copié !",
"CardDialog.copyLink": "Copier le lien",
"CardDialog.editing-template": "Vous éditez un modèle.",

186
webapp/i18n/hu.json Normal file
View File

@ -0,0 +1,186 @@
{
"BoardComponent.add-a-group": "+ Csoport hozzáadása",
"BoardComponent.delete": "Törlés",
"BoardComponent.hidden-columns": "Rejtett oszlopok",
"BoardComponent.hide": "Elrejtés",
"BoardComponent.new": "+ Új",
"BoardComponent.no-property": "Nincs {property}",
"BoardComponent.no-property-title": "Elemek üres {property} tulajdonsággal kerülnek ide. Ez az oszlop nem eltávolítható.",
"BoardComponent.show": "Mutat",
"BoardPage.syncFailed": "A tábla törölve lett vagy hozzáférés vissza lett vonva.",
"CardDetail.add-content": "Tartalom hozzáadása",
"CardDetail.add-icon": "Ikon hozzáadása",
"CardDetail.add-property": "+ Tulajdonság hozzáadása",
"CardDetail.addCardText": "kártya szövegének hozzáadása",
"CardDetail.moveContent": "kártya tartalmának mozgatása",
"CardDetail.new-comment-placeholder": "Megjegyzés hozzáadása...",
"CardDetailProperty.confirm-delete": "Tulajdonság törlésének jóváhagyása",
"CardDetailProperty.confirm-delete-subtext": "Biztos benne, hogy törölni szeretné a \"{propertyName}\" tulajdonságot? A törléssel a tulajdonság minden kártyáról el lesz távolítva.",
"CardDetailProperty.property-deleted": "{propertyName} törlése sikeres!",
"CardDialog.copiedLink": "Másolva!",
"CardDialog.copyLink": "Link másolása",
"CardDialog.editing-template": "Ön egy sablont szerkeszt.",
"CardDialog.nocard": "Ez a kártya nem létezik vagy elérhetetlen.",
"ColorOption.selectColor": "{color} szín kiválasztása",
"Comment.delete": "Törlés",
"CommentsList.send": "Küldés",
"ContentBlock.Delete": "Törlés",
"ContentBlock.DeleteAction": "törlés",
"ContentBlock.addElement": "{type} hozzáadása",
"ContentBlock.checkbox": "jelölőnégyzet",
"ContentBlock.divider": "elválasztó",
"ContentBlock.editCardCheckbox": "három állású jelölőnégyzet",
"ContentBlock.editCardCheckboxText": "kártya szövegének szerkesztése",
"ContentBlock.editCardText": "kártya szövegének szerkesztése",
"ContentBlock.editText": "Szöveg szerkesztése...",
"ContentBlock.image": "kép",
"ContentBlock.insertAbove": "Beszúrás fölé",
"ContentBlock.moveDown": "Mozgatás le",
"ContentBlock.moveUp": "Mozgatás fel",
"ContentBlock.text": "szöveg",
"DashboardPage.CenterPanel.ChangeChannels": "Használja a váltót a beszélgetések könnyű váltásához",
"DashboardPage.CenterPanel.NoWorkspaces": "Sajnáljuk, de nem találtunk egy beszélgetést sem ami ennek a kifejezésnek megfelelne",
"DashboardPage.CenterPanel.NoWorkspacesDescription": "Kérjük próbáljon keresni egy másik kifejezésre",
"DashboardPage.showEmpty": "Mutassa az üreseket",
"DashboardPage.title": "Vezérlőpult",
"Dialog.closeDialog": "Ablak bezárása",
"EditableDayPicker.today": "Ma",
"EmptyCenterPanel.no-content": "Adja hozzá vagy válassza ki a táblát az oldalsávról a kezdéshez.",
"EmptyCenterPanel.workspace": "Ez a munkaterült ehhez:",
"Error.websocket-closed": "Websocket kapcsolat bezárult, kapcsolat megszakadt, Ha ez továbbra is fennáll, akkor ellenőrizze le a kiszolgáló vagy web proxy beállítását.",
"Filter.includes": "tartalmazza",
"Filter.is-empty": "üres",
"Filter.is-not-empty": "nem üres",
"Filter.not-includes": "nem tartalmazza",
"FilterComponent.add-filter": "+ Szűrő hozzáadása",
"FilterComponent.delete": "Törlés",
"GalleryCard.copiedLink": "Másolva!",
"GalleryCard.copyLink": "Link másolása",
"GalleryCard.delete": "Törlés",
"GalleryCard.duplicate": "Duplikálás",
"General.BoardCount": "# Kártya",
"GroupBy.ungroup": "Csoportosítás megszüntetése",
"KanbanCard.copiedLink": "Másolt!",
"KanbanCard.copyLink": "Link másolása",
"KanbanCard.delete": "Törlés",
"KanbanCard.duplicate": "Duplikálás",
"KanbanCard.untitled": "Névtelen",
"Mutator.duplicate-board": "tábla duplikálása",
"Mutator.new-board-from-template": "új tábla sablonból",
"Mutator.new-card-from-template": "új kártya sablonból",
"Mutator.new-template-from-board": "új sablon táblából",
"Mutator.new-template-from-card": "új sablon kártyából",
"PropertyMenu.Delete": "Törlés",
"PropertyMenu.changeType": "Tulajdonság típusának módosítása",
"PropertyMenu.typeTitle": "Típus",
"PropertyType.Checkbox": "Jelölőnégyzet",
"PropertyType.CreatedBy": "Létrehozta",
"PropertyType.CreatedTime": "Létrehozás ideje",
"PropertyType.Date": "Dátum",
"PropertyType.Email": "E-mail",
"PropertyType.File": "Fájl vagy média",
"PropertyType.MultiSelect": "Több kiválasztós",
"PropertyType.Number": "Szám",
"PropertyType.Person": "Személy",
"PropertyType.Phone": "Telefon",
"PropertyType.Select": "Kiválasztás",
"PropertyType.Text": "Szöveg",
"PropertyType.URL": "URL",
"PropertyType.UpdatedBy": "Utoljára frissítette",
"PropertyType.UpdatedTime": "Utolsó frissítés ideje",
"RegistrationLink.confirmRegenerateToken": "Ez érvényteleníteni fogja a korábban megosztott linkeket. Folytassuk?",
"RegistrationLink.copiedLink": "Másolt!",
"RegistrationLink.copyLink": "Link másolása",
"RegistrationLink.description": "Ossza meg ezt a linket másokkat a fiók létrehozásához:",
"RegistrationLink.regenerateToken": "Token újragenerálása",
"RegistrationLink.tokenRegenerated": "Regisztrációs link újragenerálva",
"ShareBoard.confirmRegenerateToken": "Ez érvényteleníteni fogja a korábban megosztott linkeket. Folytassuk?",
"ShareBoard.copiedLink": "Másolt!",
"ShareBoard.copyLink": "Link másolása",
"ShareBoard.regenerateToken": "Token újragenerálása",
"ShareBoard.share": "Tegye közzé és ossza meg bárkivel a táblát aki ismeri a linket",
"ShareBoard.tokenRegenrated": "Token újragenerálva",
"ShareBoard.unshare": "Mindenki a link birtokában megtekintheti ezt a táblát és benne az összes kártyát.",
"Sidebar.about": "Focalboard névjegye",
"Sidebar.add-board": "+ Tábla hozzáadása",
"Sidebar.add-template": "Új sablon",
"Sidebar.changePassword": "Jelszó módosítása",
"Sidebar.delete-board": "Tábla törlése",
"Sidebar.delete-template": "Törlés",
"Sidebar.duplicate-board": "Tábla duplikálása",
"Sidebar.edit-template": "Szerkesztés",
"Sidebar.empty-board": "Üres tábla",
"Sidebar.export-archive": "Archiváltak exportálása",
"Sidebar.import-archive": "Archiváltak importálása",
"Sidebar.invite-users": "Felhasználók meghívása",
"Sidebar.logout": "Kijelentkezés",
"Sidebar.no-more-workspaces": "Nincs több munkaterület",
"Sidebar.no-views-in-board": "Nincsennek benne lapok",
"Sidebar.random-icons": "Véletlen ikonok",
"Sidebar.select-a-template": "Sablon kiválasztása",
"Sidebar.set-language": "Nyelv megadása",
"Sidebar.set-theme": "Téma megadása",
"Sidebar.settings": "Beállítások",
"Sidebar.template-from-board": "Új sablon táblából",
"Sidebar.untitled": "Névtelen",
"Sidebar.untitled-board": "(Névtelen tábla)",
"Sidebar.untitled-view": "(Névtelen nézet)",
"TableComponent.add-icon": "Ikon hozzáadása",
"TableComponent.name": "Név",
"TableComponent.plus-new": "+ Új",
"TableHeaderMenu.delete": "Törlés",
"TableHeaderMenu.duplicate": "Duplikálás",
"TableHeaderMenu.hide": "Elrejtés",
"TableHeaderMenu.insert-left": "Beillesztés balra",
"TableHeaderMenu.insert-right": "Beillesztés jobbra",
"TableHeaderMenu.sort-ascending": "Rendezés növekvő sorrendben",
"TableHeaderMenu.sort-descending": "Rendezés csökkenő sorrendben",
"TableRow.open": "Megnyitás",
"TopBar.give-feedback": "Visszajelzés",
"ValueSelector.valueSelector": "Érték kiválasztó",
"ValueSelectorLabel.openMenu": "Menü megnyitása",
"View.AddView": "Nézet hozzáadása",
"View.Board": "Tábla",
"View.DeleteView": "Nézet törlése",
"View.DuplicateView": "Nézet duplikálása",
"View.NewBoardTitle": "Tábla nézet",
"View.NewGalleryTitle": "Galéria nézet",
"View.NewTableTitle": "Táblázat nézet",
"View.Table": "Táblázat",
"ViewHeader.add-template": "Új sablon",
"ViewHeader.delete-template": "Törlés",
"ViewHeader.edit-template": "Szerkesztés",
"ViewHeader.empty-card": "Üres kártya",
"ViewHeader.export-board-archive": "Archivált tábla exportálása",
"ViewHeader.export-complete": "Exportálás kész!",
"ViewHeader.export-csv": "Exportálás CSV-be",
"ViewHeader.export-failed": "Exportálás meghiúsult!",
"ViewHeader.filter": "Szűrő",
"ViewHeader.group-by": "Csoportosítás: {property}",
"ViewHeader.new": "Új",
"ViewHeader.properties": "Tulajdonságok",
"ViewHeader.search": "Keresés",
"ViewHeader.search-text": "Szöveg keresése",
"ViewHeader.select-a-template": "Sablon kiválasztása",
"ViewHeader.share-board": "Tábla megosztása",
"ViewHeader.sort": "Rendezés",
"ViewHeader.untitled": "Névtelen",
"ViewTitle.hide-description": "leírás elrejtése",
"ViewTitle.pick-icon": "Válasszon ikont",
"ViewTitle.random-icon": "Véletlen",
"ViewTitle.remove-icon": "Ikon eltávolítása",
"ViewTitle.show-description": "leírás mutatása",
"ViewTitle.untitled-board": "Névtelen tábla",
"WelcomePage.Description": "A Táblák egy projekt kezelő segédeszköz ami segít azonosítani, rendezni, követni és vezetni a munkát csapatok között, egy ismerős kanban táblás nézet segítségével",
"WelcomePage.Explore.Button": "Felfedezés",
"WelcomePage.Heading": "Üdvözöljük a Táblákban",
"Workspace.editing-board-template": "Ön egy sablon táblát szerkeszt.",
"default-properties.title": "Cím",
"error.no-workspace": "Az Ön munkamenete lejárt vagy nincs hozzáférése ehhez a munkaterülethez.",
"error.relogin": "Jelentkezzen be újra",
"login.log-in-button": "Bejelentkezés",
"login.log-in-title": "Bejelentkezés",
"login.register-button": "vagy hozzon létre egy fiókot ha még nincs",
"register.login-button": "vagy jelentkezzen be ha már van fiókja",
"register.signup-title": "Regisztráljon fiókjáért"
}

View File

@ -14,6 +14,9 @@
"CardDetail.addCardText": "カードテキストを追加する",
"CardDetail.moveContent": "カード内容の移動",
"CardDetail.new-comment-placeholder": "コメントを追加する...",
"CardDetailProperty.confirm-delete": "プロパティの削除を確定する",
"CardDetailProperty.confirm-delete-subtext": "本当にプロパティ \"{propertyName}\" を削除しますか? 削除すると、このボードのすべてのカードからそのプロパティが削除されます。",
"CardDetailProperty.property-deleted": "{propertyName} が正常に削除されました!",
"CardDialog.copiedLink": "コピーしました!",
"CardDialog.copyLink": "リンクをコピー",
"CardDialog.editing-template": "テンプレートを編集しています。",
@ -38,6 +41,7 @@
"DashboardPage.CenterPanel.ChangeChannels": "スイッチャーで簡単にチャンネルを変更できます",
"DashboardPage.CenterPanel.NoWorkspaces": "その言葉に一致するチャンネルは見つかりませんでした",
"DashboardPage.CenterPanel.NoWorkspacesDescription": "別の言葉で検索してみてください",
"DashboardPage.showEmpty": "空の表示",
"DashboardPage.title": "ダッシュボード",
"Dialog.closeDialog": "ダイアログを閉じる",
"EditableDayPicker.today": "今日",

View File

@ -14,6 +14,9 @@
"CardDetail.addCardText": "kaarttekst toevoegen",
"CardDetail.moveContent": "inhoud van de kaart verplaatsen",
"CardDetail.new-comment-placeholder": "Voeg commentaar toe...",
"CardDetailProperty.confirm-delete": "Bevestig verwijderen eigenschap",
"CardDetailProperty.confirm-delete-subtext": "Weet je zeker dat je de eigenschap \"{propertyName}\" wilt verwijderen? Dit verwijderen zal de eigenschap van alle kaarten in dit bord verwijderen.",
"CardDetailProperty.property-deleted": "{propertyName} werd succesvol verwijderd!",
"CardDialog.copiedLink": "Gekopieerd!",
"CardDialog.copyLink": "Kopieer link",
"CardDialog.editing-template": "Je bent een sjabloon aan het bewerken.",

View File

@ -14,6 +14,9 @@
"CardDetail.addCardText": "apondre una zòna de tèxt",
"CardDetail.moveContent": "desplaçar contengut de la carta",
"CardDetail.new-comment-placeholder": "Apondre un comentari...",
"CardDetailProperty.confirm-delete": "Confirmar la supression de proprietat",
"CardDetailProperty.confirm-delete-subtext": "Volètz vertadièrament suprimir la proprietat « {propertyName} » ? La supression levarà la proprietat de totas las cartas d’aquesta tablèu.",
"CardDetailProperty.property-deleted": "Supression de {propertyName} reüssida !",
"CardDialog.copiedLink": "Copiat !",
"CardDialog.copyLink": "Copiar ligam",
"CardDialog.editing-template": "Sètz a modificar un modèl.",
@ -38,6 +41,7 @@
"DashboardPage.CenterPanel.ChangeChannels": "Utilizatz l’alternator per cambiar facilament de cadenas",
"DashboardPage.CenterPanel.NoWorkspaces": "O planhèm, avèm pas trobat cap de cadena correspondenta al tèrme",
"DashboardPage.CenterPanel.NoWorkspacesDescription": "Mercés d’ensajar d’autres tèrmes",
"DashboardPage.showEmpty": "Mostrar void",
"DashboardPage.title": "Tablèu de bòrd",
"Dialog.closeDialog": "Tampar la fenèstra de dialòg",
"EditableDayPicker.today": "Uèi",
@ -168,6 +172,7 @@
"ViewTitle.show-description": "mostrar la descripcion",
"ViewTitle.untitled-board": "Tablèu sens títol",
"WelcomePage.Explore.Button": "Explorar",
"WelcomePage.Heading": "La benvengudas als tablèus",
"Workspace.editing-board-template": "Modificatz un modèl de tablèu.",
"default-properties.title": "Títol",
"error.no-workspace": "Vòstra session pòt aver expirat o avètz pas accès a aqueste espaci de trabalh.",

View File

@ -14,6 +14,9 @@
"CardDetail.addCardText": "dodaj tekst karty",
"CardDetail.moveContent": "przenieś zawartość karty",
"CardDetail.new-comment-placeholder": "Dodaj komentarz...",
"CardDetailProperty.confirm-delete": "Potwierdź Usuń Właściwość",
"CardDetailProperty.confirm-delete-subtext": "Czy na pewno chcesz usunąć właściwość \"{propertyName}\"? Usunięcie tej właściwości spowoduje usunięcie jej z wszystkich kart na tej tablicy.",
"CardDetailProperty.property-deleted": "Usunięto pomyślnie {propertyName}!",
"CardDialog.copiedLink": "Skopiowane!",
"CardDialog.copyLink": "Kopiuj odnośnik",
"CardDialog.editing-template": "Edytujesz szablon.",

View File

@ -14,6 +14,9 @@
"CardDetail.addCardText": "lägg till korttext",
"CardDetail.moveContent": "flytta kortinnehåll",
"CardDetail.new-comment-placeholder": "Lägg till kommentar...",
"CardDetailProperty.confirm-delete": "Bekräfta att ta bort egenskap",
"CardDetailProperty.confirm-delete-subtext": "Är du säker på att du vill ta bort egenskapen \"{propertyName}\"? Om du raderar den kommer egenskapen tas bort från alla kort på tavlan.",
"CardDetailProperty.property-deleted": "{propertyName} har raderats!",
"CardDialog.copiedLink": "Kopierad!",
"CardDialog.copyLink": "Kopiera länk",
"CardDialog.editing-template": "Du redigerar en mall.",
@ -38,6 +41,7 @@
"DashboardPage.CenterPanel.ChangeChannels": "Använd kanalväljaren för att smidigt växla mellan kanaler",
"DashboardPage.CenterPanel.NoWorkspaces": "Tyvärr hittade vi inga kanaler som matchar den termen",
"DashboardPage.CenterPanel.NoWorkspacesDescription": "Försök att söka efter en annan term",
"DashboardPage.showEmpty": "Visa tomma",
"DashboardPage.title": "Dashboard",
"Dialog.closeDialog": "Stäng dialog",
"EditableDayPicker.today": "Idag",

View File

@ -14,6 +14,9 @@
"CardDetail.addCardText": "kart metni ekle",
"CardDetail.moveContent": "kart içeriğini taşı",
"CardDetail.new-comment-placeholder": "Bir yorum ekle...",
"CardDetailProperty.confirm-delete": "Özelliği silme onayı",
"CardDetailProperty.confirm-delete-subtext": "\"{propertyName}\" özelliğini silmek istediğinize emin misiniz? Bu işlem özelliği panodaki tüm kartlardan siler.",
"CardDetailProperty.property-deleted": "{propertyName} silindi!",
"CardDialog.copiedLink": "Kopyalandı!",
"CardDialog.copyLink": "Bağlantıyı kopyala",
"CardDialog.editing-template": "Bir kalıbı düzenliyorsunuz.",

View File

@ -13,6 +13,9 @@
"CardDetail.addCardText": "新增卡片文本",
"CardDetail.moveContent": "移动卡片内容",
"CardDetail.new-comment-placeholder": "新增评论...",
"CardDetailProperty.property-deleted": "成功删除 {propertyName}!",
"CardDialog.copiedLink": "已复制!",
"CardDialog.copyLink": "复制链接",
"CardDialog.editing-template": "您正在编辑模板。",
"CardDialog.nocard": "卡片不存在或者无法被存取。",
"ColorOption.selectColor": "选择{color}",

View File

@ -16,6 +16,7 @@ import {createBrowserHistory} from 'history'
import TelemetryClient from './telemetry/telemetryClient'
import {IAppWindow} from './types'
import {getMessages} from './i18n'
import {FlashMessages} from './components/flashMessages'
import BoardPage from './pages/boardPage'
@ -36,6 +37,8 @@ import {fetchClientConfig} from './store/clientConfig'
import {IUser} from './user'
import {UserSettings} from './userSettings'
declare let window: IAppWindow
export const history = createBrowserHistory({basename: Utils.getFrontendBaseURL()})
const UUID_REGEX = new RegExp(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/)
@ -47,30 +50,30 @@ if (Utils.isDesktop() && Utils.isFocalboardPlugin()) {
}
const pathName = event.data.message?.pathName
if (!pathName || !pathName.startsWith((window as any).frontendBaseURL)) {
if (!pathName || !pathName.startsWith(window.frontendBaseURL)) {
return
}
Utils.log(`Navigating Boards to ${pathName}`)
history.replace(pathName.replace((window as any).frontendBaseURL, ''))
history.replace(pathName.replace(window.frontendBaseURL, ''))
})
}
const browserHistory = {
const browserHistory: typeof history = {
...history,
push: (path: string, ...args: any[]) => {
push: (path: string, state?: unknown) => {
if (Utils.isDesktop() && Utils.isFocalboardPlugin()) {
window.postMessage(
{
type: 'browser-history-push',
message: {
path: `${(window as any).frontendBaseURL}${path}`,
path: `${window.frontendBaseURL}${path}`,
},
},
window.location.origin,
)
} else {
history.push(path, ...args)
history.push(path, state)
}
},
}
@ -97,7 +100,9 @@ const App = React.memo((): JSX.Element => {
if (Utils.isFocalboardPlugin()) {
useEffect(() => {
history.replace(window.location.pathname.replace((window as any).frontendBaseURL, ''))
if (window.frontendBaseURL) {
history.replace(window.location.pathname.replace(window.frontendBaseURL, ''))
}
}, [])
}

View File

@ -1,5 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IAppWindow} from './types'
import {ArchiveUtils, ArchiveHeader, ArchiveLine, BlockArchiveLine} from './blocks/archive'
import {Block} from './blocks/block'
import {Board} from './blocks/board'
@ -7,6 +9,8 @@ import {LineReader} from './lineReader'
import mutator from './mutator'
import {Utils} from './utils'
declare let window: IAppWindow
class Archiver {
static async exportBoardArchive(board: Board): Promise<void> {
const blocks = await mutator.exportArchive(board.id)
@ -35,8 +39,8 @@ class Archiver {
link.click()
// TODO: Review if this is needed in the future, this is to fix the problem with linux webview links
if ((window as any).openInNewBrowser) {
(window as any).openInNewBrowser(link.href)
if (window.openInNewBrowser) {
window.openInNewBrowser(link.href)
}
// TODO: Remove or reuse link

File diff suppressed because it is too large Load Diff

View File

@ -151,6 +151,8 @@ describe('components/calculations/calculation logic', () => {
updatedBy: {id: 'property_lastUpdatedBy', type: 'updatedBy', name: '', options: []},
}
const autofilledProperties = new Set([properties.createdBy, properties.createdTime, properties.updatedBy, properties.updatedTime])
const intl = createIntl({locale: 'en-us'})
// testing count
@ -160,6 +162,34 @@ describe('components/calculations/calculation logic', () => {
})
})
// testing count empty
Object.values(properties).filter((p) => !autofilledProperties.has(p)).forEach((property) => {
it(`should correctly count empty for property type "${property.type}"`, function() {
expect(Calculations.countEmpty(cards, property, intl)).toBe('1')
})
})
// testing percent empty
Object.values(properties).filter((p) => !autofilledProperties.has(p)).forEach((property) => {
it(`should correctly compute empty percent for property type "${property.type}"`, function() {
expect(Calculations.percentEmpty(cards, property, intl)).toBe('25%')
})
})
// testing count not empty
Object.values(properties).filter((p) => !autofilledProperties.has(p)).forEach((property) => {
it(`should correctly count not empty for property type "${property.type}"`, function() {
expect(Calculations.countNotEmpty(cards, property, intl)).toBe('3')
})
})
// testing percent not empty
Object.values(properties).filter((p) => !autofilledProperties.has(p)).forEach((property) => {
it(`should correctly compute not empty percent for property type "${property.type}"`, function() {
expect(Calculations.percentNotEmpty(cards, property, intl)).toBe('75%')
})
})
// testing countValues
const countValueTests: Record<string, string> = {
text: '3',

View File

@ -53,6 +53,28 @@ function count(cards: readonly Card[], property: IPropertyTemplate): string {
return String(cards.length)
}
function countEmpty(cards: readonly Card[], property: IPropertyTemplate): string {
return String(cards.length - cardsWithValue(cards, property).length)
}
function countNotEmpty(cards: readonly Card[], property: IPropertyTemplate): string {
return String(cardsWithValue(cards, property).length)
}
function percentEmpty(cards: readonly Card[], property: IPropertyTemplate): string {
if (cards.length === 0) {
return ''
}
return String((((cards.length - cardsWithValue(cards, property).length) / cards.length) * 100).toFixed(0)) + '%'
}
function percentNotEmpty(cards: readonly Card[], property: IPropertyTemplate): string {
if (cards.length === 0) {
return ''
}
return String(((cardsWithValue(cards, property).length / cards.length) * 100).toFixed(0)) + '%'
}
function countValueHelper(cards: readonly Card[], property: IPropertyTemplate): number {
let values = 0
@ -260,7 +282,7 @@ function getTimestampsFromPropertyValue(value: number | string | string[]): numb
return []
}
function dateRange(cards: readonly Card[], property: IPropertyTemplate): string {
function dateRange(cards: readonly Card[], property: IPropertyTemplate, intl: IntlShape): string {
const resultEarliest = earliestEpoch(cards, property)
if (resultEarliest === Number.POSITIVE_INFINITY) {
return ''
@ -269,11 +291,15 @@ function dateRange(cards: readonly Card[], property: IPropertyTemplate): string
if (resultLatest === Number.NEGATIVE_INFINITY) {
return ''
}
return moment.duration(resultLatest - resultEarliest, 'milliseconds').humanize()
return moment.duration(resultLatest - resultEarliest, 'milliseconds').locale(intl.locale.toLowerCase()).humanize()
}
const Calculations: Record<string, (cards: readonly Card[], property: IPropertyTemplate, intl: IntlShape) => string> = {
count,
countEmpty,
countNotEmpty,
percentEmpty,
percentNotEmpty,
countValue,
countUniqueValue,
countChecked,

View File

@ -19,6 +19,10 @@ type Option = {
export const Options:Record<string, Option> = {
none: {value: 'none', label: 'None', displayName: 'Calculate'},
count: {value: 'count', label: 'Count', displayName: 'Count'},
countEmpty: {value: 'countEmpty', label: 'Count Empty', displayName: 'Empty'},
countNotEmpty: {value: 'countNotEmpty', label: 'Count Not Empty', displayName: 'Not Empty'},
percentEmpty: {value: 'percentEmpty', label: 'Percent Empty', displayName: 'Empty'},
percentNotEmpty: {value: 'percentNotEmpty', label: 'Percent Not Empty', displayName: 'Not Empty'},
countValue: {value: 'countValue', label: 'Count Value', displayName: 'Values'},
countChecked: {value: 'countChecked', label: 'Count Checked', displayName: 'Checked'},
percentChecked: {value: 'percentChecked', label: 'Percent Checked', displayName: 'Checked'},
@ -37,7 +41,8 @@ export const Options:Record<string, Option> = {
}
export const optionsByType: Map<string, Option[]> = new Map([
['common', [Options.none, Options.count, Options.countValue, Options.countUniqueValue]],
['common', [Options.none, Options.count, Options.countEmpty, Options.countNotEmpty, Options.percentEmpty,
Options.percentNotEmpty, Options.countValue, Options.countUniqueValue]],
['checkbox', [Options.countChecked, Options.countUnchecked, Options.percentChecked, Options.percentUnchecked]],
['number', [Options.sum, Options.average, Options.median, Options.min, Options.max, Options.range]],
['date', [Options.earliest, Options.latest, Options.dateRange]],

View File

@ -36,7 +36,7 @@ exports[`components/cardDetail/cardDetailContents should match snapshot 1`] = `
class="EasyMDEContainer"
>
<div
class="CodeMirror cm-s-easymde CodeMirror-wrap CodeMirror-focused"
class="CodeMirror cm-s-easymde CodeMirror-wrap"
>
<div
style="overflow: hidden; position: relative; width: 3px; height: 0px;"
@ -168,14 +168,14 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
class="dnd-handle"
@ -232,14 +232,14 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
class="dnd-handle"
@ -287,14 +287,14 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
class="dnd-handle"
@ -357,14 +357,14 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
class="dnd-handle"
@ -417,14 +417,14 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
class="dnd-handle"
@ -490,14 +490,14 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
class="dnd-handle"
@ -550,14 +550,14 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
class="dnd-handle"
@ -773,14 +773,14 @@ exports[`components/cardDetail/cardDetailContents should match snapshot with con
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
class="dnd-handle"
@ -846,14 +846,14 @@ exports[`components/cardDetail/cardDetailContents should match snapshot with con
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
class="dnd-handle"
@ -910,14 +910,14 @@ exports[`components/cardDetail/cardDetailContents should match snapshot with con
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
class="dnd-handle"
@ -965,14 +965,14 @@ exports[`components/cardDetail/cardDetailContents should match snapshot with con
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
class="dnd-handle"

View File

@ -16,23 +16,28 @@ exports[`components/cardDetail/comment return comment 1`] = `
class="comment-username"
/>
<div
class="comment-date"
class="octo-tooltip tooltip-top"
data-tooltip="October 01, 2020, 12:00 AM"
>
October 01, 2020, 12:00 AM
<div
class="comment-date"
>
a day ago
</div>
</div>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
<div
class="Menu noselect left"
>
@ -121,23 +126,28 @@ exports[`components/cardDetail/comment return comment and delete comment 1`] = `
class="comment-username"
/>
<div
class="comment-date"
class="octo-tooltip tooltip-top"
data-tooltip="October 01, 2020, 12:00 AM"
>
October 01, 2020, 12:00 AM
<div
class="comment-date"
>
a day ago
</div>
</div>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
<div
class="Menu noselect left"
>
@ -226,9 +236,14 @@ exports[`components/cardDetail/comment return comment readonly 1`] = `
class="comment-username"
/>
<div
class="comment-date"
class="octo-tooltip tooltip-top"
data-tooltip="October 01, 2020, 12:00 AM"
>
October 01, 2020, 12:00 AM
<div
class="comment-date"
>
a day ago
</div>
</div>
</div>
<div

View File

@ -6,7 +6,7 @@
.add-buttons {
display: flex;
flex-direction: column;
min-height: 30px;
min-height: 32px;
color: rgba(var(--center-channel-color-rgb), 0.4);
width: 100%;
align-items: flex-start;

View File

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage} from 'react-intl'
import React, {useState} from 'react'
import {useIntl, FormattedMessage} from 'react-intl'
import {Board, PropertyType, IPropertyTemplate} from '../../blocks/board'
import {Card} from '../../blocks/card'
@ -14,6 +15,8 @@ import MenuWrapper from '../../widgets/menuWrapper'
import PropertyMenu from '../../widgets/propertyMenu'
import PropertyValueElement from '../propertyValueElement'
import {ConfirmationDialogBox} from '../confirmationDialogBox'
import {sendFlashMessage} from '../flashMessages'
type Props = {
board: Board
@ -27,8 +30,13 @@ type Props = {
}
const CardDetailProperties = React.memo((props: Props) => {
const intl = useIntl()
const {board, card, cards, views, activeView, contents, comments} = props
const [showConfirmationDialog, setShowConfirmationDialog] = useState<boolean>(false)
const [deletingPropId, setDeletingPropId] = useState<string>('')
const [deletingPropName, setDeletingPropName] = useState<string>('')
return (
<div className='octo-propertylist CardDetailProperties'>
{board.fields.cardProperties.map((propertyTemplate: IPropertyTemplate) => {
@ -47,7 +55,12 @@ const CardDetailProperties = React.memo((props: Props) => {
propertyName={propertyTemplate.name}
propertyType={propertyTemplate.type}
onTypeAndNameChanged={(newType: PropertyType, newName: string) => mutator.changePropertyTypeAndName(board, cards, propertyTemplate, newType, newName)}
onDelete={(id: string) => mutator.deleteProperty(board, views, cards, id)}
onDelete={(id: string) => {
setDeletingPropId(id)
setDeletingPropName(propertyTemplate.name)
setShowConfirmationDialog(true)
}
}
/>
</MenuWrapper>
}
@ -64,6 +77,26 @@ const CardDetailProperties = React.memo((props: Props) => {
)
})}
{showConfirmationDialog && (
<ConfirmationDialogBox
propertyId={deletingPropId}
onClose={() => setShowConfirmationDialog(false)}
onConfirm={() => {
mutator.deleteProperty(board, views, cards, deletingPropId)
setShowConfirmationDialog(false)
sendFlashMessage({content: intl.formatMessage({id: 'CardDetailProperty.property-deleted', defaultMessage: 'Deleted {propertyName} Successfully!'}, {propertyName: deletingPropName}), severity: 'high'})
}}
heading={intl.formatMessage({id: 'CardDetailProperty.confirm-delete', defaultMessage: 'Confirm Delete Property'})}
subText={intl.formatMessage({
id: 'CardDetailProperty.confirm-delete-subtext',
defaultMessage: 'Are you sure you want to delete the property "{propertyName}"? Deleting it will delete the property from all cards in this board.',
},
{propertyName: deletingPropName})
}
/>
)}
{!props.readonly &&
<div className='octo-propertyname add-property'>
<Button

View File

@ -4,6 +4,7 @@ import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import moment from 'moment'
import {mocked} from 'ts-jest/utils'
@ -37,9 +38,20 @@ describe('components/cardDetail/comment', () => {
},
}
const store = mockStateStore([], state)
beforeEach(() => {
jest.clearAllMocks()
moment.now = () => {
return dateFixed + (24 * 60 * 60 * 1000)
}
})
afterEach(() => {
moment.now = () => {
return Number(new Date())
}
})
test('return comment', () => {
const {container} = render(wrapIntl(
<ReduxProvider store={store}>

View File

@ -13,6 +13,7 @@ import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import {getUser} from '../../store/users'
import {useAppSelector} from '../../store/hooks'
import Tooltip from '../../widgets/tooltip'
import './comment.scss'
@ -28,6 +29,7 @@ const Comment: FC<Props> = (props: Props) => {
const intl = useIntl()
const html = Utils.htmlFromMarkdown(comment.title)
const user = useAppSelector(getUser(userId))
const date = new Date(comment.createAt)
return (
<div
@ -40,9 +42,11 @@ const Comment: FC<Props> = (props: Props) => {
src={userImageUrl}
/>
<div className='comment-username'>{user?.username}</div>
<div className='comment-date'>
{Utils.displayDateTime(new Date(comment.createAt), intl)}
</div>
<Tooltip title={Utils.displayDateTime(date, intl)}>
<div className='comment-date'>
{Utils.relativeDisplayDateTime(date, intl)}
</div>
</Tooltip>
{!props.readonly && (
<MenuWrapper>

View File

@ -0,0 +1,211 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import '@testing-library/jest-dom'
import {act, render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {mocked} from 'ts-jest/utils'
import mutator from '../mutator'
import {Utils} from '../utils'
import {TestBlockFactory} from '../test/testBlockFactory'
import {mockDOM, mockStateStore, wrapDNDIntl} from '../testUtils'
import CardDialog from './cardDialog'
jest.mock('../mutator')
jest.mock('../utils')
const mockedUtils = mocked(Utils, true)
const mockedMutator = mocked(mutator, true)
mockedUtils.createGuid.mockReturnValue('test-id')
beforeAll(() => {
mockDOM()
})
describe('components/cardDialog', () => {
const board = TestBlockFactory.createBoard()
board.fields.cardProperties = []
board.id = 'test-id'
board.rootId = board.id
const boardView = TestBlockFactory.createBoardView(board)
boardView.id = board.id
const card = TestBlockFactory.createCard(board)
card.id = board.id
card.createdBy = 'user-id-1'
const state = {
comments: {
comments: {},
},
contents: {},
cards: {
cards: {
[card.id]: card,
},
},
}
const store = mockStateStore([], state)
beforeEach(() => {
jest.clearAllMocks()
})
test('should match snapshot', async () => {
let container
await act(async () => {
const result = render(wrapDNDIntl(
<ReduxProvider store={store}>
<CardDialog
board={board}
activeView={boardView}
views={[boardView]}
cards={[card]}
cardId={card.id}
onClose={jest.fn()}
showCard={jest.fn()}
readonly={false}
/>
</ReduxProvider>,
))
container = result.container
})
expect(container).toMatchSnapshot()
})
test('return a cardDialog readonly', async () => {
let container
await act(async () => {
const result = render(wrapDNDIntl(
<ReduxProvider store={store}>
<CardDialog
board={board}
activeView={boardView}
views={[boardView]}
cards={[card]}
cardId={card.id}
onClose={jest.fn()}
showCard={jest.fn()}
readonly={false}
/>
</ReduxProvider>,
))
container = result.container
})
expect(container).toMatchSnapshot()
})
test('return cardDialog and do a close action', async () => {
const closeFn = jest.fn()
await act(async () => {
render(wrapDNDIntl(
<ReduxProvider store={store}>
<CardDialog
board={board}
activeView={boardView}
views={[boardView]}
cards={[card]}
cardId={card.id}
onClose={closeFn}
showCard={jest.fn()}
readonly={false}
/>
</ReduxProvider>,
))
})
const buttonElement = screen.getByRole('button', {name: 'Close dialog'})
userEvent.click(buttonElement)
expect(closeFn).toBeCalledTimes(1)
})
test('return cardDialog menu content', async () => {
let container
await act(async () => {
const result = render(wrapDNDIntl(
<ReduxProvider store={store}>
<CardDialog
board={board}
activeView={boardView}
views={[boardView]}
cards={[card]}
cardId={card.id}
onClose={jest.fn()}
showCard={jest.fn()}
readonly={false}
/>
</ReduxProvider>,
))
container = result.container
})
const buttonMenu = screen.getAllByRole('button', {name: 'menuwrapper'})[0]
userEvent.click(buttonMenu)
expect(container).toMatchSnapshot()
})
test('return cardDialog menu content and verify delete action', async () => {
await act(async () => {
render(wrapDNDIntl(
<ReduxProvider store={store}>
<CardDialog
board={board}
activeView={boardView}
views={[boardView]}
cards={[card]}
cardId={card.id}
onClose={jest.fn()}
showCard={jest.fn()}
readonly={false}
/>
</ReduxProvider>,
))
})
const buttonMenu = screen.getAllByRole('button', {name: 'menuwrapper'})[0]
userEvent.click(buttonMenu)
const buttonDelete = screen.getByRole('button', {name: 'Delete'})
userEvent.click(buttonDelete)
expect(mockedMutator.deleteBlock).toBeCalledTimes(1)
})
test('return cardDialog menu content and do a New template from card', async () => {
await act(async () => {
render(wrapDNDIntl(
<ReduxProvider store={store}>
<CardDialog
board={board}
activeView={boardView}
views={[boardView]}
cards={[card]}
cardId={card.id}
onClose={jest.fn()}
showCard={jest.fn()}
readonly={false}
/>
</ReduxProvider>,
))
})
const buttonMenu = screen.getAllByRole('button', {name: 'menuwrapper'})[0]
userEvent.click(buttonMenu)
const buttonTemplate = screen.getByRole('button', {name: 'New template from card'})
userEvent.click(buttonTemplate)
expect(mockedMutator.duplicateCard).toBeCalledTimes(1)
})
test('return cardDialog menu content and do a copy Link', async () => {
await act(async () => {
render(wrapDNDIntl(
<ReduxProvider store={store}>
<CardDialog
board={board}
activeView={boardView}
views={[boardView]}
cards={[card]}
cardId={card.id}
onClose={jest.fn()}
showCard={jest.fn()}
readonly={false}
/>
</ReduxProvider>,
))
})
const buttonMenu = screen.getAllByRole('button', {name: 'menuwrapper'})[0]
userEvent.click(buttonMenu)
const buttonCopy = screen.getByRole('button', {name: 'Copy link'})
userEvent.click(buttonCopy)
expect(mockedUtils.copyTextToClipboard).toBeCalledTimes(1)
})
})

View File

@ -0,0 +1,60 @@
.confirmation-dialog-box {
.dialog {
position: fixed;
top: 30%;
left: 50%;
transform: translate(-50%, -50%);
width: max-content;
height: max-content;
z-index: 300;
background-color: 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;
border-radius: var(--modal-rad);
padding: 0;
-webkit-overflow-scrolling: touch;
overflow-x: hidden;
overflow-y: auto;
> .toolbar {
position: absolute;
top: 0;
right: 0;
padding: 16px;
}
}
}
.box-area {
display: grid;
place-items: center;
.heading {
margin-top: 2rem;
padding: 2px 4px;
}
.sub-text {
width: 26rem;
word-wrap: normal;
margin: 0.5rem 3rem;
padding: 2px;
@media screen and (max-width: 400px) {
width: 12rem;
}
}
}
.action-buttons {
display: flex;
margin: 1rem;
justify-content: space-between;
.Button {
margin: 2px 1rem;
}
}

View File

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage} from 'react-intl'
import Button from '../widgets/buttons/button'
import Dialog from './dialog'
import './confirmationDialogBox.scss'
type Props = {
propertyId: string;
onClose: () => void;
onConfirm: () => void;
heading: string;
subText?: string;
}
export const ConfirmationDialogBox = (props: Props) => {
return (
<Dialog
className='confirmation-dialog-box'
onClose={props.onClose}
>
<div className='box-area'>
<h3 className='heading'>{props.heading}</h3>
<p className='sub-text'>{props.subText}</p>
<div className='action-buttons'>
<Button
title='Cancel'
active={true}
onClick={props.onClose}
>
<FormattedMessage
id='ConfirmationDialog.cancel-action'
defaultMessage='Cancel'
/>
</Button>
<Button
title='Delete'
submit={true}
emphasis='danger'
onClick={props.onConfirm}
>
<FormattedMessage
id='ConfirmationDialog.delete-action'
defaultMessage='Delete'
/>
</Button>
</div>
</div>
</Dialog>
)
}

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/content/contentElement return an element 1`] = `
exports[`components/content/contentElement should match snapshot for checkbox type 1`] = `
<div>
<div
class="CheckboxElement"

View File

@ -3,14 +3,15 @@
exports[`components/content/TextElement return a textElement 1`] = `
<div>
<div
class="MarkdownEditor octo-editor "
class="MarkdownEditor octo-editor active"
>
<div
class="octo-editor-preview octo-placeholder"
style="display: none;"
/>
<div
class="octo-editor-active Editor"
style="visibility: hidden; position: absolute; top: 0px; left: 0px;"
style=""
>
<div
id="test-id-wrapper"
@ -140,6 +141,7 @@ exports[`components/content/TextElement return a textElement and do a blur event
>
<div
class="octo-editor-preview octo-placeholder"
style=""
/>
<div
class="octo-editor-active Editor"

View File

@ -1,52 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactElement, ReactNode} from 'react'
import '@testing-library/jest-dom'
import {render} from '@testing-library/react'
import {wrapIntl} from '../../testUtils'
import {ContentBlock} from '../../blocks/contentBlock'
import {CardDetailProvider} from '../cardDetail/cardDetailContext'
import {TestBlockFactory} from '../../test/testBlockFactory'
import ContentElement from './contentElement'
const board = TestBlockFactory.createBoard()
const card = TestBlockFactory.createCard(board)
const contentBlock: ContentBlock = {
id: 'test-id',
workspaceId: '',
parentId: card.id,
rootId: card.rootId,
modifiedBy: 'test-user-id',
schema: 0,
type: 'checkbox',
title: 'test-title',
fields: {},
createdBy: 'test-user-id',
createAt: 0,
updateAt: 0,
deleteAt: 0,
}
const wrap = (child: ReactNode): ReactElement => (
wrapIntl(
<CardDetailProvider card={card}>
{child}
</CardDetailProvider>,
)
)
describe('components/content/contentElement', () => {
test('return an element', () => {
const contentBlock: ContentBlock = {
id: 'test-id',
workspaceId: '',
parentId: '',
rootId: '',
modifiedBy: 'test-user-id',
schema: 0,
type: 'checkbox',
title: 'test-title',
fields: {},
createdBy: 'test-user-id',
createAt: 0,
updateAt: 0,
deleteAt: 0,
}
const checkBoxElement = ContentElement({block: contentBlock, readonly: false})
const {container} = render(wrapIntl(checkBoxElement))
it('should match snapshot for checkbox type', () => {
const {container} = render(wrap(
<ContentElement
block={contentBlock}
readonly={false}
cords={{x: 0}}
/>,
))
expect(container).toMatchSnapshot()
})
test('return null', () => {
const contentBlock: ContentBlock = {
id: 'test-id',
workspaceId: '',
parentId: '',
rootId: '',
modifiedBy: 'test-user-id',
schema: 0,
type: 'unknown',
title: 'test-title',
fields: {},
createdBy: 'test-user-id',
createAt: 0,
updateAt: 0,
deleteAt: 0,
}
const contentElement = ContentElement({block: contentBlock, readonly: false})
expect(contentElement).toBeNull()
it('should return null for unknown type', () => {
const block: ContentBlock = {...contentBlock, type: 'unknown'}
const {container} = render(wrap(
<ContentElement
block={block}
readonly={false}
cords={{x: 0}}
/>,
))
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -12,7 +12,7 @@ import './dialog.scss'
type Props = {
children: React.ReactNode
toolsMenu: React.ReactNode
toolsMenu?: React.ReactNode // some dialogs may not require a toolmenu
hideCloseButton?: boolean
className?: string
onClose: () => void,

View File

@ -17,7 +17,7 @@
font-size: 18px;
vertical-align: middle;
border-radius: 20px;
z-index: 12;
z-index: 999;
&.flashIn {
visibility: visible;

View File

@ -0,0 +1,247 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`src/components/gallery/Gallery return Gallery and click new 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="Button 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="Button 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>
</div>
`;
exports[`src/components/gallery/Gallery return Gallery readonly 1`] = `
<div>
<div
class="Gallery"
>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
class="gallery-item"
/>
</div>
<div
class="GalleryCard"
draggable="true"
style="opacity: 1;"
>
<div
class="gallery-item"
/>
</div>
</div>
</div>
`;
exports[`src/components/gallery/Gallery should match snapshot 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="Button IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
<div
class="Menu noselect left"
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div
aria-label="Delete"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DeleteIcon Icon"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M268 416h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12zM432 80h-82.41l-34-56.7A48 48 0 0 0 274.41 0H173.59a48 48 0 0 0-41.16 23.3L98.41 80H16A16 16 0 0 0 0 96v16a16 16 0 0 0 16 16h16v336a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128h16a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16zM171.84 50.91A6 6 0 0 1 177 48h94a6 6 0 0 1 5.15 2.91L293.61 80H154.39zM368 464H80V128h288zm-212-48h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12z"
/>
</svg>
<div
class="menu-name"
>
Delete
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Duplicate"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DuplicateIcon Icon"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M464 0H144c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v320c0 26.51 21.49 48 48 48h320c26.51 0 48-21.49 48-48v-48h48c26.51 0 48-21.49 48-48V48c0-26.51-21.49-48-48-48zM362 464H54a6 6 0 0 1-6-6V150a6 6 0 0 1 6-6h42v224c0 26.51 21.49 48 48 48h224v42a6 6 0 0 1-6 6zm96-96H150a6 6 0 0 1-6-6V54a6 6 0 0 1 6-6h308a6 6 0 0 1 6 6v308a6 6 0 0 1-6 6z"
/>
</svg>
<div
class="menu-name"
>
Duplicate
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Copy link"
class="MenuOption TextOption menu-option"
role="button"
>
<i
class="CompassIcon icon-link-variant LinkIcon"
/>
<div
class="menu-name"
>
Copy link
</div>
<div
class="noicon"
/>
</div>
</div>
<div
class="menu-spacer hideOnWidescreen"
/>
<div
class="menu-options hideOnWidescreen"
>
<div
aria-label="Cancel"
class="MenuOption TextOption menu-option menu-cancel"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Cancel
</div>
<div
class="noicon"
/>
</div>
</div>
</div>
</div>
</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="Button 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>
</div>
`;

View File

@ -0,0 +1,940 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`src/components/gallery/GalleryCard with a comment content return GalleryCard with content readonly 1`] = `
<div>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
class="gallery-item"
/>
<div
class="gallery-title"
>
<div
class="octo-icon"
>
i
</div>
<div>
title
</div>
</div>
</div>
</div>
`;
exports[`src/components/gallery/GalleryCard with a comment content should match snapshot 1`] = `
<div>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<div
class="Button IconButton"
role="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
<div
class="Menu noselect left"
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div
aria-label="Delete"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DeleteIcon Icon"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M268 416h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12zM432 80h-82.41l-34-56.7A48 48 0 0 0 274.41 0H173.59a48 48 0 0 0-41.16 23.3L98.41 80H16A16 16 0 0 0 0 96v16a16 16 0 0 0 16 16h16v336a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128h16a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16zM171.84 50.91A6 6 0 0 1 177 48h94a6 6 0 0 1 5.15 2.91L293.61 80H154.39zM368 464H80V128h288zm-212-48h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12z"
/>
</svg>
<div
class="menu-name"
>
Delete
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Duplicate"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DuplicateIcon Icon"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M464 0H144c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v320c0 26.51 21.49 48 48 48h320c26.51 0 48-21.49 48-48v-48h48c26.51 0 48-21.49 48-48V48c0-26.51-21.49-48-48-48zM362 464H54a6 6 0 0 1-6-6V150a6 6 0 0 1 6-6h42v224c0 26.51 21.49 48 48 48h224v42a6 6 0 0 1-6 6zm96-96H150a6 6 0 0 1-6-6V54a6 6 0 0 1 6-6h308a6 6 0 0 1 6 6v308a6 6 0 0 1-6 6z"
/>
</svg>
<div
class="menu-name"
>
Duplicate
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Copy link"
class="MenuOption TextOption menu-option"
role="button"
>
<i
class="CompassIcon icon-link-variant LinkIcon"
/>
<div
class="menu-name"
>
Copy link
</div>
<div
class="noicon"
/>
</div>
</div>
<div
class="menu-spacer hideOnWidescreen"
/>
<div
class="menu-options hideOnWidescreen"
>
<div
aria-label="Cancel"
class="MenuOption TextOption menu-option menu-cancel"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Cancel
</div>
<div
class="noicon"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="gallery-item"
/>
<div
class="gallery-title"
>
<div
class="octo-icon"
>
i
</div>
<div>
title
</div>
</div>
</div>
</div>
`;
exports[`src/components/gallery/GalleryCard with an image content should match snapshot 1`] = `
<div>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<div
class="Button IconButton"
role="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
<div
class="Menu noselect left"
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div
aria-label="Delete"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DeleteIcon Icon"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M268 416h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12zM432 80h-82.41l-34-56.7A48 48 0 0 0 274.41 0H173.59a48 48 0 0 0-41.16 23.3L98.41 80H16A16 16 0 0 0 0 96v16a16 16 0 0 0 16 16h16v336a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128h16a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16zM171.84 50.91A6 6 0 0 1 177 48h94a6 6 0 0 1 5.15 2.91L293.61 80H154.39zM368 464H80V128h288zm-212-48h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12z"
/>
</svg>
<div
class="menu-name"
>
Delete
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Duplicate"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DuplicateIcon Icon"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M464 0H144c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v320c0 26.51 21.49 48 48 48h320c26.51 0 48-21.49 48-48v-48h48c26.51 0 48-21.49 48-48V48c0-26.51-21.49-48-48-48zM362 464H54a6 6 0 0 1-6-6V150a6 6 0 0 1 6-6h42v224c0 26.51 21.49 48 48 48h224v42a6 6 0 0 1-6 6zm96-96H150a6 6 0 0 1-6-6V54a6 6 0 0 1 6-6h308a6 6 0 0 1 6 6v308a6 6 0 0 1-6 6z"
/>
</svg>
<div
class="menu-name"
>
Duplicate
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Copy link"
class="MenuOption TextOption menu-option"
role="button"
>
<i
class="CompassIcon icon-link-variant LinkIcon"
/>
<div
class="menu-name"
>
Copy link
</div>
<div
class="noicon"
/>
</div>
</div>
<div
class="menu-spacer hideOnWidescreen"
/>
<div
class="menu-options hideOnWidescreen"
>
<div
aria-label="Cancel"
class="MenuOption TextOption menu-option menu-cancel"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Cancel
</div>
<div
class="noicon"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="gallery-image"
>
<img
alt=""
class="ImageElement"
src="test.jpg"
/>
</div>
<div
class="gallery-title"
>
<div
class="octo-icon"
>
i
</div>
<div>
title
</div>
</div>
</div>
</div>
`;
exports[`src/components/gallery/GalleryCard with many contents return GalleryCard with contents readonly 1`] = `
<div>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
class="gallery-item"
>
<div
class="DividerElement"
/>
</div>
<div
class="gallery-title"
>
<div
class="octo-icon"
>
i
</div>
<div>
title
</div>
</div>
</div>
</div>
`;
exports[`src/components/gallery/GalleryCard with many contents should match snapshot 1`] = `
<div>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<div
class="Button IconButton"
role="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
<div
class="Menu noselect left"
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div
aria-label="Delete"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DeleteIcon Icon"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M268 416h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12zM432 80h-82.41l-34-56.7A48 48 0 0 0 274.41 0H173.59a48 48 0 0 0-41.16 23.3L98.41 80H16A16 16 0 0 0 0 96v16a16 16 0 0 0 16 16h16v336a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128h16a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16zM171.84 50.91A6 6 0 0 1 177 48h94a6 6 0 0 1 5.15 2.91L293.61 80H154.39zM368 464H80V128h288zm-212-48h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12z"
/>
</svg>
<div
class="menu-name"
>
Delete
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Duplicate"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DuplicateIcon Icon"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M464 0H144c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v320c0 26.51 21.49 48 48 48h320c26.51 0 48-21.49 48-48v-48h48c26.51 0 48-21.49 48-48V48c0-26.51-21.49-48-48-48zM362 464H54a6 6 0 0 1-6-6V150a6 6 0 0 1 6-6h42v224c0 26.51 21.49 48 48 48h224v42a6 6 0 0 1-6 6zm96-96H150a6 6 0 0 1-6-6V54a6 6 0 0 1 6-6h308a6 6 0 0 1 6 6v308a6 6 0 0 1-6 6z"
/>
</svg>
<div
class="menu-name"
>
Duplicate
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Copy link"
class="MenuOption TextOption menu-option"
role="button"
>
<i
class="CompassIcon icon-link-variant LinkIcon"
/>
<div
class="menu-name"
>
Copy link
</div>
<div
class="noicon"
/>
</div>
</div>
<div
class="menu-spacer hideOnWidescreen"
/>
<div
class="menu-options hideOnWidescreen"
>
<div
aria-label="Cancel"
class="MenuOption TextOption menu-option menu-cancel"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Cancel
</div>
<div
class="noicon"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="gallery-item"
>
<div
class="DividerElement"
/>
</div>
<div
class="gallery-title"
>
<div
class="octo-icon"
>
i
</div>
<div>
title
</div>
</div>
</div>
</div>
`;
exports[`src/components/gallery/GalleryCard with many images content should match snapshot with only first image 1`] = `
<div>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<div
class="Button IconButton"
role="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
<div
class="Menu noselect left"
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div
aria-label="Delete"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DeleteIcon Icon"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M268 416h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12zM432 80h-82.41l-34-56.7A48 48 0 0 0 274.41 0H173.59a48 48 0 0 0-41.16 23.3L98.41 80H16A16 16 0 0 0 0 96v16a16 16 0 0 0 16 16h16v336a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128h16a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16zM171.84 50.91A6 6 0 0 1 177 48h94a6 6 0 0 1 5.15 2.91L293.61 80H154.39zM368 464H80V128h288zm-212-48h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12z"
/>
</svg>
<div
class="menu-name"
>
Delete
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Duplicate"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DuplicateIcon Icon"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M464 0H144c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v320c0 26.51 21.49 48 48 48h320c26.51 0 48-21.49 48-48v-48h48c26.51 0 48-21.49 48-48V48c0-26.51-21.49-48-48-48zM362 464H54a6 6 0 0 1-6-6V150a6 6 0 0 1 6-6h42v224c0 26.51 21.49 48 48 48h224v42a6 6 0 0 1-6 6zm96-96H150a6 6 0 0 1-6-6V54a6 6 0 0 1 6-6h308a6 6 0 0 1 6 6v308a6 6 0 0 1-6 6z"
/>
</svg>
<div
class="menu-name"
>
Duplicate
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Copy link"
class="MenuOption TextOption menu-option"
role="button"
>
<i
class="CompassIcon icon-link-variant LinkIcon"
/>
<div
class="menu-name"
>
Copy link
</div>
<div
class="noicon"
/>
</div>
</div>
<div
class="menu-spacer hideOnWidescreen"
/>
<div
class="menu-options hideOnWidescreen"
>
<div
aria-label="Cancel"
class="MenuOption TextOption menu-option menu-cancel"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Cancel
</div>
<div
class="noicon"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="gallery-image"
>
<img
alt=""
class="ImageElement"
src="test.jpg"
/>
</div>
<div
class="gallery-title"
>
<div
class="octo-icon"
>
i
</div>
<div>
title
</div>
</div>
</div>
</div>
`;
exports[`src/components/gallery/GalleryCard without block content return GalleryCard and cancel 1`] = `
<div>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<div
class="Button IconButton"
role="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</div>
<div
class="gallery-item"
/>
<div
class="gallery-title"
>
<div
class="octo-icon"
>
i
</div>
<div>
title
</div>
</div>
</div>
</div>
`;
exports[`src/components/gallery/GalleryCard without block content return GalleryCard and copy link 1`] = `
<div>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<div
class="Button IconButton"
role="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</div>
<div
class="gallery-item"
/>
<div
class="gallery-title"
>
<div
class="octo-icon"
>
i
</div>
<div>
title
</div>
</div>
</div>
</div>
`;
exports[`src/components/gallery/GalleryCard without block content return GalleryCard and delete card 1`] = `
<div>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<div
class="Button IconButton"
role="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</div>
<div
class="gallery-item"
/>
<div
class="gallery-title"
>
<div
class="octo-icon"
>
i
</div>
<div>
title
</div>
</div>
</div>
</div>
`;
exports[`src/components/gallery/GalleryCard without block content return GalleryCard and duplicate card 1`] = `
<div>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<div
class="Button IconButton"
role="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</div>
<div
class="gallery-item"
/>
<div
class="gallery-title"
>
<div
class="octo-icon"
>
i
</div>
<div>
title
</div>
</div>
</div>
</div>
`;
exports[`src/components/gallery/GalleryCard without block content should match snapshot 1`] = `
<div>
<div
class="GalleryCard selected"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
role="button"
>
<div
class="Button IconButton"
role="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
<div
class="Menu noselect left"
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div
aria-label="Delete"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DeleteIcon Icon"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M268 416h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12zM432 80h-82.41l-34-56.7A48 48 0 0 0 274.41 0H173.59a48 48 0 0 0-41.16 23.3L98.41 80H16A16 16 0 0 0 0 96v16a16 16 0 0 0 16 16h16v336a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128h16a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16zM171.84 50.91A6 6 0 0 1 177 48h94a6 6 0 0 1 5.15 2.91L293.61 80H154.39zM368 464H80V128h288zm-212-48h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12z"
/>
</svg>
<div
class="menu-name"
>
Delete
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Duplicate"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DuplicateIcon Icon"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M464 0H144c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v320c0 26.51 21.49 48 48 48h320c26.51 0 48-21.49 48-48v-48h48c26.51 0 48-21.49 48-48V48c0-26.51-21.49-48-48-48zM362 464H54a6 6 0 0 1-6-6V150a6 6 0 0 1 6-6h42v224c0 26.51 21.49 48 48 48h224v42a6 6 0 0 1-6 6zm96-96H150a6 6 0 0 1-6-6V54a6 6 0 0 1 6-6h308a6 6 0 0 1 6 6v308a6 6 0 0 1-6 6z"
/>
</svg>
<div
class="menu-name"
>
Duplicate
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Copy link"
class="MenuOption TextOption menu-option"
role="button"
>
<i
class="CompassIcon icon-link-variant LinkIcon"
/>
<div
class="menu-name"
>
Copy link
</div>
<div
class="noicon"
/>
</div>
</div>
<div
class="menu-spacer hideOnWidescreen"
/>
<div
class="menu-options hideOnWidescreen"
>
<div
aria-label="Cancel"
class="MenuOption TextOption menu-option menu-cancel"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Cancel
</div>
<div
class="noicon"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="gallery-item"
/>
<div
class="gallery-title"
>
<div
class="octo-icon"
>
i
</div>
<div>
title
</div>
</div>
<div
class="gallery-props"
>
<div
class="octo-tooltip tooltip-top"
data-tooltip="testTemplateProperty"
>
<div
class="octo-propertyvalue"
/>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,126 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render, screen, fireEvent} from '@testing-library/react'
import {Provider as ReduxProvider} from 'react-redux'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {wrapDNDIntl, mockStateStore} from '../../testUtils'
import {TestBlockFactory} from '../../test/testBlockFactory'
import mutator from '../../mutator'
import Gallery from './gallery'
jest.mock('../../mutator')
const mockedMutator = mocked(mutator, true)
describe('src/components/gallery/Gallery', () => {
const board = TestBlockFactory.createBoard()
const activeView = TestBlockFactory.createBoardView(board)
activeView.fields.sortOptions = []
const card = TestBlockFactory.createCard(board)
const card2 = TestBlockFactory.createCard(board)
const contents = [TestBlockFactory.createDivider(card), TestBlockFactory.createDivider(card), TestBlockFactory.createDivider(card2)]
const state = {
contents,
cards: {
cards: {
[card.id]: card,
},
},
comments: {
comments: {},
},
}
const store = mockStateStore([], state)
beforeEach(() => {
jest.clearAllMocks()
})
test('should match snapshot', () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<Gallery
board={board}
cards={[card, card2]}
activeView={activeView}
readonly={false}
addCard={jest.fn()}
selectedCardIds={[card.id]}
onCardClicked={jest.fn()}
/>
</ReduxProvider>,
))
const buttonElement = screen.getAllByRole('button', {name: 'menuwrapper'})[0]
userEvent.click(buttonElement)
expect(container).toMatchSnapshot()
})
test('return Gallery and click new', () => {
const mockAddCard = jest.fn()
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<Gallery
board={board}
cards={[card, card2]}
activeView={activeView}
readonly={false}
addCard={mockAddCard}
selectedCardIds={[card.id]}
onCardClicked={jest.fn()}
/>
</ReduxProvider>,
))
expect(container).toMatchSnapshot()
const elementNew = container.querySelector('.octo-gallery-new')!
expect(elementNew).toBeDefined()
userEvent.click(elementNew)
expect(mockAddCard).toBeCalledTimes(1)
})
test('return Gallery readonly', () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<Gallery
board={board}
cards={[card, card2]}
activeView={activeView}
readonly={true}
addCard={jest.fn()}
selectedCardIds={[card.id]}
onCardClicked={jest.fn()}
/>
</ReduxProvider>,
))
expect(container).toMatchSnapshot()
})
test('return Gallery and drag and drop card', async () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<Gallery
board={board}
cards={[card, card2]}
activeView={activeView}
readonly={false}
addCard={jest.fn()}
selectedCardIds={[]}
onCardClicked={jest.fn()}
/>
</ReduxProvider>,
))
const allGalleryCard = container.querySelectorAll('.GalleryCard')
const drag = allGalleryCard[0]
const drop = allGalleryCard[1]
fireEvent.dragStart(drag)
fireEvent.dragEnter(drop)
fireEvent.dragOver(drop)
fireEvent.drop(drop)
expect(mockedMutator.performAsUndoGroup).toBeCalledTimes(1)
})
})

View File

@ -0,0 +1,424 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {act, render, screen} from '@testing-library/react'
import {Provider as ReduxProvider} from 'react-redux'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {MockStoreEnhanced} from 'redux-mock-store'
import {wrapDNDIntl, mockStateStore} from '../../testUtils'
import {TestBlockFactory} from '../../test/testBlockFactory'
import mutator from '../../mutator'
import {Utils} from '../../utils'
import octoClient from '../../octoClient'
import GalleryCard from './galleryCard'
jest.mock('../../mutator')
jest.mock('../../utils')
jest.mock('../../octoClient')
describe('src/components/gallery/GalleryCard', () => {
const mockedMutator = mocked(mutator, true)
const mockedUtils = mocked(Utils, true)
const mockedOcto = mocked(octoClient, true)
mockedOcto.getFileAsDataUrl.mockResolvedValue('test.jpg')
const board = TestBlockFactory.createBoard()
board.id = 'boardId'
const activeView = TestBlockFactory.createBoardView(board)
activeView.fields.sortOptions = []
const card = TestBlockFactory.createCard(board)
card.id = 'cardId'
const contentImage = TestBlockFactory.createImage(card)
contentImage.id = 'contentId-image'
contentImage.fields.fileId = 'test.jpg'
const contentComment = TestBlockFactory.createComment(card)
contentComment.id = 'contentId-Comment'
let store:MockStoreEnhanced<unknown, unknown>
beforeEach(() => {
jest.clearAllMocks()
})
describe('without block content', () => {
beforeEach(() => {
const state = {
contents: {
contents: {
},
},
cards: {
cards: {
[card.id]: card,
},
},
comments: {
comments: {},
},
}
store = mockStateStore([], state)
})
test('should match snapshot', () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<GalleryCard
board={board}
card={card}
onClick={jest.fn()}
visiblePropertyTemplates={[{id: card.id, name: 'testTemplateProperty', type: 'text', options: [{id: '1', value: 'testValue', color: 'blue'}]}]}
visibleTitle={true}
isSelected={true}
readonly={false}
isManualSort={true}
onDrop={jest.fn()}
/>
</ReduxProvider>,
))
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
userEvent.click(buttonElement)
expect(container).toMatchSnapshot()
})
test('return GalleryCard and click on it', () => {
const mockedOnClick = jest.fn()
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<GalleryCard
board={board}
card={card}
onClick={mockedOnClick}
visiblePropertyTemplates={[]}
visibleTitle={true}
isSelected={true}
readonly={false}
isManualSort={true}
onDrop={jest.fn()}
/>
</ReduxProvider>,
))
const galleryCardElement = container.querySelector('.GalleryCard')
userEvent.click(galleryCardElement!)
expect(mockedOnClick).toBeCalledTimes(1)
})
test('return GalleryCard and delete card', () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<GalleryCard
board={board}
card={card}
onClick={jest.fn()}
visiblePropertyTemplates={[]}
visibleTitle={true}
isSelected={true}
readonly={false}
isManualSort={true}
onDrop={jest.fn()}
/>
</ReduxProvider>,
))
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
userEvent.click(buttonElement)
const buttonDelete = screen.getByRole('button', {name: 'Delete'})
userEvent.click(buttonDelete)
expect(container).toMatchSnapshot()
expect(mockedMutator.deleteBlock).toBeCalledTimes(1)
expect(mockedMutator.deleteBlock).toBeCalledWith(card, 'delete card')
})
test('return GalleryCard and duplicate card', () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<GalleryCard
board={board}
card={card}
onClick={jest.fn()}
visiblePropertyTemplates={[]}
visibleTitle={true}
isSelected={true}
readonly={false}
isManualSort={true}
onDrop={jest.fn()}
/>
</ReduxProvider>,
))
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
userEvent.click(buttonElement)
const buttonDuplicate = screen.getByRole('button', {name: 'Duplicate'})
userEvent.click(buttonDuplicate)
expect(container).toMatchSnapshot()
expect(mockedMutator.duplicateCard).toBeCalledTimes(1)
expect(mockedMutator.duplicateCard).toBeCalledWith(card.id)
})
test('return GalleryCard and copy link', () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<GalleryCard
board={board}
card={card}
onClick={jest.fn()}
visiblePropertyTemplates={[]}
visibleTitle={true}
isSelected={true}
readonly={false}
isManualSort={true}
onDrop={jest.fn()}
/>
</ReduxProvider>,
))
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
userEvent.click(buttonElement)
const buttonCopyLink = screen.getByRole('button', {name: 'Copy link'})
userEvent.click(buttonCopyLink)
expect(container).toMatchSnapshot()
expect(mockedUtils.copyTextToClipboard).toBeCalledTimes(1)
})
test('return GalleryCard and cancel', () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<GalleryCard
board={board}
card={card}
onClick={jest.fn()}
visiblePropertyTemplates={[]}
visibleTitle={true}
isSelected={true}
readonly={false}
isManualSort={true}
onDrop={jest.fn()}
/>
</ReduxProvider>,
))
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
userEvent.click(buttonElement)
const buttonCancel = screen.getByRole('button', {name: 'Cancel'})
userEvent.click(buttonCancel)
expect(container).toMatchSnapshot()
})
})
describe('with an image content', () => {
beforeEach(() => {
card.fields.contentOrder = [contentImage.id]
const state = {
contents: {
contents: {
[contentImage.id]: contentImage,
},
},
cards: {
cards: {
[card.id]: card,
},
},
comments: {
comments: {},
},
}
store = mockStateStore([], state)
})
test('should match snapshot', async () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<GalleryCard
board={board}
card={card}
onClick={jest.fn()}
visiblePropertyTemplates={[]}
visibleTitle={true}
isSelected={true}
readonly={false}
isManualSort={true}
onDrop={jest.fn()}
/>
</ReduxProvider>,
))
await act(async () => {
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
userEvent.click(buttonElement)
})
expect(container).toMatchSnapshot()
})
})
describe('with many images content', () => {
beforeEach(() => {
const contentImage2 = TestBlockFactory.createImage(card)
contentImage2.id = 'contentId-image2'
contentImage2.fields.fileId = 'test2.jpg'
card.fields.contentOrder = [contentImage.id, contentImage2.id]
const state = {
contents: {
contents: {
[contentImage.id]: [contentImage],
[contentImage2.id]: [contentImage2],
},
},
cards: {
cards: {
[card.id]: card,
},
},
comments: {
comments: {},
},
}
store = mockStateStore([], state)
})
test('should match snapshot with only first image', async () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<GalleryCard
board={board}
card={card}
onClick={jest.fn()}
visiblePropertyTemplates={[]}
visibleTitle={true}
isSelected={true}
readonly={false}
isManualSort={true}
onDrop={jest.fn()}
/>
</ReduxProvider>,
))
await act(async () => {
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
userEvent.click(buttonElement)
})
expect(container).toMatchSnapshot()
})
})
describe('with a comment content', () => {
beforeEach(() => {
card.fields.contentOrder = [contentComment.id]
const state = {
contents: {
contents: {
[contentComment.id]: contentComment,
},
},
cards: {
cards: {
[card.id]: card,
},
},
comments: {
comments: {},
},
}
store = mockStateStore([], state)
})
test('should match snapshot', () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<GalleryCard
board={board}
card={card}
onClick={jest.fn()}
visiblePropertyTemplates={[]}
visibleTitle={true}
isSelected={true}
readonly={false}
isManualSort={true}
onDrop={jest.fn()}
/>
</ReduxProvider>,
))
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
userEvent.click(buttonElement)
expect(container).toMatchSnapshot()
})
test('return GalleryCard with content readonly', () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<GalleryCard
board={board}
card={card}
onClick={jest.fn()}
visiblePropertyTemplates={[]}
visibleTitle={true}
isSelected={true}
readonly={true}
isManualSort={true}
onDrop={jest.fn()}
/>
</ReduxProvider>,
))
expect(container).toMatchSnapshot()
})
})
describe('with many contents', () => {
const contentDivider = TestBlockFactory.createDivider(card)
contentDivider.id = 'contentId-Text2'
beforeEach(() => {
card.fields.contentOrder = [contentComment.id, contentDivider.id]
const state = {
contents: {
contents: {
[contentComment.id]: [contentComment, contentDivider],
},
},
cards: {
cards: {
[card.id]: card,
},
},
comments: {
comments: {},
},
}
store = mockStateStore([], state)
})
test('should match snapshot', () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<GalleryCard
board={board}
card={card}
onClick={jest.fn()}
visiblePropertyTemplates={[]}
visibleTitle={true}
isSelected={true}
readonly={false}
isManualSort={true}
onDrop={jest.fn()}
/>
</ReduxProvider>,
))
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
userEvent.click(buttonElement)
expect(container).toMatchSnapshot()
})
test('return GalleryCard with contents readonly', () => {
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<GalleryCard
board={board}
card={card}
onClick={jest.fn()}
visiblePropertyTemplates={[]}
visibleTitle={true}
isSelected={true}
readonly={true}
isManualSort={true}
onDrop={jest.fn()}
/>
</ReduxProvider>,
))
expect(container).toMatchSnapshot()
})
})
})

View File

@ -28,6 +28,7 @@ import {getCardContents} from '../../store/contents'
import {getCardComments} from '../../store/comments'
import './galleryCard.scss'
import {CardDetailProvider} from '../cardDetail/cardDetailContext'
type Props = {
board: Board
@ -120,29 +121,31 @@ const GalleryCard = React.memo((props: Props) => {
<ImageElement block={image}/>
</div>}
{!image &&
<div className='gallery-item'>
{contents.map((block) => {
if (Array.isArray(block)) {
return block.map((b) => (
<CardDetailProvider card={card}>
<div className='gallery-item'>
{contents.map((block) => {
if (Array.isArray(block)) {
return block.map((b) => (
<ContentElement
key={b.id}
block={b}
readonly={true}
cords={{x: 0}}
/>
))
}
return (
<ContentElement
key={b.id}
block={b}
key={block.id}
block={block}
readonly={true}
cords={{x: 0}}
/>
))
}
return (
<ContentElement
key={block.id}
block={block}
readonly={true}
cords={{x: 0}}
/>
)
})}
</div>}
)
})}
</div>
</CardDetailProvider>}
{props.visibleTitle &&
<div className='gallery-title'>
{ card.fields.icon ? <div className='octo-icon'>{card.fields.icon}</div> : undefined }

View File

@ -45,7 +45,7 @@ exports[`components/kanban/calculation/KanbanCalculation calculations menu open
<span
id="aria-context"
>
3 results available. Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.
7 results available. Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.
</span>
</span>
<div
@ -115,6 +115,50 @@ exports[`components/kanban/calculation/KanbanCalculation calculations menu open
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
<span>
Count Empty
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
<span>
Count Not Empty
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
<span>
Percent Empty
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
<span>
Percent Not Empty
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>

View File

@ -99,7 +99,7 @@ exports[`components/kanban/calculations/KanbanCalculationOptions with menu open
<span
id="aria-context"
>
3 results available. Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.
7 results available. Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.
</span>
</span>
<div
@ -169,6 +169,50 @@ exports[`components/kanban/calculations/KanbanCalculationOptions with menu open
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
<span>
Count Empty
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
<span>
Count Not Empty
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
<span>
Percent Empty
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
<span>
Percent Not Empty
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
@ -219,7 +263,7 @@ exports[`components/kanban/calculations/KanbanCalculationOptions with submenu op
<span
id="aria-context"
>
3 results available. Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.
7 results available. Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.
</span>
</span>
<div
@ -289,6 +333,50 @@ exports[`components/kanban/calculations/KanbanCalculationOptions with submenu op
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
<span>
Count Empty
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
<span>
Count Not Empty
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
<span>
Percent Empty
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>
<span>
Percent Not Empty
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</span>
</div>
<div
class="KanbanCalculationOptions_CustomOption "
>

View File

@ -33,7 +33,6 @@ const MarkdownEditor = (props: Props): JSX. Element => {
autoDownloadFontAwesome: true,
toolbar: false,
status: false,
autofocus: true,
spellChecker: true,
nativeSpellcheck: true,
minHeight: '10px',

View File

@ -4,6 +4,7 @@
padding: 5px;
color: rgb(var(--center-channel-color-rgb));
max-width: 500px;
white-space: normal;
.Switch {
margin-left: 8px;

View File

@ -67,9 +67,9 @@ exports[`components/sidebarSidebar sidebar in dashboard page 1`] = `
<div
class="sidebarSwitcher"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<svg
class="HideSidebarIcon Icon"
@ -83,7 +83,7 @@ exports[`components/sidebarSidebar sidebar in dashboard page 1`] = `
points="50,20 20,50, 50,80"
/>
</svg>
</div>
</button>
</div>
</div>
<div

View File

@ -17,10 +17,14 @@ import {Utils} from '../../utils'
import ModalWrapper from '../modalWrapper'
import {IAppWindow} from '../../types'
import RegistrationLink from './registrationLink'
import './sidebarUserMenu.scss'
declare let window: IAppWindow
const SidebarUserMenu = React.memo(() => {
const history = useHistory()
const [showRegistrationLinkDialog, setShowRegistrationLinkDialog] = useState(false)
@ -81,8 +85,8 @@ const SidebarUserMenu = React.memo(() => {
window.open('https://www.focalboard.com?utm_source=webapp', '_blank')
// TODO: Review if this is needed in the future, this is to fix the problem with linux webview links
if ((window as any).openInNewBrowser) {
(window as any).openInNewBrowser('https://www.focalboard.com?utm_source=webapp')
if (window.openInNewBrowser) {
window.openInNewBrowser('https://www.focalboard.com?utm_source=webapp')
}
}}
/>

View File

@ -1927,9 +1927,9 @@ exports[`components/table/Table should match snapshot with GroupBy 1`] = `
class="octo-table-cell"
style="width: 100px;"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<svg
class="DisclosureTriangleIcon Icon"
@ -1940,7 +1940,7 @@ exports[`components/table/Table should match snapshot with GroupBy 1`] = `
points="37,35 37,65 63,50"
/>
</svg>
</div>
</button>
<span
class="Label empty "
title="Items with an empty Property 1 property will go here. This column cannot be removed."
@ -1962,23 +1962,23 @@ exports[`components/table/Table should match snapshot with GroupBy 1`] = `
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</div>
</button>
</div>
</div>
</div>

View File

@ -11,9 +11,9 @@ exports[`should match snapshot on read only 1`] = `
class="octo-table-cell"
style="width: 100px;"
>
<div
<button
class="Button IconButton readonly"
role="button"
type="button"
>
<svg
class="DisclosureTriangleIcon Icon"
@ -24,7 +24,7 @@ exports[`should match snapshot on read only 1`] = `
points="37,35 37,65 63,50"
/>
</svg>
</div>
</button>
<span
class="Label propColorBrown "
>
@ -61,9 +61,9 @@ exports[`should match snapshot with Group 1`] = `
class="octo-table-cell"
style="width: 100px;"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<svg
class="DisclosureTriangleIcon Icon"
@ -74,7 +74,7 @@ exports[`should match snapshot with Group 1`] = `
points="37,35 37,65 63,50"
/>
</svg>
</div>
</button>
<span
class="Label propColorBrown "
>
@ -100,23 +100,23 @@ exports[`should match snapshot with Group 1`] = `
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</div>
</button>
</div>
</div>
`;
@ -132,9 +132,9 @@ exports[`should match snapshot, add new 1`] = `
class="octo-table-cell"
style="width: 100px;"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<svg
class="DisclosureTriangleIcon Icon"
@ -145,7 +145,7 @@ exports[`should match snapshot, add new 1`] = `
points="37,35 37,65 63,50"
/>
</svg>
</div>
</button>
<span
class="Label propColorBrown "
>
@ -171,23 +171,23 @@ exports[`should match snapshot, add new 1`] = `
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</div>
</button>
</div>
</div>
`;
@ -203,9 +203,9 @@ exports[`should match snapshot, edit title 1`] = `
class="octo-table-cell"
style="width: 100px;"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<svg
class="DisclosureTriangleIcon Icon"
@ -216,7 +216,7 @@ exports[`should match snapshot, edit title 1`] = `
points="37,35 37,65 63,50"
/>
</svg>
</div>
</button>
<span
class="Label propColorBrown "
>
@ -242,23 +242,23 @@ exports[`should match snapshot, edit title 1`] = `
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</div>
</button>
</div>
</div>
`;
@ -274,9 +274,9 @@ exports[`should match snapshot, hide group 1`] = `
class="octo-table-cell"
style="width: 100px;"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<svg
class="DisclosureTriangleIcon Icon"
@ -287,7 +287,7 @@ exports[`should match snapshot, hide group 1`] = `
points="37,35 37,65 63,50"
/>
</svg>
</div>
</button>
<span
class="Label propColorBrown "
>
@ -313,23 +313,23 @@ exports[`should match snapshot, hide group 1`] = `
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</div>
</button>
</div>
</div>
`;
@ -345,9 +345,9 @@ exports[`should match snapshot, no groups 1`] = `
class="octo-table-cell"
style="width: 100px;"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<svg
class="DisclosureTriangleIcon Icon"
@ -358,7 +358,7 @@ exports[`should match snapshot, no groups 1`] = `
points="37,35 37,65 63,50"
/>
</svg>
</div>
</button>
<span
class="Label empty "
title="Items with an empty Property 1 property will go here. This column cannot be removed."
@ -380,23 +380,23 @@ exports[`should match snapshot, no groups 1`] = `
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</div>
</button>
</div>
</div>
`;

View File

@ -31,14 +31,14 @@ exports[`components/viewHeader/emptyCardButton return EmptyCardButton 1`] = `
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
</div>
</div>
@ -75,14 +75,14 @@ exports[`components/viewHeader/emptyCardButton return EmptyCardButton and Set Te
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
<div
class="Menu noselect left"
>
@ -178,14 +178,14 @@ exports[`components/viewHeader/emptyCardButton return EmptyCardButton and addCar
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
</div>
</div>

View File

@ -8,16 +8,16 @@ exports[`components/viewHeader/filterComponent return filterComponent 1`] = `
<div
class="toolbar hideOnWidescreen"
>
<div
<button
aria-label="Close"
class="Button IconButton"
role="button"
title="Close"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</div>
</button>
</div>
<div
class="FilterComponent"
@ -191,16 +191,16 @@ exports[`components/viewHeader/filterComponent return filterComponent and add Fi
<div
class="toolbar hideOnWidescreen"
>
<div
<button
aria-label="Close"
class="Button IconButton"
role="button"
title="Close"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</div>
</button>
</div>
<div
class="FilterComponent"
@ -374,16 +374,16 @@ exports[`components/viewHeader/filterComponent return filterComponent and click
<div
class="toolbar hideOnWidescreen"
>
<div
<button
aria-label="Close"
class="Button IconButton"
role="button"
title="Close"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</div>
</button>
</div>
<div
class="FilterComponent"
@ -557,16 +557,16 @@ exports[`components/viewHeader/filterComponent return filterComponent and filter
<div
class="toolbar hideOnWidescreen"
>
<div
<button
aria-label="Close"
class="Button IconButton"
role="button"
title="Close"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</div>
</button>
</div>
<div
class="FilterComponent"

View File

@ -60,14 +60,14 @@ exports[`components/viewHeader/newCardButton return NewCardButton 1`] = `
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
</div>
<div
@ -179,14 +179,14 @@ exports[`components/viewHeader/newCardButton return NewCardButton and addCard 1`
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
</div>
<div
@ -298,14 +298,14 @@ exports[`components/viewHeader/newCardButton return NewCardButton and addCardTem
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
</div>
<div

View File

@ -22,14 +22,14 @@ exports[`components/viewHeader/newCardButtonTemplateItem return NewCardButtonTem
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
<div
class="Menu noselect left"
>
@ -170,14 +170,14 @@ exports[`components/viewHeader/newCardButtonTemplateItem return NewCardButtonTem
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
<div
class="Menu noselect left"
>
@ -318,14 +318,14 @@ exports[`components/viewHeader/newCardButtonTemplateItem return NewCardButtonTem
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
</div>
</div>
@ -353,14 +353,14 @@ exports[`components/viewHeader/newCardButtonTemplateItem return NewCardButtonTem
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
<div
class="Menu noselect left"
>
@ -501,14 +501,14 @@ exports[`components/viewHeader/newCardButtonTemplateItem return NewCardButtonTem
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
<div
class="Menu noselect left"
>

View File

@ -17,14 +17,14 @@ exports[`components/viewHeader/viewHeader return viewHeader 1`] = `
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</div>
</button>
</div>
<div
class="octo-spacer"
@ -104,14 +104,14 @@ exports[`components/viewHeader/viewHeader return viewHeader 1`] = `
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
</div>
</div>
<div
@ -158,14 +158,14 @@ exports[`components/viewHeader/viewHeader return viewHeader readonly 1`] = `
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</div>
</button>
</div>
<div
class="octo-spacer"

View File

@ -10,14 +10,14 @@ exports[`components/viewHeader/viewHeaderActionsMenu return menu and verify call
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
<div
class="Menu noselect bottom"
>
@ -120,14 +120,14 @@ exports[`components/viewHeader/viewHeaderActionsMenu return menu and verify call
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
<div
class="Menu noselect bottom"
>
@ -230,14 +230,14 @@ exports[`components/viewHeader/viewHeaderActionsMenu return menu with Share Boar
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
<div
class="Menu noselect bottom"
>
@ -340,14 +340,14 @@ exports[`components/viewHeader/viewHeaderActionsMenu return menu without Share B
class="MenuWrapper"
role="button"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
</button>
<div
class="Menu noselect bottom"
>

View File

@ -70,6 +70,7 @@ const ViewHeader = React.memo((props: Props) => {
saveOnEsc={true}
readonly={props.readonly}
spellCheck={true}
autoExpand={false}
/>
<MenuWrapper>
<IconButton icon={<DropdownIcon/>}/>

View File

@ -1,8 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useRef, useEffect} from 'react'
import React, {useState, useRef, useEffect, useMemo} from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import {useHotkeys} from 'react-hotkeys-hook'
import {debounce} from 'lodash'
import Button from '../../widgets/buttons/button'
import Editable from '../../widgets/editable'
@ -19,6 +20,19 @@ const ViewHeaderSearch = (): JSX.Element => {
const [isSearching, setIsSearching] = useState(Boolean(searchText))
const [searchValue, setSearchValue] = useState(searchText)
const dispatchSearchText = (value: string) => {
dispatch(setSearchText(value))
}
const debouncedDispatchSearchText = useMemo(
() => debounce(dispatchSearchText, 200), [])
useEffect(() => {
return () => {
debouncedDispatchSearchText.cancel()
}
}, [])
useEffect(() => {
searchFieldRef.current?.focus()
}, [isSearching])
@ -34,17 +48,20 @@ const ViewHeaderSearch = (): JSX.Element => {
ref={searchFieldRef}
value={searchValue}
placeholderText={intl.formatMessage({id: 'ViewHeader.search-text', defaultMessage: 'Search text'})}
onChange={setSearchValue}
onChange={(value) => {
setSearchValue(value)
debouncedDispatchSearchText(value)
}}
onCancel={() => {
setSearchValue('')
setIsSearching(false)
dispatch(setSearchText(''))
debouncedDispatchSearchText('')
}}
onSave={() => {
if (searchValue === '') {
setIsSearching(false)
}
dispatch(setSearchText(searchValue))
debouncedDispatchSearchText(searchValue)
}}
/>
)

View File

@ -7,6 +7,9 @@ import {Board, IPropertyTemplate} from './blocks/board'
import {Card} from './blocks/card'
import {OctoUtils} from './octoUtils'
import {Utils} from './utils'
import {IAppWindow} from './types'
declare let window: IAppWindow
class CsvExporter {
static exportTableCsv(board: Board, activeView: BoardView, cards: Card[], intl: IntlShape, view?: BoardView): void {
@ -36,8 +39,8 @@ class CsvExporter {
link.click()
// TODO: Review if this is needed in the future, this is to fix the problem with linux webview links
if ((window as any).openInNewBrowser) {
(window as any).openInNewBrowser(encodedUri)
if (window.openInNewBrowser) {
window.openInNewBrowser(encodedUri)
}
// TODO: Remove or reuse link
@ -69,6 +72,9 @@ class CsvExporter {
if (template.type === 'number') {
const numericValue = propertyValue ? Number(propertyValue).toString() : ''
row.push(numericValue)
} else if (template.type === 'multiSelect') {
const multiSelectValue = ((displayValue as unknown || []) as string[]).join('|')
row.push(multiSelectValue)
} else {
// Export as string
row.push(`"${this.encodeText(displayValue)}"`)

View File

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IAppWindow} from './types'
import {exportUserSettingsBlob, importUserSettingsBlob} from './userSettings'
declare interface INativeApp {
@ -8,6 +9,7 @@ declare interface INativeApp {
}
declare const NativeApp: INativeApp
declare let window: IAppWindow
export function importNativeAppSettings(): void {
if (typeof NativeApp === 'undefined' || !NativeApp.settingsBlob) {
@ -23,6 +25,6 @@ export function notifySettingsChanged(key: string): void {
postWebKitMessage({type: 'didChangeUserSettings', settingsBlob: exportUserSettingsBlob(), key})
}
function postWebKitMessage(message: any) {
(window as any).webkit?.messageHandlers.nativeApp?.postMessage(message)
function postWebKitMessage<T>(message: T) {
window.webkit?.messageHandlers.nativeApp?.postMessage(message)
}

View File

@ -92,7 +92,7 @@ const BoardPage = (props: Props): JSX.Element => {
// Backward compatibility end
const boardId = match.params.boardId
const viewId = match.params.viewId
const viewId = match.params.viewId === '0' ? '' : match.params.viewId
if (!boardId) {
// Load last viewed boardView
@ -146,12 +146,13 @@ const BoardPage = (props: Props): JSX.Element => {
}, [board?.title, activeView?.title])
useEffect(() => {
let loadAction: any = initialLoad
let loadAction: any = initialLoad /* eslint-disable-line @typescript-eslint/no-explicit-any */
let token = localStorage.getItem('focalboardSessionId') || ''
if (props.readonly) {
loadAction = initialReadOnlyLoad
token = token || queryString.get('r') || ''
}
dispatch(loadAction(match.params.boardId))
if (wsClient.state === 'open') {

View File

@ -70,9 +70,9 @@ exports[`pages/dashboard/DashboardPage base case 1`] = `
<div
class="sidebarSwitcher"
>
<div
<button
class="Button IconButton"
role="button"
type="button"
>
<svg
class="HideSidebarIcon Icon"
@ -86,7 +86,7 @@ exports[`pages/dashboard/DashboardPage base case 1`] = `
points="50,20 20,50, 50,80"
/>
</svg>
</div>
</button>
</div>
</div>
<div

View File

@ -5,6 +5,8 @@
--sidebar-text-rgb: 255, 255, 255;
--button-color-rgb: 255, 255, 255;
--button-bg-rgb: 28, 88, 217;
--button-danger-color-rgb: 255, 255, 255;
--button-danger-bg-rgb: 210, 75, 78;
--link-color-rgb: 56, 111, 229;
--error-text-rgb: #d24b4e;
}

10
webapp/src/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export interface IAppWindow extends Window {
baseURL?: string
frontendBaseURL?: string
isFocalboardPlugin?: boolean
msCrypto: Crypto
openInNewBrowser?: ((href: string) => void) | null
webkit?: {messageHandlers: {nativeApp?: {postMessage: <T>(message: T) => void}}}
}

View File

@ -4,6 +4,9 @@
import {createIntl} from 'react-intl'
import {Utils, IDType} from './utils'
import {IAppWindow} from './types'
declare let window: IAppWindow
describe('utils', () => {
describe('assureProtocol', () => {
@ -44,11 +47,10 @@ describe('utils', () => {
})
test('should not allow XSS on links href on the desktop app', () => {
const windowAsAny = window as any
windowAsAny.openInNewBrowser = () => null
window.openInNewBrowser = () => null
const expectedHtml = '<p><a target="_blank" rel="noreferrer" href="%22xss-attack=%22true%22other=%22whatever" title="" onclick="event.stopPropagation(); openInNewBrowser && openInNewBrowser(event.target.href);"></a></p>'
expect(Utils.htmlFromMarkdown('[]("xss-attack="true"other="whatever)')).toBe(expectedHtml)
windowAsAny.openInNewBrowser = null
window.openInNewBrowser = null
})
})
@ -62,8 +64,7 @@ describe('utils', () => {
})
test('buildURL, base no slash', () => {
const windowAsAny = window as any
windowAsAny.baseURL = 'base'
window.baseURL = 'base'
expect(Utils.buildURL('test', true)).toBe('http://localhost/base/test')
expect(Utils.buildURL('/test', true)).toBe('http://localhost/base/test')
@ -73,8 +74,7 @@ describe('utils', () => {
})
test('buildUrl, base with slash', () => {
const windowAsAny = window as any
windowAsAny.baseURL = '/base/'
window.baseURL = '/base/'
expect(Utils.buildURL('test', true)).toBe('http://localhost/base/test')
expect(Utils.buildURL('/test', true)).toBe('http://localhost/base/test')

View File

@ -2,18 +2,16 @@
// See LICENSE.txt for license information.
import marked from 'marked'
import {IntlShape} from 'react-intl'
import moment from 'moment'
import {Block} from './blocks/block'
import {createBoard} from './blocks/board'
import {createBoardView} from './blocks/boardView'
import {createCard} from './blocks/card'
import {createCommentBlock} from './blocks/commentBlock'
import {IAppWindow} from './types'
declare global {
interface Window {
msCrypto: Crypto
}
}
declare let window: IAppWindow
const IconClass = 'octo-icon'
const OpenButtonClass = 'open-button'
@ -221,7 +219,7 @@ class Utils {
'rel="noreferrer" ' +
`href="${encodeURI(href || '')}" ` +
`title="${title ? encodeURI(title) : ''}" ` +
`onclick="event.stopPropagation();${((window as any).openInNewBrowser ? ' openInNewBrowser && openInNewBrowser(event.target.href);' : '')}"` +
`onclick="event.stopPropagation();${(window.openInNewBrowser ? ' openInNewBrowser && openInNewBrowser(event.target.href);' : '')}"` +
'>' + contents + '</a>'
}
@ -265,6 +263,10 @@ class Utils {
})
}
static relativeDisplayDateTime(date: Date, intl: IntlShape): string {
return moment(date).locale(intl.locale.toLowerCase()).fromNow()
}
static sleep(miliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, miliseconds))
}
@ -460,7 +462,7 @@ class Utils {
}
static getBaseURL(absolute?: boolean): string {
let baseURL = (window as any).baseURL || ''
let baseURL = window.baseURL || ''
baseURL = baseURL.replace(/\/+$/, '')
if (baseURL.indexOf('/') === 0) {
baseURL = baseURL.slice(1)
@ -472,7 +474,7 @@ class Utils {
}
static getFrontendBaseURL(absolute?: boolean): string {
let frontendBaseURL = (window as any).frontendBaseURL || this.getBaseURL(absolute)
let frontendBaseURL = window.frontendBaseURL || this.getBaseURL(absolute)
frontendBaseURL = frontendBaseURL.replace(/\/+$/, '')
if (frontendBaseURL.indexOf('/') === 0) {
frontendBaseURL = frontendBaseURL.slice(1)
@ -503,7 +505,7 @@ class Utils {
}
static isFocalboardPlugin(): boolean {
return Boolean((window as any).isFocalboardPlugin)
return Boolean(window.isFocalboardPlugin)
}
static fixBlock(block: Block): Block {

View File

@ -58,6 +58,16 @@
}
}
&.emphasis--danger {
color: rgb(var(--button-danger-color-rgb));
background-color: rgb(var(--button-danger-bg-rgb));
&:hover {
background-color: rgb(var(--button-danger-bg-rgb), 0.8);
}
}
&.active {
background: rgba(var(--button-bg-rgb), 0.08);
color: rgb(var(--button-bg-rgb));

View File

@ -5,11 +5,11 @@ import React from 'react'
import './iconButton.scss'
type Props = {
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
title?: string
icon?: React.ReactNode
className?: string
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement>) => void
}
function IconButton(props: Props): JSX.Element {
@ -18,8 +18,8 @@ function IconButton(props: Props): JSX.Element {
className += ' ' + props.className
}
return (
<div
role='button'
<button
type='button'
onClick={props.onClick}
onMouseDown={props.onMouseDown}
className={className}
@ -27,7 +27,7 @@ function IconButton(props: Props): JSX.Element {
aria-label={props.title}
>
{props.icon}
</div>
</button>
)
}

View File

@ -30,7 +30,6 @@ function SubMenuOption(props: SubMenuOptionProps): JSX.Element {
setIsOpen(true)
}, 50)
}}
onMouseLeave={() => setIsOpen(false)}
onClick={(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()

View File

@ -1,7 +1,7 @@
.Switch {
display: flex;
flex-shrink: 0;
align-items: center;
box-sizing: content-box;
height: 18px;
width: 26px;

View File

@ -87,7 +87,7 @@ const ValueSelectorLabel = React.memo((props: LabelProps): JSX.Element => {
onClick={() => props.onDeleteOption(option)}
/>
<Menu.Separator/>
{Object.entries(Constants.menuColors).map(([key, color]: any) => (
{Object.entries(Constants.menuColors).map(([key, color]: [string, string]) => (
<Menu.Color
key={key}
id={key}
@ -190,7 +190,7 @@ function ValueSelector(props: Props): JSX.Element {
onBlur={props.onBlur}
onCreateOption={props.onCreate}
autoFocus={true}
value={props.value}
value={props.value || null}
closeMenuOnSelect={true}
placeholder={props.emptyValue}
hideSelectedOptions={false}

View File

@ -33,7 +33,7 @@ export const ACTION_UPDATE_CLIENT_CONFIG = 'UPDATE_CLIENT_CONFIG'
// The Mattermost websocket client interface
export interface MMWebSocketClient {
conn: WebSocket | null;
sendMessage(action: string, data: any, responseCallback?: () => void): void
sendMessage(action: string, data: any, responseCallback?: () => void): void /* eslint-disable-line @typescript-eslint/no-explicit-any */
setReconnectCallback(callback: () => void): void
setErrorCallback(callback: (event: Event) => void): void
setCloseCallback(callback: (connectFailCount: number) => void): void

View File

@ -5,78 +5,27 @@ section: "download"
weight: 2
---
Focalboard is now integrated with Mattermost v5.36 and later.
Focalboard is installed with Mattermost v6.0, where it's called Boards. To access and use Boards, [install or upgrade to Mattermost v6.0 or later](https://mattermost.com/get-started/). Then, select the Product menu in the top left corner of Mattermost and choose **Boards**.
## Enable Focalboard plugin
No additional server or web-proxy configuration is required.
To enable Focalboard, open your Mattermost instance, then:
### Enable shared boards
1. Go to **Main Menu > Marketplace**.
2. Search for "Focalboard".
3. Install the Focalboard plugin.
4. Select **Configure** and enable the plugin.
5. Select **Save**.
The shared boards feature is disabled by default in Mattermost. To enable it:
The Focalboard plugin requires websocket traffic to be passed by the proxy. Update your NGINX or Apache web proxy config following the steps below.
1. Open the System Console.
2. Go to **Plugins** and select Mattermost Boards.
3. Set **Enable Publicly-Shared Boards** to **true**.
4. Choose **Save**.
### With NGINX
### Permissions
After following the standard [Mattermost install steps](https://docs.mattermost.com/install/installing-ubuntu-1804-LTS.html#configuring-nginx-as-a-proxy-for-mattermost-server), edit `/etc/nginx/sites-available/mattermost` and add this section to it:
```
location ~ /plugins/focalboard/ws/* {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 50M;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_buffers 256 16k;
proxy_buffer_size 16k;
client_body_timeout 60;
send_timeout 300;
lingering_timeout 5;
proxy_connect_timeout 90;
proxy_send_timeout 300;
proxy_read_timeout 90s;
proxy_pass http://backend;
}
```
Restart NGINX with `sudo systemctl restart nginx`.
### With Apache (unofficial)
After following the [install guide for Apache and Mattermost](https://docs.mattermost.com/configure/configuring-apache2.html#configuring-apache2-as-a-proxy-for-mattermost-server-unofficial), modify the web sockets section in `/etc/apache2/sites-available` as follows:
```
# Set web sockets
RewriteEngine On
RewriteCond %{REQUEST_URI} /api/v[0-9]+/(users/)?websocket|/plugins/focalboard/ws/* [NC,OR]
RewriteCond %{HTTP:UPGRADE} ^WebSocket$ [NC,OR]
RewriteCond %{HTTP:CONNECTION} ^Upgrade$ [NC]
RewriteRule .* ws://127.0.0.1:8065%{REQUEST_URI} [P,QSA,L]
```
Restart Apache with `sudo systemctl restart apache2`
## Accessing Focalboard from Mattermost
<img src='https://user-images.githubusercontent.com/46905241/121930013-bbd12880-ccf6-11eb-9647-c9e367690111.png' style='max-height: 50px' />
In Mattermost, select the Focalboard icon in the channel header to access boards for that channel.
### Focalboard Permissions
Focalboard currently uses channel-based permissions, meaning that only members of the associated channel can access (read / write) the boards for that channel.
Mattermost Boards currently uses channel-based permissions, meaning that only members of the associated channel can access (read/write) the boards for that channel.
You can use this to create private boards:
1. Create or join a private channel (or group channel or direct-message)
2. Click on the Focalboard icon in the channel header
3. Create a board
Only members of that private channel can access the board.
Create or join a Private channel, Group Message, or Direct Message. Then, select the Boards icon in the channel header to create a board. Only members of the Private channel, Group Message, or Direct Message can access the board.
You can use the [share board](/guide/user/#sharing-boards) feature to share a read-only board with anyone (incuding unauthenticated users) who has the generated link.
For more information about using Mattermost Boards, refer to the main [product documentation here](https://docs.mattermost.com/guides/boards.html).

View File

@ -13,7 +13,7 @@ Follow the steps in the [Mattermost admin guide to enable custom plugins](https:
1. Manually set `PluginSettings > EnableUploads` to `true` in your `config.json`
2. Restart the Mattermost server
Download `mattermost-plugin-focalboard.tar.gz` from the build or release, e.g. the [Focalboard 0.7.3 release](https://github.com/mattermost/focalboard/releases/tag/v0.7.3).
Download `mattermost-plugin-focalboard.tar.gz` from the latest build or [release](https://github.com/mattermost/focalboard/releases).
Then upload the Focalboard plugin:
1. Navigate to **System Console > Plugins > Plugin Management**

View File

@ -12,8 +12,8 @@ Use the URL of the Ubuntu archive package, `focalboard-server-linux-amd64.tar.gz
Create and use a clean directory, or delete any existing packages first, then run:
```
# Download the new version (e.g. 0.7.0 here, check the release for the latest one)
wget https://github.com/mattermost/focalboard/releases/download/v0.7.0/focalboard-server-linux-amd64.tar.gz
# Download the new version (e.g. 0.9.2 here, check the release for the latest one)
wget https://github.com/mattermost/focalboard/releases/download/v0.9.2/focalboard-server-linux-amd64.tar.gz
tar -xvzf focalboard-server-linux-amd64.tar.gz
# Stop the server

View File

@ -5,7 +5,7 @@ subsection: Personal Edition
weight: 2
---
Focalboard Personal Server allows your team to work together on shared project boards.
Focalboard Personal Server is a standalone server for development and personal use. For team use, check out [Mattermost Boards](../../mattermost/), which supports private boards, team communication, and more.
Follow these steps it up on an Ubuntu server. To upgrade an existing installation, see [the upgrade guide](../ubuntu-upgrade).
@ -17,10 +17,10 @@ Popular hosted options include:
## Install Focalboard
Download the Ubuntu archive package from the appropriate [release in GitHub](https://github.com/mattermost/focalboard/releases). E.g. this is the link for v0.7.0 (which may no longer be the latest one):
Download the Ubuntu archive package from the appropriate [release in GitHub](https://github.com/mattermost/focalboard/releases). E.g. this is the link for v0.9.2 (which may no longer be the latest one):
```
wget https://github.com/mattermost/focalboard/releases/download/v0.7.0/focalboard-server-linux-amd64.tar.gz
wget https://github.com/mattermost/focalboard/releases/download/v0.9.2/focalboard-server-linux-amd64.tar.gz
tar -xvzf focalboard-server-linux-amd64.tar.gz
sudo mv focalboard /opt
```
@ -197,7 +197,7 @@ Change the dbconfig setting to use the MySQL database you created:
When MySQL is being used, using collation is recommended over using charset.
Using a variant of `utf8mb4` collation is required. For example, `utf8mb4_general_ci`
Using a variant of `utf8mb4` collation is required. For example, `utf8mb4_general_ci`
is used by default when no collation is specified.
If you're using Focalboard as a Mattermost Plugin prior to version 0.9 with MySQL,

View File

@ -6,16 +6,21 @@
</h2>
<p>
It's a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view. Focalboard comes in two editions:
It's a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view. Focalboard comes in two main editions:
</p>
<div class="text-left">
<ul >
<li><b><a href="https://www.focalboard.com/download/personal-edition/desktop/">Focalboard Personal Desktop</a></b>: A stand-alone desktop app for your todos and personal projects</li>
<li><b><a href="https://www.focalboard.com/download/personal-edition/ubuntu/">Focalboard Personal Server</a></b>: A self-hosted server for your team to collaborate</li>
<li><b><a href="download/personal-edition/desktop/">Focalboard Personal Desktop</a></b>: A stand-alone single-user desktop app for your todos and personal projects</li>
<li><b><a href="download/mattermost/">Mattermost Boards</a></b>: A cloud or self-hosted server for your team to plan and collaborate</li>
</ul>
</div>
</div>
<p>
Focalboard can also be installed as a standalone <a href="download/personal-edition/ubuntu/">personal server</a> for development.
</p>
</div>
<p class="text-center">
<a href="download/personal-edition">